From 7c78e16a5089519585752fd0d720ade40db8af4f Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 17 Jul 2026 15:33:13 -0400 Subject: [PATCH 1/2] Fix Live View clipboard sync under implicit hosting (#310) Copying inside the remote browser never updated the viewer's local clipboard in an embedded Live View. Under neko's implicit_hosting, the client treated the viewer as a host (`remote.hosting` true) but never registered one server-side, so `remote.request()` short-circuited and no `control/request` was ever sent. neko delivers clipboard updates only to the registered host (`sessions.GetHost()`), so with no host they were dropped. - store/remote.ts: add a `controlling` getter that is true only when the viewer is the actual registered host, and guard `request()` on it instead of the broad `hosting`. The `id !== ''` check avoids treating the pre-connection empty-id state as controlling. - components/video.vue: on the first interaction under implicit hosting, call `remote.request()` so the session registers as host, while still forwarding the original click (the pinned neko server accepts input via LegacyIsHost() under implicit hosting, so no buffer/replay is needed). - neko/index.ts: stop surfacing the "you took the controls" toast to the viewer. Control is now registered automatically on first interaction, so it would otherwise fire on essentially every session's first click. Co-Authored-By: Claude --- .../client/src/components/video.vue | 8 ++++++++ .../chromium-headful/client/src/neko/index.ts | 17 ++++++----------- .../chromium-headful/client/src/store/remote.ts | 5 ++++- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/images/chromium-headful/client/src/components/video.vue b/images/chromium-headful/client/src/components/video.vue index 2a8a8c8f..2c5955c9 100644 --- a/images/chromium-headful/client/src/components/video.vue +++ b/images/chromium-headful/client/src/components/video.vue @@ -280,6 +280,10 @@ return this.$accessor.connecting } + get controlling() { + return this.$accessor.remote.controlling + } + get hosting() { return this.$accessor.remote.hosting } @@ -807,6 +811,10 @@ onMouseDown(e: MouseEvent) { this.unmuteOnInteraction() + if (!this.controlling && this.implicitHosting && !this.locked) { + this.$accessor.remote.request() + } + if (!this.hosting) { this.$emit('control-attempt', e) } diff --git a/images/chromium-headful/client/src/neko/index.ts b/images/chromium-headful/client/src/neko/index.ts index 92df801d..3e3a39be 100644 --- a/images/chromium-headful/client/src/neko/index.ts +++ b/images/chromium-headful/client/src/neko/index.ts @@ -240,17 +240,12 @@ export class NekoClient extends BaseClient implements EventEmitter { return } - if (this.id === id) { - this.$vue.$notify({ - group: 'neko', - type: 'info', - title: this.$vue.$t('notifications.controls_taken', { - name: member.id == this.id && this.$vue.$te('you') ? this.$vue.$t('you') : member.displayname, - }) as string, - duration: 5000, - speed: 1000, - }) - } + // KERNEL: don't surface a "you took the controls" toast to the viewer. + // With implicit hosting, control is now registered automatically on the + // first interaction (see remote.request() in video.vue), so this would + // fire on essentially every session's first click — pure noise for a + // single-viewer embedded live view. Notifications for other members + // taking/stealing control are left intact. this.$accessor.chat.newMessage({ id: member.id, diff --git a/images/chromium-headful/client/src/store/remote.ts b/images/chromium-headful/client/src/store/remote.ts index c8e9d282..73ba8b34 100644 --- a/images/chromium-headful/client/src/store/remote.ts +++ b/images/chromium-headful/client/src/store/remote.ts @@ -18,6 +18,9 @@ export const state = () => ({ }) export const getters = getterTree(state, { + controlling: (state, getters, root) => { + return root.user.id !== '' && root.user.id === state.id + }, hosting: (state, getters, root) => { return root.user.id === state.id || state.implicitHosting }, @@ -89,7 +92,7 @@ export const actions = actionTree( }, request({ getters }) { - if (!accessor.connected || getters.hosting) { + if (!accessor.connected || getters.controlling) { return } From c15d5d59aeb6b2b577a5fb0d2bf9c1fa08ba49f2 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 17 Jul 2026 16:29:41 -0400 Subject: [PATCH 2/2] Address review: guard against duplicate in-flight control requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remote.ts: add a `requesting` flag so rapid clicks before control/locked round-trips can't emit multiple control/request frames. Cleared on setHost (host resolved) and reset (disconnect/release). - neko/index.ts: clarify the toast-suppression comment — the removed $notify was self-only; the surviving other-member path is the chat event, and the suppression is a shared-component side effect intended for the embedded live view. Co-Authored-By: Claude --- images/chromium-headful/client/src/neko/index.ts | 15 +++++++++------ .../chromium-headful/client/src/store/remote.ts | 16 ++++++++++++++-- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/images/chromium-headful/client/src/neko/index.ts b/images/chromium-headful/client/src/neko/index.ts index 3e3a39be..3571bb88 100644 --- a/images/chromium-headful/client/src/neko/index.ts +++ b/images/chromium-headful/client/src/neko/index.ts @@ -240,12 +240,15 @@ export class NekoClient extends BaseClient implements EventEmitter { return } - // KERNEL: don't surface a "you took the controls" toast to the viewer. - // With implicit hosting, control is now registered automatically on the - // first interaction (see remote.request() in video.vue), so this would - // fire on essentially every session's first click — pure noise for a - // single-viewer embedded live view. Notifications for other members - // taking/stealing control are left intact. + // KERNEL: drop the self-only "you took the controls" toast (was gated on + // this.id === id). With implicit hosting, control is now registered + // automatically on the first interaction (see remote.request() in + // video.vue), so it would fire on essentially every session's first click + // — pure noise for a single-viewer embedded live view. The chat event + // below (and other members' take/steal toasts elsewhere) is unaffected. + // Note: this is a shared component, so explicit control-takes in the full + // neko UI also lose this confirmation; acceptable since Kernel only ships + // the embedded live view. this.$accessor.chat.newMessage({ id: member.id, diff --git a/images/chromium-headful/client/src/store/remote.ts b/images/chromium-headful/client/src/store/remote.ts index 73ba8b34..52c53d90 100644 --- a/images/chromium-headful/client/src/store/remote.ts +++ b/images/chromium-headful/client/src/store/remote.ts @@ -15,6 +15,10 @@ export const state = () => ({ implicitHosting: true, fileTransfer: true, keyboardModifierState: -1, + // true once a control/request has been sent and we're waiting for the + // server's control/locked response. Prevents rapid clicks from emitting + // multiple requests before `controlling` flips. + requesting: false, }) export const getters = getterTree(state, { @@ -39,6 +43,8 @@ export const mutations = mutationTree(state, { } else { state.id = host.id } + // host resolved (for us or someone else) — clear any pending request + state.requesting = false }, setClipboard(state, clipboard: string) { @@ -61,10 +67,15 @@ export const mutations = mutationTree(state, { state.fileTransfer = val }, + setRequesting(state, val: boolean) { + state.requesting = val + }, + reset(state) { state.id = '' state.clipboard = '' state.locked = false + state.requesting = false }, }) @@ -91,11 +102,12 @@ export const actions = actionTree( } }, - request({ getters }) { - if (!accessor.connected || getters.controlling) { + request({ state, getters }) { + if (!accessor.connected || getters.controlling || state.requesting) { return } + accessor.remote.setRequesting(true) $client.sendMessage(EVENT.CONTROL.REQUEST) },