8de15d6aae62

strip Capacitor native branches from popup, background, and shared code

time
2026-08-29 04:54 UTC · 1 month ago
machine
koh2
agent
Hy4-preview
session
repo cleanup: mobile removal
parent
a2d17dc903ac
25 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/codex-handlers.test.ts+0 −10src/background/codex-handlers.ts+0 −47src/background/device-identity.ts+3 −19src/background/messages.ts+0 −116src/background/vault.ts+7 −12src/content/passkeys-firefox-relay.test.ts+0 −1src/popup/App.tsx+1 −273src/popup/components/NoteToolbar.tsx+12 −43src/popup/index.tsx+0 −9src/popup/styles/vars.css+2 −2src/popup/views/CodexGenerateView.tsx+6 −21src/popup/views/Entry.tsx+2 −2src/popup/views/Lock.tsx+0 −92src/popup/views/NotesEditor.tsx+1 −44src/popup/views/RecoveryWordDisplay.tsx+6 −36src/popup/views/Settings.tsx+0 −85src/popup/views/Vault.tsx+11 −29src/shared/native.ts+0 −23src/shared/noise-js.ts+1 −1src/shared/types.ts+0 −4tests/a11y-totp-vault.spec.ts+0 −11tests/lock-screen.spec.ts+0 −10
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.10.28",4+  "version": "4.10.29",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.10.28",5+  "version": "4.10.29",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.10.28",3+  "version": "4.10.29",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/codex-handlers.test.ts+0 −10
@@ -108,10 +108,6 @@108108       isUnlocked: () => false,109109       getEntries: () => [],110110     }));111-    vi.doMock("../shared/native", () => ({112-      isNativePlatform: () => false,113-      nativePlugin: () => null,114-    }));115111 116112     const { handleRestoreFromCodex } = await import("./codex-handlers");117113     const result = await handleRestoreFromCodex({@@ -133,7 +129,6 @@133129     vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));134130     const unlockFromCodex = vi.fn();135131     vi.doMock("./vault", () => ({ unlockFromCodex, isUnlocked: () => false, getEntries: () => [] }));136-    vi.doMock("../shared/native", () => ({ isNativePlatform: () => false, nativePlugin: () => null }));137132 138133     const { handleRestoreFromCodex } = await import("./codex-handlers");139134     const result = await handleRestoreFromCodex({@@ -152,7 +147,6 @@152147     vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));153148     const unlockFromCodex = vi.fn();154149     vi.doMock("./vault", () => ({ unlockFromCodex, isUnlocked: () => false, getEntries: () => [] }));155-    vi.doMock("../shared/native", () => ({ isNativePlatform: () => false, nativePlugin: () => null }));156150 157151     const { handleRestoreFromCodex } = await import("./codex-handlers");158152     const base = {@@ -181,10 +175,6 @@181175       unlockFromCodex,182176       isUnlocked: () => false,183177       getEntries: () => [],184-    }));185-    vi.doMock("../shared/native", () => ({186-      isNativePlatform: () => false,187-      nativePlugin: () => null,188178     }));189179 190180     const payload = {

view file

src/background/codex-handlers.ts+0 −47
@@ -16,8 +16,6 @@1616 1717 import { isProUser } from "./license";1818 import * as vault from "./vault";19-import { isNativePlatform, nativePlugin } from "../shared/native";20-import type { VaultEntry } from "../shared/types";2119 import {2220   type CodexRestorePayload,2321   type CodexVaultSnapshot,@@ -79,41 +77,6 @@7977   return new TextDecoder().decode(bytes);8078 }8179 82-// ── Mobile autofill bridge — duplicated here intentionally ─────────────────83-// messages.ts has its own copy; we don't import from messages.ts because84-// that would create a cycle. The plugin shape is small and stable.85-86-async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {87-  const plugin = nativePlugin<{88-    setEntries?: (args: {89-      entries: Array<{90-        id: string;91-        title: string;92-        username: string;93-        email: string;94-        password: string;95-        url: string;96-        totp: string;97-        additionalUrls: string[];98-      }>;99-    }) => Promise<void>;100-  }>("InMemoryVaultPlugin");101-  if (!plugin?.setEntries) return;102-  await plugin.setEntries({103-    entries: entries.map((e) => ({104-      id: e.id,105-      title: e.title ?? "",106-      username: e.username ?? "",107-      email: (e as VaultEntry & { email?: string }).email ?? "",108-      password: e.password ?? "",109-      url: e.url ?? "",110-      totp: (e as VaultEntry & { totp?: string }).totp ?? "",111-      additionalUrls:112-        (e as VaultEntry & { additionalUrls?: string[] }).additionalUrls ?? [],113-    })),114-  });115-}116-11780 // ── Handlers ────────────────────────────────────────────────────────────────11881 11982 /**@@ -289,16 +252,6 @@289252     [STORAGE_KEY_VERIFY_CODE]: payload.verifyCode,290253     [STORAGE_KEY_LAST_RESTORED]: Date.now(),291254   });292-293-  // Mirror the UNLOCK handler's mobile-bridge step so the Android autofill294-  // service sees the newly-restored entries without requiring a re-lock.295-  if (isNativePlatform()) {296-    try {297-      await publishInMemoryVault(vault.getEntries());298-    } catch {299-      // Non-fatal — autofill will catch up on the next unlock.300-    }301-  }302255 303256   return {304257     ok: true,

view file

src/background/device-identity.ts+3 −19
@@ -89,7 +89,7 @@8989   return {9090     deviceId,9191     deviceName,92-    deviceType: extensionDeviceType(),92+    deviceType: "extension",9393     longTermPublicKeyHex: pubkeyToHex(kp.publicKey),9494     signingPublicKeyHex: pubkeyToHex(signing.publicKeyRaw),9595   };@@ -115,25 +115,9 @@115115   return "Browser";116116 }117117 118-function extensionDeviceType(): DeviceType {119-  // The same module ships in both the Chrome extension and the Capacitor120-  // mobile build. On Capacitor, Peach acts as an iOS / Android sync host121-  // rather than an extension; the `isNativePlatform` check toggles the tag.122-  try {123-    const native = (globalThis as unknown as { Capacitor?: { isNativePlatform?: () => boolean } }).Capacitor;124-    if (native?.isNativePlatform?.()) {125-      const platform = (globalThis as unknown as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();126-      if (platform === "ios") return "ios";127-      return "android";128-    }129-  } catch { /* not on Capacitor */ }130-  return "extension";131-}132-133118 function generateUuidV4(): string {134-  // crypto.randomUUID is available in MV3 service workers, popup pages,135-  // and modern Capacitor WebView. Falls back to a manual assembly only if136-  // a runtime is missing it (effectively unreachable for our targets).119+  // crypto.randomUUID is available in MV3 service workers and popup pages.120+  // Falls back to manual assembly only if a runtime is missing it.137121   if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {138122     return crypto.randomUUID();139123   }

view file

src/background/messages.ts+0 −116
@@ -63,7 +63,6 @@6363 import { isPasskeyOnly } from "../shared/types";6464 import { generateTOTP } from "../crypto/totp";6565 import { isValidRpId } from "../shared/passkey-utils";66-import { isNativePlatform, nativePlugin } from "../shared/native";6766 import { predictBatch as mlPredictBatch } from "./ml";6867 import type { MLFieldFeatures } from "../shared/ml";6968 import {@@ -100,11 +99,8 @@10099 } from "./sync-settings";101100 import {102101   getSyncState,103-  hydratePersistedSyncState,104-  setSyncRunner,105102   triggerSyncIfReachable,106103 } from "./sync-trigger";107-import { productionSyncRunner } from "./sync-runner";108104 109105 // Compile-time E2E flag, folded to a literal by Vite's `define` (false in110106 // every production build — verified in the bundled output). Mirrors the@@ -113,8 +109,6 @@113109 declare const __PEACH_E2E_PASSKEYS__: boolean;114110 const PASSKEY_E2E_PRO_BYPASS = __PEACH_E2E_PASSKEYS__ === true;115111 116-let nativeSyncRunnerBootstrapped = false;117-118112 function unhexBytes(hex: string): Uint8Array {119113   if (hex.length % 2 !== 0) throw new Error("invalid hex");120114   const out = new Uint8Array(hex.length / 2);@@ -209,41 +203,8 @@209203       return true;210204     }211205   );212-213-  // Android: the chrome shim queues sendMessage calls until this handler is214-  // registered. Without this hookup, every message hangs on Capacitor builds.215-  // eslint-disable-next-line @typescript-eslint/no-explicit-any216-  const reg = (globalThis as any).__peachRegisterMessageHandler;217-  if (typeof reg === "function") {218-    reg(async (m: unknown) => {219-      if (!isMessage(m)) return { ok: false, error: "invalid_message" };220-      try {221-        return await handleMessage(m);222-      } catch (err) {223-        return {224-          ok: false,225-          error: err instanceof Error ? err.message : String(err),226-        };227-      }228-    });229-  }230-  if (isNativePlatform()) {231-    void bootstrapNativeSyncRunner();232-  }233206 }234207 235-async function bootstrapNativeSyncRunner(): Promise<void> {236-  if (nativeSyncRunnerBootstrapped) return;237-  nativeSyncRunnerBootstrapped = true;238-  try {239-    setSyncRunner(productionSyncRunner);240-    await hydratePersistedSyncState();241-  } catch (err) {242-    nativeSyncRunnerBootstrapped = false;243-    console.warn("[Peach] failed to bootstrap native sync runner:", err);244-  }245-}246-247208 function isMessage(m: unknown): m is Message {248209   return typeof m === "object" && m !== null && "type" in m;249210 }@@ -376,7 +337,6 @@376337           await setupAutoLock();377338           broadcastToContentScripts({ type: "VAULT_UNLOCKED" });378339           onVaultUnlocked();379-          if (isNativePlatform()) await publishInMemoryVault(vault.getEntries());380340           return { ok: true };381341         }382342         // Distinguish corruption from wrong password so the popup can offer@@ -406,7 +366,6 @@406366         await setupAutoLock();407367         broadcastToContentScripts({ type: "VAULT_UNLOCKED" });408368         onVaultUnlocked();409-        if (isNativePlatform()) await publishInMemoryVault(vault.getEntries());410369         return { ok: true };411370       }412371       case "VALIDATE_RECOVERY_WORDS": {@@ -480,7 +439,6 @@480439       }481440       case "LOCK":482441         vault.lock();483-        if (isNativePlatform()) await clearInMemoryVault();484442         return { ok: true };485443       case "IS_UNLOCKED":486444         return { ok: true, data: vault.isUnlocked() };@@ -1366,46 +1324,12 @@13661324         return { ok: true };13671325       }13681326 1369-      // Android-only: biometric unlock with pre-derived key.1370-      case "UNLOCK_WITH_KEY": {1371-        const { masterKeyHex } = msg.payload as { masterKeyHex: string };1372-        if (!isNativePlatform()) {1373-          return { ok: false, error: "desktop_only_uses_password" };1374-        }1375-        try {1376-          const result = await vault.unlockWithKey(masterKeyHex);1377-          if (result.ok) {1378-            onVaultUnlocked();1379-            await publishInMemoryVault(result.entries);1380-          }1381-          return { ok: result.ok, data: result.ok ? { entries: result.entries } : undefined, error: result.ok ? undefined : result.error };1382-        } catch {1383-          return { ok: false, error: "invalid_key" };1384-        }1385-      }1386-      // Android: returns current in-memory derived key as hex. Used by biometric enable flow.1387-      case "GET_DERIVED_KEY": {1388-        if (!isNativePlatform()) {1389-          return { ok: false, error: "android_only" };1390-        }1391-        if (!vault.isUnlocked()) {1392-          return { ok: false, error: "vault_locked" };1393-        }1394-        return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };1395-      }13961327       case "GET_IDENTITY_SUGGESTIONS": {13971328         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };13981329         const entries = vault.getEntries();13991330         const suggestions = buildIdentitySuggestions(entries);14001331         return { ok: true, data: { suggestions } };14011332       }1402-      case "POPULATE_INLINE_VAULT": {1403-        if (!isNativePlatform()) return { ok: false };1404-        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1405-        const entries = vault.getEntries();1406-        await publishInMemoryVault(entries);1407-        return { ok: true };1408-      }14091333       case "ML_INIT":14101334         return { ok: true };14111335 @@ -1655,46 +1579,6 @@16551579       error: err instanceof Error ? err.message : "operation_failed",16561580     };16571581   }1658-}1659-1660-// --- Mobile platform helpers ---1661-1662-// Android: populates the in-memory vault used by PeachAutofillService.1663-async function publishInMemoryVault(entries: VaultEntry[]): Promise<void> {1664-  const plugin = nativePlugin<{1665-    setEntries?: (args: {1666-      entries: Array<{1667-        id: string;1668-        title: string;1669-        username: string;1670-        email: string;1671-        password: string;1672-        url: string;1673-        totp: string;1674-        additionalUrls: string[];1675-      }>;1676-    }) => Promise<void>;1677-  }>("InMemoryVaultPlugin");1678-  if (!plugin?.setEntries) return;1679-  await plugin.setEntries({1680-    entries: entries.map((e) => ({1681-      id: e.id,1682-      title: e.title ?? "",1683-      username: e.username ?? "",1684-      email: (e as VaultEntry & { email?: string }).email ?? "",1685-      password: e.password ?? "",1686-      url: e.url ?? "",1687-      totp: (e as VaultEntry & { totp?: string }).totp ?? "",1688-      additionalUrls: (e as VaultEntry & { additionalUrls?: string[] }).additionalUrls ?? [],1689-    })),1690-  });1691-}1692-1693-// Android: clear the autofill in-memory store on lock.1694-async function clearInMemoryVault(): Promise<void> {1695-  const plugin = nativePlugin<{ clearEntries?: () => Promise<void> }>("InMemoryVaultPlugin");1696-  if (!plugin?.clearEntries) return;1697-  await plugin.clearEntries();16981582 }16991583 17001584 // Returns username use-counts from the vault, sorted by frequency. Each row

view file

src/background/vault.ts+7 −12
@@ -137,12 +137,6 @@137137    * `prefers-reduced-motion`.138138    */139139   reduceMotion?: boolean;140-  /**141-   * Android-only: when true, the master key is escrowed in AndroidKeyStore142-   * and the lock screen offers biometric unlock. Toggled from Settings →143-   * Security → Biometrics. Has no effect on desktop builds.144-   */145-  biometricsEnabled?: boolean;146140   /** Whether this vault has a local PIN unlock wrapper configured. */147141   pinEnabled?: boolean;148142   /** Whether the lock screen should open on PIN instead of the password. */@@ -1613,9 +1607,9 @@16131607   return derivedKey !== null;16141608 }16151609 1616-// Opens the vault using a pre-derived AES-256 key (skipping Argon2id).1617-// Used by Android biometric unlock — the key was derived on the native side.1618-// SECURITY: only reachable via Capacitor native bridge; browser messages.ts never routes here.1610+// Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The1611+// PIN-unlock path unwraps this key from its PIN wrap; the passphrase path1612+// always derives from scratch via Argon2id.16191613 export async function unlockWithKey(16201614   masterKeyHex: string,16211615 ): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {@@ -1778,9 +1772,10 @@17781772   return { ok: true, entryCount: getEntries().length };17791773 }17801774 1781-// Returns the current in-memory derived key as a hex string.1782-// Used by the biometric enable flow in Settings to store the key in Android Keystore.1783-export function getDerivedKeyHex(): string {1775+// Returns the current in-memory derived key as a hex string. Only the PIN1776+// unlock path needs this value, and it reads module state directly; this1777+// accessor exists so unlockWithKey's root-key rehydration is testable.1778+export /* @testable */ function getDerivedKeyHex(): string {17841779   if (!derivedKey) throw new Error("vault_not_unlocked");17851780   return hex(derivedKey);17861781 }

