From a6e53c3280f067ca76fe8fca1f35ac6eda80b342 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Thu, 1 Oct 2026 12:40:41 -0500 Subject: [PATCH 1/3] Mobile: don't let a different id-less device inherit a saved TV's row (#154) A lone id-less saved row at an address was trusted on endpoint alone, so a different id-less TV that later answered there silently took over the row's name. Id-less rows now carry a soft fingerprint (model/manufacturer/codename/ user-set name) from the live device's properties; rememberDevice only refreshes a lone match when the fingerprints don't clearly disagree. A disagreement never proves anything either way -- it only rules a match out, recording the connection as a new row and leaving the old one untouched. A missing field on either side is unknown, not a disagreement, so older rows migrate for free. session.identityNote surfaces an honest "different device" note once wherever a connect flow notices the mismatch. --- v2/mobile/BACKLOG.md | 33 ++++ v2/mobile/HANDOFF.md | 14 ++ v2/mobile/src/lib/identity.ts | 45 +++++- v2/mobile/src/lib/savedDevices.ts | 72 ++++++++- v2/mobile/src/lib/session.svelte.ts | 9 +- v2/mobile/src/lib/types.ts | 17 +++ v2/mobile/src/screens/Dashboard.svelte | 11 +- v2/mobile/src/screens/Devices.svelte | 14 +- v2/mobile/tests/savedDevices.test.mjs | 202 +++++++++++++++++++++++++ 9 files changed, 407 insertions(+), 10 deletions(-) diff --git a/v2/mobile/BACKLOG.md b/v2/mobile/BACKLOG.md index c5c824d4..610a58bb 100644 --- a/v2/mobile/BACKLOG.md +++ b/v2/mobile/BACKLOG.md @@ -112,6 +112,39 @@ row was keyed by its bare endpoint. Fixed in `savedDevices.ts`/`identity.ts`/`di Covered by new tests in `tests/savedDevices.test.mjs` and `tests/discoveryRows.test.mjs`; none has run on a device. +## A different id-less device can no longer inherit a saved TV's row (2026-10-01) + +Fixed GitHub #154, raised by Codex on PR #152 (the #146 fix above) and deferred there as an edge +case needing a product decision. #146 fixed the case where *two or more* id-less saved rows already +shared an address; it did not change the original, more common case: when exactly **one** id-less +row sits at an address, `rememberDevice` still trusted that lone match on endpoint alone, so a +*different* id-less TV that later answers at the same address (DHCP reassignment, a replaced +device) silently inherited the old row's name and `localId`. + +Without a hardware id the app still cannot prove identity, so the fix is a soft signal, never a +promotion to "verified": + +- Every id-less saved row now carries an optional `fingerprint` (`model`, `manufacturer`, + `deviceCodename`, and the TV's own user-set `friendly_name`), captured from the live device's + reported properties (`identity.ts`: `deviceFingerprintOf`). Hardware-identified rows don't carry + one -- the id is already verified, so there's nothing for a fingerprint to add. +- On a lone id-less match, `fingerprintMismatch` compares saved vs. live `model`/`manufacturer`. + A clear disagreement means `rememberDevice` sets the match aside and records the connection as a + new, distinct row instead of refreshing the old one (`savedDevices.ts`). The old row is left + untouched. A missing field on either side (an older row saved before this existed, or a device + that reported nothing) is unknown, never a mismatch -- it does not block the match, so existing + rows migrate for free with no separate migration step. +- Once two id-less rows share an endpoint this way, the existing #146 ambiguity rule + (`savedDeviceIsLiveConnection`) already refuses to call either one "connected" or let a further + reconnect silently refresh either -- no new ambiguity-handling code was needed there. +- `session.svelte.ts` surfaces the mismatch as `session.identityNote` ("A different device is now + at this address."), read and cleared once by whichever screen's connect flow notices it + (Dashboard's `onMount`/`switchDevice`, Devices' `reconnect`/`reconnectCurrent`) instead of silently + going unmentioned. + +Covered by new tests in `tests/savedDevices.test.mjs` (verified to fail before the fix); none has +run on a device. + ## Stability reset (2026-09-04) A four-track audit (feature parity vs v1/desktop, connection lifecycle, screen UX, Rust backend) diff --git a/v2/mobile/HANDOFF.md b/v2/mobile/HANDOFF.md index cb6e16eb..13c018bb 100644 --- a/v2/mobile/HANDOFF.md +++ b/v2/mobile/HANDOFF.md @@ -54,6 +54,20 @@ the list; the discovery row shows a generic name and both saved rows report "sav instead. Covered by new tests in `savedDevices.test.mjs` and `discoveryRows.test.mjs`; unverified on a device. +**A different id-less device can no longer inherit the one saved row at its address (2026-10-01, +GitHub #154).** The above fixed the two-or-more-id-less-rows case; it left the original, more +common one: a lone id-less saved row at an address was still trusted on endpoint alone, so a +*different* id-less TV that later answered there silently took over that row's name. Id-less rows +now carry an optional soft `fingerprint` (model/manufacturer/codename/user-set name) captured from +the live device's properties; `rememberDevice` only refreshes a lone match when the saved and live +fingerprints don't clearly disagree (different model or manufacturer). A disagreement is never +proof either way, so it never upgrades a match to "verified" -- it only ever rules one *out*, +recording the connection as a new row and leaving the old one untouched. A missing field on either +side is unknown, not a disagreement, so older rows migrate for free. `session.identityNote` ("A +different device is now at this address.") surfaces the mismatch once in whichever screen's +connect flow notices it. Covered by new tests in `savedDevices.test.mjs`, verified to fail before +the fix; unverified on a device. + --- ## 1. What this is diff --git a/v2/mobile/src/lib/identity.ts b/v2/mobile/src/lib/identity.ts index 7a9fcaa9..3883563c 100644 --- a/v2/mobile/src/lib/identity.ts +++ b/v2/mobile/src/lib/identity.ts @@ -1,4 +1,4 @@ -import type { SavedDevice } from "./types"; +import type { DeviceFingerprint, DeviceProperties, SavedDevice } from "./types"; /// The one rule for turning a reported or stored hardware id into identity, /// matching desktop's `idKey` in `v2/src/lib/prefs.ts`: trim, and treat empty @@ -56,6 +56,49 @@ export function savedDeviceIsLiveConnection( return idlessAtEndpoint <= 1; } +/// A soft identity hint for an id-less row, taken from the live device's +/// reported properties: model, manufacturer, codename, and the user-set +/// device name (`friendly_name`, set on the TV itself under Device +/// Preferences, distinct from the saved row's own possibly-synthesized +/// `name`). Never proof -- two different TVs of the same model report the +/// same fingerprint -- but it lets a reconnect notice an obvious swap. +/// Empty fields are omitted rather than stored as blanks, so "unknown" never +/// gets compared as if it were a real disagreement. +export function deviceFingerprintOf( + properties: DeviceProperties | null | undefined, +): DeviceFingerprint | undefined { + if (!properties) return undefined; + const fingerprint: DeviceFingerprint = {}; + const model = properties.model?.trim(); + const manufacturer = properties.manufacturer?.trim(); + const deviceCodename = properties.device_codename?.trim(); + const name = properties.friendly_name?.trim(); + if (model) fingerprint.model = model; + if (manufacturer) fingerprint.manufacturer = manufacturer; + if (deviceCodename) fingerprint.deviceCodename = deviceCodename; + if (name) fingerprint.name = name; + return Object.keys(fingerprint).length > 0 ? fingerprint : undefined; +} + +/// Whether a saved id-less row's fingerprint clearly disagrees with the live +/// device's -- a different model or manufacturer reported. A missing field on +/// either side is unknown, not a disagreement, so a row saved before this +/// fingerprint existed (or a TV that didn't report a field) never blocks a +/// match on that account; it only ever rules a match *out*, never confirms +/// one in. +export function fingerprintMismatch( + saved: DeviceFingerprint | undefined, + live: DeviceFingerprint | undefined, +): boolean { + if (!saved || !live) return false; + const disagrees = (a: string | undefined, b: string | undefined) => + a !== undefined && b !== undefined && a !== b; + return ( + disagrees(saved.model, live.model) || + disagrees(saved.manufacturer, live.manufacturer) + ); +} + export function savedDeviceKey(device: SavedDevice): string { const hardwareId = normalizeHardwareId(device.hardwareId); if (hardwareId) return `hardware:${hardwareId}`; diff --git a/v2/mobile/src/lib/savedDevices.ts b/v2/mobile/src/lib/savedDevices.ts index 1ab8bf1e..c50fba61 100644 --- a/v2/mobile/src/lib/savedDevices.ts +++ b/v2/mobile/src/lib/savedDevices.ts @@ -7,16 +7,24 @@ // This never holds secrets: no keys, no PINs. Just enough to re-open the ADB // socket to a TV the phone already trusts. -import type { Device, SavedDevice } from "./types"; +import type { Device, DeviceFingerprint, SavedDevice } from "./types"; import { deviceLabelOf } from "./types"; import { + deviceFingerprintOf, + fingerprintMismatch, normalizeHardwareId, savedDeviceIsLiveConnection, savedDeviceKey, savedDeviceMatchesConnection, } from "./identity"; -export { savedDeviceIsLiveConnection, savedDeviceKey, savedDeviceMatchesConnection }; +export { + deviceFingerprintOf, + fingerprintMismatch, + savedDeviceIsLiveConnection, + savedDeviceKey, + savedDeviceMatchesConnection, +}; const KEY = "atv.savedDevices.v1"; const AUTO_KEY = "atv.autoConnect.v1"; @@ -29,6 +37,29 @@ function randomLocalId(): string { return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; } +/// Sanitize a stored fingerprint back to known string fields only, trimmed, +/// with blanks dropped -- the same "empty means unknown, never a mismatch" +/// rule as a freshly captured one. A row saved before this field existed (or +/// one stripped by corruption) normalizes to `undefined`, which is exactly +/// the "nothing to compare" case `fingerprintMismatch` already treats as no +/// disagreement, so older rows migrate for free: there is no separate +/// migration step to run. +function normalizeFingerprint(value: unknown): DeviceFingerprint | undefined { + if (!value || typeof value !== "object") return undefined; + const raw = value as Record; + const fingerprint: DeviceFingerprint = {}; + const take = (key: keyof DeviceFingerprint) => { + const v = raw[key]; + const trimmed = typeof v === "string" ? v.trim() : ""; + if (trimmed) fingerprint[key] = trimmed; + }; + take("model"); + take("manufacturer"); + take("deviceCodename"); + take("name"); + return Object.keys(fingerprint).length > 0 ? fingerprint : undefined; +} + function normalizeSavedDevice(value: unknown): SavedDevice | null { if (!value || typeof value !== "object") return null; const d = value as Record; @@ -64,11 +95,16 @@ function normalizeSavedDevice(value: unknown): SavedDevice | null { : typeof d.localId === "string" && d.localId.trim() !== "" ? d.localId.trim() : randomLocalId(); + // The fingerprint is only meaningful on an id-less row -- a hardware id is + // already verified identity, so a stale fingerprint from before the TV + // reported one is simply dropped rather than carried forward unused. + const fingerprint = hardwareId ? undefined : normalizeFingerprint(d.fingerprint); return { host: d.host.trim(), connectPort: d.connectPort, ...(hardwareId ? { hardwareId } : {}), ...(localId ? { localId } : {}), + ...(fingerprint ? { fingerprint } : {}), name: typeof d.name === "string" && d.name.trim() !== "" ? d.name.trim() @@ -188,6 +224,14 @@ function sameTv( return row.host === host && row.connectPort === connectPort; } +export interface RememberDeviceResult { + /// True when a lone id-less match was set aside because its saved + /// fingerprint clearly disagreed with the live device's -- a different TV + /// has very likely taken over this row's address. The connection was saved + /// as a new, distinct row instead of silently renaming the old one. + mismatch: boolean; +} + /// Record (or refresh) a successful connection. The hardware serial is the /// durable identity when the TV reports one (ports rotate and DHCP can hand a /// TV's old IP to another device); the host is the fallback. @@ -195,7 +239,7 @@ export function rememberDevice( host: string, connectPort: number, device: Device | null, -): void { +): RememberDeviceResult { const current = read(); const hardwareId = hardwareIdOf(device); const matches = current.filter((d) => sameTv(d, host, connectPort, hardwareId)); @@ -204,11 +248,27 @@ export function rememberDevice( // answered is not knowable from the endpoint alone, so nothing is written: // claiming one would be a guess, and saving a fresh row on every repeat // reconnect would eventually evict a genuine saved TV once MAX is reached. - if (matches.length > 1) return; - const existing = matches[0]; + if (matches.length > 1) return { mismatch: false }; + let existing: SavedDevice | undefined = matches[0]; + const liveFingerprint = deviceFingerprintOf(device?.properties); + // A lone id-less match is only ever an address coincidence, never verified + // identity. If the live TV's soft fingerprint clearly disagrees with the + // saved row's -- a different model or manufacturer -- a different TV has + // taken over this address, and refreshing the row would silently hand it + // the old TV's name. Set the match aside and save this connection as a new + // row instead, same as if nothing had matched. + const mismatch = + existing !== undefined && + !hardwareId && + !existing.hardwareId && + fingerprintMismatch(existing.fingerprint, liveFingerprint); + if (mismatch) existing = undefined; const combinedHardwareId = hardwareId ?? existing?.hardwareId; const reportedFriendlyName = device?.properties?.friendly_name?.trim(); const name = reportedFriendlyName || existing?.name || deviceLabelOf(device); + const fingerprint = combinedHardwareId + ? undefined + : (liveFingerprint ?? existing?.fingerprint); const list = current.filter((d) => d !== existing); list.unshift({ host, @@ -217,9 +277,11 @@ export function rememberDevice( deviceType: device?.device_type ?? existing?.deviceType ?? "unknown", ...(combinedHardwareId ? { hardwareId: combinedHardwareId } : {}), ...(combinedHardwareId ? {} : { localId: existing?.localId ?? randomLocalId() }), + ...(fingerprint ? { fingerprint } : {}), lastUsed: new Date().toISOString(), }); write(list); + return { mismatch }; } export function forgetDevice(host: string, connectPort: number): void { diff --git a/v2/mobile/src/lib/session.svelte.ts b/v2/mobile/src/lib/session.svelte.ts index ed8c7ad5..36e52ed8 100644 --- a/v2/mobile/src/lib/session.svelte.ts +++ b/v2/mobile/src/lib/session.svelte.ts @@ -47,6 +47,11 @@ class Session { /// True while Optimize is applying a plan; tabs lock so the loop can't be /// orphaned by navigating away. applyInProgress = $state(false); + /// Set when the last `rememberCurrentDevice()` found a different device at + /// this row's address than the one saved (see savedDevices.ts). Screens + /// read and clear it once to show an honest "different device" note rather + /// than silently keeping the old TV's name. + identityNote = $state(""); // Shared health cache. `healthLoaded` tracks a load *attempt* (an errored // load still counts as loaded so we render the error, not a spinner forever). @@ -114,6 +119,7 @@ class Session { const generation = this.nextGeneration(); this.recoveryAttempted = false; this.liveness = "connecting"; + this.identityNote = ""; try { const result = await api.wirelessConnect(host, port, generation); if (generation !== this.connectionGeneration) return { ok: false, message: "Connection attempt canceled." }; @@ -194,7 +200,8 @@ class Session { rememberCurrentDevice(): void { if (!this.host) return; - rememberDevice(this.host, this.connectPort, this.connectedDevice); + const result = rememberDevice(this.host, this.connectPort, this.connectedDevice); + if (result.mismatch) this.identityNote = "A different device is now at this address."; } reset(): void { diff --git a/v2/mobile/src/lib/types.ts b/v2/mobile/src/lib/types.ts index 55d978d0..7bc76d03 100644 --- a/v2/mobile/src/lib/types.ts +++ b/v2/mobile/src/lib/types.ts @@ -388,6 +388,18 @@ export interface BackupEntry { /// A previously-paired TV remembered on this phone so the app can offer a /// one-tap reconnect on launch (design §1.0). The RSA pairing key is persisted /// Kotlin-side, so a reconnect is silent — this is just app-side bookkeeping. +/// A soft identity hint captured from a device's reported properties. Never +/// proof of identity -- see `deviceFingerprintOf`/`fingerprintMismatch` in +/// `identity.ts` -- only ever used to rule an id-less match *out*. +export interface DeviceFingerprint { + model?: string; + manufacturer?: string; + deviceCodename?: string; + /// The TV's own user-set device name (`friendly_name`), distinct from this + /// row's possibly-synthesized `name`. + name?: string; +} + export interface SavedDevice { host: string; connectPort: number; @@ -401,6 +413,11 @@ export interface SavedDevice { /// host:port are still distinct rows -- the address alone cannot tell them /// apart, so each gets its own key instead of collapsing into one. localId?: string; + /// Soft fingerprint for an id-less row only (hardware-identified rows don't + /// need it). Lets a reconnect notice an obvious swap -- a different model + /// or manufacturer now answering at this row's address -- without ever + /// upgrading the match to "verified". + fingerprint?: DeviceFingerprint; /// ISO timestamp of the last successful connect, for "last used" copy. lastUsed: string; } diff --git a/v2/mobile/src/screens/Dashboard.svelte b/v2/mobile/src/screens/Dashboard.svelte index 344c4fb9..eed96057 100644 --- a/v2/mobile/src/screens/Dashboard.svelte +++ b/v2/mobile/src/screens/Dashboard.svelte @@ -38,6 +38,10 @@ session.loadHealth(); session.loadBloat(); session.checkLiveness(); + if (session.identityNote) { + showToast(session.identityNote, "info"); + session.identityNote = ""; + } }); const previousTvs = $derived( @@ -79,7 +83,12 @@ const result = await session.connect(device.host, device.connectPort); if (result.ok) { savedTvs = listSavedDevices(); - showToast(`Connected to ${session.deviceLabel}.`, "success"); + if (session.identityNote) { + showToast(session.identityNote, "info"); + session.identityNote = ""; + } else { + showToast(`Connected to ${session.deviceLabel}.`, "success"); + } session.loadHealth(true); session.loadBloat(true); } else { diff --git a/v2/mobile/src/screens/Devices.svelte b/v2/mobile/src/screens/Devices.svelte index d34b249f..6a55d651 100644 --- a/v2/mobile/src/screens/Devices.svelte +++ b/v2/mobile/src/screens/Devices.svelte @@ -97,7 +97,12 @@ try { const r = await session.connect(d.host, d.connectPort); if (r.ok) { - showToast(`Connected to ${session.deviceLabel}.`, "success"); + if (session.identityNote) { + showToast(session.identityNote, "info"); + session.identityNote = ""; + } else { + showToast(`Connected to ${session.deviceLabel}.`, "success"); + } session.loadHealth(true); session.loadBloat(true); refreshSaved(); @@ -120,7 +125,12 @@ connectingToken = "current"; try { const r = await session.reconnect(); - showToast(r.ok ? "Reconnected." : r.message || "Couldn't reconnect.", r.ok ? "success" : "error"); + if (r.ok && session.identityNote) { + showToast(session.identityNote, "info"); + session.identityNote = ""; + } else { + showToast(r.ok ? "Reconnected." : r.message || "Couldn't reconnect.", r.ok ? "success" : "error"); + } } catch (e) { showToast(String(e), "error"); } finally { diff --git a/v2/mobile/tests/savedDevices.test.mjs b/v2/mobile/tests/savedDevices.test.mjs index 3d790fcb..1289b890 100644 --- a/v2/mobile/tests/savedDevices.test.mjs +++ b/v2/mobile/tests/savedDevices.test.mjs @@ -648,3 +648,205 @@ test("auto-dials only when the saved list truly has one entry", () => { false, ); }); + +// #154: a lone id-less match used to be trusted on endpoint alone, so a +// *different* id-less TV that later answers at the same address silently +// inherited the saved row's name instead of being recognized as distinct. + +test("a lone id-less match stores a fingerprint from the live device's properties (#154)", () => { + seed([saved({ hardwareId: undefined, name: "Living room TV" })]); + + savedDevices.rememberDevice( + "192.168.1.10", + 5555, + device(undefined, { + properties: { + friendly_name: null, + serial_number: undefined, + model: "Shield TV Pro", + manufacturer: "NVIDIA", + device_codename: "mdarcy", + }, + }), + ); + + const rows = savedDevices.listSavedDevices(); + assert.equal(rows.length, 1); + assert.deepEqual(rows[0].fingerprint, { + model: "Shield TV Pro", + manufacturer: "NVIDIA", + deviceCodename: "mdarcy", + }); +}); + +test("a different model or manufacturer at a saved id-less row's address is not silently inherited (#154)", () => { + seed([ + saved({ + hardwareId: undefined, + name: "Living room TV", + fingerprint: { model: "Shield TV Pro", manufacturer: "NVIDIA" }, + }), + ]); + + const result = savedDevices.rememberDevice( + "192.168.1.10", + 5555, + device(undefined, { + name: "Reported device", + properties: { + friendly_name: null, + serial_number: undefined, + model: "Chromecast with Google TV", + manufacturer: "Google", + }, + }), + ); + + assert.equal(result.mismatch, true); + const rows = savedDevices.listSavedDevices(); + // The old row survives untouched -- it is not renamed or repurposed. + assert.equal(rows.length, 2); + const old = rows.find((row) => row.name === "Living room TV"); + const fresh = rows.find((row) => row.name !== "Living room TV"); + assert.deepEqual(old.fingerprint, { model: "Shield TV Pro", manufacturer: "NVIDIA" }); + assert.equal(old.hardwareId, undefined); + assert.notEqual(fresh, undefined); + assert.deepEqual(fresh.fingerprint, { model: "Chromecast with Google TV", manufacturer: "Google" }); + + // Neither row can now be told apart as "the" live connection -- the address + // is ambiguous between two distinct saved TVs, so the UI must not claim + // either one is connected or let a reconnect silently refresh either. + assert.equal( + savedDevices.savedDeviceIsLiveConnection(old, rows, "192.168.1.10", 5555, undefined), + false, + ); + assert.equal( + savedDevices.savedDeviceIsLiveConnection(fresh, rows, "192.168.1.10", 5555, undefined), + false, + ); +}); + +test("a manufacturer-only disagreement also counts as a mismatch (#154)", () => { + seed([ + saved({ + hardwareId: undefined, + name: "Living room TV", + fingerprint: { model: "ATV1000", manufacturer: "NVIDIA" }, + }), + ]); + + const result = savedDevices.rememberDevice( + "192.168.1.10", + 5555, + device(undefined, { + properties: { + friendly_name: null, + serial_number: undefined, + model: "ATV1000", + manufacturer: "Some Other Vendor", + }, + }), + ); + + assert.equal(result.mismatch, true); + assert.equal(savedDevices.listSavedDevices().length, 2); +}); + +test("an empty saved fingerprint (an older row, or a device that reported nothing) never blocks a match (#154)", () => { + seed([saved({ hardwareId: undefined, name: "Living room TV" })]); + + // No fingerprint stored yet (row predates this feature) and the live + // device reports no model/manufacturer either -- unknown vs unknown must + // not be treated as a disagreement. + const result = savedDevices.rememberDevice( + "192.168.1.10", + 5555, + device(undefined, { + name: "Generic report", + properties: { friendly_name: null, serial_number: undefined }, + }), + ); + + assert.equal(result.mismatch, false); + const rows = savedDevices.listSavedDevices(); + assert.equal(rows.length, 1); + assert.equal(rows[0].name, "Living room TV"); +}); + +test("a matching fingerprint still refreshes the row normally, and is never treated as proof (#154)", () => { + seed([ + saved({ + hardwareId: undefined, + name: "Living room TV", + fingerprint: { model: "Shield TV Pro", manufacturer: "NVIDIA" }, + }), + ]); + + const result = savedDevices.rememberDevice( + "192.168.1.10", + 5555, + device(undefined, { + name: "Generic report", + properties: { + friendly_name: "Renamed on the TV", + serial_number: undefined, + model: "Shield TV Pro", + manufacturer: "NVIDIA", + }, + }), + ); + + assert.equal(result.mismatch, false); + const rows = savedDevices.listSavedDevices(); + assert.equal(rows.length, 1); + // The row refreshed in place (still id-less, no hardwareId appeared out of + // a mere model/manufacturer agreement) and picked up the new friendly name. + assert.equal(rows[0].hardwareId, undefined); + assert.equal(rows[0].name, "Renamed on the TV"); +}); + +test("a hardware-identified reconnect is never second-guessed by fingerprint (#154)", () => { + seed([saved({ fingerprint: undefined })]); + + const result = savedDevices.rememberDevice( + "192.168.1.10", + 5555, + device("shield-a", { + properties: { + friendly_name: null, + serial_number: "shield-a", + model: "Completely Different Model", + manufacturer: "Completely Different Vendor", + }, + }), + ); + + assert.equal(result.mismatch, false); + const rows = savedDevices.listSavedDevices(); + assert.equal(rows.length, 1); + assert.equal(rows[0].hardwareId, "shield-a"); + // Hardware-identified rows don't carry a fingerprint -- the id is already + // verified identity, so there is nothing for it to add. + assert.equal(rows[0].fingerprint, undefined); +}); + +test("a stored fingerprint survives a read/normalize round trip and sanitizes stray fields", () => { + seed([ + saved({ + hardwareId: undefined, + fingerprint: { + model: " Shield TV Pro ", + manufacturer: "NVIDIA", + deviceCodename: "", + extra: "should be dropped", + }, + }), + ]); + + const rows = savedDevices.listSavedDevices(); + assert.equal(rows.length, 1); + assert.deepEqual(rows[0].fingerprint, { + model: "Shield TV Pro", + manufacturer: "NVIDIA", + }); +}); From 067ac16a6afcaf2f6cedebfce5fc521d19614e5b Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Thu, 1 Oct 2026 12:58:00 -0500 Subject: [PATCH 2/3] Mobile: run the identity check on silent recovery, don't lose the note on navigate Codex review on PR #155 (#154) found two gaps: - recoverOrMarkLost() redialed the same host:port on a failed liveness probe without ever running the fingerprint check, so a different id-less TV that took over mid-session during a silent recovery was trusted unconditionally. It now calls rememberCurrentDevice() on a successful recovery, before marking the device live. - Devices' reconnect(d) cleared session.identityNote and showed its own toast, then immediately navigated to Dashboard -- the toast never survived to be seen. It now leaves the note for Dashboard's onMount to show instead. Both covered by new Playwright cases in tests/session.test.mjs, verified to fail before each fix. --- v2/mobile/BACKLOG.md | 14 ++++ v2/mobile/src/lib/session.svelte.ts | 7 ++ v2/mobile/src/screens/Devices.svelte | 9 ++- v2/mobile/tests/session.test.mjs | 115 +++++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 4 deletions(-) diff --git a/v2/mobile/BACKLOG.md b/v2/mobile/BACKLOG.md index 610a58bb..b6b278bc 100644 --- a/v2/mobile/BACKLOG.md +++ b/v2/mobile/BACKLOG.md @@ -145,6 +145,20 @@ promotion to "verified": Covered by new tests in `tests/savedDevices.test.mjs` (verified to fail before the fix); none has run on a device. +**Codex review follow-up on PR #155 (same day):** two valid findings, both fixed: + +- `recoverOrMarkLost()` (the silent one-shot reconnect `checkLiveness()` triggers when the cheap + liveness probe fails) redialed the same host:port directly and never ran the identity check + above, so a different id-less TV that took over mid-session during a silent recovery was + trusted without comparison. It now calls `rememberCurrentDevice()` on a successful recovery, + before flipping liveness to `"live"` -- the same check an explicit reconnect gets. Covered by a + new Playwright case in `tests/session.test.mjs`, verified to fail before the fix. +- Devices' `reconnect(d)` cleared `session.identityNote` and showed its toast locally, then + immediately navigated to Dashboard -- the toast never had a chance to be seen. It now leaves the + note unread when connecting a saved row so Dashboard's own `onMount` can show it instead (that + path already existed and is exercised, so there was nothing else to change there). Covered by a + new Playwright case in `tests/session.test.mjs`, verified to fail before the fix. + ## Stability reset (2026-09-04) A four-track audit (feature parity vs v1/desktop, connection lifecycle, screen UX, Rust backend) diff --git a/v2/mobile/src/lib/session.svelte.ts b/v2/mobile/src/lib/session.svelte.ts index 36e52ed8..b0b86620 100644 --- a/v2/mobile/src/lib/session.svelte.ts +++ b/v2/mobile/src/lib/session.svelte.ts @@ -279,6 +279,13 @@ class Session { await this.refreshDevices(generation); if (generation !== this.connectionGeneration) return false; const live = this.connectedDevice != null; + // A silent recovery redials the same host:port, exactly like an + // explicit reconnect -- so it needs the same identity check before + // being trusted as live. Without it, a different id-less TV that + // took over this address mid-session would be silently treated as + // "the" saved TV reconnecting, the same bug #154 fixed for an + // explicit connect. + if (live) this.rememberCurrentDevice(); this.liveness = live ? "live" : "lost"; if (live) this.recoveryAttempted = false; return live; diff --git a/v2/mobile/src/screens/Devices.svelte b/v2/mobile/src/screens/Devices.svelte index 6a55d651..640bd75d 100644 --- a/v2/mobile/src/screens/Devices.svelte +++ b/v2/mobile/src/screens/Devices.svelte @@ -97,10 +97,11 @@ try { const r = await session.connect(d.host, d.connectPort); if (r.ok) { - if (session.identityNote) { - showToast(session.identityNote, "info"); - session.identityNote = ""; - } else { + // This navigates to Dashboard immediately below, so a toast shown + // here never survives to be seen. Leave `session.identityNote` + // unread for Dashboard's own `onMount` to show -- it must not be + // cleared here only to be silently lost (#154 follow-up). + if (!session.identityNote) { showToast(`Connected to ${session.deviceLabel}.`, "success"); } session.loadHealth(true); diff --git a/v2/mobile/tests/session.test.mjs b/v2/mobile/tests/session.test.mjs index db3a9e15..3c4ca6c7 100644 --- a/v2/mobile/tests/session.test.mjs +++ b/v2/mobile/tests/session.test.mjs @@ -95,8 +95,10 @@ async function createPage(t, options = {}) { await page.evaluate(async () => { const { session } = await import("/src/lib/session.svelte.ts"); const { router } = await import("/src/lib/router.svelte.ts"); + const savedDevices = await import("/src/lib/savedDevices.ts"); window.session = session; window.router = router; + window.savedDevices = savedDevices; session.entitlement = "pro"; }); return page; @@ -224,6 +226,119 @@ test("one failed recovery stays lost until an explicit retry", async (t) => { assert.deepEqual(result, { automatic: 1, lost: "lost", retried: true }); }); +test("a silent recovery onto a different device does not inherit the old saved row or its name (#154 follow-up)", async (t) => { + const page = await createPage(t, { + savedDevices: [{ ...savedA, fingerprint: { model: "Shield TV Pro", manufacturer: "NVIDIA" } }], + activeHost: "A", + }); + await page.evaluate(async () => { + await window.session.connect("A", 5555); + window.router.reset("dashboard"); + }); + const result = await page.evaluate(async () => { + window.calls = []; + window.handlers.wireless_status = () => ({ connected: false }); + window.handlers.wireless_connect = () => ({ ok: true }); + // A different, id-less TV now answers at the same address: same serial + // string (adb has no hardware id for it either), but a disagreeing + // model/manufacturer and its own reported name. + window.handlers.list_devices = () => [{ + id: 2, + serial: "A:5555", + name: "Reported device", + model: "Chromecast with Google TV", + status: "device", + connection: "network", + device_type: "google_tv", + properties: { + friendly_name: null, + brand: "google", + model: "Chromecast with Google TV", + device_codename: "", + manufacturer: "Google", + android_release: "", + sdk_level: "", + build_id: "", + board_platform: "", + }, + }]; + // The cheap probe finds the TV unreachable and triggers one silent + // recovery attempt -- the path under test. + await window.session.checkLiveness(); + const rows = window.savedDevices.listSavedDevices(); + return { + liveness: window.session.liveness, + identityNote: window.session.identityNote, + deviceLabel: window.session.deviceLabel, + rowCount: rows.length, + oldRowIntact: rows.some( + (r) => r.name === "Living Room" && r.fingerprint?.model === "Shield TV Pro", + ), + }; + }); + assert.equal(result.liveness, "live"); + assert.match(result.identityNote, /different device/i); + assert.notEqual(result.deviceLabel, "Living Room"); + assert.equal(result.rowCount, 2); + assert.equal(result.oldRowIntact, true); +}); + +test("the Devices screen hands its different-device note on to Dashboard instead of losing it on navigate (#154 follow-up)", async (t) => { + const page = await createPage(t, { + savedDevices: [ + { ...savedA, hardwareId: "shield-a" }, + { ...savedB, fingerprint: { model: "Shield TV Pro", manufacturer: "NVIDIA" } }, + ], + activeHost: "A", + }); + await page.evaluate(() => { + // A reports its saved hardware id, so it never shows up in "Other TVs" + // once connected. Connecting to the saved Bedroom row (B) instead lands + // on a different, id-less TV that disagrees with its stored fingerprint. + window.handlers.list_devices = () => { + if (window.activeHost === "A") { + return [{ + ...window.device("A"), + properties: { friendly_name: null, serial_number: "shield-a" }, + }]; + } + if (window.activeHost !== "B") return [window.device(window.activeHost)]; + return [{ + id: 3, + serial: "B:5555", + name: "Reported device", + model: "Chromecast with Google TV", + status: "device", + connection: "network", + device_type: "google_tv", + properties: { + friendly_name: null, + brand: "google", + model: "Chromecast with Google TV", + device_codename: "", + manufacturer: "Google", + android_release: "", + sdk_level: "", + build_id: "", + board_platform: "", + }, + }]; + }; + }); + await page.evaluate(async () => { + await window.session.connect("A", 5555); + window.router.reset("dashboard"); + window.router.navigate("devices"); + }); + await page.getByRole("button", { name: "Connect", exact: true }).click(); + await page.waitForFunction(() => window.router.current === "dashboard"); + const toastText = await page.evaluate(() => document.querySelector(".toast")?.textContent ?? ""); + assert.match(toastText, /different device/i); + // Dashboard's own onMount consumed and cleared it -- it is shown exactly + // once, not left to reappear on a later visit. + assert.equal(await page.evaluate(() => window.session.identityNote), ""); +}); + test("canceled connect cannot update the session when its old reply arrives", async (t) => { const page = await open(t); const result = await page.evaluate(async () => { From fb42eb48a1b991b16e254e5a4db71cf86a5dd621 Mon Sep 17 00:00:00 2001 From: Bryan Roscoe Date: Thu, 1 Oct 2026 13:13:09 -0500 Subject: [PATCH 3/3] Mobile: show the different-device note globally, not just on Dashboard mount Codex review found the round-2 fix still only displayed session.identityNote from Dashboard's onMount, so a silent recovery landing while the user is already on Dashboard (or any other screen) set the note but nothing showed it until a later remount. App.svelte now renders the note directly at the root in a reactive Toast with its own self-clearing timer, so it appears the moment it's set regardless of which screen is open. Dashboard and Devices no longer read or clear identityNote themselves; they just suppress their own success toast while a note is pending. Covered by new Playwright cases in tests/session.test.mjs, including one that never navigates, verified to fail before the fix. --- v2/mobile/BACKLOG.md | 26 ++++++++---- v2/mobile/src/App.svelte | 18 +++++++++ v2/mobile/src/screens/Dashboard.svelte | 11 ++---- v2/mobile/src/screens/Devices.svelte | 18 ++++----- v2/mobile/tests/session.test.mjs | 55 ++++++++++++++++++++++++-- 5 files changed, 101 insertions(+), 27 deletions(-) diff --git a/v2/mobile/BACKLOG.md b/v2/mobile/BACKLOG.md index b6b278bc..7906d46e 100644 --- a/v2/mobile/BACKLOG.md +++ b/v2/mobile/BACKLOG.md @@ -145,19 +145,31 @@ promotion to "verified": Covered by new tests in `tests/savedDevices.test.mjs` (verified to fail before the fix); none has run on a device. -**Codex review follow-up on PR #155 (same day):** two valid findings, both fixed: +**Codex review follow-up on PR #155 (same day, two rounds):** three valid findings, all fixed. + +Round 1: - `recoverOrMarkLost()` (the silent one-shot reconnect `checkLiveness()` triggers when the cheap liveness probe fails) redialed the same host:port directly and never ran the identity check above, so a different id-less TV that took over mid-session during a silent recovery was trusted without comparison. It now calls `rememberCurrentDevice()` on a successful recovery, - before flipping liveness to `"live"` -- the same check an explicit reconnect gets. Covered by a - new Playwright case in `tests/session.test.mjs`, verified to fail before the fix. + before flipping liveness to `"live"` -- the same check an explicit reconnect gets. - Devices' `reconnect(d)` cleared `session.identityNote` and showed its toast locally, then - immediately navigated to Dashboard -- the toast never had a chance to be seen. It now leaves the - note unread when connecting a saved row so Dashboard's own `onMount` can show it instead (that - path already existed and is exercised, so there was nothing else to change there). Covered by a - new Playwright case in `tests/session.test.mjs`, verified to fail before the fix. + immediately navigated to Dashboard -- the toast never had a chance to be seen. + +Round 2 (a sharper version of the same finding): the round-1 fix for the second point routed the +note through Dashboard's `onMount`, but a silent recovery can land while the user is already +sitting on Dashboard (or any other screen) with no mount event to trigger it -- the note would set +but nothing displayed it until the user happened to leave and come back. Fixed by making the +display global instead of per-screen: `App.svelte` now renders `session.identityNote` directly in +a `Toast` at the root (reactive to the runes store from wherever it's set, with its own 4-second +self-clearing `$effect`), and Dashboard/Devices no longer read or clear `identityNote` themselves +-- they only suppress their own misleading "Connected"/"Reconnected" success toast when a note is +pending, matching the existing `savedHostHasMultipleIdentities` ambiguity-messaging pattern. + +Covered by Playwright cases in `tests/session.test.mjs`, each verified to fail before its fix, +including one that stays on a non-Dashboard screen throughout to prove the note still surfaces +without any navigation or remount. ## Stability reset (2026-09-04) diff --git a/v2/mobile/src/App.svelte b/v2/mobile/src/App.svelte index a5853e88..6d05dfff 100644 --- a/v2/mobile/src/App.svelte +++ b/v2/mobile/src/App.svelte @@ -17,6 +17,7 @@ import Files from "./screens/Files.svelte"; import Backups from "./screens/Backups.svelte"; import ConnectionBanner from "./components/ConnectionBanner.svelte"; + import Toast from "./components/Toast.svelte"; function navigate(screen: Screen) { router.navigate(screen); @@ -56,11 +57,28 @@ document.removeEventListener("visibilitychange", probe); }; }); + + // Global and reactive on purpose: a silent recovery (the heartbeat above, + // or a connection-lost redial) can set `session.identityNote` while the + // user is already sitting on any screen, with no mount event to hang a + // per-screen consumer off of. Watching it here means the note is shown the + // moment it is set no matter what screen is open, instead of waiting for + // the user to happen to leave and revisit Dashboard (#154 follow-up). + $effect(() => { + if (!session.identityNote) return; + const timer = setTimeout(() => { + session.identityNote = ""; + }, 4000); + return () => clearTimeout(timer); + }); {#if router.current !== "onboarding" && router.current !== "addtv"} navigate("devices")} /> {/if} +{#if session.identityNote} + +{/if} {#if router.current === "onboarding"} diff --git a/v2/mobile/src/screens/Dashboard.svelte b/v2/mobile/src/screens/Dashboard.svelte index eed96057..17d4beb5 100644 --- a/v2/mobile/src/screens/Dashboard.svelte +++ b/v2/mobile/src/screens/Dashboard.svelte @@ -38,10 +38,6 @@ session.loadHealth(); session.loadBloat(); session.checkLiveness(); - if (session.identityNote) { - showToast(session.identityNote, "info"); - session.identityNote = ""; - } }); const previousTvs = $derived( @@ -83,10 +79,9 @@ const result = await session.connect(device.host, device.connectPort); if (result.ok) { savedTvs = listSavedDevices(); - if (session.identityNote) { - showToast(session.identityNote, "info"); - session.identityNote = ""; - } else { + // The global identity-note banner in App.svelte owns showing a + // mismatch; suppress only the now-misleading success toast here. + if (!session.identityNote) { showToast(`Connected to ${session.deviceLabel}.`, "success"); } session.loadHealth(true); diff --git a/v2/mobile/src/screens/Devices.svelte b/v2/mobile/src/screens/Devices.svelte index 640bd75d..70c4caf9 100644 --- a/v2/mobile/src/screens/Devices.svelte +++ b/v2/mobile/src/screens/Devices.svelte @@ -97,10 +97,9 @@ try { const r = await session.connect(d.host, d.connectPort); if (r.ok) { - // This navigates to Dashboard immediately below, so a toast shown - // here never survives to be seen. Leave `session.identityNote` - // unread for Dashboard's own `onMount` to show -- it must not be - // cleared here only to be silently lost (#154 follow-up). + // The global identity-note banner in App.svelte owns showing a + // mismatch (it survives this navigate; a toast shown here would not + // -- #154 follow-up). Suppress only the now-misleading success toast. if (!session.identityNote) { showToast(`Connected to ${session.deviceLabel}.`, "success"); } @@ -126,11 +125,12 @@ connectingToken = "current"; try { const r = await session.reconnect(); - if (r.ok && session.identityNote) { - showToast(session.identityNote, "info"); - session.identityNote = ""; - } else { - showToast(r.ok ? "Reconnected." : r.message || "Couldn't reconnect.", r.ok ? "success" : "error"); + if (!r.ok) { + showToast(r.message || "Couldn't reconnect.", "error"); + } else if (!session.identityNote) { + // The global identity-note banner in App.svelte owns showing a + // mismatch; suppress only the now-misleading success toast here. + showToast("Reconnected.", "success"); } } catch (e) { showToast(String(e), "error"); diff --git a/v2/mobile/tests/session.test.mjs b/v2/mobile/tests/session.test.mjs index 3c4ca6c7..162953e0 100644 --- a/v2/mobile/tests/session.test.mjs +++ b/v2/mobile/tests/session.test.mjs @@ -283,6 +283,53 @@ test("a silent recovery onto a different device does not inherit the old saved r assert.equal(result.oldRowIntact, true); }); +test("a silent recovery's different-device note shows immediately on whatever screen is already open, and clears itself (#154 follow-up)", async (t) => { + // The user never leaves Remote, so no screen's onMount ever runs again -- + // only a global, reactive consumer can show this. + const page = await open(t, "remote"); + // Give the already-connected row A a fingerprint to disagree with. Seeded + // directly in storage since this TV connected before the test installed a + // custom list_devices handler. + await page.evaluate(() => { + const rows = JSON.parse(localStorage.getItem("atv.savedDevices.v1")); + for (const row of rows) { + if (row.host === "A") row.fingerprint = { model: "Shield TV Pro", manufacturer: "NVIDIA" }; + } + localStorage.setItem("atv.savedDevices.v1", JSON.stringify(rows)); + }); + await page.evaluate(() => { + window.handlers.wireless_status = () => ({ connected: false }); + window.handlers.wireless_connect = () => ({ ok: true }); + window.handlers.list_devices = () => [{ + id: 2, + serial: "A:5555", + name: "Reported device", + model: "Chromecast with Google TV", + status: "device", + connection: "network", + device_type: "google_tv", + properties: { + friendly_name: null, + brand: "google", + model: "Chromecast with Google TV", + device_codename: "", + manufacturer: "Google", + android_release: "", + sdk_level: "", + build_id: "", + board_platform: "", + }, + }]; + void window.session.checkLiveness(); + }); + await page.waitForFunction(() => !!document.querySelector(".toast")); + assert.equal(await page.evaluate(() => window.router.current), "remote"); + const toastText = await page.evaluate(() => document.querySelector(".toast")?.textContent ?? ""); + assert.match(toastText, /different device/i); + await page.waitForFunction(() => window.session.identityNote === "", { timeout: 6000 }); + assert.equal(await page.evaluate(() => !!document.querySelector(".toast")), false); +}); + test("the Devices screen hands its different-device note on to Dashboard instead of losing it on navigate (#154 follow-up)", async (t) => { const page = await createPage(t, { savedDevices: [ @@ -332,11 +379,13 @@ test("the Devices screen hands its different-device note on to Dashboard instead }); await page.getByRole("button", { name: "Connect", exact: true }).click(); await page.waitForFunction(() => window.router.current === "dashboard"); + // The global identity-note banner (App.svelte) is what must still be + // showing here -- it survived the navigate that unmounted Devices and its + // own local toast. const toastText = await page.evaluate(() => document.querySelector(".toast")?.textContent ?? ""); assert.match(toastText, /different device/i); - // Dashboard's own onMount consumed and cleared it -- it is shown exactly - // once, not left to reappear on a later visit. - assert.equal(await page.evaluate(() => window.session.identityNote), ""); + // It clears itself on its own timeout rather than lingering forever. + await page.waitForFunction(() => window.session.identityNote === "", { timeout: 6000 }); }); test("canceled connect cannot update the session when its old reply arrives", async (t) => {