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
59 changes: 59 additions & 0 deletions v2/mobile/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,65 @@ 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.

**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.
- 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.

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)

A four-track audit (feature parity vs v1/desktop, connection lifecycle, screen UX, Rust backend)
Expand Down
14 changes: 14 additions & 0 deletions v2/mobile/HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions v2/mobile/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
});
</script>

{#if router.current !== "onboarding" && router.current !== "addtv"}
<ConnectionBanner onSwitch={() => navigate("devices")} />
{/if}
{#if session.identityNote}
<Toast message={session.identityNote} type="info" />
{/if}

{#if router.current === "onboarding"}
<Onboarding intent="launch" onConnected={handleConnected} />
Expand Down
45 changes: 44 additions & 1 deletion v2/mobile/src/lib/identity.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}`;
Expand Down
72 changes: 67 additions & 5 deletions v2/mobile/src/lib/savedDevices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string, unknown>;
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<string, unknown>;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -188,14 +224,22 @@ 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.
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));
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
16 changes: 15 additions & 1 deletion v2/mobile/src/lib/session.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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." };
Expand Down Expand Up @@ -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.";
Comment on lines +203 to +204

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Run the identity check after silent recovery

When an id-less transport is restored through recoverOrMarkLost, that path calls wirelessConnect and refreshDevices directly and never reaches this rememberDevice check. If the old address now belongs to a different model or manufacturer, the heartbeat recovery marks the new TV live while retaining the sole old saved row, allowing cachedDeviceName to display the old TV's name and subsequent actions to target the wrong device; run the same identity check after a successful silent recovery before marking it live.

AGENTS.md reference: AGENTS.md:L45-L48

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 067ac16: recoverOrMarkLost() now calls rememberCurrentDevice() on a successful silent recovery, before setting liveness to "live" -- the same fingerprint check an explicit reconnect gets. On a mismatch it sets identityNote and records the connection as a new row rather than refreshing the old one, so cachedDeviceName becomes ambiguous (not the old name) once that happens. Covered by a new Playwright case in tests/session.test.mjs ("a silent recovery onto a different device does not inherit the old saved row or its name"), verified to fail before the fix.

}

reset(): void {
Expand Down Expand Up @@ -272,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();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Surface mismatches from silent recovery immediately

When the heartbeat or connection-loss handler recovers while the user remains on Dashboard (or another screen), this new call can set identityNote, but no mounted component reacts to that state: Dashboard only consumes it in onMount or its explicit switch flow. Thus the follow-up identity check detects the replacement but leaves the warning invisible until the user happens to leave and revisit Dashboard, allowing actions to continue against the unexpected TV; display the note through a reactive/global notification path as part of silent recovery. This is fresh evidence beyond the earlier missing-check finding because the check now runs, but its result has no live consumer.

AGENTS.md reference: AGENTS.md:L45-L48

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fb42eb4: the display is now global instead of per-screen. App.svelte renders session.identityNote directly in a Toast at the root, reactive to the runes store from wherever it's set, with its own self-clearing 4s $effect -- so it appears the instant rememberCurrentDevice() sets it, regardless of which screen is mounted or whether anything remounts. Dashboard and Devices no longer read or clear identityNote themselves; they only suppress their own "Connected"/"Reconnected" success toast while a note is pending. Covered by two new Playwright cases in tests/session.test.mjs, including one that stays on the Remote screen the entire time (no navigation, no remount) to prove the note still surfaces; both verified to fail before the fix.

this.liveness = live ? "live" : "lost";
if (live) this.recoveryAttempted = false;
return live;
Expand Down
17 changes: 17 additions & 0 deletions v2/mobile/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
Loading
Loading