view file

src/content/passkeys-firefox-relay.test.ts+0 −1
@@ -49,7 +49,6 @@4949     "GET_ENTRIES",5050     "GET_RECOVERY_WORDS",5151     "DESTROY_VAULT",52-    "GET_DERIVED_KEY",5352     "ADD_ENTRY",5453     "UPDATE_ENTRY",5554     "DELETE_ENTRY",

view file

src/popup/App.tsx+1 −273
@@ -1,5 +1,4 @@11 import { createEffect, createMemo, createSignal, For, lazy, onCleanup, onMount, Show, Suspense } from "solid-js";2-import { Portal } from "solid-js/web";32 import { storageGet, storageRemove, storageSet } from "../shared/storage";43 import { sendMessage } from "../shared/bridge";54 import { ACCENT_COLORS } from "../shared/types";@@ -43,7 +42,6 @@4342   type ShortcutAction,4443 } from "../shared/shortcuts";4544 import { generateTOTP } from "../crypto/totp";46-import { isNativePlatform, nativePlugin } from "../shared/native";4745 4846 export { sendMessage } from "../shared/bridge";4947 @@ -144,7 +142,6 @@144142   const [view, setView] = createSignal<View>("lock");145143   // Mobile bottom-sheet live drag offset in px (null = not actively dragging,146144   // CSS transition takes over for snap animation).147-  const [sheetTranslate, setSheetTranslate] = createSignal<number | null>(null);148145   const [contentPhase, setContentPhase] = createSignal<"enter" | "exit" | "idle">(149146     "enter"150147   );@@ -673,30 +670,6 @@673670       } catch { /* best-effort */ }674671     }675672 676-    // Native: closing the app before recovery-words confirmation means the677-    // user didn't finish vault creation. Throw the partial vault away and678-    // restart onboarding rather than asking them to unlock a vault they679-    // never finished creating. Desktop keeps the existing "unlock to680-    // resume" flow because closing the popup there is much more casual.681-    if (recoveryPending && !unlocked && vaultExists && isNativePlatform()) {682-      try {683-        await chrome.storage.local?.remove([684-          "peach_vault_a",685-          "peach_vault_b",686-          "peach_recovery_pending",687-          "peach_onboarded",688-          "peach_vault_history",689-          ONBOARDING_CARD_STORAGE_KEY,690-          ONBOARDING_EXIT_STORAGE_KEY,691-        ]);692-      } catch { /* best-effort */ }693-      setHasVault(false);694-      setOnboarded(false);695-      setView("lock");696-      setBootState("ready");697-      return;698-    }699-700673     if (recoveryPending && unlocked) {701674       // forceRecoveryWords overrides LockView's hasVault gate so the702675       // recovery surface renders even though the vault now exists.@@ -786,83 +759,6 @@786759     } catch { /* extension context invalidated — ignore */ }787760     onCleanup(() => { try { popupPort?.disconnect(); } catch {} });788761 789-    // Android autolock. chrome.alarms is no-op'd on Capacitor, so the only790-    // reliable signal is appStateChange — setTimeout is killed when the OS791-    // backgrounds the app. We measure elapsed background time on resume.792-    if (isNativePlatform()) {793-      let backgroundedAt: number | null = null;794-795-      const CapApp = nativePlugin<{796-        addListener: (797-          ev: string,798-          cb: (s: { isActive: boolean }) => void,799-        ) => Promise<{ remove: () => Promise<void> }> | { remove: () => Promise<void> };800-      }>("App");801-      if (CapApp?.addListener) {802-        const sub = CapApp.addListener(803-          "appStateChange",804-          ({ isActive }: { isActive: boolean }) => {805-            if (!isActive) {806-              backgroundedAt = Date.now();807-              return;808-            }809-            if (backgroundedAt !== null && autoLockMinutes() > 0) {810-              const elapsed = Date.now() - backgroundedAt;811-              if (elapsed >= autoLockMinutes() * 60_000) handleHeaderLock();812-            }813-            backgroundedAt = null;814-          }815-        );816-        // Capacitor 6 listeners return a Promise<{ remove: () => Promise<void> }>.817-        onCleanup(() => {818-          Promise.resolve(sub).then((s) => s?.remove?.()).catch(() => {});819-        });820-      }821-822-      // L12/COR-011: foreground idle autolock. appStateChange only fires when823-      // the OS backgrounds the app — if the user idles with the screen on,824-      // chrome.alarms is no-op'd in Capacitor so the vault would stay open825-      // indefinitely. Poll every 30s and lock if elapsed since last user826-      // interaction exceeds autoLockMinutes.827-      let lastUserActivityAt = Date.now();828-      const bumpActivity = () => { lastUserActivityAt = Date.now(); };829-      const ACTIVITY_EVENTS = ["touchstart", "click", "keydown", "input"] as const;830-      for (const ev of ACTIVITY_EVENTS) {831-        document.addEventListener(ev, bumpActivity, { passive: true, capture: true });832-      }833-      const idleLockInterval = setInterval(() => {834-        const minutes = autoLockMinutes();835-        if (!minutes || minutes <= 0) return;836-        const elapsed = Date.now() - lastUserActivityAt;837-        if (elapsed >= minutes * 60_000) {838-          lastUserActivityAt = Date.now();839-          handleHeaderLock();840-        }841-      }, 30_000);842-      onCleanup(() => {843-        clearInterval(idleLockInterval);844-        for (const ev of ACTIVITY_EVENTS) {845-          document.removeEventListener(ev, bumpActivity, { capture: true });846-        }847-      });848-849-      // The retired native peer-discovery trigger went with the old Capacitor850-      // pairing plugin. Reconnection is address-cache first, subnet probe second851-      // (sync-runner.ts); relocating a peer that changed networks is an852-      // Android-side discovery concern now.853-      if (isNativePlatform()) {854-        // Android runs the background/sync dispatcher in-process with the855-        // popup, so the production runner has to be registered from the856-        // UI bootstrap path instead of the MV3 service-worker entry.857-        void import("../background/sync-trigger").then(({ hydratePersistedSyncState, setSyncRunner }) =>858-          import("../background/sync-runner").then(({ productionSyncRunner }) => {859-            setSyncRunner(productionSyncRunner);860-            void hydratePersistedSyncState();861-          })862-        );863-      }864-    }865-866762     // Background auto-lock broadcasts VAULT_LOCKED so the popup drops stale867763     // entries instead of waiting for the next GET_ENTRIES round-trip.868764     const handleRuntimeMessage = (msg: { type?: string; payload?: { feature?: ProFeature } }) => {@@ -879,142 +775,6 @@879775     chrome.runtime.onMessage.addListener(handleRuntimeMessage);880776     onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));881777 882-    // Android hardware/gesture back. MainActivity fires peach:back; the popup883-    // decides what "back" means per surface. Lock+no-vault is the common case884-    // — return to onboarding rather than letting the OS dismiss the activity.885-    const handlePeachBack = () => {886-      // Notes mode captures back first887-      if (showNotes()) {888-        setShowNotes(false);889-        setOpenNoteId(undefined);890-        return;891-      }892-      const v = view();893-      if (v === "lock" && !hasVault() && onboarded()) {894-        setOnboarded(false);895-        return;896-      }897-      if (v === "list") { setView("entry"); return; }898-      if (v === "settings") {899-        window.dispatchEvent(new Event("peach:settings-back"));900-        return;901-      }902-      if (v === "import" || v === "health") { setView("settings"); return; }903-      if (v === "export") { handleExportBack(); return; }904-      if (v === "add") { handleBack(); return; }905-      if (v === "pair-device") { setView("settings"); return; }906-    };907-    window.addEventListener("peach:back", handlePeachBack);908-    onCleanup(() => window.removeEventListener("peach:back", handlePeachBack));909-910-    // Mobile bottom-sheet drag tracking. The vault list sits as a sheet that911-    // peeks from the bottom (search bar handle visible) and drags up to fill912-    // the screen. dragOffset is the LIVE finger offset during a drag, in px,913-    // 0 when no drag in progress. The wrapper element computes transform =914-    // (rest position) + dragOffset, so the user sees 1:1 finger tracking.915-    if (isNativePlatform()) {916-      let touchStartY = 0;917-      let touchStartX = 0;918-      let lastY = 0;919-      let lastT = 0;920-      let dragging = false;921-      let captured = false;922-      const HORIZONTAL_TOLERANCE = 16;923-      const ACTIVATION_DY = 8;924-      const PEEK_PX = 60;925-926-      const onTouchStart = (e: TouchEvent) => {927-        const t = e.touches[0];928-        if (!t) return;929-        const v = view();930-        if (v !== "entry" && v !== "list") return;931-        // Entry view: only intercept drags starting in the bottom 40%.932-        // List view: intercept drags starting in the top 35% (the search/handle row).933-        const inBand = v === "entry"934-          ? t.clientY > window.innerHeight * 0.6935-          : t.clientY < window.innerHeight * 0.35;936-        if (!inBand) return;937-        touchStartY = lastY = t.clientY;938-        touchStartX = t.clientX;939-        lastT = e.timeStamp;940-        dragging = true;941-        captured = false;942-      };943-944-      const onTouchMove = (e: TouchEvent) => {945-        if (!dragging) return;946-        const t = e.touches[0];947-        if (!t) return;948-        const dy = t.clientY - touchStartY;949-        const dx = Math.abs(t.clientX - touchStartX);950-951-        if (!captured) {952-          if (dx > HORIZONTAL_TOLERANCE && Math.abs(dy) < dx) {953-            // Looks like a horizontal swipe — release.954-            dragging = false;955-            return;956-          }957-          if (Math.abs(dy) < ACTIVATION_DY) return;958-          captured = true;959-        }960-961-        // Translate the sheet by dy from its rest (peek) position. Peek is962-        // (viewport height - PEEK_PX). Open is 0.963-        const v = view();964-        const restClosed = window.innerHeight - PEEK_PX;965-        const baseTranslate = v === "list" ? 0 : restClosed;966-        const next = Math.max(0, Math.min(restClosed, baseTranslate + dy));967-        setSheetTranslate(next);968-        lastY = t.clientY;969-        lastT = e.timeStamp;970-      };971-972-      const onTouchEnd = (e: TouchEvent) => {973-        if (!dragging) { return; }974-        const t = e.changedTouches[0];975-        const elapsed = Math.max(1, e.timeStamp - lastT);976-        const velocity = t ? (t.clientY - lastY) / elapsed : 0; // px/ms977-        const wasCaptured = captured;978-        dragging = false;979-        captured = false;980-        const v = view();981-982-        if (!wasCaptured) {983-          // Tap in the peek strip while in entry view → open the vault list.984-          if (v === "entry" && touchStartY > window.innerHeight - PEEK_PX) setView("list");985-          setSheetTranslate(null);986-          return;987-        }988-989-        const restClosed = window.innerHeight - PEEK_PX;990-        const current = sheetTranslate() ?? restClosed;991-992-        // Snap rules:993-        //   - Strong upward velocity → open994-        //   - Strong downward velocity → close995-        //   - Otherwise snap to whichever rest is closer996-        let nextOpen: boolean;997-        if (velocity < -0.05) nextOpen = true;998-        else if (velocity > 0.05) nextOpen = false;999-        else nextOpen = current < restClosed * 0.5;1000-1001-        if (nextOpen && v !== "list") setView("list");1002-        if (!nextOpen && v !== "entry") setView("entry");1003-        // Release the live offset so CSS transition handles the snap.1004-        setSheetTranslate(null);1005-      };1006-1007-      document.addEventListener("touchstart", onTouchStart, { passive: true });1008-      document.addEventListener("touchmove",  onTouchMove,  { passive: true });1009-      document.addEventListener("touchend",   onTouchEnd,   { passive: true });1010-      document.addEventListener("touchcancel", () => { dragging = false; captured = false; setSheetTranslate(null); }, { passive: true });1011-      onCleanup(() => {1012-        document.removeEventListener("touchstart", onTouchStart);1013-        document.removeEventListener("touchmove",  onTouchMove);1014-        document.removeEventListener("touchend",   onTouchEnd);1015-      });1016-    }1017-1018778     // Action handlers — keyed by ShortcutAction. The dispatcher below tests1019779     // each binding against the event and runs the matching handler. New1020780     // shortcuts only need an entry here; the binding is data, not code.@@ -1823,9 +1583,6 @@18231583               </section>18241584             </div>18251585 1826-            {/* Desktop: shown on demand via Show. Native sheet is rendered1827-                via Portal below at the document root so its position:fixed1828-                isn't trapped by .peach-shell's transform-based animations. */}18291586             {/* Rare device-trust alerts; normal sync changes are silent. */}18301587             <Show when={alertGroups().length > 0}>18311588               <div class="peach-sync-alerts">@@ -1865,7 +1622,7 @@18651622               </div>18661623             </Show>18671624 1868-            <Show when={!isNativePlatform() && view() === "list"}>1625+            <Show when={view() === "list"}>18691626               <VaultView18701627                 entries={entries()}18711628                 selectedEntryId={selectedEntry()?.id}@@ -1880,35 +1637,6 @@18801637                 onOpenTrash={() => setView("trash")}18811638                 onDeleteMany={handleDeleteMany}18821639               />1883-            </Show>1884-            <Show when={isNativePlatform() && hasVault()}>1885-              <Portal>1886-                <div1887-                  class="peach-vault-sheet"1888-                  classList={{ "is-peeking": view() === "entry", "is-open": view() === "list", "is-dragging": sheetTranslate() !== null }}1889-                  style={(() => {1890-                    const live = sheetTranslate();1891-                    if (live === null) return undefined;1892-                    return { transform: `translateY(${live}px)` };1893-                  })()}1894-                >1895-                  <VaultView1896-                    entries={entries()}1897-                    selectedEntryId={selectedEntry()?.id}1898-                    currentTabDomain={currentTabDomain()}1899-                    sortOrder={sortOrder()}1900-                    searchSeed={listSearchSeed()}1901-                    searchSeedNonce={listSearchSeedNonce()}1902-                    isNative={true}1903-                    onSelectEntry={handleSelectEntry}1904-                    onSortOrderChange={setSortOrder}1905-                    onAdd={handleAdd}1906-                    onClose={() => setView("entry")}1907-                    onOpenTrash={() => setView("trash")}1908-                    onDeleteMany={handleDeleteMany}1909-                  />1910-                </div>1911-              </Portal>19121640             </Show>19131641             <Show when={view() === "settings" || settingsExiting()}>19141642               <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>

