From 8275e937bc46a11c6bafd82e6772a054295686ec Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:44:26 +0000 Subject: [PATCH 1/3] Add a view-only live view member profile --- images/chromium-headful/Dockerfile | 2 +- images/chromium-headful/client/README.md | 5 +++++ images/chromium-headful/client/src/app.vue | 10 ++++++---- .../client/src/utils/read-only.ts | 13 +++++++++++++ .../client/tests/read-only.test.ts | 16 +++++++++++++++- images/chromium-headful/neko.yaml | 11 ++++++----- 6 files changed, 46 insertions(+), 11 deletions(-) diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index f72c4b076..8dd380572 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -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-rc.1 AS neko FROM node:22-bullseye-slim AS node-22 FROM docker.io/library/docker:29.8.2-dind AS docker ARG TARGETARCH diff --git a/images/chromium-headful/client/README.md b/images/chromium-headful/client/README.md index 1aa958529..1e8429a89 100644 --- a/images/chromium-headful/client/README.md +++ b/images/chromium-headful/client/README.md @@ -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. diff --git a/images/chromium-headful/client/src/app.vue b/images/chromium-headful/client/src/app.vue index cf25d9983..0bcfdacee 100644 --- a/images/chromium-headful/client/src/app.vue +++ b/images/chromium-headful/client/src/app.vue @@ -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' @@ -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) } @@ -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, diff --git a/images/chromium-headful/client/src/utils/read-only.ts b/images/chromium-headful/client/src/utils/read-only.ts index 735af2c44..cf08ed3d2 100644 --- a/images/chromium-headful/client/src/utils/read-only.ts +++ b/images/chromium-headful/client/src/utils/read-only.ts @@ -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 && @@ -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 +} diff --git a/images/chromium-headful/client/tests/read-only.test.ts b/images/chromium-headful/client/tests/read-only.test.ts index dee17e31d..5d7cd000c 100644 --- a/images/chromium-headful/client/tests/read-only.test.ts +++ b/images/chromium-headful/client/tests/read-only.test.ts @@ -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 })) @@ -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() diff --git a/images/chromium-headful/neko.yaml b/images/chromium-headful/neko.yaml index baf664f3b..792ffb7b0 100644 --- a/images/chromium-headful/neko.yaml +++ b/images/chromium-headful/neko.yaml @@ -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: From e3e61e398682654c7419c785e2ce183b8edb27b8 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:30:03 +0000 Subject: [PATCH 2/3] Use the final Neko 3.0.8-v1.6.4 base --- images/chromium-headful/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 8dd380572..5a17c35d2 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -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.4-rc.1 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 From b265c4b78e81d63b659cf7956c0d729cf6616108 Mon Sep 17 00:00:00 2001 From: masnwilliams <43387599+masnwilliams@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:19:26 +0000 Subject: [PATCH 3/3] Expect an exact odd-width screen now that Neko generates exact modes --- server/e2e/e2e_display_resize_window_test.go | 43 +++++++------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/server/e2e/e2e_display_resize_window_test.go b/server/e2e/e2e_display_resize_window_test.go index 8009d9b6d..e90b9d4af 100644 --- a/server/e2e/e2e_display_resize_window_test.go +++ b/server/e2e/e2e_display_resize_window_test.go @@ -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) } @@ -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) @@ -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) }