From 1e02de210c3da172219153e9ba26d85623b19404 Mon Sep 17 00:00:00 2001 From: Edward Rosado Date: Tue, 4 Aug 2026 23:31:03 -0400 Subject: [PATCH 1/6] Show the active transport and trim artwork for slow links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small badge to the connection overlay naming the link that is actually carrying data. The transport is probed, not assumed: /__bt is answered locally by the Bluetooth mux when it owns the client port, so a JSON reply means Bluetooth while a 404 means the request travelled to the server over USB — no server support required, the badge simply reads USB without it. Also prefers Spotify's 300x300 artwork variant over the 640x640 one on the proxied thumbnail path. The panel is 800x480, so the larger asset is wasted bytes — about 0.7s of a saturated ~155KB/s Bluetooth link on every track change. Co-Authored-By: Claude Fable 5 --- package-lock.json | 4 +- src/assets/Icons/Icons/IconBluetooth.tsx | 13 +++++ src/assets/Icons/Icons/IconUsb.tsx | 13 +++++ src/assets/Icons/index.ts | 2 + src/overlay/ConnectionStatus.tsx | 63 +++++++++++++++++++++--- src/stores/musicStore.ts | 12 ++++- 6 files changed, 97 insertions(+), 10 deletions(-) create mode 100644 src/assets/Icons/Icons/IconBluetooth.tsx create mode 100644 src/assets/Icons/Icons/IconUsb.tsx diff --git a/package-lock.json b/package-lock.json index fd80ed1..68f2ee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "deskthing-client", - "version": "0.11.1", + "version": "0.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "deskthing-client", - "version": "0.11.1", + "version": "0.11.2", "dependencies": { "clsx": "^2.1.1", "react": "^18.3.1", diff --git a/src/assets/Icons/Icons/IconBluetooth.tsx b/src/assets/Icons/Icons/IconBluetooth.tsx new file mode 100644 index 0000000..9f7ea2e --- /dev/null +++ b/src/assets/Icons/Icons/IconBluetooth.tsx @@ -0,0 +1,13 @@ +import { Icon } from '.' + +function IconBluetooth(props): JSX.Element { + return ( + + + + + + ) +} + +export default IconBluetooth diff --git a/src/assets/Icons/Icons/IconUsb.tsx b/src/assets/Icons/Icons/IconUsb.tsx new file mode 100644 index 0000000..4fe61d4 --- /dev/null +++ b/src/assets/Icons/Icons/IconUsb.tsx @@ -0,0 +1,13 @@ +import { Icon } from '.' + +function IconUsb(props): JSX.Element { + return ( + + + + + + ) +} + +export default IconUsb diff --git a/src/assets/Icons/index.ts b/src/assets/Icons/index.ts index 9cd7708..f9ce532 100644 --- a/src/assets/Icons/index.ts +++ b/src/assets/Icons/index.ts @@ -53,3 +53,5 @@ export { default as IconVolUp } from './Icons/IconVolUp' export { default as IconVolDown } from './Icons/IconVolDown' export { default as IconFullscreen } from './Icons/IconFullscreen' export { default as IconFullscreenReverse } from './Icons/IconFullscreenReverse' +export { default as IconBluetooth } from './Icons/IconBluetooth' +export { default as IconUsb } from './Icons/IconUsb' diff --git a/src/overlay/ConnectionStatus.tsx b/src/overlay/ConnectionStatus.tsx index 5b21821..0c158a6 100644 --- a/src/overlay/ConnectionStatus.tsx +++ b/src/overlay/ConnectionStatus.tsx @@ -1,26 +1,75 @@ -import { IconLoading } from '@src/assets/Icons' +import { IconBluetooth, IconConnected, IconLoading, IconUsb } from '@src/assets/Icons' import { useWebSocketStore } from '@src/stores/' +import { useSettingsStore } from '@src/stores/settingsStore' import { useUIStore } from '@src/stores/uiStore' +import { useEffect, useState } from 'react' + +type Transport = 'bluetooth' | 'usb' | 'unknown' /** - * Renders a component that displays the connection status of the WebSocket connection. + * Renders the connection status of the WebSocket connection. + * + * While connected, shows a small badge naming the transport actually carrying the + * data. The transport is probed rather than assumed: /__bt is answered locally by + * the Bluetooth mux when it owns the port, so a JSON reply means Bluetooth, while + * anything else means the request travelled to the server over USB. * - * If the WebSocket connection is connected, this component will not render anything. - * If the WebSocket connection is disconnected, this component will render a fixed - * notification at the top-left of the screen indicating the disconnection status. + * While disconnected or reconnecting, shows the original notice instead. */ export const ServerStatus = () => { const isConnected = useWebSocketStore((state) => state.isConnected) const isReconnecting = useWebSocketStore((state) => state.isReconnecting) const isScreensaverActive = useUIStore((state) => state.isScreensaverActive) + const context = useSettingsStore((state) => state.manifest.context) + const [transport, setTransport] = useState('unknown') + + useEffect(() => { + if (!isConnected || !context?.ip || !context?.port) return + let cancelled = false + + const probe = async (): Promise => { + try { + const res = await fetch(`http://${context.ip}:${context.port}/__bt`, { + cache: 'no-store' + }) + const data = res.ok ? await res.json() : null + if (!cancelled) setTransport(data?.transport === 'bluetooth' ? 'bluetooth' : 'usb') + } catch { + if (!cancelled) setTransport('usb') + } + } + + probe() + const id = setInterval(probe, 15000) + return () => { + cancelled = true + clearInterval(id) + } + }, [isConnected, context?.ip, context?.port]) + + if (isScreensaverActive) return null - if (isConnected || isScreensaverActive) return null + if (isConnected) { + const label = transport === 'bluetooth' ? 'BT' : transport === 'usb' ? 'USB' : '' + return ( +
+ {transport === 'bluetooth' ? ( + + ) : transport === 'usb' ? ( + + ) : ( + + )} + {label && {label}} +
+ ) + } return (
{isReconnecting ? (
- +

Reconnecting

) : ( diff --git a/src/stores/musicStore.ts b/src/stores/musicStore.ts index 3bebd33..4d20970 100644 --- a/src/stores/musicStore.ts +++ b/src/stores/musicStore.ts @@ -34,6 +34,16 @@ export interface MusicState { setShuffle: () => void } +/** + * Spotify encodes the artwork size in the CDN path prefix: 0000b273 is 640x640 + * (~110KB) while 00001e02 is 300x300 (~36KB). The Car Thing panel is 800x480, so + * the 640px asset is mostly wasted bytes — and on a Bluetooth-tunneled connection + * (~155KB/s) it costs about 0.7s of a saturated link on every track change. + * Non-Spotify URLs are left untouched. + */ +const preferSmallerArtwork = (url: string): string => + url.replace('/image/ab67616d0000b273', '/image/ab67616d00001e02') + export const useMusicStore = create((set, get) => ({ song: null, setSong: (newData) => { @@ -49,7 +59,7 @@ export const useMusicStore = create((set, get) => ({ if (context.id == ClientPlatformIDs.CarThing || context.ip == 'localhost') { if (newData.thumbnail.includes(`${context.ip}:${context.port}`)) return // already parsed as a corrected IP - newData.thumbnail = `http://${context.ip}:${context.port}/proxy/v1?url=${encodeURIComponent(newData.thumbnail)}` + newData.thumbnail = `http://${context.ip}:${context.port}/proxy/v1?url=${encodeURIComponent(preferSmallerArtwork(newData.thumbnail))}` } } else if (newData.thumbnail.startsWith('/')) { const context = useSettingsStore.getState().manifest?.context From 59609133f70de7d032900ae49fe964a98752ff8b Mon Sep 17 00:00:00 2001 From: Edward Rosado Date: Wed, 5 Aug 2026 01:17:44 -0400 Subject: [PATCH 2/6] Show the Bluetooth pairing code on screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the computer initiates pairing, the on-device agent exposes the 6-digit numeric-comparison code at 127.0.0.1:8892/pairing; this overlay polls it and draws the code fullscreen, then flashes the outcome — the original Car Thing pairing experience. On anything that is not a provisioned Car Thing the endpoint does not exist, the first two polls fail, and the overlay permanently stands down. Co-Authored-By: Claude Fable 5 --- src/overlay/PairingOverlay.tsx | 91 ++++++++++++++++++++++++++++++++++ src/overlay/index.tsx | 2 + 2 files changed, 93 insertions(+) create mode 100644 src/overlay/PairingOverlay.tsx diff --git a/src/overlay/PairingOverlay.tsx b/src/overlay/PairingOverlay.tsx new file mode 100644 index 0000000..cdf5037 --- /dev/null +++ b/src/overlay/PairingOverlay.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react' + +/** + * Fullscreen pairing code display, shown when the computer initiates + * Bluetooth pairing with this device — the original Car Thing flow: the + * code appears here, the person confirms it on the computer. + * + * State comes from the on-device pairing agent's local endpoint, which + * exists only on provisioned hardware; anywhere else the fetch fails once + * and the overlay stays dormant. + */ + +const AGENT_URL = 'http://127.0.0.1:8892/pairing' +const POLL_MS = 2000 + +type PairingState = { + active: boolean + passkey: string | null + result: 'ok' | 'failed' | null +} + +export const PairingOverlay = () => { + const [state, setState] = useState(null) + const [dead, setDead] = useState(false) + const [flash, setFlash] = useState<'ok' | 'failed' | null>(null) + + useEffect(() => { + if (dead) return + let cancelled = false + let misses = 0 + + const poll = async (): Promise => { + try { + const res = await fetch(AGENT_URL, { cache: 'no-store' }) + if (!res.ok) throw new Error() + const data = (await res.json()) as PairingState + if (cancelled) return + misses = 0 + setState((prev) => { + // Show a brief success/failure flash when a pairing round ends. + if (prev?.active && !data.active && data.result) setFlash(data.result) + return data + }) + } catch { + if (cancelled) return + // No agent (not a provisioned Car Thing) — stop polling entirely. + if (++misses >= 2) setDead(true) + } + } + + poll() + const id = setInterval(poll, POLL_MS) + return () => { + cancelled = true + clearInterval(id) + } + }, [dead]) + + useEffect(() => { + if (!flash) return + const id = setTimeout(() => setFlash(null), 4000) + return () => clearTimeout(id) + }, [flash]) + + if (flash) { + return ( +
+

+ {flash === 'ok' ? 'Paired!' : 'Pairing failed'} +

+ {flash === 'ok' && ( +

Connecting to your computer…

+ )} +
+ ) + } + + if (!state?.active || !state.passkey) return null + + return ( +
+

Bluetooth pairing request

+

{state.passkey}

+

+ Confirm this code on your computer to finish pairing +

+
+ ) +} + +export default PairingOverlay diff --git a/src/overlay/index.tsx b/src/overlay/index.tsx index aa6bef9..ea669d7 100644 --- a/src/overlay/index.tsx +++ b/src/overlay/index.tsx @@ -8,6 +8,7 @@ import { useActionStore } from '@src/stores/actionStore' import { useEffect, useMemo, useState } from 'react' import YouAreHere from './YouAreHere' import { ServerStatus } from './ConnectionStatus' +import { PairingOverlay } from './PairingOverlay' import ScreenSaverWrapper from './ScreenSaver/ScreenSaverWrapper' interface OverlayProps { @@ -49,6 +50,7 @@ const Overlays: React.FC = ({ children }) => {
{!preferences.onboarding || } + {!isConnected && } From a48d255a6efffccf49e16c9fcb24f762f564413d Mon Sep 17 00:00:00 2001 From: Edward Rosado Date: Wed, 5 Aug 2026 08:01:31 -0400 Subject: [PATCH 3/6] Poll the pairing endpoint fast enough to catch the code window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pairing exchange can resolve in a couple of seconds; at a 2s poll the overlay could miss the whole window (observed on a real pairing — the device agent captured the code but the screen never showed it). Poll every 500ms so the code is on screen the entire time the person is comparing it. Co-Authored-By: Claude Fable 5 --- src/overlay/PairingOverlay.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/overlay/PairingOverlay.tsx b/src/overlay/PairingOverlay.tsx index cdf5037..ab81cf0 100644 --- a/src/overlay/PairingOverlay.tsx +++ b/src/overlay/PairingOverlay.tsx @@ -11,7 +11,9 @@ import { useEffect, useState } from 'react' */ const AGENT_URL = 'http://127.0.0.1:8892/pairing' -const POLL_MS = 2000 +// Poll fast: a pairing exchange can resolve in a couple of seconds, and the +// code must be on screen the whole time the person is comparing it. +const POLL_MS = 500 type PairingState = { active: boolean From c73e2842b9fa03c5bd23ac8f94e85e0a920f38da Mon Sep 17 00:00:00 2001 From: Edward Rosado Date: Wed, 5 Aug 2026 09:16:31 -0400 Subject: [PATCH 4/6] Close CONNECTING sockets when reconnecting the websocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closeExisting only closed a socket in the OPEN state, so a socket stuck in CONNECTING — which is what happens when the server is unreachable at dial time, the norm on a Bluetooth link that comes up ~80s after the device boots — was abandoned without closing. Those half-open sockets lingered, held a tunnel stream, and kept the client from cleanly reconnecting once the link was finally up. Over USB the server is reachable instantly so this never surfaced. Co-Authored-By: Claude Fable 5 --- src/utils/websocketManager.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/utils/websocketManager.ts b/src/utils/websocketManager.ts index 546d120..8dd43f0 100644 --- a/src/utils/websocketManager.ts +++ b/src/utils/websocketManager.ts @@ -52,8 +52,16 @@ export class WebSocketManager { this.socket.onerror = null this.socket.onmessage = null - // Close connection if open - if (this.socket.readyState === WebSocket.OPEN) { + // Close the connection if it is open OR still connecting. A socket left + // in CONNECTING (e.g. the server was unreachable when we dialed — the + // case on a Bluetooth link that comes up ~80s after the device boots) + // must be closed too, or it lingers half-open, holds a tunnel stream, + // and blocks the client from ever cleanly reconnecting once the link is + // up. Only closing OPEN sockets leaked these on every failed attempt. + if ( + this.socket.readyState === WebSocket.OPEN || + this.socket.readyState === WebSocket.CONNECTING + ) { await this.socket.close(4000, 'Closing existing connection') } From 5ffb54152963a60060c21ee28277f08ab85d00fd Mon Sep 17 00:00:00 2001 From: Edward Rosado Date: Wed, 5 Aug 2026 18:48:31 -0400 Subject: [PATCH 5/6] =?UTF-8?q?Actually=20apply=20the=20smaller=20artwork?= =?UTF-8?q?=20=E2=80=94=20the=20old=20path=20never=20ran?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The size preference was only applied in the startsWith('http') branch, but that branch is not the one Spotify artwork takes. The DeskThing server rewrites thumbnails to a relative /proxy/v1?url=... before sending them (songCache.ts:124), so the client lands in the startsWith('/') branch, which only prefixed the host. The optimization was dead code and the device kept fetching 640x640. Caught by screenshotting the device and reading the actual src: ab67616d0000b273 (640px) where 00001e02 (300px) was expected. Two changes: match the bare Spotify id prefix instead of '/image/', because by the time we see the URL the slashes are usually percent- encoded inside the proxy query while the id is not; and apply it in the relative branch too. Verified on hardware over Bluetooth: the element now loads ab67616d00001e02... at 300x276 instead of 640x588, and the art renders. That is ~110KB down to ~36KB per track change on a link that saturates at 155KB/s. Co-Authored-By: Claude Fable 5 --- src/stores/musicStore.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/stores/musicStore.ts b/src/stores/musicStore.ts index 4d20970..a9a6d21 100644 --- a/src/stores/musicStore.ts +++ b/src/stores/musicStore.ts @@ -42,7 +42,11 @@ export interface MusicState { * Non-Spotify URLs are left untouched. */ const preferSmallerArtwork = (url: string): string => - url.replace('/image/ab67616d0000b273', '/image/ab67616d00001e02') + // Match the bare id prefix rather than "/image/": by the time we see the + // URL the server has usually wrapped it in /proxy/v1?url=..., where the + // slashes are percent-encoded but the id is not. Keying off the id alone + // works for both the raw and the wrapped form. + url.replace(/ab67616d0000b273/g, 'ab67616d00001e02') export const useMusicStore = create((set, get) => ({ song: null, @@ -62,8 +66,13 @@ export const useMusicStore = create((set, get) => ({ newData.thumbnail = `http://${context.ip}:${context.port}/proxy/v1?url=${encodeURIComponent(preferSmallerArtwork(newData.thumbnail))}` } } else if (newData.thumbnail.startsWith('/')) { + // The server normally hands us a relative /proxy/v1?url=... — this is + // the path Spotify artwork actually takes, so the size preference has + // to be applied here too or it never runs at all. const context = useSettingsStore.getState().manifest?.context - newData.thumbnail = `http://${context.ip}:${context.port}${newData.thumbnail}` + newData.thumbnail = preferSmallerArtwork( + `http://${context.ip}:${context.port}${newData.thumbnail}` + ) } } From 961de700ee9ced96f0973aef1680bdd25bb241e0 Mon Sep 17 00:00:00 2001 From: Edward Rosado Date: Wed, 5 Aug 2026 18:59:03 -0400 Subject: [PATCH 6/6] Show the current track as soon as we connect, and retry faster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on hardware: 11.9s from page load until the track and artwork were on screen. Two independent causes, both fixed. The client never asked for song state. requestMusicData was only called from inside the handler for an incoming song message, so the only path to data was an unsolicited push — after any (re)connect the screen sat on "Waiting For Track…" until the track happened to change or the server's refresh interval came round. It now requests the current track as soon as the socket reports connected. Reconnect took a flat 15s before even trying. onclose waited 5s before calling reconnect(), which waited a further 10s before dialling, with no fast first attempt — so a link that dropped and returned immediately still cost 15 seconds. Now 250ms to the first attempt and exponential backoff from 500ms to a 10s ceiling, reset on a successful open. Both are general client behaviour, not Bluetooth-specific, but Bluetooth makes them constantly visible: the link legitimately drops when the device is power-cycled, and it has no battery, so every power change is a reconnect. Co-Authored-By: Claude Fable 5 --- src/components/websocketListener.tsx | 11 +++++++++++ src/utils/websocketManager.ts | 27 +++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/components/websocketListener.tsx b/src/components/websocketListener.tsx index 202746c..6128e23 100644 --- a/src/components/websocketListener.tsx +++ b/src/components/websocketListener.tsx @@ -14,8 +14,19 @@ import { handleServerSocket } from '@src/utils/serverWebsocketHandler' export const WebSocketListener = () => { const setSong = useMusicStore((store) => store.setSong) const getSong = useMusicStore((store) => store.requestMusicData) + const isConnected = useWebSocketStore((store) => store.isConnected) const [prevTrackName, setPrevTrackName] = useState('') + // Ask for the current track as soon as we are connected. Without this the + // only path to song data is an unsolicited push, so after any reconnect the + // screen stays blank until the track happens to change or the server's + // refresh interval comes round — seconds of "Waiting For Track…" while the + // connection is actually fine. Most visible over Bluetooth, where the link + // legitimately drops and returns. + useEffect(() => { + if (isConnected) getSong(true) + }, [isConnected, getSong]) + useEffect(() => { const websocketManager = useWebSocketStore.getState() diff --git a/src/utils/websocketManager.ts b/src/utils/websocketManager.ts index 8dd43f0..8ee7e47 100644 --- a/src/utils/websocketManager.ts +++ b/src/utils/websocketManager.ts @@ -8,6 +8,10 @@ import { type SocketEventListener = (msg: DeskThingToDeviceCore & { app?: string }) => void type ConnectionStatus = 'connected' | 'disconnected' | 'reconnecting' + +/** Reconnect backoff: quick first attempt, then ease off to this ceiling. */ +const RECONNECT_BASE_MS = 500 +const RECONNECT_MAX_MS = 10000 type StatusListener = (status: ConnectionStatus) => void /** @@ -19,6 +23,7 @@ export class WebSocketManager { private listeners: SocketEventListener[] = [] private statusListeners: StatusListener[] = [] private reconnecting = false + private reconnectAttempts = 0 private url: string private heartbeatInterval: NodeJS.Timeout | null = null private pongTimeout: NodeJS.Timeout | null = null @@ -105,6 +110,7 @@ export class WebSocketManager { clearTimeout(timeout) console.info(`[${id}] Connected to ${this.url}`) this.reconnecting = false + this.reconnectAttempts = 0 // connected: start the backoff over this.startHeartbeat() this.notifyStatusChange('connected') resolve() @@ -120,6 +126,7 @@ export class WebSocketManager { this.socket.onopen = () => { console.info(`[${id}] Connected to ${this.url}`) this.reconnecting = false + this.reconnectAttempts = 0 // connected: start the backoff over this.startHeartbeat() this.notifyStatusChange('connected') } @@ -133,9 +140,12 @@ export class WebSocketManager { clearTimeout(this.closeTimeoutId) } + // Retry promptly. This used to wait 5s here and then a further 10s in + // reconnect(), so the first attempt was 15s after a drop no matter how + // briefly the link had gone away. this.closeTimeoutId = setTimeout(() => { this.reconnect() - }, 5000) + }, 250) } this.socket.onerror = (error) => { @@ -175,7 +185,7 @@ export class WebSocketManager { this.closeTimeoutId = setTimeout(() => { this.reconnect() - }, 5000) + }, 250) return false } } @@ -185,7 +195,6 @@ export class WebSocketManager { } reconnect() { - Logger.info('Reconnecting in 10s...') if (this.reconnecting) return this.reconnecting = true this.notifyStatusChange('reconnecting') @@ -196,11 +205,21 @@ export class WebSocketManager { clearTimeout(this.reconnectId) } + // Back off instead of waiting a flat 10s. A dropped link usually comes + // back immediately, so the first retry should be quick; only a genuinely + // absent server should push the delay out. + const delay = Math.min( + RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, + RECONNECT_MAX_MS + ) + this.reconnectAttempts += 1 + Logger.info(`Reconnecting in ${delay}ms...`) + this.reconnectId = setTimeout(() => { Logger.info('Conecting...') this.connect() this.reconnecting = false - }, 10000) // Reconnect after 10 seconds + }, delay) } sendMessage(message: DeviceToDeskthingData) {