diff --git a/AGENTS.md b/AGENTS.md index 829a78d..1c395d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,4 +3,5 @@ - Never commit changes unless explicitly told to do so. Always ask first or wait for the user's command. - Never manually edit `.router/router.ts` — sv-router auto-generates it from the filesystem. Just create the route file in the correct filesystem location. - Only commit when explicitly told to do so. When asked to commit, always write a single simple line commit message. +- When asked to publish, always rebuild the DMG first (`bun run build:stable` from `apps/desktop`), wait for notarization to complete, verify with `xcrun stapler validate`, then publish the fresh DMG. Never publish a stale build. - At the end of every response, always surface a "Pending from you" section listing anything you need from the user (decisions, permissions, credentials, confirming a plan, etc.). If nothing is pending, say "Nothing pending." diff --git a/apps/desktop/src/bun/index.ts b/apps/desktop/src/bun/index.ts index 752e8be..5642121 100644 --- a/apps/desktop/src/bun/index.ts +++ b/apps/desktop/src/bun/index.ts @@ -13,7 +13,13 @@ import { createMoneroHandlers } from './rpc/monero' import { createXrpHandlers } from './rpc/xrp' import type { MoneroWalletState } from './lib/monero' import { canPromptTouchID, promptTouchID } from './lib/biometric' -import { getXrpWalletList } from './lib/xrp' +import { + getSecret, + setSecret, + deleteSecret, + clearSecretsCache, + VAULT_SERVICE, +} from './lib/secrets-cache' import type { RPC } from '../lib/rpc-schema' import { log } from './lib/logger' @@ -89,23 +95,8 @@ const rpc = BrowserView.defineRPC({ rpc.setRequestHandler({ resetApp: async () => { try { - const wallets = await getXrpWalletList() - await Promise.all([ - Bun.secrets.delete({ - service: 'koins', - name: 'xrp_wallets', - }), - ...wallets.flatMap((w) => [ - Bun.secrets.delete({ - service: 'koins', - name: w.vaultKey, - }), - Bun.secrets.delete({ - service: 'koins', - name: `xrp_auth_${w.id}`, - }), - ]), - ]) + await deleteSecret(VAULT_SERVICE, 'vault') + clearSecretsCache() console.log('[rpc] resetApp complete') return true } catch (e) { @@ -131,10 +122,10 @@ rpc.setRequestHandler({ } }, getSecret: async ({ name, service }) => { - return await Bun.secrets.get({ name, service }) + return await getSecret(service, name) }, setSecret: async ({ name, service, value }) => { - await Bun.secrets.set({ name, service, value }) + await setSecret(service, name, value) }, openExternal: async ({ url }) => { Utils.openExternal(url) diff --git a/apps/desktop/src/bun/lib/secrets-cache.ts b/apps/desktop/src/bun/lib/secrets-cache.ts new file mode 100644 index 0000000..604c716 --- /dev/null +++ b/apps/desktop/src/bun/lib/secrets-cache.ts @@ -0,0 +1,87 @@ +import { getENV } from './get-env' + +const env = getENV() +export const VAULT_SERVICE = env.appName +const VAULT_NAME = 'vault' + +const cache = new Map() +const key = (service: string, name: string) => `${service}:${name}` + +let loaded = false +let loadPromise: Promise | null = null +let writeChain: Promise = Promise.resolve() + +async function ensureLoaded(): Promise { + if (loaded) return + if (!loadPromise) { + loadPromise = (async () => { + const raw = await Bun.secrets.get({ + service: VAULT_SERVICE, + name: VAULT_NAME, + }) + if (raw) { + try { + const data = JSON.parse(raw) as Record + for (const [k, v] of Object.entries(data)) cache.set(k, v) + } catch (e) { + console.log('[secrets] vault parse error:', e) + } + } + loaded = true + })() + } + await loadPromise +} + +async function persist(): Promise { + const raw = JSON.stringify(Object.fromEntries(cache)) + writeChain = writeChain.then(() => + Bun.secrets.set({ + service: VAULT_SERVICE, + name: VAULT_NAME, + value: raw, + }), + ) + return writeChain +} + +export async function getSecret( + service: string, + name: string, +): Promise { + const k = key(service, name) + await ensureLoaded() + return cache.get(k) ?? null +} + +export async function setSecret( + service: string, + name: string, + value: string, +): Promise { + await ensureLoaded() + cache.set(key(service, name), value) + await persist() +} + +export async function deleteSecret( + service: string, + name: string, +): Promise { + if (service === VAULT_SERVICE && name === VAULT_NAME) { + cache.clear() + loaded = false + loadPromise = null + await Bun.secrets.delete({ service, name }) + return + } + await ensureLoaded() + cache.delete(key(service, name)) + await persist() +} + +export function clearSecretsCache(): void { + cache.clear() + loaded = false + loadPromise = null +} diff --git a/apps/desktop/src/bun/lib/xrp/index.ts b/apps/desktop/src/bun/lib/xrp/index.ts index 59e3ee1..d736d52 100644 --- a/apps/desktop/src/bun/lib/xrp/index.ts +++ b/apps/desktop/src/bun/lib/xrp/index.ts @@ -11,6 +11,7 @@ import type { XrpTxEntry, XrpWalletInfo, } from '../../../lib/rpc-schema' +import { getSecret, setSecret } from '../secrets-cache' const SERVER = 'wss://s1.ripple.com' const RIPPLE_EPOCH = 946684800 @@ -54,7 +55,7 @@ export function walletForSigning(secret: string): Wallet { } export async function getXrpWalletList(): Promise { - const raw = await Bun.secrets.get({ service: SERVICE, name: WALLET_LIST_KEY }) + const raw = await getSecret(SERVICE, WALLET_LIST_KEY) if (!raw) return [] try { return JSON.parse(raw) @@ -66,11 +67,7 @@ export async function getXrpWalletList(): Promise { export async function saveXrpWalletList( wallets: XrpWalletInfo[], ): Promise { - await Bun.secrets.set({ - service: SERVICE, - name: WALLET_LIST_KEY, - value: JSON.stringify(wallets), - }) + await setSecret(SERVICE, WALLET_LIST_KEY, JSON.stringify(wallets)) } export async function getBalance(address: string): Promise { diff --git a/apps/desktop/src/bun/native/biometric-helper b/apps/desktop/src/bun/native/biometric-helper index d6875f9..bc74588 100755 Binary files a/apps/desktop/src/bun/native/biometric-helper and b/apps/desktop/src/bun/native/biometric-helper differ diff --git a/apps/desktop/src/bun/rpc/xrp/index.ts b/apps/desktop/src/bun/rpc/xrp/index.ts index 4dcbcb4..2dc989c 100644 --- a/apps/desktop/src/bun/rpc/xrp/index.ts +++ b/apps/desktop/src/bun/rpc/xrp/index.ts @@ -10,6 +10,7 @@ import { getFee, } from '../../lib/xrp' import type { XrpWalletInfo } from '../../../lib/rpc-schema' +import { getSecret, setSecret, deleteSecret } from '../../lib/secrets-cache' const SERVICE = 'koins' @@ -30,7 +31,7 @@ export function createXrpHandlers() { const { address, seed } = generateWallet() const id = crypto.randomUUID() const vaultKey = `xrp_seed_${id}` - await Bun.secrets.set({ service: SERVICE, name: vaultKey, value: seed }) + await setSecret(SERVICE, vaultKey, seed) const wallets = await getXrpWalletList() const createdAt = new Date().toISOString() const info: XrpWalletInfo = { @@ -58,11 +59,7 @@ export function createXrpHandlers() { const { address } = walletFromSecret(secret) const id = crypto.randomUUID() const vaultKey = `xrp_seed_${id}` - await Bun.secrets.set({ - service: SERVICE, - name: vaultKey, - value: secret.trim(), - }) + await setSecret(SERVICE, vaultKey, secret.trim()) const wallets = await getXrpWalletList() const createdAt = new Date().toISOString() const info: XrpWalletInfo = { @@ -83,15 +80,15 @@ export function createXrpHandlers() { const wallet = wallets.find((w) => w.id === id) if (!wallet) throw new Error('Wallet not found') await Promise.all([ - Bun.secrets.delete({ service: SERVICE, name: wallet.vaultKey }), - Bun.secrets.delete({ service: SERVICE, name: `xrp_auth_${id}` }), + deleteSecret(SERVICE, wallet.vaultKey), + deleteSecret(SERVICE, `xrp_auth_${id}`), ]) await saveXrpWalletList(wallets.filter((w) => w.id !== id)) console.log('[rpc] xrpDeleteWallet complete') }, xrpGetSeed: async ({ vaultKey }: { vaultKey: string }) => { console.log('[rpc] xrpGetSeed:', vaultKey) - const seed = await Bun.secrets.get({ service: SERVICE, name: vaultKey }) + const seed = await getSecret(SERVICE, vaultKey) if (!seed) throw new Error('Seed not found in keychain') return seed },