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
11 changes: 11 additions & 0 deletions .github/workflows/live-view-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,17 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: images/chromium-headful/client/package-lock.json

- name: Install client dependencies
run: npm ci --no-audit --no-fund
working-directory: images/chromium-headful/client

- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0

Expand Down
32 changes: 32 additions & 0 deletions images/chromium-headful/client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Embedded live-view control

The `readOnly=true` query parameter sets the initial input mode (aliases: `readonly`,
`ro`; accepted true values: `1`, `true`, `yes`).

Embedded parents can change the mode without reconnecting:

```js
iframe.contentWindow.postMessage(
{ type: 'KERNEL_SET_READ_ONLY', readOnly: true, requestId: crypto.randomUUID() },
new URL(iframe.src).origin,
)
```

The viewer accepts messages only from its immediate parent and the exact origin
in `document.referrer`. Parents must allow an origin referrer, for example with
`referrerPolicy="strict-origin-when-cross-origin"`. Missing or opaque referrers
cannot authorize mode changes.

`KERNEL_CONNECTED` includes `capabilities: ['setReadOnly']` on supporting images.
After applying a valid request, the viewer sends `KERNEL_READ_ONLY_CHANGED` with
the same `requestId` and the applied boolean `readOnly` to the parent origin.
Requests without `requestId` remain supported for existing parents.
Parents must validate the iframe window, origin, request ID, and applied mode.

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 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.
74 changes: 28 additions & 46 deletions images/chromium-headful/client/src/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
import { Vue, Component, Ref, Watch } from 'vue-property-decorator'

import Connect from '~/components/connect.vue'
import { isReadOnlyMessage } 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 @@ -209,7 +210,6 @@

shakeKbd = false
wasConnected = false
readOnlyOverride: boolean | null = null

get volume() {
const numberParam = parseFloat(new URL(location.href).searchParams.get('volume') || '1.0')
Expand All @@ -225,13 +225,32 @@
}

get isReadOnlyMode() {
if (this.readOnlyOverride !== null) {
return this.readOnlyOverride
}
return this.$accessor.remote.readOnly
}

created() {
const params = new URL(location.href).searchParams
const value = params.get('readOnly') || params.get('readonly') || params.get('ro')
return typeof value === 'string' && ['1', 'true', 'yes'].includes(value.toLowerCase())
this.$accessor.remote.setReadOnly(typeof value === 'string' && ['1', 'true', 'yes'].includes(value.toLowerCase()))
window.addEventListener('message', this.onParentMessage)
}

beforeDestroy() {
window.removeEventListener('message', this.onParentMessage)
}

onParentMessage(event: MessageEvent) {
if (window.parent === window || !isReadOnlyMessage(event, window.parent, this.parentOrigin)) return

if (event.data.readOnly && !this.isReadOnlyMode) {
if (this.video) this.video.releaseInput()
this.$accessor.remote.release()
}
this.$accessor.remote.setReadOnly(event.data.readOnly)
window.parent.postMessage(
{ type: 'KERNEL_READ_ONLY_CHANGED', readOnly: this.isReadOnlyMode, requestId: event.data.requestId },
this.parentOrigin,
)
}

get hideControls() {
Expand Down Expand Up @@ -292,7 +311,10 @@
this.applyQueryResolution()
try {
if (window.parent !== window) {
window.parent.postMessage({ type: 'KERNEL_CONNECTED', connected: true }, this.parentOrigin)
window.parent.postMessage(
{ type: 'KERNEL_CONNECTED', connected: true, capabilities: ['setReadOnly'] },
this.parentOrigin,
)
}
} catch (e) {
console.error('Failed to post message to parent', e)
Expand Down Expand Up @@ -329,46 +351,6 @@
this.$accessor.video.screenSet(resolution)
}
}

if (this.isReadOnlyMode) {
this.applyReadOnlyMode(true, false)
}
}

mounted() {
window.addEventListener('message', this.onParentMessage)
}

beforeDestroy() {
window.removeEventListener('message', this.onParentMessage)
}

private onParentMessage(event: MessageEvent) {
if (event.source !== window.parent) return
if (this.parentOrigin !== '*' && event.origin !== this.parentOrigin) return

const data = event.data as { type?: string; readOnly?: unknown }
if (data?.type !== 'KERNEL_SET_READ_ONLY' || typeof data.readOnly !== 'boolean') return

this.applyReadOnlyMode(data.readOnly, true)
}

private applyReadOnlyMode(readOnly: boolean, releaseControl: boolean) {
this.readOnlyOverride = readOnly

if (readOnly) {
if (releaseControl) {
this.$accessor.remote.release()
}
// Disable implicit hosting so the user doesn't automatically gain control
this.$accessor.remote.setImplicitHosting(false)
// Lock the session locally to block any input even if hosting is later requested
this.$accessor.remote.setLocked(true)
return
}

this.$accessor.remote.setLocked(false)
this.$accessor.remote.setImplicitHosting(true)
}

// KERNEL: end custom resolution, frame rate, and readOnly control via query params
Expand Down
25 changes: 21 additions & 4 deletions images/chromium-headful/client/src/components/video.vue
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@
@Prop(Boolean) readonly readOnly!: boolean

private keyboard = GuacamoleKeyboard()
private pressedMouseButtons = new Set<number>()
private observer = new ResizeObserver(this.onResize.bind(this))
private focused = false
private pastePending = false
Expand Down Expand Up @@ -332,7 +333,7 @@
}

