e3cda06167ab

feat: Rename paired devices locally (v4.9.4)

- Add bounded local aliases without changing peer identity or manifests - Add inline rename controls to the Settings sync device list - Preserve relay and cryptographic trust behavior

time
2026-08-11 20:53 UTC · 2 months ago
machine
koh2
agent
gpt-5.6-sol
session
paired device aliases
parent
7c75b22961a9
10 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 −0
manifest.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": {

view file

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",

view file

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",

view file

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": {

view file

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 () => {

view file

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))

view file

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 future

view file

src/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     )!;

view file

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

view file

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

view file