Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion images/chromium-headful/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ RUN --mount=type=cache,target=/tmp/cache/ffmpeg,sharing=locked,id=$CACHEIDPREFIX
rm -rf /tmp/ffmpeg*
EOT

FROM ghcr.io/kernel/neko/base:3.0.8-v1.6.3 AS neko
FROM ghcr.io/kernel/neko/base:3.0.8-v1.6.4 AS neko
FROM node:22-bullseye-slim AS node-22
FROM docker.io/library/docker:29.8.2-dind AS docker
ARG TARGETARCH
Expand Down
5 changes: 5 additions & 0 deletions images/chromium-headful/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ For older images without the capability, change the query parameter and reload.
If an advertised capability fails to acknowledge a request, fall back to a reload
with the desired query mode. The dashboard uses a one-second acknowledgement timeout.

Read-only links stay read-only. When the live-view proxy serves a read-only link it
sets `window.KERNEL_LIVE_VIEW_READ_ONLY = true`; the viewer then ignores the query
parameter and answers unlock requests with `readOnly: true`. The server refuses that
viewer's input regardless.

Read-only mode locks local input and disables implicit hosting. Unlocking restores
the server's configured implicit-hosting setting. Mode changes persist across
transport reconnects; a full page reload uses the URL's initial mode again.
10 changes: 6 additions & 4 deletions images/chromium-headful/client/src/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@
import { Vue, Component, Ref, Watch } from 'vue-property-decorator'

import Connect from '~/components/connect.vue'
import { isReadOnlyMessage } from '~/utils/read-only'
import { isReadOnlyMessage, resolveReadOnly } from '~/utils/read-only'
import Disconnected from '~/components/disconnected.vue'
import Video from '~/components/video.vue'
import Menu from '~/components/menu.vue'
Expand Down Expand Up @@ -234,7 +234,8 @@
created() {
const params = new URL(location.href).searchParams
const value = params.get('readOnly') || params.get('readonly') || params.get('ro')
this.$accessor.remote.setReadOnly(typeof value === 'string' && ['1', 'true', 'yes'].includes(value.toLowerCase()))
const requested = typeof value === 'string' && ['1', 'true', 'yes'].includes(value.toLowerCase())
this.$accessor.remote.setReadOnly(resolveReadOnly(requested, window.KERNEL_LIVE_VIEW_READ_ONLY))
window.addEventListener('message', this.onParentMessage)
}

