From 1714e6a96841e475597a8a00fd3cb6c0fc781929 Mon Sep 17 00:00:00 2001 From: robertjamesprior <83608739+robertjamesprior@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:14:42 +0000 Subject: [PATCH 1/4] Report live view connection timeouts --- .../chromium-headful/client/src/neko/base.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/images/chromium-headful/client/src/neko/base.ts b/images/chromium-headful/client/src/neko/base.ts index d72fc069c..5bacc96fe 100644 --- a/images/chromium-headful/client/src/neko/base.ts +++ b/images/chromium-headful/client/src/neko/base.ts @@ -407,6 +407,21 @@ export abstract class BaseClient extends EventEmitter { this.emit('error', (event as ErrorEvent).error) } + private postParentMessage(message: Record) { + if (window.parent === window) { + return + } + + let targetOrigin = '*' + try { + if (document.referrer) { + targetOrigin = new URL(document.referrer).origin + } + } catch (e) {} + + window.parent.postMessage(message, targetOrigin) + } + private onConnected() { if (this._timeout) { clearTimeout(this._timeout) @@ -424,6 +439,14 @@ export abstract class BaseClient extends EventEmitter { private onTimeout() { this.emit('debug', `connection timeout`) + this.postParentMessage({ + type: 'KERNEL_CONNECTION_TIMEOUT', + reason: 'connection timeout', + iceConnectionState: this._peer?.iceConnectionState ?? this._state, + connectionState: this._peer?.connectionState, + signalingState: this._peer?.signalingState, + socketOpen: this.socketOpen, + }) if (this._timeout) { clearTimeout(this._timeout) this._timeout = undefined From 9fb7a589832b6aefd51cef05fa159a039ea0cf46 Mon Sep 17 00:00:00 2001 From: robertjamesprior <83608739+robertjamesprior@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:11:51 +0000 Subject: [PATCH 2/4] Report live view ICE candidate families Co-authored-by: Cursor --- images/chromium-headful/client/src/app.vue | 40 ++++++++++++++++- .../chromium-headful/client/src/neko/base.ts | 44 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/images/chromium-headful/client/src/app.vue b/images/chromium-headful/client/src/app.vue index 060184b11..1d1998574 100644 --- a/images/chromium-headful/client/src/app.vue +++ b/images/chromium-headful/client/src/app.vue @@ -209,6 +209,7 @@ shakeKbd = false wasConnected = false + readOnlyOverride: boolean | null = null get volume() { const numberParam = parseFloat(new URL(location.href).searchParams.get('volume') || '1.0') @@ -224,6 +225,10 @@ } get isReadOnlyMode() { + if (this.readOnlyOverride !== null) { + return this.readOnlyOverride + } + 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()) @@ -326,11 +331,42 @@ } if (this.isReadOnlyMode) { - // Disable implicit hosting so the user doesn't automatically gain control + 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() + } 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 diff --git a/images/chromium-headful/client/src/neko/base.ts b/images/chromium-headful/client/src/neko/base.ts index 5bacc96fe..402ed94f3 100644 --- a/images/chromium-headful/client/src/neko/base.ts +++ b/images/chromium-headful/client/src/neko/base.ts @@ -18,6 +18,12 @@ export interface BaseEvents { error: (error: Error) => void } +type IceCandidateSummary = { + candidateType?: string + protocol?: string + addressFamily?: 'ipv4' | 'ipv6' | 'unknown' +} + export abstract class BaseClient extends EventEmitter { protected _ws?: WebSocket protected _ws_heartbeat?: number @@ -28,6 +34,8 @@ export abstract class BaseClient extends EventEmitter { protected _state: RTCIceConnectionState = 'disconnected' protected _id = '' protected _candidates: RTCIceCandidate[] = [] + private _localIceCandidates: IceCandidateSummary[] = [] + private _remoteIceCandidates: IceCandidateSummary[] = [] get id() { return this._id @@ -134,6 +142,8 @@ export abstract class BaseClient extends EventEmitter { this._state = 'disconnected' this._displayname = undefined this._id = '' + this._localIceCandidates = [] + this._remoteIceCandidates = [] } public sendData(event: 'wheel', data: { x: number; y: number; controlKey?: boolean }): void @@ -272,6 +282,10 @@ export abstract class BaseClient extends EventEmitter { } const init = event.candidate.toJSON() + const summary = this.summarizeIceCandidate(init.candidate) + if (summary) { + this._localIceCandidates.push(summary) + } this.emit('debug', `sending local ICE candidate`, init) this._ws!.send( @@ -372,6 +386,10 @@ export abstract class BaseClient extends EventEmitter { if (event === EVENT.SIGNAL.CANDIDATE) { const { data } = payload as SignalCandidatePayload const candidate: RTCIceCandidate = JSON.parse(data) + const summary = this.summarizeIceCandidate(candidate.candidate) + if (summary) { + this._remoteIceCandidates.push(summary) + } if (this._peer) { this._peer.addIceCandidate(candidate) } else { @@ -389,6 +407,30 @@ export abstract class BaseClient extends EventEmitter { } } + private summarizeIceCandidate(candidate: string | undefined): IceCandidateSummary | undefined { + if (!candidate) { + return undefined + } + + const parts = candidate.trim().split(/\s+/) + const address = parts[4] + const typeIndex = parts.indexOf('typ') + + return { + protocol: parts[2]?.toLowerCase(), + candidateType: typeIndex >= 0 ? parts[typeIndex + 1] : undefined, + addressFamily: this.addressFamily(address), + } + } + + private addressFamily(address: string | undefined): 'ipv4' | 'ipv6' | 'unknown' { + if (!address) { + return 'unknown' + } + + return address.includes(':') ? 'ipv6' : 'ipv4' + } + private onData(e: MessageEvent) { this[EVENT.DATA](e.data) } @@ -446,6 +488,8 @@ export abstract class BaseClient extends EventEmitter { connectionState: this._peer?.connectionState, signalingState: this._peer?.signalingState, socketOpen: this.socketOpen, + localCandidates: this._localIceCandidates.slice(-10), + remoteCandidates: this._remoteIceCandidates.slice(-10), }) if (this._timeout) { clearTimeout(this._timeout) From 52a22b793e60314f52b947ab55f8343cfc0db638 Mon Sep 17 00:00:00 2001 From: robertjamesprior <83608739+robertjamesprior@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:27:19 +0000 Subject: [PATCH 3/4] Retry failed live view connections over relay Co-authored-by: Cursor --- images/chromium-headful/client/src/neko/base.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/images/chromium-headful/client/src/neko/base.ts b/images/chromium-headful/client/src/neko/base.ts index 402ed94f3..3c7ae89a6 100644 --- a/images/chromium-headful/client/src/neko/base.ts +++ b/images/chromium-headful/client/src/neko/base.ts @@ -24,6 +24,8 @@ type IceCandidateSummary = { addressFamily?: 'ipv4' | 'ipv6' | 'unknown' } +type IceTransportPolicy = 'all' | 'relay' + export abstract class BaseClient extends EventEmitter { protected _ws?: WebSocket protected _ws_heartbeat?: number @@ -36,6 +38,7 @@ export abstract class BaseClient extends EventEmitter { protected _candidates: RTCIceCandidate[] = [] private _localIceCandidates: IceCandidateSummary[] = [] private _remoteIceCandidates: IceCandidateSummary[] = [] + private _iceTransportPolicy: IceTransportPolicy = 'all' get id() { return this._id @@ -144,6 +147,7 @@ export abstract class BaseClient extends EventEmitter { this._id = '' this._localIceCandidates = [] this._remoteIceCandidates = [] + this._iceTransportPolicy = 'all' } public sendData(event: 'wheel', data: { x: number; y: number; controlKey?: boolean }): void @@ -227,9 +231,12 @@ export abstract class BaseClient extends EventEmitter { } if (lite !== true) { + this._iceTransportPolicy = this.iceTransportPolicy() this._peer = new RTCPeerConnection({ iceServers: servers, + iceTransportPolicy: this._iceTransportPolicy, }) + this.emit('debug', `created peer with ICE transport policy: ${this._iceTransportPolicy}`) } else { this._peer = new RTCPeerConnection() } @@ -407,6 +414,11 @@ export abstract class BaseClient extends EventEmitter { } } + private iceTransportPolicy(): IceTransportPolicy { + const attempt = Number(new URL(location.href).searchParams.get('kernelLiveViewAttempt') || '0') + return Number.isFinite(attempt) && attempt > 0 ? 'relay' : 'all' + } + private summarizeIceCandidate(candidate: string | undefined): IceCandidateSummary | undefined { if (!candidate) { return undefined @@ -459,7 +471,7 @@ export abstract class BaseClient extends EventEmitter { if (document.referrer) { targetOrigin = new URL(document.referrer).origin } - } catch (e) {} + } catch {} window.parent.postMessage(message, targetOrigin) } @@ -488,6 +500,7 @@ export abstract class BaseClient extends EventEmitter { connectionState: this._peer?.connectionState, signalingState: this._peer?.signalingState, socketOpen: this.socketOpen, + iceTransportPolicy: this._iceTransportPolicy, localCandidates: this._localIceCandidates.slice(-10), remoteCandidates: this._remoteIceCandidates.slice(-10), }) From b3ee84c7e4cfa1fe067ce426bd27950d57634bdd Mon Sep 17 00:00:00 2001 From: robertjamesprior <83608739+robertjamesprior@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:30:00 +0000 Subject: [PATCH 4/4] Use Neko server ICE logs Co-authored-by: Cursor --- server/go.mod | 2 +- server/go.sum | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/server/go.mod b/server/go.mod index afebf3b29..b65da64cb 100644 --- a/server/go.mod +++ b/server/go.mod @@ -127,4 +127,4 @@ require ( modernc.org/sqlite v1.23.1 // indirect ) -replace github.com/m1k1o/neko/server => github.com/kernel/neko/server v0.0.0-20260213021128-abe9ac59a634 +replace github.com/m1k1o/neko/server => github.com/kernel/neko/server v0.0.0-20260902162719-16605715b141 diff --git a/server/go.sum b/server/go.sum index 153e94634..1d9759fba 100644 --- a/server/go.sum +++ b/server/go.sum @@ -255,6 +255,8 @@ github.com/kernel/hypeman-go v0.20.0 h1:9kEMjtlko5oYSETwn9v829rJBv5GpcmoYjBjhjuw github.com/kernel/hypeman-go v0.20.0/go.mod h1:guRrhyP9QW/ebUS1UcZ0uZLLJeGAAhDNzSi68U4M9hI= github.com/kernel/neko/server v0.0.0-20260213021128-abe9ac59a634 h1:Pn8Zag7TMXnMPdjz136NTjpGwI7rgx++BNzsH2b4w3I= github.com/kernel/neko/server v0.0.0-20260213021128-abe9ac59a634/go.mod h1:0+zactiySvtKwfe5JFjyNrSuQLA+EEPZl5bcfcZf1RM= +github.com/kernel/neko/server v0.0.0-20260902162719-16605715b141 h1:v+5hfR6E9qEH8xQwHYaFfcoEmWs0wJhEqGmKQ0MvPws= +github.com/kernel/neko/server v0.0.0-20260902162719-16605715b141/go.mod h1:06r88Ixwd/djXAxJTC7aLtsJ8pStQelzGoWDf0+H26E= github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=