view file

src/popup/components/NoteToolbar.tsx+12 −43
@@ -1,6 +1,3 @@1-import { Show } from "solid-js";2-import { isNativePlatform } from "../../shared/native";3-41 export type ToolbarExec = (cmd: string, val?: string) => void;52 63 const BoldIcon = () => (@@ -78,50 +75,22 @@7875 7976 interface ToolbarProps {8077   exec: ToolbarExec;81-  keyboardVisible?: boolean;82-  onDismissKeyboard?: () => void;8378   class?: string;8479 }8580 8681 export function NoteToolbar(props: ToolbarProps) {8782   return (88-    <Show89-      when={isNativePlatform()}90-      fallback={91-        // Desktop: toolbar rendered inline above the body, shown always92-        <div class={props.class ?? "peach-note-toolbar-desktop-inline"}>93-          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyBold(props.exec); }} title="Bold" aria-label="Bold"><BoldIcon /></button>94-          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyItalic(props.exec); }} title="Italic" aria-label="Italic"><ItalicIcon /></button>95-          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyUnderline(props.exec); }} title="Underline" aria-label="Underline"><UnderlineIcon /></button>96-          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyStrike(props.exec); }} title="Strikethrough" aria-label="Strikethrough"><StrikeIcon /></button>97-          <div class="peach-note-toolbar-divider" />98-          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyHeading(props.exec); }} title="Heading" aria-label="Heading"><HeadingIcon /></button>99-          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyQuote(props.exec); }} title="Quote" aria-label="Quote"><QuoteIcon /></button>100-          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyBullet(props.exec); }} title="Bullet list" aria-label="Bullet list"><BulletIcon /></button>101-          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyNumbered(props.exec); }} title="Numbered list" aria-label="Numbered list"><NumberedIcon /></button>102-          <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyRule(props.exec); }} title="Divider" aria-label="Divider"><RuleIcon /></button>103-        </div>104-      }105-    >106-      {/* Mobile: sits above keyboard when keyboard is visible */}107-      <Show when={props.keyboardVisible}>108-        <div class={props.class ?? "peach-note-toolbar-mobile"}>109-          <button class="peach-note-toolbar-btn" onPointerDown={(e) => { e.preventDefault(); applyBold(props.exec); }} aria-label="Bold"><BoldIcon /></button>110-          <button class="peach-note-toolbar-btn" onPointerDown={(e) => { e.preventDefault(); applyItalic(props.exec); }} aria-label="Italic"><ItalicIcon /></button>111-          <button class="peach-note-toolbar-btn" onPointerDown={(e) => { e.preventDefault(); applyUnderline(props.exec); }} aria-label="Underline"><UnderlineIcon /></button>112-          <button class="peach-note-toolbar-btn" onPointerDown={(e) => { e.preventDefault(); applyStrike(props.exec); }} aria-label="Strikethrough"><StrikeIcon /></button>113-          <div class="peach-note-toolbar-divider" />114-          <button class="peach-note-toolbar-btn" onPointerDown={(e) => { e.preventDefault(); applyHeading(props.exec); }} aria-label="Heading"><HeadingIcon /></button>115-          <button class="peach-note-toolbar-btn" onPointerDown={(e) => { e.preventDefault(); applyQuote(props.exec); }} aria-label="Quote"><QuoteIcon /></button>116-          <button class="peach-note-toolbar-btn" onPointerDown={(e) => { e.preventDefault(); applyBullet(props.exec); }} aria-label="Bullet list"><BulletIcon /></button>117-          <button class="peach-note-toolbar-btn" onPointerDown={(e) => { e.preventDefault(); applyNumbered(props.exec); }} aria-label="Numbered list"><NumberedIcon /></button>118-          <button class="peach-note-toolbar-btn" onPointerDown={(e) => { e.preventDefault(); applyRule(props.exec); }} aria-label="Divider"><RuleIcon /></button>119-          <div class="peach-note-toolbar-spacer" />120-          <button class="peach-note-toolbar-btn" onPointerDown={(e) => { e.preventDefault(); props.onDismissKeyboard?.(); }} aria-label="Dismiss keyboard">121-            <svg viewBox="0 0 14 14" aria-hidden="true"><path d="M1.5 3h11v7h-11V3ZM3 5h.01M5 5h.01M7 5h.01M9 5h.01M11 5h.01M3 7h.01M5 7h.01M7 7h.01M9 7h2M4 9h6M5 12l2 1.5L9 12" /></svg>122-          </button>123-        </div>124-      </Show>125-    </Show>83+    <div class={props.class ?? "peach-note-toolbar-desktop-inline"}>84+      <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyBold(props.exec); }} title="Bold" aria-label="Bold"><BoldIcon /></button>85+      <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyItalic(props.exec); }} title="Italic" aria-label="Italic"><ItalicIcon /></button>86+      <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyUnderline(props.exec); }} title="Underline" aria-label="Underline"><UnderlineIcon /></button>87+      <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyStrike(props.exec); }} title="Strikethrough" aria-label="Strikethrough"><StrikeIcon /></button>88+      <div class="peach-note-toolbar-divider" />89+      <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyHeading(props.exec); }} title="Heading" aria-label="Heading"><HeadingIcon /></button>90+      <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyQuote(props.exec); }} title="Quote" aria-label="Quote"><QuoteIcon /></button>91+      <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyBullet(props.exec); }} title="Bullet list" aria-label="Bullet list"><BulletIcon /></button>92+      <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyNumbered(props.exec); }} title="Numbered list" aria-label="Numbered list"><NumberedIcon /></button>93+      <button class="peach-note-toolbar-btn" onMouseDown={(e) => { e.preventDefault(); applyRule(props.exec); }} title="Divider" aria-label="Divider"><RuleIcon /></button>94+    </div>12695   );12796 }

view file

src/popup/index.tsx+0 −9
@@ -1,14 +1,5 @@11 import { render } from "solid-js/web";22 import App from "./App";3-import { isNativePlatform } from "../shared/native";4-5-// Android: there is no MV3 service worker, so the popup itself owns message6-// handling. Without this the chrome shim's sendMessage queue fills up and7-// every call times out as SERVICE_WORKER_UNAVAILABLE. setupMessageHandler8-// also wires __peachRegisterMessageHandler for in-process routing.9-if (isNativePlatform()) {10-  void import("../background/messages").then((m) => m.setupMessageHandler());11-}123 134 const root = document.getElementById("app");145 if (root) {

view file

src/popup/styles/vars.css+2 −2
@@ -180,8 +180,8 @@180180   --stack-pad: 14px;181181   --stack-spring: cubic-bezier(0.18, 0.9, 0.22, 1.02);182182   /* Tell the browser engine which color schemes the UI knows how to render —183-     this gets us correct form-control colors and scrollbar tinting in the184-     WebView when the user follows the system theme. */183+     this gets us correct form-control colors and scrollbar tinting when the184+     user follows the system theme. */185185   color-scheme: dark light;186186 }187187 

view file

src/popup/views/CodexGenerateView.tsx+6 −21
@@ -16,7 +16,6 @@1616 1717 import { createSignal, createMemo, For, onMount, onCleanup, Show } from "solid-js";1818 import { sendMessage } from "../../shared/bridge";19-import { isNativePlatform } from "../../shared/native";2019 import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";2120 import type {2221   CodexDeps,@@ -201,26 +200,12 @@201200   function downloadPdf(bytes: Uint8Array): void {202201     const a = document.createElement("a");203202     a.download = codexFilename();204-    if (isNativePlatform()) {205-      // Capacitor WebView: blob: URLs are opaque to native, but data: URLs206-      // are intercepted by MainActivity and written to public Downloads via207-      // MediaStore. Encode in chunks to avoid blowing the call stack on208-      // large PDFs.209-      let bin = "";210-      const chunk = 0x8000;211-      for (let i = 0; i < bytes.length; i += chunk) {212-        const slice = bytes.subarray(i, Math.min(i + chunk, bytes.length));213-        bin += String.fromCharCode(...slice);214-      }215-      a.href = `data:application/pdf;base64,${btoa(bin)}`;216-    } else {217-      // Browser: copy into a fresh Uint8Array to ensure a tightly-owned218-      // ArrayBuffer (Blob is happier with this than a subarray view).219-      const owned = new Uint8Array(bytes);220-      const blob = new Blob([owned.buffer], { type: "application/pdf" });221-      a.href = URL.createObjectURL(blob);222-      setTimeout(() => URL.revokeObjectURL(a.href), 4000);223-    }203+    // Copy into a fresh Uint8Array to ensure a tightly-owned ArrayBuffer204+    // (Blob is happier with this than a subarray view).205+    const owned = new Uint8Array(bytes);206+    const blob = new Blob([owned.buffer], { type: "application/pdf" });207+    a.href = URL.createObjectURL(blob);208+    setTimeout(() => URL.revokeObjectURL(a.href), 4000);224209     document.body.appendChild(a);225210     a.click();226211     a.remove();

view file

src/popup/views/Entry.tsx+2 −2
@@ -33,8 +33,8 @@3333   entry: VaultEntry | null;3434   /**3535    * Current active tab's full URL (https only). Empty string when unavailable36-   * — Capacitor, an extension page, or a chrome:///about: URL. The URL37-   * editor uses this to render an "add current site" shortcut.36+   * — an extension page or a chrome:///about: URL. The URL editor uses this37+   * to render an "add current site" shortcut.3838    */3939   currentTabUrl?: string;4040   onDelete: () => void;

view file

src/popup/views/Lock.tsx+0 −92
@@ -9,7 +9,6 @@99 import { isPeachScript, decodePeachScript, normalisePeachScript } from "../../crypto/peachScript";1010 import { parsePayload } from "../../crypto/peachPayload";1111 import { parseImportSource } from "../../shared/import";12-import { isNativePlatform, nativePlugin } from "../../shared/native";1312 1413 const peachMarkImg = new Image();1514 peachMarkImg.src = `data:image/svg+xml,${encodeURIComponent(@@ -92,8 +91,6 @@9291   const MIN_ZXCVBN_SCORE = 2;9392 9493   const [strengthScore, setStrengthScore] = createSignal<number | null>(null);95-  const [biometricAvailable, setBiometricAvailable] = createSignal(false);96-  const [biometricBusy, setBiometricBusy] = createSignal(false);9794   const [pinAvailable, setPinAvailable] = createSignal(false);9895   const [usingPin, setUsingPin] = createSignal(false);9996   const [pin, setPin] = createSignal("");@@ -193,75 +190,7 @@193190     } catch { /* settings are optional at the lock screen */ }194191   }195192 196-  async function checkBiometricAvailable(): Promise<void> {197-    if (!props.hasVault || !isNativePlatform()) return;198-    const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");199-    const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");200-    if (!keystore || !biometric) return;201-    try {202-      const stored = await keystore.hasMasterKey();203-      if (!stored.exists) return;204-      const avail = await biometric.isAvailable();205-      if (!avail.available) return;206-      setBiometricAvailable(true);207-      window.setTimeout(() => { void handleBiometricUnlock(); }, 400);208-    } catch { /* missing plugins or runtime error — silently skip */ }209-  }210-211-  async function handleBiometricUnlock(): Promise<void> {212-    if (biometricBusy()) return;213-    const biometric = nativePlugin<{ authenticate: (opts: Record<string, string>) => Promise<void> }>("BiometricPlugin");214-    const keystore = nativePlugin<{ retrieveMasterKey: () => Promise<{ masterKeyHex: string }> }>("KeystorePlugin");215-    if (!biometric || !keystore) return;216-    setBiometricBusy(true);217-    setError("");218-    try {219-      await biometric.authenticate({220-        title: "Unlock Peach",221-        subtitle: "Use biometrics to access your vault",222-        negativeButtonText: "Use Password",223-      });224-      const { masterKeyHex } = await keystore.retrieveMasterKey();225-      const res = (await sendMessage({226-        type: "UNLOCK_WITH_KEY",227-        payload: { masterKeyHex },228-      })) as { ok: boolean; error?: string };229-      if (res.ok) {230-        await props.onUnlock();231-        return;232-      }233-      if (res.error === "biometric_key_invalidated") {234-        await sendMessage({235-          type: "UPDATE_SETTINGS",236-          payload: { biometricsEnabled: false },237-        });238-        setBiometricAvailable(false);239-        setError("biometric key expired — re-enable in settings");240-      } else {241-        setError("biometric unlock failed");242-      }243-    } catch (e) {244-      const reason = e instanceof Error ? e.message : String(e);245-      if (reason.includes("user_cancelled")) return;246-      if (reason.includes("biometric_key_invalidated")) {247-        await sendMessage({248-          type: "UPDATE_SETTINGS",249-          payload: { biometricsEnabled: false },250-        });251-        setBiometricAvailable(false);252-        setError("biometric key expired — re-enable in settings");253-        return;254-      }255-      if (reason.includes("biometric_lockout")) {256-        setError("biometric locked out — use your password");257-      }258-    } finally {259-      setBiometricBusy(false);260-    }261-  }262-263193   onMount(() => {264-    void checkBiometricAvailable();265194     void checkPinAvailable();266195   });267196 @@ -444,27 +373,6 @@444373                 onClick={() => { setUsingPin(true); setError(""); }}445374               >446375                 use PIN447-              </button>448-            </Show>449-450-            <Show when={biometricAvailable()}>451-              <button452-                class="peach-biometric-btn"453-                type="button"454-                onClick={() => void handleBiometricUnlock()}455-                disabled={biometricBusy() || loading()}456-                aria-label="Unlock with biometrics"457-              >458-                <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">459-                  <path d="M12 11c-1.7 0-3 1.3-3 3v3"/>460-                  <path d="M15 17v-3"/>461-                  <path d="M9 4.5C9.7 4.2 10.8 4 12 4s2.3.2 3 .5"/>462-                  <path d="M5 9c.7-1.6 2-2.9 3.6-3.6"/>463-                  <path d="M19 9c-.7-1.6-2-2.9-3.6-3.6"/>464-                  <path d="M5 15c0-1.7.3-3.3.8-4.7"/>465-                  <path d="M19 15c0-1.7-.3-3.3-.8-4.7"/>466-                </svg>467-                {biometricBusy() ? "verifying…" : "use biometrics"}468376               </button>469377             </Show>470378 

view file

src/popup/views/NotesEditor.tsx+1 −44
@@ -1,11 +1,9 @@11 import { createSignal, onCleanup, onMount, Show } from "solid-js";22 import type { VaultEntry } from "../../shared/types";33 import { sendMessage } from "../../shared/bridge";4-import { isNativePlatform } from "../../shared/native";54 import { NoteToolbar, type ToolbarExec } from "../components/NoteToolbar";65 import { toHtml } from "./NoteEditor";76 8-const HEADER_HEIGHT = 52;97 const AUTOSAVE_MS = 800;108 const SAVED_DISPLAY_MS = 1500;119 @@ -20,14 +18,11 @@2018 export function NotesEditor(props: NotesEditorProps) {2119   let titleRef!: HTMLDivElement;2220   let bodyRef!: HTMLDivElement;23-  let contentScrollRef!: HTMLDivElement;2421 2522   const [noteId, setNoteId] = createSignal<string | undefined>(props.note?.id);2623   const [title, setTitle] = createSignal(props.note?.title ?? "");2724   const [body, setBody] = createSignal(props.note?.notes ?? "");2825   const [saved, setSaved] = createSignal(false);29-  const [keyboardVisible, setKeyboardVisible] = createSignal(false);30-  const [contentHeight, setContentHeight] = createSignal<number | null>(null);3126   const [dirty, setDirty] = createSignal(false);3227 3328   let saveTimer: number | null = null;@@ -92,23 +87,6 @@9287     props.onBack();9388   }9489 95-  function handleKeyboardResize() {96-    if (!isNativePlatform()) return;97-    const vp = window.visualViewport;98-    const viewportH = vp?.height ?? window.innerHeight;99-    const kbH = window.innerHeight - viewportH;100-    if (kbH > 50) {101-      setKeyboardVisible(true);102-      setContentHeight(viewportH - HEADER_HEIGHT);103-      if (document.activeElement) {104-        (document.activeElement as HTMLElement).scrollIntoView?.({ behavior: "smooth", block: "nearest" });105-      }106-    } else {107-      setKeyboardVisible(false);108-      setContentHeight(null);109-    }110-  }111-11290   onMount(() => {11391     // Populate editor content11492     if (titleRef) titleRef.textContent = props.note?.title ?? "";@@ -128,14 +106,9 @@128106       }, 300);129107     }130108 131-    if (isNativePlatform()) {132-      window.addEventListener("resize", handleKeyboardResize);133-    }134-135109     onCleanup(() => {136110       if (saveTimer) clearTimeout(saveTimer);137111       if (savedFadeTimer) clearTimeout(savedFadeTimer);138-      if (isNativePlatform()) window.removeEventListener("resize", handleKeyboardResize);139112     });140113   });141114 @@ -209,14 +182,9 @@209182 210183       {/* Scrollable content */}211184       <div212-        ref={contentScrollRef}213185         class="peach-note-editor-scroll"214-        style={contentHeight() !== null ? { height: `${contentHeight()}px` } : undefined}215186       >216-        {/* Desktop toolbar — above content */}217-        <Show when={!isNativePlatform()}>218-          <NoteToolbar exec={execFn} />219-        </Show>187+        <NoteToolbar exec={execFn} />220188 221189         <div222190           ref={titleRef}@@ -263,17 +231,6 @@263231         saved.264232       </div>265233 266-      {/* Mobile keyboard toolbar */}267-      <Show when={isNativePlatform()}>268-        <NoteToolbar269-          exec={execFn}270-          keyboardVisible={keyboardVisible()}271-          onDismissKeyboard={() => {272-            titleRef?.blur();273-            bodyRef?.blur();274-          }}275-        />276-      </Show>277234     </div>278235   );279236 }

view file

src/popup/views/RecoveryWordDisplay.tsx+6 −36
@@ -2,7 +2,6 @@22 import { sendMessage } from "../App";33 import { PeachWordmark } from "../components/PeachMark";44 import { RecoveryRuneBackground } from "../components/RecoveryRuneBackground";5-import { isNativePlatform } from "../../shared/native";65 76 interface RecoveryWordDisplayViewProps {87   onDone: () => void;@@ -34,17 +33,6 @@3433   const [confirmed, setConfirmed] = createSignal(false);3534   // 5-second countdown before confirmation input is enabled3635   const [countdown, setCountdown] = createSignal(5);37-  const [copied, setCopied] = createSignal(false);38-39-  async function copyRecoveryWords() {40-    try {41-      await navigator.clipboard.writeText(recoveryWordsText(words()));42-      setCopied(true);43-      setTimeout(() => setCopied(false), 1800);44-    } catch {45-      /* clipboard denied — the words remain on screen for transcription */46-    }47-  }4836   let countdownTimer: ReturnType<typeof setInterval> | null = null;4937 5038   onMount(async () => {@@ -147,31 +135,13 @@147135               )}148136             </For>149137           </ol>150-          {/* On native we must NOT write the master recovery secret to a file:151-              the Android download sink lands in world-readable public Downloads152-              (audit MEDIUM). Offer a transient clipboard copy the user routes153-              into their own secure store instead. Desktop keeps the154-              user-chosen-location .txt download. */}155-          <Show156-            when={isNativePlatform()}157-            fallback={158-              <button159-                type="button"160-                class="peach-recovery-download-btn"161-                onClick={() => void saveRecoveryWords(words())}162-              >163-                download as .txt164-              </button>165-            }138+          <button139+            type="button"140+            class="peach-recovery-download-btn"141+            onClick={() => void saveRecoveryWords(words())}166142           >167-            <button168-              type="button"169-              class="peach-recovery-download-btn"170-              onClick={() => void copyRecoveryWords()}171-            >172-              {copied() ? "copied ✓ — paste somewhere safe" : "copy words"}173-            </button>174-          </Show>143+            download as .txt144+          </button>175145         </div>176146 177147         {/* Confirmation step */}

view file

src/popup/views/Settings.tsx+0 −85
@@ -29,7 +29,6 @@2929   type Keybinding,3030   type ShortcutAction,3131 } from "../../shared/shortcuts";32-import { isNativePlatform, nativePlugin } from "../../shared/native";3332 import { WEB3FORMS_KEY } from "../../shared/config";3433 3534 interface LicenseInfo {@@ -237,10 +236,6 @@237236       icon: "bubble",238237     },239238   ]);240-  const [biometricSupported, setBiometricSupported] = createSignal(false);241-  const [biometricStored, setBiometricStored] = createSignal(false);242-  const [biometricBusy, setBiometricBusy] = createSignal(false);243-  const [biometricError, setBiometricError] = createSignal<string | null>(null);244239   const [passkeyState, setPasskeyState] = createSignal<PasskeyProviderState | null>(null);245240   const [passkeySaving, setPasskeySaving] = createSignal(false);246241 @@ -638,68 +633,8 @@638633       );639634     }640635 641-642-    if (isNativePlatform()) {643-      const biometric = nativePlugin<{ isAvailable: () => Promise<{ available: boolean }> }>("BiometricPlugin");644-      const keystore = nativePlugin<{ hasMasterKey: () => Promise<{ exists: boolean }> }>("KeystorePlugin");645-      try {646-        if (biometric) {647-          const a = await biometric.isAvailable();648-          setBiometricSupported(a.available);649-        }650-        if (keystore) {651-          const k = await keystore.hasMasterKey();652-          setBiometricStored(k.exists);653-        }654-      } catch { /* missing plugins — leave defaults */ }655-    }656636   });657637 658-  async function handleEnableBiometrics() {659-    setBiometricBusy(true);660-    setBiometricError(null);661-    try {662-      const keyRes = (await sendMessage({ type: "GET_DERIVED_KEY" })) as {663-        ok: boolean;664-        data?: { masterKeyHex: string };665-        error?: string;666-      };667-      if (!keyRes.ok || !keyRes.data?.masterKeyHex) {668-        setBiometricError(keyRes.error === "vault_locked"669-          ? "unlock the vault first"670-          : "couldn't read derived key");671-        return;672-      }673-      const keystore = nativePlugin<{ storeMasterKey: (p: { masterKeyHex: string }) => Promise<void> }>("KeystorePlugin");674-      if (!keystore) {675-        setBiometricError("biometric plugin unavailable");676-        return;677-      }678-      await keystore.storeMasterKey({ masterKeyHex: keyRes.data.masterKeyHex });679-      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: true } });680-      setBiometricStored(true);681-    } catch (e) {682-      setBiometricError(e instanceof Error ? e.message : "failed to enable biometrics");683-    } finally {684-      setBiometricBusy(false);685-    }686-  }687-688-  async function handleDisableBiometrics() {689-    setBiometricBusy(true);690-    setBiometricError(null);691-    try {692-      const keystore = nativePlugin<{ clearMasterKey: () => Promise<void> }>("KeystorePlugin");693-      if (keystore) await keystore.clearMasterKey();694-      await sendMessage({ type: "UPDATE_SETTINGS", payload: { biometricsEnabled: false } });695-      setBiometricStored(false);696-    } catch (e) {697-      setBiometricError(e instanceof Error ? e.message : "failed to disable biometrics");698-    } finally {699-      setBiometricBusy(false);700-    }701-  }702-703638   function resetPinFlow() {704639     setPinFlow(null);705640     setPinCurrentPassword("");@@ -1066,26 +1001,6 @@10661001                 {pinSaving() ? "removing…" : "remove PIN"}10671002               </button>10681003             </div>1069-          </Show>1070-1071-          <Show when={isNativePlatform() && biometricSupported()}>1072-            <p class="peach-settings-sub-label">BIOMETRICS</p>1073-            <div class="peach-settings-row">1074-              <span>Unlock with biometrics</span>1075-              <button1076-                class={`peach-toggle ${biometricStored() ? "is-on" : ""}`}1077-                onClick={() => void (biometricStored() ? handleDisableBiometrics() : handleEnableBiometrics())}1078-                disabled={biometricBusy()}1079-                aria-label="Toggle biometric unlock"1080-              >1081-                <span class="peach-toggle-knob"></span>1082-              </button>1083-            </div>1084-            <Show when={biometricError()}>1085-              <div class="peach-settings-row peach-settings-row--meta">1086-                <span class="peach-hint peach-hint-error">{biometricError()}</span>1087-              </div>1088-            </Show>10891004           </Show>10901005 10911006           <p class="peach-settings-sub-label">BREACH PROTECTION</p>

view file

src/popup/views/Vault.tsx+11 −29
@@ -92,10 +92,6 @@9292   sortOrder: SortOrder;9393   searchSeed?: string;9494   searchSeedNonce?: number;95-  // When true, render the mobile flat top bar: no back arrow (use swipe-down96-  // on the bottom sheet), search-glass icon on the left, counter on the97-  // right. Matches the extension's `.peach-search-bar` look.98-  isNative?: boolean;9995   onSelectEntry: (entry: VaultEntry) => void;10096   onSortOrderChange: (order: SortOrder) => void;10197   onAdd: () => void;@@ -346,33 +342,21 @@346342 347343   return (348344     <div class="peach-overlay-view ov-enter-up" style="display:flex">349-      <div class="peach-list-top" classList={{ "peach-list-top--native": !!props.isNative }}>350-        <Show351-          when={!props.isNative}352-          fallback={353-            <span class="peach-search-icon" aria-hidden="true">354-              <svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round">355-                <circle cx="5.5" cy="5.5" r="3.75" />356-                <path d="M8.75 8.75L11.5 11.5" />357-              </svg>358-            </span>359-          }345+      <div class="peach-list-top">346+        <button347+          class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"348+          onClick={props.onClose}349+          aria-label="Back"360350         >361-          <button362-            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"363-            onClick={props.onClose}364-            aria-label="Back"365-          >366-            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">367-              <path d="M6 1L1 6L6 11" />368-            </svg>369-          </button>370-        </Show>351+          <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">352+            <path d="M6 1L1 6L6 11" />353+          </svg>354+        </button>371355         <input372356           class="peach-search-input"373357           placeholder="search vault..."374358           autocomplete="off"375-          style={props.isNative ? undefined : "font-size:14px"}359+          style="font-size:14px"376360           ref={focusSearch}377361           value={search()}378362           onInput={(e) => setSearch((e.currentTarget as HTMLInputElement).value)}@@ -381,9 +365,7 @@381365         <span class="peach-search-counter peach-search-counter-list">382366           {search() || selectedTags().size > 0383367             ? `${filteredEntries().length} of ${props.entries.length}`384-            : props.isNative385-              ? `${props.entries.length}`386-              : `${props.entries.length} entries`}368+            : `${props.entries.length} entries`}387369         </span>388370       </div>389371 

view file

src/shared/native.ts+0 −23
@@ -1,23 +0,0 @@1-// Mobile platform detection helpers used by popup views to gate Capacitor-only paths.2-// Kept tiny and runtime-only — never import @capacitor/core at module scope, since3-// desktop builds must not pull it.4-5-// eslint-disable-next-line @typescript-eslint/no-explicit-any6-type Cap = { isNativePlatform?: () => boolean; Plugins?: Record<string, any> };7-8-function cap(): Cap | null {9-  const g = globalThis as unknown as { Capacitor?: Cap };10-  return g.Capacitor ?? null;11-}12-13-export function isNativePlatform(): boolean {14-  const c = cap();15-  if (!c) return false;16-  if (typeof c.isNativePlatform === "function") return c.isNativePlatform();17-  return false;18-}19-20-// eslint-disable-next-line @typescript-eslint/no-explicit-any21-export function nativePlugin<T = any>(name: string): T | null {22-  return (cap()?.Plugins?.[name] as T | undefined) ?? null;23-}

view file at the parent

src/shared/noise-js.ts+1 −1
@@ -1,5 +1,5 @@11 /**2- * Noise NK initiator for Android / Capacitor.2+ * Noise NK initiator for browser-to-browser sync.33  * Pattern: Noise_NK_25519_ChaChaPoly_BLAKE2s44  *55  * The protocol state machine lives in the canonical Rust `snow` core and is

view file

src/shared/types.ts+0 −4
@@ -349,16 +349,12 @@349349   | "PASSKEY_HAS_CREDENTIALS"350350   | "PASSKEY_CREATE"351351   | "PASSKEY_GET"352-  // Android / iOS message types — activate only when Capacitor.isNativePlatform() is true.353-  | "UNLOCK_WITH_KEY"354-  | "GET_DERIVED_KEY"355352   // Recovery word messages356353   | "VALIDATE_RECOVERY_WORDS"357354   | "RECOVER_WITH_WORDS"358355   | "GET_RECOVERY_WORDS"359356   | "GET_IDENTITY_SUGGESTIONS"360357   | "TOUCH_AUTO_LOCK"361-  | "POPULATE_INLINE_VAULT"362358   | "SAVE_NOTE"363359   | "DELETE_NOTE"364360   | "GET_NOTES"

view file

tests/a11y-totp-vault.spec.ts+0 −11
@@ -312,17 +312,6 @@312312     await ctx.close();313313   });314314 315-  test("VAULT03 - biometric unlock shows when available", async () => {316-    const ctx = await makeContext();317-    const extId = await waitForSW(ctx);318-    const popup = await ctx.newPage();319-    await popup.goto(`chrome-extension://${extId}/src/popup/index.html`);320-    await popup.waitForTimeout(2000);321-    const biometric = popup.locator("text=biometric, text=fingerprint, text=face id");322-    await biometric.first().waitFor({ state: "visible", timeout: 3000 }).catch(() => {});323-    await ctx.close();324-  });325-326315   test("VAULT04 - vault persists through navigation", async () => {327316     const ctx = await makeContext();328317     const { popup } = await setupVault(ctx);

view file

tests/lock-screen.spec.ts+0 −10
@@ -230,16 +230,6 @@230230     await ctx.close();231231   });232232 233-  test("LS18 - biometric unlock option if available", async () => {234-    const { popup } = await setupVault(ctx);235-    await lockVault(popup);236-    const biometricBtn = popup.locator("[aria-label*='biometric' i], [aria-label*='fingerprint' i], text=biometric");237-    const hasBiometric = await biometricBtn.isVisible({ timeout: 2000 }).catch(() => false);238-    if (hasBiometric) expect(true).toBeTruthy();239-    else expect(hasBiometric).toBeFalsy();240-    await ctx.close();241-  });242-243233   test("LS19 - lock transitions are smooth", async () => {244234     const { popup } = await setupVault(ctx);245235     const lockBtn = popup.locator(UNLOCKED);

view file