Expand All @@ -245,11 +246,12 @@
onParentMessage(event: MessageEvent) {
if (window.parent === window || !isReadOnlyMessage(event, window.parent, this.parentOrigin)) return

if (event.data.readOnly && !this.isReadOnlyMode) {
const readOnly = resolveReadOnly(event.data.readOnly, window.KERNEL_LIVE_VIEW_READ_ONLY)
if (readOnly && !this.isReadOnlyMode) {
if (this.video) this.video.releaseInput()
this.$accessor.remote.release()
}
this.$accessor.remote.setReadOnly(event.data.readOnly)
this.$accessor.remote.setReadOnly(readOnly)
window.parent.postMessage(
{ type: 'KERNEL_READ_ONLY_CHANGED', readOnly: this.isReadOnlyMode, requestId: event.data.requestId },
this.parentOrigin,
Expand Down
13 changes: 13 additions & 0 deletions images/chromium-headful/client/src/utils/read-only.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
declare global {
interface Window {
// Injected by the live-view proxy for read-only links.
KERNEL_LIVE_VIEW_READ_ONLY?: unknown
}
}

export function isReadOnlyMessage(event: MessageEvent, parent: Window, parentOrigin: string): boolean {
return (
event.source === parent &&
Expand All @@ -11,3 +18,9 @@ export function isReadOnlyMessage(event: MessageEvent, parent: Window, parentOri
(event.data.requestId === undefined || typeof event.data.requestId === 'string')
)
}

// A read-only link stays read-only: the server refuses its input either way, so
// the page neither unlocks nor offers controls it cannot use.
export function resolveReadOnly(requested: boolean, forced: unknown): boolean {
return forced === true || requested
}
16 changes: 15 additions & 1 deletion images/chromium-headful/client/tests/read-only.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, mock, test } from 'bun:test'
import { isReadOnlyMessage } from '../src/utils/read-only'
import { isReadOnlyMessage, resolveReadOnly } from '../src/utils/read-only'

const accessor = { connected: true, remote: { setKeyboardModifierState: mock() } }
mock.module('~/store', () => ({ accessor }))
Expand Down Expand Up @@ -52,6 +52,20 @@ describe('read-only parent messages', () => {
})
})

describe('read-only links', () => {
test('keep read-only regardless of the requested mode', () => {
expect(resolveReadOnly(false, true)).toBe(true)
expect(resolveReadOnly(true, true)).toBe(true)
})

test('follow the requested mode when the proxy does not force it', () => {
for (const forced of [undefined, false, 'true', 1]) {
expect(resolveReadOnly(false, forced)).toBe(false)
expect(resolveReadOnly(true, forced)).toBe(true)
}
})
})

describe('read-only input state', () => {
test('locks and unlocks without resetting the current controller', () => {
const remote = state()
Expand Down
11 changes: 6 additions & 5 deletions images/chromium-headful/neko.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,18 @@ member:
can_access_clipboard: true
sends_inactive_cursor: true
can_see_inactive_cursors: true
user_password: "neko"
# View-only member, used by the live-view proxy for read-only links.
user_password: "live-view-read-only"
user_profile:
name: "" # if empty, the login username is used
is_admin: false
can_login: true
can_connect: true
can_watch: true
can_host: true
can_share_media: true
can_access_clipboard: true
sends_inactive_cursor: true
can_host: false
can_share_media: false
can_access_clipboard: false
sends_inactive_cursor: false
can_see_inactive_cursors: false

session:
Expand Down
43 changes: 14 additions & 29 deletions server/e2e/e2e_display_resize_window_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -553,24 +553,17 @@ func waitForNeko(t *testing.T, ctx context.Context, c *TestContainer) {
}, time.Minute, 500*time.Millisecond, "neko not ready")
}

// TestDisplayResizeOddWidthHonoursLibxcvtRounding covers the path where a
// caller requests dimensions libxcvt cannot honour exactly. libxcvt rounds
// widths to the CVT 8-pixel grid and then applies a hard-coded FWXGA bump
// for 1360×768 → 1366×768 (xf86EdidModes.c convention so virtual displays
// match real "HD ready" laptop panels). For request 1365×768 the realized
// X root is 1366×768.
// TestDisplayResizeOddWidthIsExact covers a width off the CVT 8-pixel grid.
// Neko generates the mode at exactly the requested size, so 1365x768 gives a
// 1365x768 X root rather than libxcvt's rounded 1360 (which X bumps to 1366
// for 768-line screens).
//
// Before the fix, waitForXRootSize compared the X root against the
// requested 1365 and returned 500 with "X root verification: x root is
// 1366x768, want 1365x768". The browser pool treats that 500 as fatal —
// the production incident that prompted this test recycled the VM on
// every odd-width /configure call.
//
// After the fix (PatchDisplay reads the realized dimensions back from
// neko's ScreenConfigurationChange response and uses them for both the
// post-condition and the 200 body), the resize succeeds and the response
// truthfully reports 1366×768.
func TestDisplayResizeOddWidthHonoursLibxcvtRounding(t *testing.T) {
// An odd-width resize used to return 500 ("X root verification: x root is
// 1366x768, want 1365x768"), which the browser pool treats as fatal, so it
// recycled the VM on every odd-width /configure call. PatchDisplay reads the
// realized size back from Neko and reports it, so the resize must succeed
// and the response must match the X root.
func TestDisplayResizeOddWidthIsExact(t *testing.T) {
if _, err := exec.LookPath("docker"); err != nil {
t.Skipf("docker not available: %v", err)
}
Expand Down Expand Up @@ -598,10 +591,7 @@ func TestDisplayResizeOddWidthHonoursLibxcvtRounding(t *testing.T) {
navigateBlank(t, ctx, c)

// Request an odd width — the exact pattern from the production taint.
// libxcvt rounds 1365 to 1360 (CVT 8-pixel grid) then bumps to 1366
// (FWXGA hack for 1360×768 specifically). The X root lands at 1366×768.
const requestedWidth, height, refreshRate = 1365, 768, 60
const realizedWidth = 1366

client, err := c.APIClient()
require.NoError(t, err)
Expand All @@ -619,21 +609,16 @@ func TestDisplayResizeOddWidthHonoursLibxcvtRounding(t *testing.T) {
t.Logf("PATCH /display(width=%d, height=%d) -> status=%d body=%s; x_root=%dx%d",
requestedWidth, height, rsp.StatusCode(), strings.TrimSpace(string(rsp.Body)), rootW, rootH)

// X root reflects libxcvt's realized mode, not the request.
require.Equal(t, realizedWidth, rootW,
"expected libxcvt FWXGA hack to round %dx%d to %dx%d in X root; got %dx%d. If libxcvt's rounding rule changed, update the expected realized width.",
requestedWidth, height, realizedWidth, height, rootW, rootH)
require.Equal(t, requestedWidth, rootW,
"expected an exact %dx%d X root; got %dx%d", requestedWidth, height, rootW, rootH)
require.Equal(t, height, rootH, "height should pass through unchanged")

// API now succeeds and reports the realized dimensions — the caller
// learns the screen is 1366×768 even though they asked for 1365×768.
require.Equal(t, http.StatusOK, rsp.StatusCode(),
"PATCH /display: expected 200 after fix, got %d body=%s", rsp.StatusCode(), string(rsp.Body))
"PATCH /display: expected 200, got %d body=%s", rsp.StatusCode(), string(rsp.Body))
require.NotNil(t, rsp.JSON200, "JSON200 response must be present")
require.NotNil(t, rsp.JSON200.Width, "response must include realized width")
require.NotNil(t, rsp.JSON200.Height, "response must include realized height")
require.Equal(t, realizedWidth, *rsp.JSON200.Width,
"response width should be the realized %d, not the requested %d", realizedWidth, requestedWidth)
require.Equal(t, rootW, *rsp.JSON200.Width, "response width should match the X root")
require.Equal(t, height, *rsp.JSON200.Height)
}

Expand Down
Loading