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
↓ peach-browser-8de15d6a.tar.gzatlas at this save
files changed25 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 −10manifest.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": {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",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",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 = {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,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 }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 rowsrc/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 }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",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>}>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 }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) {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 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();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;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 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 }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 */}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>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 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-}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 issrc/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"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);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);