e3cda06167ab
feat: Rename paired devices locally (v4.9.4)
- time
- 2026-08-11 20:53 UTC · 2 months ago
- machine
- koh2
- agent
- gpt-5.6-sol
- session
- paired device aliases
- parent
- 7c75b22961a9
↓ peach-browser-e3cda061.tar.gzatlas at this save
files changed10 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+10 −0src/background/paired-devices.test.ts+19 −0src/background/paired-devices.ts+17 −0src/popup/views/Settings.tsx+50 −5src/popup/views/contracts.test.tsx+21 −0src/shared/sync-types.ts+2 −0src/shared/types.ts+1 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.9.3",4+ "version": "4.9.4",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "4.9.3",5+ "version": "4.9.4",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.9.3",3+ "version": "4.9.4",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/messages.ts+10 −0
@@ -74,6 +74,7 @@7474 import {7575 isDeviceRevoked,7676 listPairedDevices as listPairedDevicesV3,77+ renamePairedDevice as renamePairedDeviceV3,7778 revokeDeviceById as revokeDeviceByIdV3,7879 upsertPairedDevice as upsertPairedDeviceV3,7980 } from "./paired-devices";@@ -1373,6 +1374,15 @@13731374 case "LIST_PAIRED_DEVICES": {13741375 const devices = await listPairedDevicesV3();13751376 return { ok: true, data: { devices } };1377+ }1378+1379+ case "RENAME_PAIRED_DEVICE": {1380+ const { deviceId, name } = (msg.payload ?? {}) as { deviceId?: string; name?: string };1381+ if (typeof deviceId !== "string" || !deviceId || typeof name !== "string") {1382+ return { ok: false, error: "invalid_payload" };1383+ }1384+ await renamePairedDeviceV3(deviceId, name);1385+ return { ok: true };13761386 }13771387 13781388 case "UNPAIR_DEVICE": {src/background/paired-devices.test.ts+19 −0
@@ -15,6 +15,7 @@1515 mergeDeviceRevocations,1616 repairPairedRevocationOverlaps,1717 removePairedDeviceById,18+ renamePairedDevice,1819 revokeDeviceById,1920 setLastKnownIp,2021 setLastSyncAt,@@ -75,6 +76,24 @@7576 const all = await listPairedDevices();7677 expect(all).toHaveLength(1);7778 expect(all[0].deviceName).toBe("Renamed Pixel");79+ });80+81+ it("stores a local alias without changing the peer-reported name or identity", async () => {82+ await upsertPairedDevice(SAMPLE);83+ await renamePairedDevice(SAMPLE.deviceId, " Travel phone ");84+ const renamed = await findPairedDeviceById(SAMPLE.deviceId);85+ expect(renamed).toMatchObject({86+ deviceName: SAMPLE.deviceName,87+ localAlias: "Travel phone",88+ longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,89+ });90+ });91+92+ it("rejects blank, oversized, and unknown-device aliases", async () => {93+ await upsertPairedDevice(SAMPLE);94+ await expect(renamePairedDevice(SAMPLE.deviceId, " ")).rejects.toThrow(/empty/);95+ await expect(renamePairedDevice(SAMPLE.deviceId, "x".repeat(65))).rejects.toThrow(/64/);96+ await expect(renamePairedDevice("missing", "Phone")).rejects.toThrow(/not_found/);7897 });7998 8099 it("upsert with a new longTermPublicKeyHex on re-pair replaces the old key", async () => {src/background/paired-devices.ts+17 −0
@@ -86,6 +86,17 @@8686 // here would race with the test setup pattern (setSyncRunner AFTER upsert).8787 }8888 89+export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {90+ const alias = name.trim();91+ if (!alias) throw new Error("device name must not be empty");92+ if (alias.length > 64) throw new Error("device name must be 64 chars or fewer");93+ const devices = await listPairedDevices();94+ const index = devices.findIndex((device) => device.deviceId === deviceId);95+ if (index < 0) throw new Error("device_not_found");96+ devices[index] = { ...devices[index], localAlias: alias };97+ await chrome.storage.local.set({ [REGISTRY_KEY]: devices });98+}99+89100 /**90101 * Move every existing pair to the user-selected mesh relay.91102 *@@ -401,6 +412,12 @@401412 const r = x as Record<string, unknown>;402413 if (typeof r.deviceId !== "string" || !r.deviceId) return false;403414 if (typeof r.pairedAt !== "number") return false;415+ if (416+ r.localAlias !== undefined &&417+ (typeof r.localAlias !== "string" || !r.localAlias || r.localAlias.length > 64)418+ ) {419+ return false;420+ }404421 if (405422 r.rendezvousSecretHex !== undefined &&406423 (typeof r.rendezvousSecretHex !== "string" || !/^[0-9a-f]{64}$/.test(r.rendezvousSecretHex))src/popup/views/Settings.tsx+50 −5
@@ -275,6 +275,8 @@275275 // Paired-device management lives here — Settings is the sync hub.276276 const [pairedDevices, setPairedDevices] = createSignal<PairedDevice[]>([]);277277 const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);278+ const [renameDeviceId, setRenameDeviceId] = createSignal<string | null>(null);279+ const [renameDraft, setRenameDraft] = createSignal("");278280 const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");279281 const [syncFlash, setSyncFlash] = createSignal<string | null>(null);280282 // The last sync ATTEMPT, kept on screen rather than flashed for 3 seconds —@@ -338,6 +340,17 @@338340 setConfirmUnpair(null);339341 await loadPairedDevices();340342 }343+ async function renameDevice(deviceId: string) {344+ const name = renameDraft().trim();345+ if (!name) return;346+ const result = (await sendMessage({347+ type: "RENAME_PAIRED_DEVICE",348+ payload: { deviceId, name },349+ })) as { ok: boolean };350+ if (!result.ok) return;351+ setRenameDeviceId(null);352+ await loadPairedDevices();353+ }341354 async function syncNow() {342355 setSyncStatus("syncing");343356 setSyncFlash(null);@@ -1629,7 +1642,22 @@16291642 {(d) => (16301643 <div class="peach-settings-row peach-settings-row--top">16311644 <div class="peach-settings-row-main">1632- <span>{d.deviceName ?? "Unnamed device"}</span>1645+ <Show1646+ when={renameDeviceId() === d.deviceId}1647+ fallback={<span>{d.localAlias ?? d.deviceName ?? "Unnamed device"}</span>}1648+ >1649+ <input1650+ class="peach-settings-input"1651+ aria-label={`Rename ${d.localAlias ?? d.deviceName ?? "device"}`}1652+ maxlength={64}1653+ value={renameDraft()}1654+ onInput={(event) => setRenameDraft(event.currentTarget.value)}1655+ onKeyDown={(event) => {1656+ if (event.key === "Enter") void renameDevice(d.deviceId);1657+ if (event.key === "Escape") setRenameDeviceId(null);1658+ }}1659+ />1660+ </Show>16331661 <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">16341662 {deviceTypeLabel(d.deviceType)} · {syncRelativeTime(d.lastSyncedAt)}{isSyncStale(d) ? " · not seen recently" : ""}16351663 </span>@@ -1637,10 +1665,27 @@16371665 <Show16381666 when={confirmUnpair() === d.deviceId}16391667 fallback={1640- <button1641- class="peach-btn peach-btn--subtle peach-btn--sm"1642- onClick={() => setConfirmUnpair(d.deviceId)}1643- >remove</button>1668+ <div style="display:flex;gap:4px;flex-shrink:0">1669+ <Show1670+ when={renameDeviceId() === d.deviceId}1671+ fallback={1672+ <button1673+ class="peach-btn peach-btn--subtle peach-btn--sm"1674+ onClick={() => {1675+ setRenameDraft(d.localAlias ?? d.deviceName ?? "");1676+ setRenameDeviceId(d.deviceId);1677+ }}1678+ >rename</button>1679+ }1680+ >1681+ <button class="peach-btn peach-btn--subtle peach-btn--sm" onClick={() => setRenameDeviceId(null)}>cancel</button>1682+ <button class="peach-btn peach-btn--subtle peach-btn--sm" disabled={!renameDraft().trim()} onClick={() => void renameDevice(d.deviceId)}>save</button>1683+ </Show>1684+ <button1685+ class="peach-btn peach-btn--subtle peach-btn--sm"1686+ onClick={() => setConfirmUnpair(d.deviceId)}1687+ >remove</button>1688+ </div>16441689 }16451690 >16461691 {/* HOLE 4: revocation stops this device receiving futuresrc/popup/views/contracts.test.tsx+21 −0
@@ -457,6 +457,12 @@457457 paired = paired.filter((d) => d.deviceId !== msg.payload?.deviceId);458458 return { ok: true };459459 }460+ if (msg?.type === "RENAME_PAIRED_DEVICE") {461+ paired = paired.map((d) =>462+ d.deviceId === msg.payload?.deviceId ? { ...d, localAlias: msg.payload.name } : d,463+ );464+ return { ok: true };465+ }460466 return { ok: true, data: null };461467 });462468 @@ -471,6 +477,21 @@471477 expect(container.textContent).toContain("PAIRED DEVICES");472478 expect(container.textContent).toContain("June's Phone");473479 expect(container.textContent).toContain("Android");480+ Array.from(container.querySelectorAll("button"))481+ .find((b) => (b.textContent ?? "").trim() === "rename")!482+ .click();483+ const renameInput = container.querySelector<HTMLInputElement>('input[aria-label="Rename June\'s Phone"]')!;484+ renameInput.value = "Travel phone";485+ renameInput.dispatchEvent(new InputEvent("input", { bubbles: true }));486+ Array.from(container.querySelectorAll("button"))487+ .find((b) => (b.textContent ?? "").trim() === "save")!488+ .click();489+ for (let i = 0; i < 30; i++) await Promise.resolve();490+ expect(sendMessageMock).toHaveBeenCalledWith({491+ type: "RENAME_PAIRED_DEVICE",492+ payload: { deviceId: "phone-1", name: "Travel phone" },493+ });494+ expect(container.textContent).toContain("Travel phone");474495 const syncNowButton = container.querySelector<HTMLButtonElement>(475496 'button[aria-label="Sync this vault now"]',476497 )!;src/shared/sync-types.ts+2 −0
@@ -53,6 +53,8 @@5353 export interface PairedDevice {5454 deviceId: string;5555 deviceName?: string;56+ /** User-chosen label stored only on this device; never sent to peers. */57+ localAlias?: string;5658 deviceType?: DeviceType;5759 longTermPublicKeyHex?: string;5860 /**src/shared/types.ts+1 −0
@@ -299,6 +299,7 @@299299 | "GET_SITE_PASSWORD_POLICY"300300 | "GET_LOCAL_IDENTITY"301301 | "LIST_PAIRED_DEVICES"302+ | "RENAME_PAIRED_DEVICE"302303 | "UNPAIR_DEVICE"303304 | "RENAME_LOCAL_DEVICE"304305 // Relay pairing. START/CONFIRM/CANCEL are popup→bg requests handled in the