get locked() {
return this.$accessor.remote.locked || (this.controlLocked && (!this.hosting || this.implicitHosting))
return this.readOnly || this.$accessor.remote.locked || (this.controlLocked && (!this.hosting || this.implicitHosting))
}

get scroll() {
Expand Down Expand Up @@ -739,16 +740,19 @@
}

async syncClipboard() {
if (!this.clipboard_read_available || !window.document.hasFocus()) {
if (!this.hosting || this.locked || !this.clipboard_read_available || !window.document.hasFocus()) {
return
}

if (window.self !== window.top && !(await isClipboardReadGranted())) {
return
}

if (!this.hosting || this.locked) return

try {
const text = await navigator.clipboard.readText()
if (!this.hosting || this.locked) return
if (this.clipboard !== text) {
this.$accessor.remote.setClipboard(text)
this.$accessor.remote.sendClipboard(text)
Expand Down Expand Up @@ -858,6 +862,7 @@
this.focusOverlay(e)

this.sendMousePos(e)
this.pressedMouseButtons.add(e.button + 1)
this.$client.sendData('mousedown', { key: e.button + 1 })
}

Expand All @@ -868,6 +873,7 @@

this.focusOverlay(e)
this.sendMousePos(e)
this.pressedMouseButtons.delete(e.button + 1)
this.$client.sendData('mouseup', { key: e.button + 1 })
}

Expand All @@ -880,7 +886,7 @@
}

onMouseEnter(e: MouseEvent) {
if (this.hosting) {
if (this.hosting && !this.locked) {
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
masnwilliams marked this conversation as resolved.
this.$accessor.remote.syncKeyboardModifierState({
capsLock: e.getModifierState('CapsLock'),
numLock: e.getModifierState('NumLock'),
Expand All @@ -894,7 +900,8 @@
}

onMouseLeave(e: MouseEvent) {
if (this.hosting) {
// Keep an invalidated cache until mouse entry synchronizes with the remote.
if (this.hosting && !this.locked && this.$accessor.remote.keyboardModifierState !== -1) {
this.$accessor.remote.setKeyboardModifierState({
capsLock: e.getModifierState('CapsLock'),
numLock: e.getModifierState('NumLock'),
Expand All @@ -906,6 +913,14 @@
this.focused = false
}

releaseInput() {
this.resetKeyboard()
for (const key of this.pressedMouseButtons) {
this.$client.sendData('mouseup', { key })
}
this.pressedMouseButtons.clear()
}

resetKeyboard() {
this.keyboard.reset()
}
Expand Down Expand Up @@ -946,6 +961,8 @@
// without this delay the remote pastes stale content.
await new Promise((resolve) => setTimeout(resolve, 80))

if (!this.hosting || this.locked) return

// Send the full Ctrl+V sequence. We can't rely on Guacamole having
// captured the original Cmd/Ctrl keydown because Safari may intercept
// modifier shortcuts before they reach iframe JavaScript.
Expand Down
23 changes: 19 additions & 4 deletions images/chromium-headful/client/src/store/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const state = () => ({
id: '',
clipboard: '',
locked: false,
readOnly: false,
configuredImplicitHosting: true,
implicitHosting: true,
fileTransfer: true,
keyboardModifierState: -1,
Expand Down Expand Up @@ -60,7 +62,18 @@ export const mutations = mutationTree(state, {
},

setImplicitHosting(state, val: boolean) {
state.implicitHosting = val
state.configuredImplicitHosting = val
state.implicitHosting = val && !state.readOnly
},

setReadOnly(state, readOnly: boolean) {
if (state.readOnly && !readOnly) {
// The local lock keys may have changed while remote input was blocked.
state.keyboardModifierState = -1
}
state.readOnly = readOnly
state.locked = readOnly
state.implicitHosting = state.configuredImplicitHosting && !readOnly
},

setFileTransfer(state, val: boolean) {
Expand All @@ -74,16 +87,16 @@ export const mutations = mutationTree(state, {
reset(state) {
state.id = ''
state.clipboard = ''
state.locked = false
state.locked = state.readOnly
state.requesting = false
},
})

export const actions = actionTree(
{ state, getters, mutations },
{
sendClipboard({ getters }, clipboard: string) {
if (!accessor.connected || !getters.hosting) {
sendClipboard({ state, getters }, clipboard: string) {
if (!accessor.connected || state.readOnly || !getters.hosting) {
return
}

Expand Down Expand Up @@ -176,6 +189,8 @@ export const actions = actionTree(
},

syncKeyboardModifierState({ state }, { capsLock, numLock, scrollLock }) {
if (state.readOnly) return

if (state.keyboardModifierState === keyboardModifierState(capsLock, numLock, scrollLock)) {
return
}
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
@@ -0,0 +1,13 @@
export function isReadOnlyMessage(event: MessageEvent, parent: Window, parentOrigin: string): boolean {
return (
event.source === parent &&
parentOrigin !== '*' &&
parentOrigin !== 'null' &&
event.origin === parentOrigin &&
event.data !== null &&
typeof event.data === 'object' &&
event.data.type === 'KERNEL_SET_READ_ONLY' &&
typeof event.data.readOnly === 'boolean' &&
(event.data.requestId === undefined || typeof event.data.requestId === 'string')
)
}
Loading
Loading