diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 3f7578c3..d4c46ed4 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 1aa95852..1e8429a8 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 cf25d998..0bcfdace 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 735af2c4..cf08ed3d 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 dee17e31..5d7cd000 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 baf664f3..792ffb7b 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: