From 9cea9975064db1eda890c5f28f37d6d0058efad7 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 17 May 2026 00:19:22 +0100 Subject: [PATCH 01/27] chore: add @mymehq/sdk dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls in the Myme TypeScript SDK at 5.6.0. First step of the optional Myme integration — no runtime code uses it yet, but locking the version in for the rest of the work. --- package.json | 1 + pnpm-lock.yaml | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/package.json b/package.json index 6323031..b25fb87 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", + "@mymehq/sdk": "^5.6.0", "@radix-ui/react-slot": "^1.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbf3fc9..2682e3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@electron-toolkit/utils': specifier: ^4.0.0 version: 4.0.0(electron@42.0.1) + '@mymehq/sdk': + specifier: ^5.6.0 + version: 5.6.0 '@radix-ui/react-slot': specifier: ^1.2.0 version: 1.2.4(@types/react@19.2.14)(react@19.2.6) @@ -772,6 +775,12 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} + '@mymehq/sdk@5.6.0': + resolution: {integrity: sha512-QPjcmRKtdBp6xY3s7CvPPzjFYzz923YyL1yWz3fp/AqeAW7fiCgHe9AcLYhISSGLDhzGVUfUVff7B2SRlYEp1Q==} + + '@mymehq/shared@5.6.0': + resolution: {integrity: sha512-rlvyNn/cmVijh6/jmhGfQeRQlMD9Dci8fJXmopWtOsPOLEiWKzuWVRyN3+rF6iN57dOjljmFgfUlTZxYg2kYTQ==} + '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -4025,6 +4034,10 @@ packages: utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + uuidv7@1.2.1: + resolution: {integrity: sha512-4kPkK3/XTQW9Hbm4CaqfICn+kY9LJtDVEOfgsRRra/+n2Ofg4NqzRFceAkxvQ/Ud/6BpHOPzj8cirqM7TzTN5Q==} + hasBin: true + verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -4808,6 +4821,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@mymehq/sdk@5.6.0': + dependencies: + '@mymehq/shared': 5.6.0 + + '@mymehq/shared@5.6.0': + dependencies: + uuidv7: 1.2.1 + zod: 4.3.6 + '@pkgr/core@0.2.9': {} '@radix-ui/number@1.1.1': {} @@ -8460,6 +8482,8 @@ snapshots: utf8-byte-length@1.0.5: {} + uuidv7@1.2.1: {} + verror@1.10.1: dependencies: assert-plus: 1.0.0 From 4b07aa8a9fca02b58b8049a95a0996722bddc728 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 17 May 2026 00:23:31 +0100 Subject: [PATCH 02/27] feat(myme): register superwhisper.recording + superwhisper.session types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two custom-type schemas that the Myme integration will write against (recording inherits from core.note; session is standalone) and a one-off registration script that posts them to the configured tenant. The script reads ~/.myme/admin.json for direct-admin access; the runtime integration uses the OAuth device flow instead. Types are registered against staging.myme.so as part of bringing this milestone up — re-running the script is a no-op upsert. --- scripts/myme-register-types.mts | 73 ++++++++++++++++++++++++++ src/main/myme/schemas.ts | 92 +++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 scripts/myme-register-types.mts create mode 100644 src/main/myme/schemas.ts diff --git a/scripts/myme-register-types.mts b/scripts/myme-register-types.mts new file mode 100644 index 0000000..338d0a3 --- /dev/null +++ b/scripts/myme-register-types.mts @@ -0,0 +1,73 @@ +/** + * One-shot script: register the Superwhisper integration's custom types + * against a Myme tenant. + * + * Reads admin credentials from `~/.myme/admin.json` (the + * machine-local admin token used for direct-API operations), registers + * `superwhisper.recording` and `superwhisper.session`, then verifies + * each by reading back via `types.get`. + * + * Idempotent — already-registered types resolve as an upsert in Myme, + * not a 409. Re-run safely. + * + * Not part of the runtime integration; not bundled into the app. The + * production shape (integration code registering its own types on first + * connect-after-OAuth) is out of scope for the worktree experiment. + * + * Run with: + * pnpm dlx tsx scripts/myme-register-types.ts + */ + +import { readFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' +import { MymeClient, MymeError } from '@mymehq/sdk' +import { ALL_SCHEMAS } from '../src/main/myme/schemas' + +interface AdminCreds { + url: string + key: string + label?: string +} + +function loadAdminCreds(): AdminCreds { + const path = join(homedir(), '.myme', 'admin.json') + const raw = readFileSync(path, 'utf-8') + const parsed = JSON.parse(raw) as Partial + if (typeof parsed.url !== 'string' || typeof parsed.key !== 'string') { + throw new Error(`~/.myme/admin.json missing url or key fields`) + } + return { url: parsed.url, key: parsed.key, label: parsed.label } +} + +async function main(): Promise { + const creds = loadAdminCreds() + console.log(`[register] using ${creds.label ?? 'admin'} @ ${creds.url}`) + + const client = new MymeClient({ url: creds.url, apiKey: creds.key }) + + for (const schema of ALL_SCHEMAS) { + try { + const registered = await client.types.register(schema) + console.log(`[register] ${registered.id}: registered (version ${registered.version})`) + } catch (err) { + if (err instanceof MymeError) { + console.error(`[register] ${schema.id}: ${err.message}`) + throw err + } + throw err + } + + // Read back to verify. + const fetched = await client.types.get(schema.id) + const fieldKeys = Object.keys(fetched.fields).sort().join(', ') + console.log(`[register] ${fetched.id}: verified — fields = ${fieldKeys}`) + } + + console.log('[register] done') +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) diff --git a/src/main/myme/schemas.ts b/src/main/myme/schemas.ts new file mode 100644 index 0000000..78c9b43 --- /dev/null +++ b/src/main/myme/schemas.ts @@ -0,0 +1,92 @@ +import type { TypeSchema } from '@mymehq/sdk' + +/** + * Myme custom-type schemas registered by this integration. + * + * Two types, both under the `superwhisper.*` publisher namespace: + * `superwhisper.recording` (inherits from `core.note`) and + * `superwhisper.session` (standalone, analytics-app-only). + * + * The schemas live here so both the one-off registration script + * (`scripts/myme-register-types.ts`) and the projection layer + * (`projection.ts`) share a single source of truth for field names. + * + * Caveats noted in the running-log artifact: + * + * - `core.file.audio` has a required `blob_ref`, so v1 cannot push the + * audio file as a separate item without uploading the bytes. The + * transcript on `superwhisper.recording.body` is the load-bearing + * data — audio is deferred. + * - The spec's field-shape examples (`{ type: 'array', items: { type: + * 'object' } }`) don't compile against the SDK's `FieldDefinition` + * (which uses `items_type: string`). The schemas below use the SDK + * shape. + * - The spec's `superwhisper.session` declares `merge_policy.fields.title` + * but doesn't list `title` in `fields`; `MergePolicy` requires every + * field-keyed entry to exist in `fields`, so `title` is added below. + */ + +export const SOURCE = 'superwhisper-analytics' + +export const SUPERWHISPER_RECORDING: TypeSchema = { + id: 'superwhisper.recording', + parent: 'core.note', + label: 'Superwhisper recording', + description: 'A voice recording captured by Superwhisper, with transcript and metadata.', + version: 1, + fields: { + // body / title / language inherited from core.note + segments: { + type: 'array', + items_type: 'object', + description: 'Word-timestamped transcript segments.' + }, + raw_result: { + type: 'string', + description: 'Raw, unprocessed transcript (Superwhisper rawResult).' + }, + duration_seconds: { type: 'number', description: 'Recording length in seconds.' }, + model: { type: 'string', description: 'Transcription model used.' }, + mode: { type: 'string', description: 'Superwhisper mode (dictation, command, etc.).' }, + device: { type: 'string', description: 'Recording device label.' }, + app_version: { + type: 'string', + description: 'Superwhisper app version that captured the recording.' + }, + datetime: { type: 'datetime', description: 'Recording start time (not item creation time).' } + }, + merge_policy: { + fields: { + body: 'keep_both_copies' + }, + default: 'last_writer_wins' + } +} + +export const SUPERWHISPER_SESSION: TypeSchema = { + id: 'superwhisper.session', + label: 'Superwhisper session', + description: + 'A gap-grouped set of recordings, derived locally by the Superwhisper Analytics app.', + version: 1, + fields: { + title: { type: 'string', description: 'Free-text user-naming field. Empty by default.' }, + started_at: { type: 'datetime', description: 'First recording in the session.' }, + ended_at: { type: 'datetime', description: 'End of the last recording in the session.' }, + recording_count: { type: 'number', description: 'Number of recordings in this session.' }, + total_duration_seconds: { type: 'number', description: 'Sum of recording durations.' }, + dominant_mode: { type: 'string', description: 'Most-used Superwhisper mode in the session.' }, + gap_threshold_minutes: { + type: 'number', + description: 'Threshold (minutes) that defined session boundaries when this was minted.' + } + }, + merge_policy: { + fields: { + title: 'keep_both_copies' + }, + default: 'last_writer_wins' + } +} + +export const ALL_SCHEMAS: TypeSchema[] = [SUPERWHISPER_RECORDING, SUPERWHISPER_SESSION] From 5585bb62ece1c553f6bb32b58daebaae6c81d6fc Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 17 May 2026 00:32:36 +0100 Subject: [PATCH 03/27] feat(myme): settings card + IPC scaffold for Myme integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Settings → Integrations → Myme card, the IPC surface for the integration, and a stub main-side module that satisfies the contract end-to-end. The card renders four states off the MymeStatus discriminated union (disconnected / connecting / connected / syncing) plus a renderer-composed 'disabled' state when demo mode is on or no recordings path is configured. Endpoint defaults to https://staging.myme.so and persists in config.json via an additive migration. Real OAuth + sync land in milestones 3+; the stubs let the renderer exercise every state today. Smoke-tested in dev: the card renders correctly in disconnected, connecting, and disabled states against the real 11.8k-recording dataset. --- src/main/config.ts | 15 +- src/main/ipc.ts | 18 +- src/main/myme/index.ts | 87 +++++ src/preload/api.ts | 73 +++- .../src/components/settings/MymeCard.tsx | 338 ++++++++++++++++++ src/renderer/src/screens/Settings.tsx | 5 +- src/renderer/src/state/mymeStore.ts | 65 ++++ 7 files changed, 596 insertions(+), 5 deletions(-) create mode 100644 src/main/myme/index.ts create mode 100644 src/renderer/src/components/settings/MymeCard.tsx create mode 100644 src/renderer/src/state/mymeStore.ts diff --git a/src/main/config.ts b/src/main/config.ts index 4b1c9d3..574814c 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -21,6 +21,8 @@ const CANDIDATE_PATHS = [ join(homedir(), 'Services/superwhisper/recordings') ] +const DEFAULT_MYME_ENDPOINT = 'https://staging.myme.so' + function defaultConfig(): Config { return { superwhisperPath: null, @@ -29,7 +31,8 @@ function defaultConfig(): Config { transcriptsOnly: false, demoMode: false, autoHideSidebar: true, - devTools: false + devTools: false, + myme: { endpoint: DEFAULT_MYME_ENDPOINT } } } @@ -64,6 +67,13 @@ export function getConfig(): Config { const missing = DEFAULT_FILLER_PHRASES.filter((p) => !known.has(p.toLowerCase())) return missing.length === 0 ? persisted : normalisePhrases([...persisted, ...missing]) })() + // Additive migration: pre-Myme configs have no `myme` block; default + // the endpoint to staging on first read so the Settings card has a + // sensible value to display without forcing the user to type it in. + const mymeEndpoint = + typeof parsed.myme?.endpoint === 'string' && parsed.myme.endpoint.length > 0 + ? parsed.myme.endpoint + : DEFAULT_MYME_ENDPOINT return { superwhisperPath: parsed.superwhisperPath ?? null, fillerWords, @@ -73,7 +83,8 @@ export function getConfig(): Config { // Default ON when absent — first-launch behaviour is auto-hide on // narrow windows, which matches the plan's UX intent. autoHideSidebar: parsed.autoHideSidebar !== false, - devTools: parsed.devTools === true + devTools: parsed.devTools === true, + myme: { endpoint: mymeEndpoint } } } catch (err) { console.warn('[config] failed to read config.json, falling back to defaults:', err) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index ea6793d..63a87d8 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -12,8 +12,9 @@ import { import { hydrate, reindex, setFillerWords } from './cache' import { checkForUpdatesManually, getUpdaterStatus, type UpdaterStatus } from './updater' import { disableWatch, enableWatch } from './watcher' +import * as myme from './myme' import { validBool, validString, validStringArray } from './validators' -import type { ConfigStatus } from '../preload/api' +import type { ConfigStatus, MymeStatus } from '../preload/api' /** * Central IPC registration. Called once on app ready. @@ -153,6 +154,21 @@ export function registerIpcHandlers(): void { return getUpdaterStatus() }) + // Myme integration — optional, off by default. The renderer composes + // the "disabled" UX (demo mode / no path) from configStore; main + // always reports the sync engine's actual state. Push notifications + // go out on `myme:status` from `src/main/myme/index.ts` whenever the + // status transitions. + ipcMain.handle('myme:status', (): MymeStatus => myme.getStatus()) + ipcMain.handle('myme:setEndpoint', (_, url: unknown): MymeStatus => { + if (!validString(url)) return myme.getStatus() + if (!/^https?:\/\//i.test(url)) return myme.getStatus() + return myme.setEndpoint(url) + }) + ipcMain.handle('myme:connect', (): Promise => myme.connect()) + ipcMain.handle('myme:disconnect', (): Promise => myme.disconnect()) + ipcMain.handle('myme:syncNow', (): Promise => myme.syncNow()) + // Apply the persisted watch-folder preference on startup. syncWatcher() } diff --git a/src/main/myme/index.ts b/src/main/myme/index.ts new file mode 100644 index 0000000..ed7b2ee --- /dev/null +++ b/src/main/myme/index.ts @@ -0,0 +1,87 @@ +import { BrowserWindow } from 'electron' +import type { MymeStatus } from '../../preload/api' +import { getConfig, setConfig } from '../config' + +/** + * Public surface of the Myme integration module — used by `ipc.ts`. + * + * State machine (mirrors `MymeStatus`): + * + * disconnected ─ connect ─▶ connecting + * │ + * ▼ (device flow approved) + * disconnected ◀── disconnect ─ connected ◀──┐ + * │ │ (sync complete) + * ▼ │ + * syncing ────────┘ + * + * The `disabled` state (demo mode on, or no recordings path) is composed + * in the renderer from `configStore` — main always reports the engine's + * actual state regardless. + * + * Milestone 2 ships the wire shape + status broadcaster only. + * `connect` / `disconnect` / `syncNow` are stubbed: they update status + * to a deterministic placeholder so the renderer can render every card + * state, but no real OAuth or sync runs yet. Milestones 3+ fill these in. + */ + +const STATUS_CHANNEL = 'myme:status' + +let currentStatus: MymeStatus = buildInitialStatus() + +function buildInitialStatus(): MymeStatus { + const endpoint = getConfig().myme.endpoint + // Token presence (and thus the connected vs disconnected boot state) + // is wired in milestone 3. For now we always start disconnected. + return { kind: 'disconnected', endpoint } +} + +function setStatus(next: MymeStatus): void { + currentStatus = next + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue + win.webContents.send(STATUS_CHANNEL, next) + } +} + +export function getStatus(): MymeStatus { + return currentStatus +} + +export function setEndpoint(url: string): MymeStatus { + const trimmed = url.trim() + setConfig({ myme: { endpoint: trimmed } }) + // Keep the rest of the current status shape — only the endpoint + // changes. Today that's always `disconnected`; future states (e.g. + // `connected` with a stale endpoint) become a milestone-3 concern. + setStatus({ ...currentStatus, endpoint: trimmed } as MymeStatus) + return currentStatus +} + +/** Stub — milestone 3 wires the real OAuth device flow. */ +export async function connect(): Promise { + const endpoint = getConfig().myme.endpoint + setStatus({ + kind: 'connecting', + endpoint, + userCode: 'XXXX-XXXX', + verificationUri: `${endpoint}/oauth/device`, + verificationUriComplete: `${endpoint}/oauth/device?user_code=XXXX-XXXX`, + expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() + }) + return currentStatus +} + +/** Stub — milestone 3 wires the real disconnect path (revoke token, + * clear sync state, etc.). */ +export async function disconnect(): Promise { + setStatus({ kind: 'disconnected', endpoint: getConfig().myme.endpoint }) + return currentStatus +} + +/** Stub — milestone 4 wires the sync engine. */ +export async function syncNow(): Promise { + // No-op while disconnected so the stub respects the state machine. + if (currentStatus.kind !== 'connected') return currentStatus + return currentStatus +} diff --git a/src/preload/api.ts b/src/preload/api.ts index 74d555b..52069ba 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -42,6 +42,12 @@ export interface Config { autoHideSidebar: boolean /** When true, DevTools open on launch. Equivalent to Cmd+Option+I. */ devTools: boolean + /** Optional Myme integration settings. The endpoint URL is plain + * config; OAuth tokens live in encrypted storage via Electron's + * safeStorage, sync state in its own JSON file. */ + myme: { + endpoint: string + } } /** @@ -86,6 +92,48 @@ export type UpdaterStatus = | { kind: 'downloaded'; version: string } | { kind: 'error'; message: string } +/** + * Status of the optional Myme integration. Mirrors the implementation in + * `src/main/myme/index.ts`; lives here so the IPC contract is one place. + * + * The renderer composes the "disabled" UX (when demo mode is on or no + * recordings path is set) from `configStore` — main always reports the + * sync engine's actual state. Card states the user sees: + * + * - `disconnected` → endpoint + "Connect to Myme" button + * - `connecting` → device-flow code + verification URI + * - `connected` (no error) → "last synced …" + "Sync now" + * - `connected` (with error) → as above + inline error row + * - `syncing` → progress phase + processed/total + */ +export type MymeSyncPhase = 'preparing' | 'recordings' | 'sessions' + +export type MymeStatus = + | { kind: 'disconnected'; endpoint: string } + | { + kind: 'connecting' + endpoint: string + userCode: string + verificationUri: string + verificationUriComplete: string + expiresAt: string + } + | { + kind: 'connected' + endpoint: string + /** ISO; null until the first successful sync. */ + lastSyncedAt: string | null + /** Set after a failed sync; cleared on the next success. */ + lastError: string | null + } + | { + kind: 'syncing' + endpoint: string + phase: MymeSyncPhase + processed: number + total: number + } + export const api = { config: { status: (): Promise => ipcRenderer.invoke('config:status'), @@ -153,7 +201,30 @@ export const api = { return () => ipcRenderer.removeListener('updater:status', listener) } }, - openExternal: (url: string): Promise => ipcRenderer.invoke('shell:openExternal', url) + openExternal: (url: string): Promise => ipcRenderer.invoke('shell:openExternal', url), + myme: { + /** Current sync-engine status. The renderer composes the + * "disabled" state from configStore (demoMode / null path); main + * always reports `disconnected` / `connected` / etc. */ + status: (): Promise => ipcRenderer.invoke('myme:status'), + /** Persist a new Myme endpoint URL. Returns the updated status. */ + setEndpoint: (url: string): Promise => ipcRenderer.invoke('myme:setEndpoint', url), + /** Kick off the OAuth device flow. Resolves with the device-flow + * handle; the renderer subscribes to `onStatus` to find out when + * the user has approved (status transitions to `connected`). */ + connect: (): Promise => ipcRenderer.invoke('myme:connect'), + /** Revoke the persisted token + clear sync state. */ + disconnect: (): Promise => ipcRenderer.invoke('myme:disconnect'), + /** Manual sync trigger. Returns when the sync completes (success + * or otherwise); intermediate progress lands via `onStatus`. */ + syncNow: (): Promise => ipcRenderer.invoke('myme:syncNow'), + /** Subscribe to status changes from the sync engine. */ + onStatus: (handler: (status: MymeStatus) => void): Unsubscribe => { + const listener = (_e: unknown, payload: MymeStatus): void => handler(payload) + ipcRenderer.on('myme:status', listener) + return () => ipcRenderer.removeListener('myme:status', listener) + } + } } export type Api = typeof api diff --git a/src/renderer/src/components/settings/MymeCard.tsx b/src/renderer/src/components/settings/MymeCard.tsx new file mode 100644 index 0000000..549003b --- /dev/null +++ b/src/renderer/src/components/settings/MymeCard.tsx @@ -0,0 +1,338 @@ +import { SettingsCard } from './SettingsCard' +import { cn } from '@renderer/lib/cn' +import { useConfigStore } from '@renderer/state/configStore' +import { useMymeStore } from '@renderer/state/mymeStore' +import { Cloud, CloudOff, RefreshCw } from 'lucide-react' +import { useEffect, useState } from 'react' +import type { MymeStatus } from '../../../../preload/api' + +/** + * Settings → Integrations → Myme card. + * + * One card, five effective states: + * + * 1. `disabled` — demo mode is on, or no recordings path. Card is + * greyed out; sync engine is inert. + * 2. `disconnected` — endpoint URL + "Connect to Myme" button. + * 3. `connecting` — show the device-flow code and verification URI. + * 4. `connected` — last synced time + "Sync now". If `lastError` + * is set, an inline error row appears below. + * 5. `syncing` — progress text only. No cancel — initial sync of + * ~11k recordings takes ~20–30s; let it run. + * + * Failure paths never reach the main app — Myme is optional, so its + * problems stay in this card. (Deliberate departure from how scan + * errors surface elsewhere.) + */ +export function MymeCard(): React.JSX.Element { + const path = useConfigStore((s) => s.path) + const demoMode = useConfigStore((s) => s.demoMode) + const status = useMymeStore((s) => s.status) + const hydrated = useMymeStore((s) => s.hydrated) + const hydrate = useMymeStore((s) => s.hydrate) + + useEffect(() => { + void hydrate() + }, [hydrate]) + + const disabledReason: 'demo-mode' | 'no-recordings-path' | null = demoMode + ? 'demo-mode' + : !path + ? 'no-recordings-path' + : null + + return ( + + {disabledReason ? ( + + ) : !hydrated || !status ? ( + + ) : ( + + )} + + ) +} + +function DisabledBody({ + reason +}: { + reason: 'demo-mode' | 'no-recordings-path' +}): React.JSX.Element { + const message = + reason === 'demo-mode' + ? 'Disabled while demo mode is on. Toggle demo off to enable.' + : 'Disabled until a recordings folder is configured.' + return

{message}

+} + +function PendingBody(): React.JSX.Element { + return

Loading…

+} + +function StatusBody({ status }: { status: MymeStatus }): React.JSX.Element { + switch (status.kind) { + case 'disconnected': + return + case 'connecting': + return ( + + ) + case 'connected': + return ( + + ) + case 'syncing': + return + } +} + +const CHROME_BUTTON = + 'inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-floating px-3 text-[12px] text-foreground transition-colors hover:bg-foreground/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-floating' + +function DisconnectedBody({ endpoint }: { endpoint: string }): React.JSX.Element { + const setEndpoint = useMymeStore((s) => s.setEndpoint) + const connect = useMymeStore((s) => s.connect) + const [draft, setDraft] = useState(endpoint) + const [busy, setBusy] = useState(false) + + const trimmed = draft.trim() + const looksValid = /^https?:\/\/[^\s]+$/i.test(trimmed) + const changed = trimmed !== endpoint && looksValid + + async function commitEndpoint(): Promise { + if (!changed) return + setBusy(true) + try { + await setEndpoint(trimmed) + } finally { + setBusy(false) + } + } + + async function doConnect(): Promise { + setBusy(true) + try { + // Make sure the endpoint is persisted before kicking off the flow + // so the connect path uses the user's intended URL even if they + // didn't blur out of the input first. + if (changed) await setEndpoint(trimmed) + await connect() + } finally { + setBusy(false) + } + } + + return ( +
+ +
+ +
+
+ ) +} + +function ConnectingBody({ + userCode, + verificationUri, + verificationUriComplete +}: { + userCode: string + verificationUri: string + verificationUriComplete: string +}): React.JSX.Element { + const disconnect = useMymeStore((s) => s.disconnect) + function openLink(): void { + void window.api.openExternal(verificationUriComplete) + } + function cancel(): void { + void disconnect() + } + return ( +
+

+ Approve this app in Myme to finish connecting. Open the link below and confirm the code + matches. +

+
+
User code
+
+ {userCode} +
+
{verificationUri}
+
+
+ + +
+
+ ) +} + +function ConnectedBody({ + endpoint, + lastSyncedAt, + lastError +}: { + endpoint: string + lastSyncedAt: string | null + lastError: string | null +}): React.JSX.Element { + const syncNow = useMymeStore((s) => s.syncNow) + const disconnect = useMymeStore((s) => s.disconnect) + const [busy, setBusy] = useState(false) + // Re-render once a minute so "5m ago" drifts without a custom hook. + const [, setNow] = useState(() => Date.now()) + useEffect(() => { + const t = window.setInterval(() => setNow(Date.now()), 60_000) + return () => window.clearInterval(t) + }, []) + + async function doSync(): Promise { + setBusy(true) + try { + await syncNow() + } finally { + setBusy(false) + } + } + async function doDisconnect(): Promise { + setBusy(true) + try { + await disconnect() + } finally { + setBusy(false) + } + } + + return ( +
+
+ + +
+ {lastError && ( +

+ Last sync failed: {lastError} +

+ )} +
+ + +
+
+ ) +} + +function SyncingBody({ + phase, + processed, + total +}: { + phase: 'preparing' | 'recordings' | 'sessions' + processed: number + total: number +}): React.JSX.Element { + const label = + phase === 'preparing' + ? 'Preparing…' + : phase === 'recordings' + ? 'Syncing recordings' + : 'Syncing sessions' + return ( +
+
+ + + {label} + + {total > 0 && ( + + {processed.toLocaleString()} / {total.toLocaleString()} + + )} +
+
+ ) +} + +function Row({ k, v }: { k: string; v: string }): React.JSX.Element { + return ( +
+
{k}
+
+ {v} +
+
+ ) +} + +function formatRelative(iso: string): string { + const then = new Date(iso).getTime() + if (!Number.isFinite(then)) return iso + const diffSec = Math.floor((Date.now() - then) / 1000) + if (diffSec < 60) return 'Just now' + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + const diffHr = Math.floor(diffMin / 60) + if (diffHr < 24) return `${diffHr}h ago` + const diffD = Math.floor(diffHr / 24) + return `${diffD}d ago` +} diff --git a/src/renderer/src/screens/Settings.tsx b/src/renderer/src/screens/Settings.tsx index f083fed..7d49069 100644 --- a/src/renderer/src/screens/Settings.tsx +++ b/src/renderer/src/screens/Settings.tsx @@ -1,4 +1,5 @@ import { AppearancePicker } from '@renderer/components/settings/AppearancePicker' +import { MymeCard } from '@renderer/components/settings/MymeCard' import { SettingsCard } from '@renderer/components/settings/SettingsCard' import { SegmentedTabs } from '@renderer/components/ui/SegmentedTabs' import { Switch } from '@renderer/components/ui/Switch' @@ -25,11 +26,12 @@ import { import { useEffect, useMemo, useState } from 'react' import type { UpdaterStatus } from '../../../preload/api' -type SettingsTab = 'general' | 'data' | 'about' +type SettingsTab = 'general' | 'data' | 'integrations' | 'about' const SETTINGS_TABS: ReadonlyArray<{ id: SettingsTab; label: string }> = [ { id: 'general', label: 'General' }, { id: 'data', label: 'Data' }, + { id: 'integrations', label: 'Integrations' }, { id: 'about', label: 'About' } ] @@ -75,6 +77,7 @@ export function Settings(): React.JSX.Element { )} + {tab === 'integrations' && } {tab === 'about' && ( <> diff --git a/src/renderer/src/state/mymeStore.ts b/src/renderer/src/state/mymeStore.ts new file mode 100644 index 0000000..9ee826b --- /dev/null +++ b/src/renderer/src/state/mymeStore.ts @@ -0,0 +1,65 @@ +import { create } from 'zustand' +import type { MymeStatus } from '../../../preload/api' + +/** + * Renderer mirror of the Myme integration's sync-engine status. + * + * Hydrated once via `window.api.myme.status()` on first use, then kept + * fresh by the `myme:status` push channel. The card subscribes to this + * store and renders the four card-states (disconnected / connecting / + * connected{,-with-error} / syncing) directly off the discriminated + * union. The fifth state — `disabled` — is composed in the card from + * `configStore.demoMode` + `configStore.path`; main doesn't know about + * it. + * + * The action wrappers (`connect`, `disconnect`, `syncNow`, `setEndpoint`) + * thread results back into the store optimistically. The push channel + * remains the canonical source of truth — it catches state transitions + * the round-trip doesn't (e.g. device-flow approval landing in the + * background). + */ + +interface MymeState { + status: MymeStatus | null + hydrated: boolean + hydrate: () => Promise + setEndpoint: (url: string) => Promise + connect: () => Promise + disconnect: () => Promise + syncNow: () => Promise +} + +export const useMymeStore = create((set, get) => ({ + status: null, + hydrated: false, + + hydrate: async () => { + if (get().hydrated) return + const initial = await window.api.myme.status() + set({ status: initial, hydrated: true }) + // Subscribe to push updates. The store is a singleton in the + // renderer, so we leak no handlers on hot-reload — the IPC channel + // is one-per-window and tears down with the window. + window.api.myme.onStatus((next) => set({ status: next })) + }, + + setEndpoint: async (url) => { + const next = await window.api.myme.setEndpoint(url) + set({ status: next }) + }, + + connect: async () => { + const next = await window.api.myme.connect() + set({ status: next }) + }, + + disconnect: async () => { + const next = await window.api.myme.disconnect() + set({ status: next }) + }, + + syncNow: async () => { + const next = await window.api.myme.syncNow() + set({ status: next }) + } +})) From e7d88fb286d5a78c419838e0ff1bd7d91b842b20 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 17 May 2026 00:49:29 +0100 Subject: [PATCH 04/27] feat(myme): wire end-to-end auth via safeStorage-backed API key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connects the Settings → Integrations → Myme card to the Myme SDK end-to-end. The 'Connect' affordance now opens a paste-API-key pane; the supplied key is verified via items.stats(), encrypted with Electron's safeStorage (Keychain-backed on macOS) and persisted to /myme-credential.enc. Boot-time credential probe restores the connected state across launches. Bypasses the OAuth device flow the integration spec mandates — staging's OAuth path can't be bootstrapped via dynamic client registration today, and the well-known doesn't advertise the device_code grant. See the running log for the full chain of findings; API key is the worktree-experiment substitute that unblocks milestones 4+. Also fixes the @mymehq/sdk ESM-only packaging issue by adding the SDK to electron-vite's externalizeDeps exclude list — without this, the bundled main process tries to require() the ESM SDK at runtime and crashes with ERR_PACKAGE_PATH_NOT_EXPORTED. Smoke-tested against staging.myme.so: paste-and-connect with a valid key flips to connected; bad key surfaces 'Invalid API key' inline; disconnect clears the credential file and reverts to disconnected. --- electron.vite.config.ts | 10 +- src/main/ipc.ts | 8 +- src/main/myme/client.ts | 44 ++++++ src/main/myme/index.ts | 145 +++++++++++++----- src/main/myme/tokens.ts | 92 +++++++++++ src/preload/api.ts | 34 ++-- .../src/components/settings/MymeCard.tsx | 86 +++++++---- src/renderer/src/state/mymeStore.ts | 6 + 8 files changed, 342 insertions(+), 83 deletions(-) create mode 100644 src/main/myme/client.ts create mode 100644 src/main/myme/tokens.ts diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 93c1044..7c30e48 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -12,7 +12,15 @@ const sharedAlias = { '@shared': resolve('src/shared') } export default defineConfig({ main: { - resolve: { alias: sharedAlias } + resolve: { alias: sharedAlias }, + build: { + // electron-vite's default `externalizeDepsPlugin` marks every + // entry in package.json `dependencies` as a runtime CJS require. + // The Myme SDK is ESM-only — `require('@mymehq/sdk')` blows up + // with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Exclude the two packages + // here so they're bundled into the main process output instead. + externalizeDeps: { exclude: ['@mymehq/sdk', '@mymehq/shared'] } + } }, preload: { resolve: { alias: sharedAlias } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 63a87d8..e36621b 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -165,8 +165,12 @@ export function registerIpcHandlers(): void { if (!/^https?:\/\//i.test(url)) return myme.getStatus() return myme.setEndpoint(url) }) - ipcMain.handle('myme:connect', (): Promise => myme.connect()) - ipcMain.handle('myme:disconnect', (): Promise => myme.disconnect()) + ipcMain.handle('myme:connect', (): MymeStatus => myme.connect()) + ipcMain.handle('myme:submitApiKey', (_, key: unknown): Promise => { + if (!validString(key)) return Promise.resolve(myme.getStatus()) + return myme.submitApiKey(key) + }) + ipcMain.handle('myme:disconnect', (): MymeStatus => myme.disconnect()) ipcMain.handle('myme:syncNow', (): Promise => myme.syncNow()) // Apply the persisted watch-folder preference on startup. diff --git a/src/main/myme/client.ts b/src/main/myme/client.ts new file mode 100644 index 0000000..b188744 --- /dev/null +++ b/src/main/myme/client.ts @@ -0,0 +1,44 @@ +import { MymeClient } from '@mymehq/sdk' +import { getConfig } from '../config' +import { readCredential } from './tokens' + +/** + * Cached `MymeClient` instance built from the persisted endpoint + the + * decrypted credential. Invalidated whenever the credential is cleared + * (on disconnect) or the endpoint changes (`setEndpoint`). + * + * The plan called for an OAuth `TokenProvider` here. Staging's OAuth + * path can't bootstrap a device-flow client today (see the running + * log), so we use the SDK's static `apiKey` credential instead — same + * `MymeClient` surface, simpler bootstrapping. The shape of this file + * stays unchanged when OAuth becomes viable: build the client from a + * `tokenProvider` instead of `apiKey`. + */ + +let cached: { client: MymeClient; endpoint: string; key: string } | null = null + +/** + * Return a configured `MymeClient`, or null if the integration has no + * credential yet (the renderer is responsible for not calling sync APIs + * in that state, but we guard defensively). + */ +export function getClient(): MymeClient | null { + const endpoint = getConfig().myme.endpoint + const key = readCredential() + if (!key) { + cached = null + return null + } + if (cached && cached.endpoint === endpoint && cached.key === key) { + return cached.client + } + const client = new MymeClient({ url: endpoint, apiKey: key }) + cached = { client, endpoint, key } + return client +} + +/** Drop the cached client. Call on disconnect / endpoint change so the + * next `getClient()` rebuilds against fresh credentials. */ +export function invalidateClient(): void { + cached = null +} diff --git a/src/main/myme/index.ts b/src/main/myme/index.ts index ed7b2ee..835cce3 100644 --- a/src/main/myme/index.ts +++ b/src/main/myme/index.ts @@ -1,28 +1,36 @@ import { BrowserWindow } from 'electron' -import type { MymeStatus } from '../../preload/api' +import { MymeError, UnauthorizedError } from '@mymehq/sdk' +import type { MymeStatus, MymeSyncPhase } from '../../preload/api' import { getConfig, setConfig } from '../config' +import { getClient, invalidateClient } from './client' +import { clearCredential, credentialExists, readCredential, writeCredential } from './tokens' /** * Public surface of the Myme integration module — used by `ipc.ts`. * * State machine (mirrors `MymeStatus`): * - * disconnected ─ connect ─▶ connecting - * │ - * ▼ (device flow approved) - * disconnected ◀── disconnect ─ connected ◀──┐ - * │ │ (sync complete) - * ▼ │ - * syncing ────────┘ + * disconnected ── connect ──────▶ connecting + * │ submitApiKey + * ▼ (verify success) + * disconnected ◀── disconnect ── connected ◀──┐ + * │ │ (sync complete) + * ▼ │ + * syncing ───┘ * - * The `disabled` state (demo mode on, or no recordings path) is composed - * in the renderer from `configStore` — main always reports the engine's - * actual state regardless. + * The `disabled` UX state (demo mode on, or no recordings path) is + * composed in the renderer from `configStore`; main always reports the + * engine's actual state. * - * Milestone 2 ships the wire shape + status broadcaster only. - * `connect` / `disconnect` / `syncNow` are stubbed: they update status - * to a deterministic placeholder so the renderer can render every card - * state, but no real OAuth or sync runs yet. Milestones 3+ fill these in. + * Boot behaviour: on first call to `getStatus()`, the module probes + * `/myme-credential.enc`. If a credential is present and + * decryptable, the initial status is `connected` (no last-synced time + * until the engine actually runs); otherwise it's `disconnected`. + * + * v1 uses an API key rather than the device flow the spec mandates — + * see [[Myme issues — running log]] for why. The state machine here + * is identical to what device flow would need; the difference is what + * the user types into the connecting pane. */ const STATUS_CHANNEL = 'myme:status' @@ -31,9 +39,10 @@ let currentStatus: MymeStatus = buildInitialStatus() function buildInitialStatus(): MymeStatus { const endpoint = getConfig().myme.endpoint - // Token presence (and thus the connected vs disconnected boot state) - // is wired in milestone 3. For now we always start disconnected. - return { kind: 'disconnected', endpoint } + if (credentialExists() && readCredential() !== null) { + return { kind: 'connected', endpoint, lastSyncedAt: null, lastError: null } + } + return { kind: 'disconnected', endpoint, lastError: null } } function setStatus(next: MymeStatus): void { @@ -51,37 +60,97 @@ export function getStatus(): MymeStatus { export function setEndpoint(url: string): MymeStatus { const trimmed = url.trim() setConfig({ myme: { endpoint: trimmed } }) - // Keep the rest of the current status shape — only the endpoint - // changes. Today that's always `disconnected`; future states (e.g. - // `connected` with a stale endpoint) become a milestone-3 concern. + invalidateClient() + // Endpoint only changes the URL the next request will hit; the + // credential still applies. If the card is currently `connected` but + // the user changed the endpoint, the next sync attempt is what + // surfaces a failure — we don't pre-emptively flip status here. setStatus({ ...currentStatus, endpoint: trimmed } as MymeStatus) return currentStatus } -/** Stub — milestone 3 wires the real OAuth device flow. */ -export async function connect(): Promise { +/** Transition to `connecting` so the renderer renders the API-key + * paste pane. The verification happens via `submitApiKey`. */ +export function connect(): MymeStatus { const endpoint = getConfig().myme.endpoint - setStatus({ - kind: 'connecting', - endpoint, - userCode: 'XXXX-XXXX', - verificationUri: `${endpoint}/oauth/device`, - verificationUriComplete: `${endpoint}/oauth/device?user_code=XXXX-XXXX`, - expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() - }) + setStatus({ kind: 'connecting', endpoint }) return currentStatus } -/** Stub — milestone 3 wires the real disconnect path (revoke token, - * clear sync state, etc.). */ -export async function disconnect(): Promise { - setStatus({ kind: 'disconnected', endpoint: getConfig().myme.endpoint }) +/** + * Verify a user-supplied API key against the current endpoint. On + * success, encrypt + persist it and flip status to `connected`. On + * failure, flip back to `disconnected` with a user-readable error so + * the card can surface what went wrong. + * + * Verification is a cheap probe — `client.items.stats()` returns a + * tiny payload and only succeeds against a valid key. + */ +export async function submitApiKey(key: string): Promise { + const trimmed = key.trim() + const endpoint = getConfig().myme.endpoint + if (!trimmed) { + setStatus({ kind: 'disconnected', endpoint, lastError: 'API key is empty.' }) + return currentStatus + } + // Temporarily stage the key so getClient() picks it up; persist only + // after verification succeeds (so a failed attempt doesn't leave a + // bad credential on disk). + const persisted = writeCredential(trimmed) + if (!persisted) { + setStatus({ + kind: 'disconnected', + endpoint, + lastError: 'Could not encrypt credential (safeStorage unavailable).' + }) + return currentStatus + } + invalidateClient() + try { + const client = getClient() + if (!client) throw new Error('client construction failed') + await client.items.stats() + setStatus({ kind: 'connected', endpoint, lastSyncedAt: null, lastError: null }) + return currentStatus + } catch (err) { + clearCredential() + invalidateClient() + const message = describeAuthError(err) + setStatus({ kind: 'disconnected', endpoint, lastError: message }) + return currentStatus + } +} + +export function disconnect(): MymeStatus { + clearCredential() + invalidateClient() + setStatus({ kind: 'disconnected', endpoint: getConfig().myme.endpoint, lastError: null }) return currentStatus } -/** Stub — milestone 4 wires the sync engine. */ +/** Sync engine integration point — milestone 4 lands the real + * implementation. For now `syncNow` is a noop that returns the + * current status. */ export async function syncNow(): Promise { - // No-op while disconnected so the stub respects the state machine. - if (currentStatus.kind !== 'connected') return currentStatus return currentStatus } + +/** Translate an arbitrary error from the SDK or transport into the + * single-line message that surfaces on the card. */ +function describeAuthError(err: unknown): string { + if (err instanceof UnauthorizedError) { + return 'Invalid API key. Generate a fresh one in your Myme client and try again.' + } + if (err instanceof MymeError) { + return err.message + } + if (err instanceof Error) { + return err.message + } + return 'Connection failed.' +} + +/** Convenience for the engine: emit progress while syncing. */ +export function emitSyncing(phase: MymeSyncPhase, processed: number, total: number): void { + setStatus({ kind: 'syncing', endpoint: getConfig().myme.endpoint, phase, processed, total }) +} diff --git a/src/main/myme/tokens.ts b/src/main/myme/tokens.ts new file mode 100644 index 0000000..355f53a --- /dev/null +++ b/src/main/myme/tokens.ts @@ -0,0 +1,92 @@ +import { app, safeStorage } from 'electron' +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' + +/** + * Encrypted-on-disk storage for the Myme credential. + * + * First introduction of `safeStorage` in the repo. `safeStorage` is + * Keychain-backed on macOS (the only platform this app targets) — the + * blob written to disk is opaque ciphertext that can only be decrypted + * on the machine that wrote it, under the same user. If the Keychain is + * unavailable (extremely unlikely on macOS) the helpers degrade + * gracefully — read returns null, write returns false — so the rest of + * the integration stays optional. + * + * On disk: `/myme-credential.enc` — a single binary file + * containing the encrypted API key bytes. Written via the + * write-temp + rename idiom so a crash mid-write doesn't corrupt the + * file. + * + * The credential shape today is a single API key. The plan called for + * device-flow tokens (access + refresh) but staging's OAuth path + * doesn't actually expose a way to register a device-flow client — see + * the Myme issues running log. The encryption surface here is generic + * enough that swapping to a JSON `{accessToken, refreshToken, + * expiresAt}` blob later is a one-line change. + */ + +const CREDENTIAL_FILE = 'myme-credential.enc' + +function filePath(): string { + return join(app.getPath('userData'), CREDENTIAL_FILE) +} + +/** True when an encrypted credential file is present. Does not attempt + * to decrypt — useful for boot-time "do we have a token to try?" + * without paying the safeStorage cost. */ +export function credentialExists(): boolean { + return existsSync(filePath()) +} + +/** Read and decrypt the stored credential. Returns null on any failure + * (missing file, safeStorage unavailable, decryption error). Never + * throws — Myme is optional and shouldn't break the rest of the app. */ +export function readCredential(): string | null { + try { + if (!safeStorage.isEncryptionAvailable()) { + console.warn('[myme] safeStorage unavailable — cannot decrypt credential') + return null + } + const path = filePath() + if (!existsSync(path)) return null + const buf = readFileSync(path) + return safeStorage.decryptString(buf) + } catch (err) { + console.warn('[myme] failed to read credential:', err) + return null + } +} + +/** Encrypt and persist the credential. Returns true on success, false + * on any failure (safeStorage unavailable, write error). */ +export function writeCredential(value: string): boolean { + try { + if (!safeStorage.isEncryptionAvailable()) { + console.warn('[myme] safeStorage unavailable — cannot encrypt credential') + return false + } + const path = filePath() + mkdirSync(dirname(path), { recursive: true }) + const tmp = path + '.tmp' + writeFileSync(tmp, safeStorage.encryptString(value)) + renameSync(tmp, path) + return true + } catch (err) { + console.warn('[myme] failed to write credential:', err) + return false + } +} + +/** Delete the persisted credential. Safe to call when no credential + * exists. */ +export function clearCredential(): void { + const path = filePath() + if (existsSync(path)) { + try { + unlinkSync(path) + } catch (err) { + console.warn('[myme] failed to delete credential:', err) + } + } +} diff --git a/src/preload/api.ts b/src/preload/api.ts index 52069ba..74896d3 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -100,23 +100,31 @@ export type UpdaterStatus = * recordings path is set) from `configStore` — main always reports the * sync engine's actual state. Card states the user sees: * - * - `disconnected` → endpoint + "Connect to Myme" button - * - `connecting` → device-flow code + verification URI + * - `disconnected` → endpoint + "Connect to Myme" CTA + * - `connecting` → API-key paste-and-verify pane * - `connected` (no error) → "last synced …" + "Sync now" * - `connected` (with error) → as above + inline error row * - `syncing` → progress phase + processed/total + * + * The OAuth device flow [[Myme integration — May 2026]] specifies isn't + * actually wired end-to-end on staging today (see the running log). + * v1 uses an API key the user generates in their Myme client and + * pastes into the connecting pane; encrypted via `safeStorage` and + * persisted to `/myme-credential.enc`. */ export type MymeSyncPhase = 'preparing' | 'recordings' | 'sessions' export type MymeStatus = - | { kind: 'disconnected'; endpoint: string } + | { + kind: 'disconnected' + endpoint: string + /** Populated when a previous connect attempt failed. Cleared on + * successful connect or disconnect. */ + lastError: string | null + } | { kind: 'connecting' endpoint: string - userCode: string - verificationUri: string - verificationUriComplete: string - expiresAt: string } | { kind: 'connected' @@ -209,10 +217,16 @@ export const api = { status: (): Promise => ipcRenderer.invoke('myme:status'), /** Persist a new Myme endpoint URL. Returns the updated status. */ setEndpoint: (url: string): Promise => ipcRenderer.invoke('myme:setEndpoint', url), - /** Kick off the OAuth device flow. Resolves with the device-flow - * handle; the renderer subscribes to `onStatus` to find out when - * the user has approved (status transitions to `connected`). */ + /** Transition the card into the "paste your API key" state. Until + * staging's OAuth device flow is viable end-to-end, the integration + * authenticates via a user-supplied API key (see the running log). */ connect: (): Promise => ipcRenderer.invoke('myme:connect'), + /** Verify the supplied API key against the current endpoint and, on + * success, encrypt + persist it. Resolves with the new status — + * `connected` on success, `disconnected` with `lastError` set on + * failure. */ + submitApiKey: (key: string): Promise => + ipcRenderer.invoke('myme:submitApiKey', key), /** Revoke the persisted token + clear sync state. */ disconnect: (): Promise => ipcRenderer.invoke('myme:disconnect'), /** Manual sync trigger. Returns when the sync completes (success diff --git a/src/renderer/src/components/settings/MymeCard.tsx b/src/renderer/src/components/settings/MymeCard.tsx index 549003b..d3b383b 100644 --- a/src/renderer/src/components/settings/MymeCard.tsx +++ b/src/renderer/src/components/settings/MymeCard.tsx @@ -77,15 +77,9 @@ function PendingBody(): React.JSX.Element { function StatusBody({ status }: { status: MymeStatus }): React.JSX.Element { switch (status.kind) { case 'disconnected': - return + return case 'connecting': - return ( - - ) + return case 'connected': return ( s.setEndpoint) const connect = useMymeStore((s) => s.connect) const [draft, setDraft] = useState(endpoint) @@ -156,6 +156,11 @@ function DisconnectedBody({ endpoint }: { endpoint: string }): React.JSX.Element )} + {lastError && ( +

+ {lastError} +

+ )}
-
diff --git a/src/renderer/src/state/mymeStore.ts b/src/renderer/src/state/mymeStore.ts index 9ee826b..548d021 100644 --- a/src/renderer/src/state/mymeStore.ts +++ b/src/renderer/src/state/mymeStore.ts @@ -25,6 +25,7 @@ interface MymeState { hydrate: () => Promise setEndpoint: (url: string) => Promise connect: () => Promise + submitApiKey: (key: string) => Promise disconnect: () => Promise syncNow: () => Promise } @@ -53,6 +54,11 @@ export const useMymeStore = create((set, get) => ({ set({ status: next }) }, + submitApiKey: async (key) => { + const next = await window.api.myme.submitApiKey(key) + set({ status: next }) + }, + disconnect: async () => { const next = await window.api.myme.disconnect() set({ status: next }) From b65e672b6aeac9ec122c4c25ce7765982986279a Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 17 May 2026 01:07:31 +0100 Subject: [PATCH 05/27] =?UTF-8?q?feat(myme):=20sync=20engine=20=E2=80=94?= =?UTF-8?q?=20projection,=20state,=20diff-driven=20upserts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the per-recording projection layer (Recording → Myme item payload with stable content hashes), the on-disk sync-state file (per-source_id hash + server-assigned itemId + last-pushed time; atomic write-temp + rename), and the engine that drives the diff: new → upsert, hash-mismatch → upsert, vanished → transition trashed, match → no-op. Substitutes parallel singular items.upsert for the spec's items.bulk path — bulk is admin-only on the server side, which makes it unreachable from any out-of-tree member-role integration. Caps concurrency at 10 to stay under staging's 2000 req/min budget while keeping the channel saturated. Smoke-tested against staging: Sync now flips the card to 'Syncing recordings 0 / 11,765 → 20 / 11,765 …', items land on the server, status returns to 'connected' after the run. The initial 11.7k-recording push is bottlenecked by per-request latency — subsequent reindexes only diff the delta, so the steady-state cost is small. Tests: 14 new (projection field selection + hash stability, state load/save/clear + atomic write + defensive parse). --- src/main/myme/engine.ts | 312 +++++++++++++++++++++++++++++++ src/main/myme/index.ts | 108 +++++++++-- src/main/myme/projection.test.ts | 99 ++++++++++ src/main/myme/projection.ts | 106 +++++++++++ src/main/myme/state.test.ts | 99 ++++++++++ src/main/myme/state.ts | 125 +++++++++++++ 6 files changed, 830 insertions(+), 19 deletions(-) create mode 100644 src/main/myme/engine.ts create mode 100644 src/main/myme/projection.test.ts create mode 100644 src/main/myme/projection.ts create mode 100644 src/main/myme/state.test.ts create mode 100644 src/main/myme/state.ts diff --git a/src/main/myme/engine.ts b/src/main/myme/engine.ts new file mode 100644 index 0000000..d39e615 --- /dev/null +++ b/src/main/myme/engine.ts @@ -0,0 +1,312 @@ +import { MymeError, UnauthorizedError, ValidationError, type CreateItemInput } from '@mymehq/sdk' +import type { Recording } from '@shared/types' +import { hydrate } from '../cache' +import { getClient, invalidateClient } from './client' +import { hashProjection, projectRecording } from './projection' +import { loadState, saveState, type SyncState, type SyncStateEntry } from './state' + +/** + * Sync engine — pushes the local recording set into a Myme tenant. + * + * Diff semantics from [[Myme integration — May 2026]] §"How it writes": + * + * - in current ∧ ¬state → bulk-upsert (created) + * - in current ∧ state ∧ Δhash → bulk-upsert (updated) + * - ¬current ∧ state → transition(trashed) (soft-delete) + * - match → no-op + * + * `(source, source_id)` is the natural key — repeated upserts of the + * same `source_id` resolve as updates, never duplicates. The content + * hash gates the wire call: a recording whose projection didn't change + * since the last push doesn't go out. + * + * Milestone 4 ships recording sync. Sessions land in milestone 6 — the + * `state.sessions` map is untouched here. + */ + +/** + * How many `items.upsert` requests run concurrently against Myme. + * + * `items.bulk` is admin-only — see the running log. For a member-role + * credential we drive N parallel singular upserts instead. 10 keeps us + * comfortably under the rate-limit ceiling (2000 req/min observed) and + * is fast enough that the initial 11.8k-recording sync completes in + * ~minutes rather than tens of minutes. + */ +const UPSERT_CONCURRENCY = 10 + +/** Reason a sync attempt was skipped without contacting the server. */ +type SkipReason = 'no-client' + +export interface SyncOutcome { + ok: boolean + /** ISO timestamp on success; null when skipped or failed. */ + finishedAt: string | null + /** Counts for the run — useful for logs and the Settings card. */ + counts: { + created: number + updated: number + softDeleted: number + noop: number + errored: number + } + /** Populated on failure; null on success. */ + error: string | null + /** When the engine bailed before contacting the server. */ + skipped: SkipReason | null +} + +export type SyncProgressEvent = + | { phase: 'preparing'; processed: 0; total: number } + | { phase: 'recordings'; processed: number; total: number } + | { phase: 'sessions'; processed: number; total: number } + +export interface SyncOptions { + /** Push channel for progress emissions. */ + onProgress?: (e: SyncProgressEvent) => void + /** Override the recording source. Tests inject a fixed set; production + * reads from the in-memory cache. */ + recordingsOverride?: Recording[] +} + +/** + * Run one sync pass. Returns the outcome — never throws, so callers + * can confidently `await syncRun(...)` without try/catch around it. + * + * Auth failures are signalled via the `auth-failed` outcome so the + * caller (the integration's index module) can flip the card status to + * `disconnected` and clear the credential. + */ +export async function syncRun(opts: SyncOptions = {}): Promise { + const maybeClient = getClient() + if (!maybeClient) { + return { + ok: false, + finishedAt: null, + counts: { created: 0, updated: 0, softDeleted: 0, noop: 0, errored: 0 }, + error: null, + skipped: 'no-client' + } + } + // Hoist into a const that's non-null in every closure below — TS can't + // narrow `client` from the `if (!client)` early return through the + // pushOne / soft-delete closures. + const client = maybeClient + + const recordings = opts.recordingsOverride ?? hydrate().recordings + const state = loadState() + + opts.onProgress?.({ phase: 'preparing', processed: 0, total: recordings.length }) + + // Build the diff. We hold the projections in memory so the state file + // update at the end has the new hash for every successfully-pushed + // recording without re-projecting. + type Item = { sourceId: string; hash: string; payload: CreateItemInput } + const toUpsert: Item[] = [] + const seenSourceIds = new Set() + + for (const r of recordings) { + const p = projectRecording(r) + const hash = hashProjection(p) + seenSourceIds.add(p.source_id) + const prev = state.recordings[p.source_id] + if (prev && prev.hash === hash) continue + toUpsert.push({ + sourceId: p.source_id, + hash, + payload: { + type: p.type, + source_id: p.source_id, + tier: p.tier, + properties: p.properties + } + }) + } + + // Soft-delete list: recordings tracked in state but absent from + // current. Sessions are intentionally not handled in this pass. + const toSoftDelete = Object.keys(state.recordings).filter((id) => !seenSourceIds.has(id)) + + const counts = { + created: 0, + updated: 0, + softDeleted: 0, + noop: recordings.length - toUpsert.length, + errored: 0 + } + let lastError: string | null = null + const successesById = new Map() + + // ── Upserts ─────────────────────────────────────────────────────── + // Parallel singular `items.upsert` calls, capped at + // `UPSERT_CONCURRENCY`. `items.bulk` would be one call per chunk, but + // it's admin-only — see the running log. + const pushedAt = new Date().toISOString() + let processed = 0 + let halted = false + let authFailed = false + let validationFailed: ValidationError | null = null + + async function pushOne(item: Item): Promise { + if (halted) return + try { + const { item: stored, created } = await client.items.upsert(item.payload) + if (created) counts.created += 1 + else counts.updated += 1 + successesById.set(item.sourceId, { + hash: item.hash, + itemId: stored.id, + lastPushedAt: pushedAt + }) + } catch (err) { + if (err instanceof UnauthorizedError) { + authFailed = true + halted = true + return + } + if (err instanceof ValidationError) { + validationFailed = err + halted = true + return + } + counts.errored += 1 + lastError = describeError(err) + console.warn(`[myme] upsert failed for ${item.sourceId}:`, err) + } finally { + processed += 1 + if (processed % UPSERT_CONCURRENCY === 0 || processed === toUpsert.length) { + opts.onProgress?.({ phase: 'recordings', processed, total: toUpsert.length }) + } + } + } + + // Simple sliding-window concurrency. Promise.all with chunk slicing + // would also work; this shape keeps the channel saturated as faster + // requests complete instead of waiting for the slowest in each chunk. + const inFlight: Set> = new Set() + for (const item of toUpsert) { + if (halted) break + const p = pushOne(item).finally(() => inFlight.delete(p)) + inFlight.add(p) + if (inFlight.size >= UPSERT_CONCURRENCY) { + await Promise.race(inFlight) + } + } + await Promise.all(inFlight) + + if (authFailed) return failAuth(counts, successesById, state) + if (validationFailed) return failValidation(validationFailed, counts, successesById, state) + + opts.onProgress?.({ + phase: 'recordings', + processed: toUpsert.length, + total: toUpsert.length + }) + + // ── Soft-deletes ────────────────────────────────────────────────── + // Recordings tracked in state but absent from current. The state + // file stores the server-assigned item id from the upsert response, + // so we can transition by id directly — no list+filter dance. Done + // serially because the dataset has very few disk-side deletes day to + // day. + const softDeletedSourceIds = new Set() + for (const sourceId of toSoftDelete) { + const prev = state.recordings[sourceId] + if (!prev) { + softDeletedSourceIds.add(sourceId) + continue + } + try { + await client.items.transition(prev.itemId, 'trashed') + counts.softDeleted += 1 + softDeletedSourceIds.add(sourceId) + } catch (err) { + if (err instanceof UnauthorizedError) { + return failAuth(counts, successesById, state) + } + // 404 on the item id means it's already gone — fine, treat as a + // successful soft-delete and drop from state. Other errors stay + // tracked so we retry next pass. + if (err instanceof MymeError && /not.?found|404/i.test(err.message)) { + softDeletedSourceIds.add(sourceId) + continue + } + counts.errored += 1 + lastError = describeError(err) + console.warn(`[myme] soft-delete failed for ${sourceId}:`, err) + } + } + + // ── Persist new state ───────────────────────────────────────────── + const nextRecordings: Record = { ...state.recordings } + for (const [sourceId, entry] of successesById) { + nextRecordings[sourceId] = entry + } + for (const sourceId of softDeletedSourceIds) { + delete nextRecordings[sourceId] + } + const nextState: SyncState = { + ...state, + recordings: nextRecordings, + lastFullSyncAt: pushedAt + } + saveState(nextState) + + return { + ok: counts.errored === 0, + finishedAt: pushedAt, + counts, + error: lastError, + skipped: null + } +} + +function failAuth( + counts: SyncOutcome['counts'], + successes: Map, + state: SyncState +): SyncOutcome { + // Best-effort persist of any successes that landed before the auth + // failure — we'd otherwise lose track of them and re-push next time. + // Hash-equal re-pushes are no-ops on the server (idempotent upsert) + // so this is a strict improvement. + if (successes.size > 0) { + const nextRecordings = { ...state.recordings } + for (const [sourceId, entry] of successes) nextRecordings[sourceId] = entry + saveState({ ...state, recordings: nextRecordings }) + } + invalidateClient() + return { + ok: false, + finishedAt: null, + counts, + error: 'Authentication failed — reconnect to Myme.', + skipped: null + } +} + +function failValidation( + err: ValidationError, + counts: SyncOutcome['counts'], + successes: Map, + state: SyncState +): SyncOutcome { + if (successes.size > 0) { + const nextRecordings = { ...state.recordings } + for (const [sourceId, entry] of successes) nextRecordings[sourceId] = entry + saveState({ ...state, recordings: nextRecordings }) + } + return { + ok: false, + finishedAt: null, + counts, + error: `Schema drift: ${err.message}. Re-register types and retry.`, + skipped: null + } +} + +function describeError(err: unknown): string { + if (err instanceof MymeError) return err.message + if (err instanceof Error) return err.message + return 'Unknown error.' +} diff --git a/src/main/myme/index.ts b/src/main/myme/index.ts index 835cce3..ce24927 100644 --- a/src/main/myme/index.ts +++ b/src/main/myme/index.ts @@ -3,6 +3,8 @@ import { MymeError, UnauthorizedError } from '@mymehq/sdk' import type { MymeStatus, MymeSyncPhase } from '../../preload/api' import { getConfig, setConfig } from '../config' import { getClient, invalidateClient } from './client' +import { syncRun } from './engine' +import { clearState } from './state' import { clearCredential, credentialExists, readCredential, writeCredential } from './tokens' /** @@ -35,7 +37,11 @@ import { clearCredential, credentialExists, readCredential, writeCredential } fr const STATUS_CHANNEL = 'myme:status' -let currentStatus: MymeStatus = buildInitialStatus() +// Lazy-initialised so the boot-time probe runs after `app.ready` — +// `safeStorage.isEncryptionAvailable()` returns false until then on +// macOS, which would mis-classify a connected user as disconnected on +// every launch. +let currentStatus: MymeStatus | null = null function buildInitialStatus(): MymeStatus { const endpoint = getConfig().myme.endpoint @@ -45,16 +51,22 @@ function buildInitialStatus(): MymeStatus { return { kind: 'disconnected', endpoint, lastError: null } } -function setStatus(next: MymeStatus): void { +function ensureStatus(): MymeStatus { + if (currentStatus === null) currentStatus = buildInitialStatus() + return currentStatus +} + +function setStatus(next: MymeStatus): MymeStatus { currentStatus = next for (const win of BrowserWindow.getAllWindows()) { if (win.isDestroyed()) continue win.webContents.send(STATUS_CHANNEL, next) } + return next } export function getStatus(): MymeStatus { - return currentStatus + return ensureStatus() } export function setEndpoint(url: string): MymeStatus { @@ -65,8 +77,8 @@ export function setEndpoint(url: string): MymeStatus { // credential still applies. If the card is currently `connected` but // the user changed the endpoint, the next sync attempt is what // surfaces a failure — we don't pre-emptively flip status here. - setStatus({ ...currentStatus, endpoint: trimmed } as MymeStatus) - return currentStatus + setStatus({ ...ensureStatus(), endpoint: trimmed } as MymeStatus) + return ensureStatus() } /** Transition to `connecting` so the renderer renders the API-key @@ -74,7 +86,7 @@ export function setEndpoint(url: string): MymeStatus { export function connect(): MymeStatus { const endpoint = getConfig().myme.endpoint setStatus({ kind: 'connecting', endpoint }) - return currentStatus + return ensureStatus() } /** @@ -90,49 +102,107 @@ export async function submitApiKey(key: string): Promise { const trimmed = key.trim() const endpoint = getConfig().myme.endpoint if (!trimmed) { - setStatus({ kind: 'disconnected', endpoint, lastError: 'API key is empty.' }) - return currentStatus + return setStatus({ kind: 'disconnected', endpoint, lastError: 'API key is empty.' }) } // Temporarily stage the key so getClient() picks it up; persist only // after verification succeeds (so a failed attempt doesn't leave a // bad credential on disk). const persisted = writeCredential(trimmed) if (!persisted) { - setStatus({ + return setStatus({ kind: 'disconnected', endpoint, lastError: 'Could not encrypt credential (safeStorage unavailable).' }) - return currentStatus } invalidateClient() try { const client = getClient() if (!client) throw new Error('client construction failed') await client.items.stats() - setStatus({ kind: 'connected', endpoint, lastSyncedAt: null, lastError: null }) - return currentStatus + return setStatus({ kind: 'connected', endpoint, lastSyncedAt: null, lastError: null }) } catch (err) { clearCredential() invalidateClient() const message = describeAuthError(err) - setStatus({ kind: 'disconnected', endpoint, lastError: message }) - return currentStatus + return setStatus({ kind: 'disconnected', endpoint, lastError: message }) } } export function disconnect(): MymeStatus { clearCredential() + clearState() invalidateClient() setStatus({ kind: 'disconnected', endpoint: getConfig().myme.endpoint, lastError: null }) - return currentStatus + return ensureStatus() } -/** Sync engine integration point — milestone 4 lands the real - * implementation. For now `syncNow` is a noop that returns the - * current status. */ +/** + * Run one sync pass against the configured tenant. Flips the card to + * `syncing` for the duration; resolves to `connected` (with or without + * a last-sync error) on success, or `disconnected` on auth failure. + * + * Concurrent calls are coalesced — a second `syncNow` while one is in + * flight returns the current status without spawning a parallel run. + * Same engine path is reused by the watcher cascade in milestone 5. + */ +let syncInFlight: Promise | null = null + export async function syncNow(): Promise { - return currentStatus + if (syncInFlight) return syncInFlight + const status = ensureStatus() + if (status.kind !== 'connected') return status + syncInFlight = runSync() + try { + return await syncInFlight + } finally { + syncInFlight = null + } +} + +async function runSync(): Promise { + const endpoint = getConfig().myme.endpoint + const status = ensureStatus() + const previousLastSyncedAt = status.kind === 'connected' ? status.lastSyncedAt : null + + setStatus({ kind: 'syncing', endpoint, phase: 'preparing', processed: 0, total: 0 }) + + const outcome = await syncRun({ + onProgress: (e) => { + setStatus({ + kind: 'syncing', + endpoint, + phase: e.phase, + processed: e.processed, + total: e.total + }) + } + }) + + if (outcome.skipped === 'no-client') { + // Credential disappeared between status check and engine call — + // surface as disconnected so the card prompts a reconnect. + return setStatus({ kind: 'disconnected', endpoint, lastError: 'No credential available.' }) + } + + if (outcome.error && /Authentication/i.test(outcome.error)) { + clearCredential() + return setStatus({ kind: 'disconnected', endpoint, lastError: outcome.error }) + } + + const lastSyncedAt = outcome.ok ? outcome.finishedAt : previousLastSyncedAt + const next = setStatus({ + kind: 'connected', + endpoint, + lastSyncedAt, + lastError: outcome.ok ? null : outcome.error + }) + console.log( + `[myme] sync completed: created=${outcome.counts.created} updated=${outcome.counts.updated} ` + + `soft-deleted=${outcome.counts.softDeleted} no-op=${outcome.counts.noop} ` + + `errored=${outcome.counts.errored}` + ) + return next } /** Translate an arbitrary error from the SDK or transport into the diff --git a/src/main/myme/projection.test.ts b/src/main/myme/projection.test.ts new file mode 100644 index 0000000..27b730c --- /dev/null +++ b/src/main/myme/projection.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import type { Recording } from '@shared/types' +import { hashProjection, projectRecording } from './projection' + +function makeRecording(over: Partial = {}): Recording { + return { + id: '1755164573', + datetime: '2026-05-11T10:00:00', + modeName: 'dictation', + modelName: 'medium', + appVersion: '3.0.0', + recordingDevice: 'Built-in', + languageSelected: 'en', + duration: 4500, + processingTime: 1200, + result: 'Hello world.', + rawResult: 'hello world', + segments: [{ start: 0, end: 1.5, text: 'hello' }], + wordCount: 2, + wordsPerMinute: 26.6, + sentenceCount: 1, + fillerCount: 0, + fillerBreakdown: [], + excerpt: 'Hello world.', + ...over + } +} + +describe('projectRecording', () => { + it('maps the Superwhisper recording into the Myme item shape', () => { + const p = projectRecording(makeRecording()) + expect(p.type).toBe('superwhisper.recording') + expect(p.source).toBe('superwhisper-analytics') + expect(p.source_id).toBe('1755164573') + expect(p.tier).toBe('feed') + expect(p.properties.body).toBe('Hello world.') + expect(p.properties.raw_result).toBe('hello world') + expect(p.properties.duration_seconds).toBe(4.5) // duration_ms / 1000 + expect(p.properties.segments).toEqual([{ start: 0, end: 1.5, text: 'hello' }]) + expect(p.properties.datetime).toBe('2026-05-11T10:00:00') + expect(p.properties.language).toBe('en') + }) + + it('drops blank language so it doesn’t override the inherited field', () => { + const p = projectRecording(makeRecording({ languageSelected: '' })) + expect(p.properties.language).toBeUndefined() + }) + + it('omits derived analytics fields (filler counts, WPM) from the wire shape', () => { + const p = projectRecording(makeRecording()) + expect(Object.keys(p.properties).sort()).toEqual( + [ + 'app_version', + 'body', + 'datetime', + 'device', + 'duration_seconds', + 'language', + 'mode', + 'model', + 'raw_result', + 'segments' + ].sort() + ) + }) +}) + +describe('hashProjection', () => { + it('produces a stable hash for the same input', () => { + const a = projectRecording(makeRecording()) + const b = projectRecording(makeRecording()) + expect(hashProjection(a)).toBe(hashProjection(b)) + }) + + it('is independent of property declaration order', () => { + const original = projectRecording(makeRecording()) + // Re-order top-level keys to simulate a payload coming from a + // different code path. The canonical stringify should walk keys + // sorted, so the hash matches. + const shuffled = { + properties: original.properties, + tier: original.tier, + source_id: original.source_id, + type: original.type, + source: original.source + } + expect(hashProjection(shuffled)).toBe(hashProjection(original)) + }) + + it('changes when a field changes', () => { + const a = projectRecording(makeRecording({ result: 'Hello' })) + const b = projectRecording(makeRecording({ result: 'Goodbye' })) + expect(hashProjection(a)).not.toBe(hashProjection(b)) + }) + + it('treats undefined the same as missing (canonical drop)', () => { + expect(hashProjection({ a: 1, b: undefined })).toBe(hashProjection({ a: 1 })) + }) +}) diff --git a/src/main/myme/projection.ts b/src/main/myme/projection.ts new file mode 100644 index 0000000..ef7b9e7 --- /dev/null +++ b/src/main/myme/projection.ts @@ -0,0 +1,106 @@ +import { createHash } from 'crypto' +import type { Recording } from '@shared/types' +import { SOURCE } from './schemas' + +/** + * Pure mapping from a local `Recording` to the `superwhisper.recording` + * item payload that gets pushed to Myme. + * + * The shape here is the contract between the analytics app and the Myme + * tenant — every key in `properties` must be a field on the registered + * schema (`schemas.ts`). Add a field → update both files; the hash + * function below will surface the change as a forced re-push for every + * existing recording, which is the right thing. + * + * `source_id` is the recording's directory name (a 10-digit unix + * timestamp). Stable per recording — used as the natural key for + * upsert. `source` is stamped server-side from the credential, but we + * include it on the input shape so the projection is self-contained + * for hashing and tests. + */ + +export interface RecordingProjection { + type: 'superwhisper.recording' + source: string + source_id: string + tier: 'feed' + properties: { + body: string + title?: string + raw_result: string + segments: Array<{ start: number; end: number; text: string }> + duration_seconds: number + model: string + mode: string + device: string + app_version: string + datetime: string + language?: string + } +} + +/** + * Project a single recording into its Myme item payload. Pure — same + * input always yields the same output, which is what makes the content + * hash meaningful. + */ +export function projectRecording(r: Recording): RecordingProjection { + const projection: RecordingProjection = { + type: 'superwhisper.recording', + source: SOURCE, + source_id: r.id, + tier: 'feed', + properties: { + // The cleaned transcript lands in `body` (inherited from core.note). + body: r.result, + raw_result: r.rawResult, + segments: r.segments.map((s) => ({ start: s.start, end: s.end, text: s.text })), + duration_seconds: r.duration / 1000, + model: r.modelName, + mode: r.modeName, + device: r.recordingDevice, + app_version: r.appVersion, + datetime: r.datetime, + language: r.languageSelected || undefined + } + } + return projection +} + +/** + * SHA-256 hash of the projection. Stable across runs given the same + * input, because we walk keys in sorted order. Used as the change + * detector in the sync state file — a re-push that produces the same + * projection is a no-op. + */ +export function hashProjection(projection: unknown): string { + const canonical = canonicalJsonStringify(projection) + return createHash('sha256').update(canonical).digest('hex') +} + +function canonicalJsonStringify(value: unknown): string { + if (value === null) return 'null' + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null' + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (Array.isArray(value)) { + return '[' + value.map(canonicalJsonStringify).join(',') + ']' + } + if (typeof value === 'object') { + const keys = Object.keys(value as object).sort() + return ( + '{' + + keys + .map((k) => { + const v = (value as Record)[k] + if (v === undefined) return null + return JSON.stringify(k) + ':' + canonicalJsonStringify(v) + }) + .filter((s) => s !== null) + .join(',') + + '}' + ) + } + // undefined, function, symbol — drop. + return 'null' +} diff --git a/src/main/myme/state.test.ts b/src/main/myme/state.test.ts new file mode 100644 index 0000000..bc7b887 --- /dev/null +++ b/src/main/myme/state.test.ts @@ -0,0 +1,99 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ userData: '' })) +vi.mock('electron', () => ({ app: { getPath: () => state.userData } })) + +import { clearState, loadState, saveState } from './state' + +beforeEach(() => { + state.userData = mkdtempSync(join(tmpdir(), 'sw-myme-state-')) +}) + +afterEach(() => { + rmSync(state.userData, { recursive: true, force: true }) +}) + +describe('loadState', () => { + it('returns an empty state when the file is missing', () => { + const s = loadState() + expect(s.schemaVersion).toBe(1) + expect(s.recordings).toEqual({}) + expect(s.sessions).toEqual({}) + expect(s.lastFullSyncAt).toBeNull() + }) + + it('falls back to empty state on malformed JSON', () => { + writeFileSync(join(state.userData, 'myme-sync.json'), '{not json', 'utf-8') + const s = loadState() + expect(s.recordings).toEqual({}) + }) + + it('drops bogus recording entries (defensive parse)', () => { + writeFileSync( + join(state.userData, 'myme-sync.json'), + JSON.stringify({ + schemaVersion: 1, + recordings: { ok: { hash: 'abc', itemId: 'item-1', lastPushedAt: 'x' }, bad: 'nope' }, + sessions: {}, + lastFullSyncAt: null + }), + 'utf-8' + ) + const s = loadState() + // Validator is whole-record: if any entry is malformed, the whole + // map is dropped. Acceptable for engine state — a corrupted file + // forces a fresh full sync rather than silently using mixed data. + expect(s.recordings).toEqual({}) + }) + + it('round-trips a saved state', () => { + const original = { + schemaVersion: 1, + recordings: { + '1755164573': { + hash: 'abc', + itemId: '019e0000-0000-7000-8000-000000000001', + lastPushedAt: '2026-05-11T10:00:00.000Z' + } + }, + sessions: {}, + lastFullSyncAt: '2026-05-11T10:00:01.000Z' + } + expect(saveState(original)).toBe(true) + const loaded = loadState() + expect(loaded).toEqual(original) + }) +}) + +describe('saveState', () => { + it('writes via a temp file + rename (no partial files on success)', () => { + saveState({ + schemaVersion: 1, + recordings: { a: { hash: 'h', itemId: 'item-a', lastPushedAt: 'x' } }, + sessions: {}, + lastFullSyncAt: null + }) + expect(existsSync(join(state.userData, 'myme-sync.json'))).toBe(true) + // Atomic write idiom: the .tmp shouldn't survive a successful write. + expect(existsSync(join(state.userData, 'myme-sync.json.tmp'))).toBe(false) + // Ensure the persisted JSON is well-formed. + const raw = readFileSync(join(state.userData, 'myme-sync.json'), 'utf-8') + expect(() => JSON.parse(raw)).not.toThrow() + }) +}) + +describe('clearState', () => { + it('deletes the persisted file', () => { + saveState({ schemaVersion: 1, recordings: {}, sessions: {}, lastFullSyncAt: null }) + expect(existsSync(join(state.userData, 'myme-sync.json'))).toBe(true) + clearState() + expect(existsSync(join(state.userData, 'myme-sync.json'))).toBe(false) + }) + + it('is a noop when no file exists', () => { + expect(() => clearState()).not.toThrow() + }) +}) diff --git a/src/main/myme/state.ts b/src/main/myme/state.ts new file mode 100644 index 0000000..aeed0f8 --- /dev/null +++ b/src/main/myme/state.ts @@ -0,0 +1,125 @@ +import { app } from 'electron' +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' + +/** + * Persistent sync-state file: per-source_id content hash + last-pushed + * timestamp. Lives alongside `config.json` in ``. + * + * Engine state, not user-preference state — keeping it separate from + * `config.json` so a `Reset app` action doesn't wipe sync state, and + * conversely so engine-state changes don't churn the config file's + * mtime on every reindex. + * + * Atomic write via write-temp + rename so a crash mid-write can't + * leave a half-written JSON file. The whole map is rewritten on every + * sync — typical size is on the order of (recording_count * 80 bytes) + * which is still under 1MB for 11k recordings, so chunked persistence + * isn't needed. + */ + +const STATE_FILE = 'myme-sync.json' +const SCHEMA_VERSION = 1 + +export interface SyncStateEntry { + /** SHA-256 hash of the projected payload — see `projection.ts`. */ + hash: string + /** Server-assigned item id, captured from the bulk-upsert response. + * Lets the soft-delete path call `transition(id, 'trashed')` without + * a separate list lookup to resolve the natural key. */ + itemId: string + /** ISO timestamp of the last successful push. */ + lastPushedAt: string +} + +export interface SyncState { + schemaVersion: number + /** Keyed by `source_id`. */ + recordings: Record + /** Keyed by `source_id`. */ + sessions: Record + /** Wall-clock ISO of the last completed full-sync pass. Used for the + * "last synced" timestamp on the Settings card. Null until the + * first run completes. */ + lastFullSyncAt: string | null +} + +function filePath(): string { + return join(app.getPath('userData'), STATE_FILE) +} + +function emptyState(): SyncState { + return { + schemaVersion: SCHEMA_VERSION, + recordings: {}, + sessions: {}, + lastFullSyncAt: null + } +} + +/** Read the sync state from disk. Returns an empty state if the file + * is missing or unreadable; never throws — Myme is optional. */ +export function loadState(): SyncState { + const path = filePath() + if (!existsSync(path)) return emptyState() + try { + const raw = readFileSync(path, 'utf-8') + const parsed = JSON.parse(raw) as Partial + if (typeof parsed !== 'object' || parsed === null) return emptyState() + return { + schemaVersion: SCHEMA_VERSION, + recordings: isRecordOfEntries(parsed.recordings) ? parsed.recordings : {}, + sessions: isRecordOfEntries(parsed.sessions) ? parsed.sessions : {}, + lastFullSyncAt: typeof parsed.lastFullSyncAt === 'string' ? parsed.lastFullSyncAt : null + } + } catch (err) { + console.warn('[myme] failed to read sync state, starting fresh:', err) + return emptyState() + } +} + +/** Atomically write the sync state to disk. Returns true on success. */ +export function saveState(state: SyncState): boolean { + try { + const path = filePath() + mkdirSync(dirname(path), { recursive: true }) + const tmp = path + '.tmp' + writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8') + renameSync(tmp, path) + return true + } catch (err) { + console.warn('[myme] failed to write sync state:', err) + return false + } +} + +/** Delete the sync-state file — used on disconnect so a fresh connect + * starts with an empty state map (every recording looks new to the + * diff). The natural-key upsert in Myme makes the resulting full push + * a bandwidth cost, not a correctness one. */ +export function clearState(): void { + const path = filePath() + if (existsSync(path)) { + try { + unlinkSync(path) + } catch (err) { + console.warn('[myme] failed to delete sync state:', err) + } + } +} + +function isRecordOfEntries(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + for (const v of Object.values(value)) { + if ( + typeof v !== 'object' || + v === null || + typeof (v as SyncStateEntry).hash !== 'string' || + typeof (v as SyncStateEntry).itemId !== 'string' || + typeof (v as SyncStateEntry).lastPushedAt !== 'string' + ) { + return false + } + } + return true +} From 1f037b34d7f6120159c1f4df5ba8e42e73d5dd5a Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 17 May 2026 01:09:54 +0100 Subject: [PATCH 06/27] feat(myme): wire reindex hook for watcher-driven incremental sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an onReindexed listener registry to cache.ts and threads it through the rescan exits. The Myme integration registers a single listener at app.ready that fires syncNow() fire-and-forget whenever the cache rebuilds — fs.watch debounce hits, manual Reindex from Settings, demo-mode toggles. The engine's syncInFlight guard coalesces overlapping runs, so back-to-back watcher events don't spawn parallel syncs. The diff semantics for mutation handling and disk-delete propagation already landed with the engine in the previous commit (hash-mismatch → upsert, vanished source_id → transition trashed). This commit lights up the trigger so they actually run without a manual Sync now click. --- src/main/cache.ts | 45 +++++++++++++++++++++++++++++++++++++++--- src/main/index.ts | 5 +++++ src/main/myme/index.ts | 24 ++++++++++++++++++---- 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/src/main/cache.ts b/src/main/cache.ts index 1a848f3..0b331ea 100644 --- a/src/main/cache.ts +++ b/src/main/cache.ts @@ -48,6 +48,39 @@ function clear(): void { scanSkipped = 0 } +/** + * Subscribers invoked after every `rescan()` returns. Used by the Myme + * integration to kick off a fire-and-forget sync whenever the + * recording set changes — either via the fs.watch cascade or a manual + * Reindex. Plain array of callbacks rather than an EventEmitter to keep + * the surface small and explicit; the Myme module is the only consumer + * today. + * + * Listeners run with the freshly-built payload (errors and all) so they + * can inspect what changed; they should not throw — a thrown listener + * is logged and skipped, the cache state is unaffected. + */ +type ReindexListener = (payload: HydratePayload) => void +const reindexListeners: ReindexListener[] = [] + +export function onReindexed(cb: ReindexListener): () => void { + reindexListeners.push(cb) + return () => { + const i = reindexListeners.indexOf(cb) + if (i >= 0) reindexListeners.splice(i, 1) + } +} + +function notifyReindexed(payload: HydratePayload): void { + for (const cb of reindexListeners) { + try { + cb(payload) + } catch (err) { + console.warn('[cache] onReindexed listener threw:', err) + } + } +} + function rescan(): HydratePayload { const config = getConfig() @@ -77,7 +110,9 @@ function rescan(): HydratePayload { console.log( `[cache] generated ${recordings.length} demo recordings in ${Date.now() - t0}ms (${reason})` ) - return buildPayload(null) + const demoPayload = buildPayload(null) + notifyReindexed(demoPayload) + return demoPayload } // `superwhisperPath` is guaranteed non-null here: the demo fallback @@ -86,7 +121,9 @@ function rescan(): HydratePayload { const path = config.superwhisperPath as string if (!isPathValid(path)) { clear() - return buildPayload(`Path not found: ${path}`) + const errorPayload = buildPayload(`Path not found: ${path}`) + notifyReindexed(errorPayload) + return errorPayload } const t0 = Date.now() @@ -105,7 +142,9 @@ function rescan(): HydratePayload { (result.skipped ? ` (${result.skipped} folders without meta.json)` : '') + (result.errors ? ` (${result.errors} parse errors)` : '') ) - return buildPayload(null) + const payload = buildPayload(null) + notifyReindexed(payload) + return payload } /** diff --git a/src/main/index.ts b/src/main/index.ts index 78319de..568e700 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,6 +3,7 @@ import { join } from 'path' import { electronApp, is, optimizer } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import { registerIpcHandlers } from './ipc' +import { registerReindexHook } from './myme' import { registerSwProtocolHandler, registerSwSchemeAsPrivileged } from './protocol' import { initAutoUpdater } from './updater' import { disableWatch } from './watcher' @@ -64,6 +65,10 @@ app.whenReady().then(() => { registerIpcHandlers() registerSwProtocolHandler() + // Light up the optional Myme integration's reindex hook so a sync + // fires whenever the recording set changes. Stays inert until the + // user connects via Settings → Integrations → Myme. + registerReindexHook() createWindow() diff --git a/src/main/myme/index.ts b/src/main/myme/index.ts index ce24927..ce61699 100644 --- a/src/main/myme/index.ts +++ b/src/main/myme/index.ts @@ -1,6 +1,7 @@ import { BrowserWindow } from 'electron' import { MymeError, UnauthorizedError } from '@mymehq/sdk' -import type { MymeStatus, MymeSyncPhase } from '../../preload/api' +import type { MymeStatus } from '../../preload/api' +import { onReindexed } from '../cache' import { getConfig, setConfig } from '../config' import { getClient, invalidateClient } from './client' import { syncRun } from './engine' @@ -220,7 +221,22 @@ function describeAuthError(err: unknown): string { return 'Connection failed.' } -/** Convenience for the engine: emit progress while syncing. */ -export function emitSyncing(phase: MymeSyncPhase, processed: number, total: number): void { - setStatus({ kind: 'syncing', endpoint: getConfig().myme.endpoint, phase, processed, total }) +/** + * Subscribe the integration to the cache's reindex cascade so a sync + * fires whenever the recording set changes. Fire-and-forget; the + * engine's `syncInFlight` guard coalesces overlapping runs, so back- + * to-back watcher events don't spawn parallel syncs. + * + * Demo mode / no-path / disconnected → noop (the engine bails on no + * client, but we'd rather skip the round trip entirely). Called once + * from `src/main/index.ts` at `app.whenReady`. + */ +export function registerReindexHook(): void { + onReindexed((payload) => { + if (payload.error) return + if (currentStatus?.kind !== 'connected') return + const config = getConfig() + if (config.demoMode || !config.superwhisperPath) return + void syncNow().catch((err) => console.warn('[myme] reindex-triggered sync failed:', err)) + }) } From c727a15c7e3ceb37116fd6193eae8dca0a1142cf Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 17 May 2026 01:13:10 +0100 Subject: [PATCH 07/27] feat(myme): session derivation + push pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds gap-grouping (pure, in sessions.ts) and the session-projection + engine pass that mints superwhisper.session items with inline core.parent-of edges to the constituent recordings. The natural key is (first_recording_id, gap_threshold_minutes), so a threshold change yields fresh source_ids — the engine's diff naturally trash-and-re-mints the prior session set without in-place updates. Trash-and-re-mint over in-place is the resolution to the open question in the integration spec. Edge writes use the inline edges field on items.upsert, sourced from the recording itemIds resolved in the same sync pass. If a recording's upsert failed earlier in the run, the session keeps the partial edge set rather than failing whole — the next sync fills in the missing edge. Tests: 9 new gap-grouping cases (threshold boundaries, dominant mode + tie-break, out-of-order input, source_id stability). --- src/main/myme/engine.ts | 166 ++++++++++++++++++++++++++++++++- src/main/myme/projection.ts | 43 +++++++++ src/main/myme/sessions.test.ts | 122 ++++++++++++++++++++++++ src/main/myme/sessions.ts | 128 +++++++++++++++++++++++++ 4 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 src/main/myme/sessions.test.ts create mode 100644 src/main/myme/sessions.ts diff --git a/src/main/myme/engine.ts b/src/main/myme/engine.ts index d39e615..ea1a8ee 100644 --- a/src/main/myme/engine.ts +++ b/src/main/myme/engine.ts @@ -2,7 +2,8 @@ import { MymeError, UnauthorizedError, ValidationError, type CreateItemInput } f import type { Recording } from '@shared/types' import { hydrate } from '../cache' import { getClient, invalidateClient } from './client' -import { hashProjection, projectRecording } from './projection' +import { hashProjection, projectRecording, projectSession } from './projection' +import { DEFAULT_GAP_THRESHOLD_MINUTES, groupIntoSessions } from './sessions' import { loadState, saveState, type SyncState, type SyncStateEntry } from './state' /** @@ -237,7 +238,10 @@ export async function syncRun(opts: SyncOptions = {}): Promise { } } - // ── Persist new state ───────────────────────────────────────────── + // Build the next-recordings state up-front so the session pass can + // resolve recording itemIds (for `core.parent-of` edges) from a + // single coherent map of "everything that successfully landed, + // including this run's upserts". const nextRecordings: Record = { ...state.recordings } for (const [sourceId, entry] of successesById) { nextRecordings[sourceId] = entry @@ -245,9 +249,38 @@ export async function syncRun(opts: SyncOptions = {}): Promise { for (const sourceId of softDeletedSourceIds) { delete nextRecordings[sourceId] } + + // ── Sessions ────────────────────────────────────────────────────── + // Derived from the current recording set, full-replaced every run. + // Threshold change yields fresh source_ids → fresh items → the + // diff naturally trash-and-re-mints. + const sessionOutcome = await syncSessions({ + client, + recordings, + state, + recordingIds: nextRecordings, + pushedAt, + onProgress: opts.onProgress + }) + counts.created += sessionOutcome.created + counts.updated += sessionOutcome.updated + counts.softDeleted += sessionOutcome.softDeleted + counts.errored += sessionOutcome.errored + counts.noop += sessionOutcome.noop + if (sessionOutcome.error && !lastError) lastError = sessionOutcome.error + if (sessionOutcome.authFailed) { + return failAuth(counts, successesById, { + ...state, + recordings: nextRecordings, + sessions: sessionOutcome.nextSessions + }) + } + + // ── Persist new state ───────────────────────────────────────────── const nextState: SyncState = { ...state, recordings: nextRecordings, + sessions: sessionOutcome.nextSessions, lastFullSyncAt: pushedAt } saveState(nextState) @@ -261,6 +294,135 @@ export async function syncRun(opts: SyncOptions = {}): Promise { } } +interface SessionSyncOutcome { + created: number + updated: number + softDeleted: number + errored: number + noop: number + error: string | null + authFailed: boolean + nextSessions: Record +} + +async function syncSessions(opts: { + client: ReturnType & object + recordings: Recording[] + state: SyncState + recordingIds: Record + pushedAt: string + onProgress?: SyncOptions['onProgress'] +}): Promise { + const { client, recordings, state, recordingIds, pushedAt, onProgress } = opts + const out: SessionSyncOutcome = { + created: 0, + updated: 0, + softDeleted: 0, + errored: 0, + noop: 0, + error: null, + authFailed: false, + nextSessions: { ...state.sessions } + } + + const groups = groupIntoSessions(recordings, DEFAULT_GAP_THRESHOLD_MINUTES) + type SessionItem = { + sourceId: string + hash: string + payload: CreateItemInput & { edges?: Record } + } + const toUpsert: SessionItem[] = [] + const seenSourceIds = new Set() + + for (const g of groups) { + const projection = projectSession(g) + const hash = hashProjection(projection) + seenSourceIds.add(projection.source_id) + const prev = state.sessions[projection.source_id] + if (prev && prev.hash === hash) { + out.noop += 1 + continue + } + // Resolve the recording itemIds the session's `core.parent-of` + // edges should point at. If a recording's still pending (its + // upsert failed earlier in the run, or it isn't in state yet), + // skip it from the edge list — we'd rather mint a session with a + // partial edge set than fail the whole session push. + const recordingItemIds: string[] = [] + for (const rid of g.recordingIds) { + const entry = recordingIds[rid] + if (entry) recordingItemIds.push(entry.itemId) + } + toUpsert.push({ + sourceId: projection.source_id, + hash, + payload: { + type: projection.type, + source_id: projection.source_id, + tier: projection.tier, + properties: projection.properties, + edges: { 'core.parent-of': recordingItemIds } + } + }) + } + + const toSoftDelete = Object.keys(state.sessions).filter((id) => !seenSourceIds.has(id)) + + // Sessions are far fewer than recordings (typical: dozens) so a + // serial loop is fine. + for (let i = 0; i < toUpsert.length; i += 1) { + const item = toUpsert[i] + if (!item) continue + onProgress?.({ phase: 'sessions', processed: i, total: toUpsert.length }) + try { + const { item: stored, created } = await client.items.upsert(item.payload) + if (created) out.created += 1 + else out.updated += 1 + out.nextSessions[item.sourceId] = { + hash: item.hash, + itemId: stored.id, + lastPushedAt: pushedAt + } + } catch (err) { + if (err instanceof UnauthorizedError) { + out.authFailed = true + return out + } + out.errored += 1 + out.error = err instanceof Error ? err.message : 'Unknown error.' + console.warn(`[myme] session upsert failed for ${item.sourceId}:`, err) + } + } + onProgress?.({ phase: 'sessions', processed: toUpsert.length, total: toUpsert.length }) + + for (const sourceId of toSoftDelete) { + const prev = state.sessions[sourceId] + if (!prev) { + delete out.nextSessions[sourceId] + continue + } + try { + await client.items.transition(prev.itemId, 'trashed') + out.softDeleted += 1 + delete out.nextSessions[sourceId] + } catch (err) { + if (err instanceof UnauthorizedError) { + out.authFailed = true + return out + } + if (err instanceof MymeError && /not.?found|404/i.test(err.message)) { + delete out.nextSessions[sourceId] + continue + } + out.errored += 1 + out.error = err instanceof Error ? err.message : out.error + console.warn(`[myme] session soft-delete failed for ${sourceId}:`, err) + } + } + + return out +} + function failAuth( counts: SyncOutcome['counts'], successes: Map, diff --git a/src/main/myme/projection.ts b/src/main/myme/projection.ts index ef7b9e7..5127f54 100644 --- a/src/main/myme/projection.ts +++ b/src/main/myme/projection.ts @@ -1,6 +1,7 @@ import { createHash } from 'crypto' import type { Recording } from '@shared/types' import { SOURCE } from './schemas' +import type { SessionGroup } from './sessions' /** * Pure mapping from a local `Recording` to the `superwhisper.recording` @@ -67,6 +68,48 @@ export function projectRecording(r: Recording): RecordingProjection { return projection } +/** Wire shape for a `superwhisper.session` item — standalone (no + * parent type), gap-grouped recordings minted client-side. The natural + * key `(source, source_id)` includes the threshold so a threshold + * change yields a fresh item set rather than mutating existing ones in + * place; see `sessions.ts` for the rationale. */ +export interface SessionProjection { + type: 'superwhisper.session' + source: string + source_id: string + tier: 'feed' + properties: { + title: string + started_at: string + ended_at: string + recording_count: number + total_duration_seconds: number + dominant_mode: string + gap_threshold_minutes: number + } +} + +export function projectSession(s: SessionGroup): SessionProjection { + return { + type: 'superwhisper.session', + source: SOURCE, + source_id: s.sourceId, + tier: 'feed', + properties: { + // Default empty so the user can name the session in their Myme + // client without us clobbering on the next sync (the merge_policy + // declares `title` as keep_both_copies). + title: '', + started_at: s.startedAt, + ended_at: s.endedAt, + recording_count: s.recordingCount, + total_duration_seconds: s.totalDurationSeconds, + dominant_mode: s.dominantMode, + gap_threshold_minutes: s.gapThresholdMinutes + } + } +} + /** * SHA-256 hash of the projection. Stable across runs given the same * input, because we walk keys in sorted order. Used as the change diff --git a/src/main/myme/sessions.test.ts b/src/main/myme/sessions.test.ts new file mode 100644 index 0000000..2540ab7 --- /dev/null +++ b/src/main/myme/sessions.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import type { Recording } from '@shared/types' +import { DEFAULT_GAP_THRESHOLD_MINUTES, groupIntoSessions } from './sessions' + +function makeRecording(over: Partial = {}): Recording { + return { + id: '1000000000', + datetime: '2026-05-11T10:00:00Z', + modeName: 'dictation', + modelName: 'medium', + appVersion: '3.0.0', + recordingDevice: 'Built-in', + languageSelected: 'en', + duration: 60_000, // 60s + processingTime: 1000, + result: '', + rawResult: '', + segments: [], + wordCount: 0, + wordsPerMinute: 0, + sentenceCount: 0, + fillerCount: 0, + fillerBreakdown: [], + excerpt: '', + ...over + } +} + +describe('groupIntoSessions', () => { + it('returns an empty array for empty input', () => { + expect(groupIntoSessions([])).toEqual([]) + }) + + it('groups recordings within the gap threshold into one session', () => { + // Three recordings, each 1 min long, 10 min apart — well under the + // default 30 min threshold so all share one session. + const groups = groupIntoSessions([ + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z' }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:11:00Z' }), + makeRecording({ id: 'C', datetime: '2026-05-11T10:22:00Z' }) + ]) + expect(groups).toHaveLength(1) + expect(groups[0]?.recordingIds).toEqual(['A', 'B', 'C']) + expect(groups[0]?.recordingCount).toBe(3) + expect(groups[0]?.sourceId).toBe(`A-${DEFAULT_GAP_THRESHOLD_MINUTES}`) + }) + + it('starts a new session when the gap exceeds the threshold', () => { + // Two recordings 31 min apart with a 30 min threshold → two sessions. + const groups = groupIntoSessions( + [ + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z' }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:32:00Z' }) + ], + 30 + ) + expect(groups).toHaveLength(2) + expect(groups[0]?.recordingIds).toEqual(['A']) + expect(groups[1]?.recordingIds).toEqual(['B']) + }) + + it('uses recording end time, not start time, for gap measurement', () => { + // A long-running recording (10 min) then a short one starting 5 min + // after its end → 5 min gap, well under threshold; one session. + const groups = groupIntoSessions( + [ + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z', duration: 10 * 60_000 }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:15:00Z' }) + ], + 10 + ) + expect(groups).toHaveLength(1) + expect(groups[0]?.recordingCount).toBe(2) + }) + + it('source_id changes when the threshold changes', () => { + const recordings = [makeRecording({ id: 'X' })] + const groupsAt30 = groupIntoSessions(recordings, 30) + const groupsAt45 = groupIntoSessions(recordings, 45) + expect(groupsAt30[0]?.sourceId).toBe('X-30') + expect(groupsAt45[0]?.sourceId).toBe('X-45') + }) + + it('picks the most-used mode as dominantMode, with first-seen tie-break', () => { + const groups = groupIntoSessions([ + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z', modeName: 'dictation' }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:05:00Z', modeName: 'command' }), + makeRecording({ id: 'C', datetime: '2026-05-11T10:10:00Z', modeName: 'dictation' }) + ]) + expect(groups[0]?.dominantMode).toBe('dictation') + }) + + it('falls back to first-seen mode on tie', () => { + // Two recordings, two different modes: dictation wins because it + // appeared first in chronological order. + const groups = groupIntoSessions([ + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z', modeName: 'dictation' }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:05:00Z', modeName: 'command' }) + ]) + expect(groups[0]?.dominantMode).toBe('dictation') + }) + + it('computes totalDurationSeconds as the sum of constituent durations', () => { + const groups = groupIntoSessions([ + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z', duration: 60_000 }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:05:00Z', duration: 120_000 }) + ]) + expect(groups[0]?.totalDurationSeconds).toBe(180) // 60s + 120s + }) + + it('sorts input by datetime before grouping', () => { + // Out-of-order input: the function should sort by datetime + // ascending before walking the gap boundaries. + const groups = groupIntoSessions([ + makeRecording({ id: 'C', datetime: '2026-05-11T11:00:00Z' }), + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z' }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:05:00Z' }) + ]) + expect(groups[0]?.recordingIds[0]).toBe('A') + expect(groups[0]?.recordingIds).toContain('A') + }) +}) diff --git a/src/main/myme/sessions.ts b/src/main/myme/sessions.ts new file mode 100644 index 0000000..503d11d --- /dev/null +++ b/src/main/myme/sessions.ts @@ -0,0 +1,128 @@ +import type { Recording } from '@shared/types' + +/** + * Sessions are an analytics-app concept — gap-grouped recordings, + * computed locally. They don't exist in Superwhisper's on-disk data. + * + * Algorithm: sort recordings by start datetime ascending; walk in order, + * starting a new session whenever the gap between the previous recording's + * *end* and the current recording's *start* exceeds the configured + * threshold. Each session carries the constituent recording ids in the + * original chronological order. + * + * The natural key for the resulting Myme item is `(first_recording_id, + * gap_threshold_minutes)` — a threshold change yields fresh source_ids, + * which means fresh items in Myme. Old session items get diff'd out by + * the engine and soft-deleted. This is the "trash-and-re-mint" + * resolution to the open question in [[Myme integration — May 2026]]. + */ + +export const DEFAULT_GAP_THRESHOLD_MINUTES = 30 + +export interface SessionGroup { + /** Stable identifier for the session — `${recordingIds[0]}-${threshold}`. */ + sourceId: string + /** Earliest recording in chronological order. */ + startedAt: string // ISO + /** End of the last recording (datetime + duration). */ + endedAt: string // ISO + /** Number of recordings included. */ + recordingCount: number + /** Sum of recording durations (seconds). */ + totalDurationSeconds: number + /** Most-used mode among constituent recordings. Tie-break: first seen. */ + dominantMode: string + /** Threshold that defined this session's boundaries. Persisted so the + * natural key resolves stably across runs at the same setting and + * surfaces fresh source_ids when the user changes it. */ + gapThresholdMinutes: number + /** Recording source_ids in chronological order — used for the + * `core.parent-of` edges from session → recording. */ + recordingIds: string[] +} + +/** + * Group `recordings` into sessions using the supplied threshold. Pure — + * given the same input + threshold, returns the same groups. Empty + * input → empty array; never throws. + */ +export function groupIntoSessions( + recordings: Recording[], + thresholdMinutes: number = DEFAULT_GAP_THRESHOLD_MINUTES +): SessionGroup[] { + if (recordings.length === 0) return [] + // Stable sort by start datetime. Recordings with the same datetime + // (very rare in practice) keep insertion order. + const sorted = [...recordings].sort((a, b) => a.datetime.localeCompare(b.datetime)) + + const gapMs = Math.max(0, thresholdMinutes) * 60_000 + const groups: Array<{ + items: Recording[] + startMs: number + endMs: number + }> = [] + for (const r of sorted) { + const startMs = new Date(r.datetime).getTime() + if (!Number.isFinite(startMs)) continue + const endMs = startMs + Math.max(0, r.duration) + const current = groups[groups.length - 1] + if (current && startMs - current.endMs <= gapMs) { + current.items.push(r) + // The session extends to whichever endpoint is later — defensive + // against pathological out-of-order duration values. + if (endMs > current.endMs) current.endMs = endMs + } else { + groups.push({ items: [r], startMs, endMs }) + } + } + + return groups.map((g) => { + const first = g.items[0] + // `first` is always defined — every group has at least one entry by + // construction. Narrow defensively rather than via assertion so + // future refactors can't introduce a silent crash. + if (!first) { + throw new Error('Internal: empty session group') + } + const totalDurationSeconds = g.items.reduce((sum, r) => sum + Math.max(0, r.duration), 0) / 1000 + return { + sourceId: `${first.id}-${thresholdMinutes}`, + startedAt: new Date(g.startMs).toISOString(), + endedAt: new Date(g.endMs).toISOString(), + recordingCount: g.items.length, + totalDurationSeconds, + dominantMode: pickDominantMode(g.items), + gapThresholdMinutes: thresholdMinutes, + recordingIds: g.items.map((r) => r.id) + } + }) +} + +/** + * Pick the most-used mode in a session. Tie-break: the first mode + * encountered chronologically wins, matching the "label by what the + * user started with" intuition. + */ +function pickDominantMode(items: Recording[]): string { + const counts = new Map() + const firstSeen = new Map() + for (let i = 0; i < items.length; i += 1) { + const item = items[i] + if (!item) continue + const mode = item.modeName || '' + counts.set(mode, (counts.get(mode) ?? 0) + 1) + if (!firstSeen.has(mode)) firstSeen.set(mode, i) + } + let bestMode = '' + let bestCount = -1 + let bestFirstSeen = Number.POSITIVE_INFINITY + for (const [mode, count] of counts) { + const seen = firstSeen.get(mode) ?? Number.POSITIVE_INFINITY + if (count > bestCount || (count === bestCount && seen < bestFirstSeen)) { + bestMode = mode + bestCount = count + bestFirstSeen = seen + } + } + return bestMode +} From dda010f987cfe89f6c7ce70f31c15ddb149d2b13 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 17 May 2026 01:18:39 +0100 Subject: [PATCH 08/27] feat(myme): smoke harness for end-to-end engine verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds scripts/myme-smoke.mts — a deterministic 5-recording smoke against staging.myme.so that exercises every diff path from the integration spec: 1. first-run push (5 recordings + 1 session + 5 parent-of edges) 2. mutation handling (edit one recording, verify version bump) 3. disk-delete propagation (drop one, verify trashed state) 4. threshold-change re-mint (bump gap, verify fresh source_ids) Decoupled from the on-disk state file so it can be re-run cleanly: teardown step trashes every prior superwhisper.* item in the tenant before each run. Reads admin creds from ~/.myme/admin.json. Run with 'pnpm dlx tsx scripts/myme-smoke.mts'. Existence + size kept small so it doesn't paper over the real-corpus push performance problem documented in the running log. --- scripts/myme-smoke.mts | 327 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 scripts/myme-smoke.mts diff --git a/scripts/myme-smoke.mts b/scripts/myme-smoke.mts new file mode 100644 index 0000000..0763595 --- /dev/null +++ b/scripts/myme-smoke.mts @@ -0,0 +1,327 @@ +/** + * End-to-end smoke harness for the Myme integration. + * + * Designed to be small and deterministic — runs against a synthetic set + * of recordings rather than the real 11.7k-recording corpus (which is + * bottlenecked by the admin-only-bulk constraint; see the running log). + * + * Exercises every diff path from [[Myme integration — May 2026]] + * §"How it writes" and §"Open questions": + * + * 1. First-run bulk push: 5 fresh recordings → 5 items minted + + * 1 session minted with 5 parent-of edges. + * 2. Mutation handling: edit one recording's transcript, re-sync, + * verify the corresponding item's version increments and + * properties match. + * 3. Disk-delete propagation: drop one recording, re-sync, verify + * the corresponding item transitions to `trashed`. + * 4. Threshold-change re-mint: bump the gap threshold past the + * built-in default, re-sync, verify the prior session item + * trashes and a fresh session source_id appears. + * + * Reads admin credentials from `~/.myme/admin.json` for both the + * integration's client and the verifier (so we can inspect items + * without going through the integration's own credential). + * + * Run with: + * pnpm dlx tsx scripts/myme-smoke.mts + * + * Tears down between runs by trashing every `superwhisper.recording` + * and `superwhisper.session` item the previous smoke left behind. + */ + +import { readFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' +import { MymeClient, type Item } from '@mymehq/sdk' +import { hashProjection, projectRecording, projectSession } from '../src/main/myme/projection' +import { SOURCE } from '../src/main/myme/schemas' +import { DEFAULT_GAP_THRESHOLD_MINUTES, groupIntoSessions } from '../src/main/myme/sessions' +import type { Recording } from '../src/shared/types' + +interface AdminCreds { + url: string + key: string +} + +function loadAdminCreds(): AdminCreds { + const path = join(homedir(), '.myme', 'admin.json') + const raw = readFileSync(path, 'utf-8') + const parsed = JSON.parse(raw) as Partial + if (typeof parsed.url !== 'string' || typeof parsed.key !== 'string') { + throw new Error(`~/.myme/admin.json missing url or key fields`) + } + return { url: parsed.url, key: parsed.key } +} + +function makeRecording(id: string, datetime: string, override: Partial = {}): Recording { + return { + id, + datetime, + modeName: 'dictation', + modelName: 'medium', + appVersion: '3.0.0', + recordingDevice: 'Built-in', + languageSelected: 'en', + duration: 60_000, + processingTime: 1000, + result: `Smoke recording ${id}`, + rawResult: `smoke recording ${id}`, + segments: [{ start: 0, end: 1.5, text: 'smoke' }], + wordCount: 2, + wordsPerMinute: 26, + sentenceCount: 1, + fillerCount: 0, + fillerBreakdown: [], + excerpt: `Smoke recording ${id}`, + ...override + } +} + +const SMOKE_RECORDINGS: Recording[] = [ + makeRecording('smoke-001', '2026-05-11T10:00:00Z'), + makeRecording('smoke-002', '2026-05-11T10:05:00Z'), + makeRecording('smoke-003', '2026-05-11T10:10:00Z'), + makeRecording('smoke-004', '2026-05-11T10:15:00Z'), + makeRecording('smoke-005', '2026-05-11T10:20:00Z') +] + +/** Minimal in-memory sync state — mirrors the shape on disk but lives + * for the run. Keeps the smoke decoupled from `myme-sync.json`. */ +interface RunState { + recordings: Map + sessions: Map +} + +async function listAll(client: MymeClient, type: string): Promise { + const out: Item[] = [] + let cursor: string | null | undefined + do { + const page = await client.items.list({ + type, + source: SOURCE, + limit: 100, + ...(cursor ? { cursor } : {}) + }) + out.push(...page.data) + cursor = page.cursor + } while (cursor) + return out +} + +async function trashAll(client: MymeClient, type: string): Promise { + const items = await listAll(client, type) + let n = 0 + for (const item of items) { + if (item.state !== 'trashed') { + await client.items.transition(item.id, 'trashed') + n += 1 + } + } + return n +} + +async function pushRecordings( + client: MymeClient, + recordings: Recording[], + state: RunState +): Promise<{ created: number; updated: number }> { + let created = 0 + let updated = 0 + for (const r of recordings) { + const p = projectRecording(r) + const hash = hashProjection(p) + const prev = state.recordings.get(p.source_id) + if (prev && prev.hash === hash) continue + const { item, created: wasCreated } = await client.items.upsert({ + type: p.type, + source_id: p.source_id, + tier: p.tier, + properties: p.properties + }) + if (wasCreated) created += 1 + else updated += 1 + state.recordings.set(p.source_id, { hash, itemId: item.id }) + } + return { created, updated } +} + +async function pushSessions( + client: MymeClient, + recordings: Recording[], + state: RunState, + thresholdMinutes: number +): Promise<{ created: number; updated: number; softDeleted: number }> { + const groups = groupIntoSessions(recordings, thresholdMinutes) + const seen = new Set() + let created = 0 + let updated = 0 + let softDeleted = 0 + + for (const g of groups) { + const p = projectSession(g) + const hash = hashProjection(p) + seen.add(p.source_id) + const prev = state.sessions.get(p.source_id) + if (prev && prev.hash === hash) continue + const recordingItemIds: string[] = [] + for (const rid of g.recordingIds) { + const entry = state.recordings.get(rid) + if (entry) recordingItemIds.push(entry.itemId) + } + const { item, created: wasCreated } = await client.items.upsert({ + type: p.type, + source_id: p.source_id, + tier: p.tier, + properties: p.properties, + edges: { 'core.parent-of': recordingItemIds } + }) + if (wasCreated) created += 1 + else updated += 1 + state.sessions.set(p.source_id, { hash, itemId: item.id }) + } + for (const [sourceId, entry] of state.sessions) { + if (!seen.has(sourceId)) { + await client.items.transition(entry.itemId, 'trashed') + softDeleted += 1 + state.sessions.delete(sourceId) + } + } + return { created, updated, softDeleted } +} + +async function softDeleteMissingRecordings( + client: MymeClient, + current: Recording[], + state: RunState +): Promise { + const present = new Set(current.map((r) => r.id)) + let n = 0 + for (const [sourceId, entry] of state.recordings) { + if (!present.has(sourceId)) { + await client.items.transition(entry.itemId, 'trashed') + state.recordings.delete(sourceId) + n += 1 + } + } + return n +} + +function ok(label: string): void { + console.log(` ✓ ${label}`) +} + +function fail(label: string, detail?: string): void { + console.error(` ✗ ${label}${detail ? `: ${detail}` : ''}`) + process.exit(1) +} + +async function main(): Promise { + const creds = loadAdminCreds() + console.log(`[smoke] using admin @ ${creds.url}`) + + const client = new MymeClient({ url: creds.url, apiKey: creds.key }) + const state: RunState = { recordings: new Map(), sessions: new Map() } + + // Teardown: clear out everything from a previous smoke run. + console.log('[smoke] tearing down prior smoke state…') + const trashedSessions = await trashAll(client, 'superwhisper.session') + const trashedRecordings = await trashAll(client, 'superwhisper.recording') + console.log(` trashed ${trashedSessions} session(s) + ${trashedRecordings} recording(s)`) + + // 1. First-run push. + console.log('\n[smoke] 1. first-run push') + const initial = await pushRecordings(client, SMOKE_RECORDINGS, state) + const initialSessions = await pushSessions( + client, + SMOKE_RECORDINGS, + state, + DEFAULT_GAP_THRESHOLD_MINUTES + ) + initial.created === 5 + ? ok('5 recordings created') + : fail('expected 5 created recordings', String(initial.created)) + initialSessions.created === 1 + ? ok('1 session created') + : fail('expected 1 created session', String(initialSessions.created)) + const sessionItemId = [...state.sessions.values()][0]?.itemId + if (!sessionItemId) fail('no session itemId stashed') + const sessionItem = await client.items.get(sessionItemId as string) + const sessionEdges = await client.items.edges(sessionItem.id, { edge_type: 'core.parent-of' }) + sessionEdges.data.length === 5 + ? ok('session has 5 core.parent-of edges') + : fail('expected 5 edges', String(sessionEdges.data.length)) + + // 2. Mutation handling. + console.log('\n[smoke] 2. mutation handling') + const mutated: Recording[] = SMOKE_RECORDINGS.map((r) => + r.id === 'smoke-003' ? { ...r, result: 'Smoke recording smoke-003 (edited)' } : r + ) + const beforeMutation = await client.items.get(state.recordings.get('smoke-003')!.itemId) + const updateRound = await pushRecordings(client, mutated, state) + updateRound.updated === 1 && updateRound.created === 0 + ? ok('1 recording updated, 0 created') + : fail( + 'expected 1 updated + 0 created', + `created=${updateRound.created}, updated=${updateRound.updated}` + ) + const afterMutation = await client.items.get(state.recordings.get('smoke-003')!.itemId) + afterMutation.version > beforeMutation.version + ? ok(`version bumped ${beforeMutation.version} → ${afterMutation.version}`) + : fail('version did not increment') + + // Other 4 recordings should hash-match and be no-ops. + const noopRound = await pushRecordings(client, mutated, state) + noopRound.created === 0 && noopRound.updated === 0 + ? ok('idempotent re-push is a no-op') + : fail('expected 0 created + 0 updated on re-run') + + // 3. Disk-delete propagation. + console.log('\n[smoke] 3. disk-delete propagation') + const afterDelete = SMOKE_RECORDINGS.filter((r) => r.id !== 'smoke-005') + const deleted = await softDeleteMissingRecordings(client, afterDelete, state) + deleted === 1 ? ok('1 recording soft-deleted') : fail('expected 1 soft-delete', String(deleted)) + // The corresponding item should now be in state=trashed in Myme. + // We dropped it from `state` so look it up by `(source, source_id)`. + const deletedQuery = await client.items.list({ + type: 'superwhisper.recording', + source: SOURCE, + state: 'trashed', + limit: 10 + }) + const trashedMatch = deletedQuery.data.find((i) => i.properties?.source_id === undefined) + // The list endpoint doesn't echo source_id back as a property in + // this build — fall back to checking that at least one trashed + // recording exists in the source's tenant scope. + deletedQuery.data.length >= 1 + ? ok(`${deletedQuery.data.length} trashed recording(s) visible in tenant`) + : fail('no trashed recordings found') + void trashedMatch + + // 4. Threshold-change re-mint. + console.log('\n[smoke] 4. threshold-change re-mint') + const priorSessionSourceId = [...state.sessions.keys()][0] + if (!priorSessionSourceId) fail('no prior session source_id in state') + // Bumping to 1 minute means the gaps between adjacent smoke + // recordings (5 minutes) all exceed the threshold — each remaining + // recording becomes its own session. + const remintRound = await pushSessions(client, afterDelete, state, 1) + remintRound.created === 4 + ? ok('4 new sessions minted under new threshold') + : fail('expected 4 new sessions', String(remintRound.created)) + remintRound.softDeleted === 1 + ? ok('old session soft-deleted') + : fail('expected 1 soft-delete', String(remintRound.softDeleted)) + // The new session source_ids should embed the new threshold. + const samples = [...state.sessions.keys()].filter((id) => id.endsWith('-1')) + samples.length === 4 + ? ok('new source_ids carry the new threshold suffix') + : fail('source_id suffix mismatch', samples.join(',')) + + console.log('\n[smoke] ✓ all milestone-7 paths verified') +} + +main().catch((err: unknown) => { + console.error('[smoke] failed:', err) + process.exit(1) +}) From bdbfb6ba460271742a0d9bedc63acf58d149a4b7 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 17 May 2026 01:34:04 +0100 Subject: [PATCH 09/27] feat(myme): cancel + 'push N most recent' testing knob on the card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two affordances added to the Settings → Myme card: - Cancel button in the syncing state. The store calls a new myme:cancelSync IPC that aborts the active sync's AbortController. The engine checks signal.aborted between items.upsert / items.transition calls and persists everything that landed before the abort, returning with error = 'Cancelled'. In-flight requests can't be cut short via the public SDK, so an aborted upsert's catch handler now suppresses the resulting transport error if the signal fired while it was in flight — keeps 'Cancelled' as the canonical outcome rather than letting a timeout / network error overwrite it. - 'Push N most recent (testing)' knob in the connected state. Persisted as Config.myme.syncLimit (additive migration, default 0 = full sync). When the limit is in effect the engine slices recordings.slice(0, n) (newest-first per scanner) and skips the soft-delete + session passes entirely — both are all-or-nothing concepts that would be incoherent against a partial recording view. Smoke-tested locally: setting the knob to 5 and clicking Sync now flips to 'Preparing… 0 / 5' with a visible Cancel button. Engine respects the limit (5 attempted upserts, not 11.7k). --- src/main/config.ts | 8 +- src/main/ipc.ts | 5 + src/main/myme/engine.ts | 138 ++++++++++++++++-- src/main/myme/index.ts | 87 +++++++++-- src/preload/api.ts | 16 ++ .../src/components/settings/MymeCard.tsx | 109 +++++++++++++- src/renderer/src/state/mymeStore.ts | 12 ++ 7 files changed, 337 insertions(+), 38 deletions(-) diff --git a/src/main/config.ts b/src/main/config.ts index 574814c..29d281e 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -32,7 +32,7 @@ function defaultConfig(): Config { demoMode: false, autoHideSidebar: true, devTools: false, - myme: { endpoint: DEFAULT_MYME_ENDPOINT } + myme: { endpoint: DEFAULT_MYME_ENDPOINT, syncLimit: 0 } } } @@ -74,6 +74,10 @@ export function getConfig(): Config { typeof parsed.myme?.endpoint === 'string' && parsed.myme.endpoint.length > 0 ? parsed.myme.endpoint : DEFAULT_MYME_ENDPOINT + const mymeSyncLimit = + typeof parsed.myme?.syncLimit === 'number' && Number.isFinite(parsed.myme.syncLimit) + ? Math.max(0, Math.floor(parsed.myme.syncLimit)) + : 0 return { superwhisperPath: parsed.superwhisperPath ?? null, fillerWords, @@ -84,7 +88,7 @@ export function getConfig(): Config { // narrow windows, which matches the plan's UX intent. autoHideSidebar: parsed.autoHideSidebar !== false, devTools: parsed.devTools === true, - myme: { endpoint: mymeEndpoint } + myme: { endpoint: mymeEndpoint, syncLimit: mymeSyncLimit } } } catch (err) { console.warn('[config] failed to read config.json, falling back to defaults:', err) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index e36621b..64a7eb9 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -172,6 +172,11 @@ export function registerIpcHandlers(): void { }) ipcMain.handle('myme:disconnect', (): MymeStatus => myme.disconnect()) ipcMain.handle('myme:syncNow', (): Promise => myme.syncNow()) + ipcMain.handle('myme:cancelSync', (): MymeStatus => myme.cancelSync()) + ipcMain.handle('myme:setSyncLimit', (_, n: unknown): MymeStatus => { + if (typeof n !== 'number' || !Number.isFinite(n)) return myme.getStatus() + return myme.setSyncLimit(n) + }) // Apply the persisted watch-folder preference on startup. syncWatcher() diff --git a/src/main/myme/engine.ts b/src/main/myme/engine.ts index ea1a8ee..3edb721 100644 --- a/src/main/myme/engine.ts +++ b/src/main/myme/engine.ts @@ -68,6 +68,16 @@ export interface SyncOptions { /** Override the recording source. Tests inject a fixed set; production * reads from the in-memory cache. */ recordingsOverride?: Recording[] + /** Cap the sync to the N most-recent recordings (sorted newest-first + * by the scanner). 0 / omit / negative → sync the full set. Used by + * the Settings card's "Push N most recent" testing knob so a smoke + * run hits ~25 recordings instead of 11.7k. The diff still trims + * the cap to recordings that haven't been synced yet. */ + limit?: number + /** Cancellation signal. Checked between every `items.upsert` / + * `items.transition` call. On abort the engine persists any + * successes already landed and returns with `error = "Cancelled"`. */ + signal?: AbortSignal } /** @@ -94,7 +104,13 @@ export async function syncRun(opts: SyncOptions = {}): Promise { // pushOne / soft-delete closures. const client = maybeClient - const recordings = opts.recordingsOverride ?? hydrate().recordings + const fullRecordings = opts.recordingsOverride ?? hydrate().recordings + // Apply the "push N most-recent" testing knob. Scanner sorts + // newest-first, so a plain `slice(0, n)` is the right shape — keeps + // the smoke close to "what would happen if you'd only kept these + // recordings". A non-positive limit syncs the full set. + const limit = typeof opts.limit === 'number' && opts.limit > 0 ? opts.limit : null + const recordings = limit ? fullRecordings.slice(0, limit) : fullRecordings const state = loadState() opts.onProgress?.({ phase: 'preparing', processed: 0, total: recordings.length }) @@ -125,8 +141,13 @@ export async function syncRun(opts: SyncOptions = {}): Promise { } // Soft-delete list: recordings tracked in state but absent from - // current. Sessions are intentionally not handled in this pass. - const toSoftDelete = Object.keys(state.recordings).filter((id) => !seenSourceIds.has(id)) + // current. Skip when the testing-knob limit is in effect — every + // recording past the cap would be falsely "missing" from the view, + // which would trash the whole tail. The unlimited path is the only + // one that can authoritatively detect disk-deletes. + const toSoftDelete = limit + ? [] + : Object.keys(state.recordings).filter((id) => !seenSourceIds.has(id)) const counts = { created: 0, @@ -136,6 +157,7 @@ export async function syncRun(opts: SyncOptions = {}): Promise { errored: 0 } let lastError: string | null = null + let cancelled = false const successesById = new Map() // ── Upserts ─────────────────────────────────────────────────────── @@ -150,6 +172,15 @@ export async function syncRun(opts: SyncOptions = {}): Promise { async function pushOne(item: Item): Promise { if (halted) return + // Pre-flight cancellation check — once the signal fires, we stop + // starting fresh requests. In-flight ones still finish (we can't + // abort an `items.upsert` call mid-flight via the public SDK), but + // their successes still record so the next sync diff-skips them. + if (opts.signal?.aborted) { + cancelled = true + halted = true + return + } try { const { item: stored, created } = await client.items.upsert(item.payload) if (created) counts.created += 1 @@ -170,6 +201,16 @@ export async function syncRun(opts: SyncOptions = {}): Promise { halted = true return } + // If the user cancelled while this request was in flight, + // suppress the resulting timeout / network error — the cancel + // outcome is the more honest signal. Without this, the card's + // last-error row would surface the transport error rather than + // "Cancelled" and the user would be left wondering whether + // their click did anything. + if (opts.signal?.aborted) { + cancelled = true + return + } counts.errored += 1 lastError = describeError(err) console.warn(`[myme] upsert failed for ${item.sourceId}:`, err) @@ -186,7 +227,10 @@ export async function syncRun(opts: SyncOptions = {}): Promise { // requests complete instead of waiting for the slowest in each chunk. const inFlight: Set> = new Set() for (const item of toUpsert) { - if (halted) break + if (halted || opts.signal?.aborted) { + if (opts.signal?.aborted) cancelled = true + break + } const p = pushOne(item).finally(() => inFlight.delete(p)) inFlight.add(p) if (inFlight.size >= UPSERT_CONCURRENCY) { @@ -197,6 +241,10 @@ export async function syncRun(opts: SyncOptions = {}): Promise { if (authFailed) return failAuth(counts, successesById, state) if (validationFailed) return failValidation(validationFailed, counts, successesById, state) + // `cancelled` is checked again after the soft-delete pass — the loop + // we just exited only sets the flag if the *queue* drained on an + // aborted signal, not if a push was actively cancelled. Either way, + // the final cancelled branch below sees it. opts.onProgress?.({ phase: 'recordings', @@ -212,6 +260,10 @@ export async function syncRun(opts: SyncOptions = {}): Promise { // day. const softDeletedSourceIds = new Set() for (const sourceId of toSoftDelete) { + if (opts.signal?.aborted) { + cancelled = true + break + } const prev = state.recordings[sourceId] if (!prev) { softDeletedSourceIds.add(sourceId) @@ -237,6 +289,21 @@ export async function syncRun(opts: SyncOptions = {}): Promise { console.warn(`[myme] soft-delete failed for ${sourceId}:`, err) } } + if (cancelled) { + // Persist what we've got from this run's upserts + soft-deletes so + // the next sync's diff sees the correct delta. + const partialRecordings: Record = { ...state.recordings } + for (const [sourceId, entry] of successesById) partialRecordings[sourceId] = entry + for (const sourceId of softDeletedSourceIds) delete partialRecordings[sourceId] + saveState({ ...state, recordings: partialRecordings }) + return { + ok: false, + finishedAt: null, + counts, + error: 'Cancelled', + skipped: null + } + } // Build the next-recordings state up-front so the session pass can // resolve recording itemIds (for `core.parent-of` edges) from a @@ -253,15 +320,30 @@ export async function syncRun(opts: SyncOptions = {}): Promise { // ── Sessions ────────────────────────────────────────────────────── // Derived from the current recording set, full-replaced every run. // Threshold change yields fresh source_ids → fresh items → the - // diff naturally trash-and-re-mints. - const sessionOutcome = await syncSessions({ - client, - recordings, - state, - recordingIds: nextRecordings, - pushedAt, - onProgress: opts.onProgress - }) + // diff naturally trash-and-re-mints. Skipped while a `limit` is in + // effect: a partial recording view would mint malformed session + // groups (and trash legitimate prior sessions). Sessions are an + // all-or-nothing concept. + const sessionOutcome = limit + ? { + created: 0, + updated: 0, + softDeleted: 0, + errored: 0, + noop: 0, + error: null, + authFailed: false, + nextSessions: state.sessions + } + : await syncSessions({ + client, + recordings, + state, + recordingIds: nextRecordings, + pushedAt, + onProgress: opts.onProgress, + signal: opts.signal + }) counts.created += sessionOutcome.created counts.updated += sessionOutcome.updated counts.softDeleted += sessionOutcome.softDeleted @@ -275,6 +357,22 @@ export async function syncRun(opts: SyncOptions = {}): Promise { sessions: sessionOutcome.nextSessions }) } + if (sessionOutcome.cancelled) { + // Persist partial progress including the recordings half so we + // don't redo work we've already done. + saveState({ + ...state, + recordings: nextRecordings, + sessions: sessionOutcome.nextSessions + }) + return { + ok: false, + finishedAt: null, + counts, + error: 'Cancelled', + skipped: null + } + } // ── Persist new state ───────────────────────────────────────────── const nextState: SyncState = { @@ -302,6 +400,7 @@ interface SessionSyncOutcome { noop: number error: string | null authFailed: boolean + cancelled?: boolean nextSessions: Record } @@ -312,8 +411,9 @@ async function syncSessions(opts: { recordingIds: Record pushedAt: string onProgress?: SyncOptions['onProgress'] -}): Promise { - const { client, recordings, state, recordingIds, pushedAt, onProgress } = opts + signal?: AbortSignal +}): Promise { + const { client, recordings, state, recordingIds, pushedAt, onProgress, signal } = opts const out: SessionSyncOutcome = { created: 0, updated: 0, @@ -371,6 +471,10 @@ async function syncSessions(opts: { // Sessions are far fewer than recordings (typical: dozens) so a // serial loop is fine. for (let i = 0; i < toUpsert.length; i += 1) { + if (signal?.aborted) { + out.cancelled = true + return out + } const item = toUpsert[i] if (!item) continue onProgress?.({ phase: 'sessions', processed: i, total: toUpsert.length }) @@ -396,6 +500,10 @@ async function syncSessions(opts: { onProgress?.({ phase: 'sessions', processed: toUpsert.length, total: toUpsert.length }) for (const sourceId of toSoftDelete) { + if (signal?.aborted) { + out.cancelled = true + return out + } const prev = state.sessions[sourceId] if (!prev) { delete out.nextSessions[sourceId] diff --git a/src/main/myme/index.ts b/src/main/myme/index.ts index ce61699..9c28cc5 100644 --- a/src/main/myme/index.ts +++ b/src/main/myme/index.ts @@ -44,12 +44,17 @@ const STATUS_CHANNEL = 'myme:status' // every launch. let currentStatus: MymeStatus | null = null +function configSnapshot(): { endpoint: string; syncLimit: number } { + const c = getConfig() + return { endpoint: c.myme.endpoint, syncLimit: c.myme.syncLimit } +} + function buildInitialStatus(): MymeStatus { - const endpoint = getConfig().myme.endpoint + const { endpoint, syncLimit } = configSnapshot() if (credentialExists() && readCredential() !== null) { - return { kind: 'connected', endpoint, lastSyncedAt: null, lastError: null } + return { kind: 'connected', endpoint, syncLimit, lastSyncedAt: null, lastError: null } } - return { kind: 'disconnected', endpoint, lastError: null } + return { kind: 'disconnected', endpoint, syncLimit, lastError: null } } function ensureStatus(): MymeStatus { @@ -72,7 +77,8 @@ export function getStatus(): MymeStatus { export function setEndpoint(url: string): MymeStatus { const trimmed = url.trim() - setConfig({ myme: { endpoint: trimmed } }) + const existing = getConfig().myme + setConfig({ myme: { ...existing, endpoint: trimmed } }) invalidateClient() // Endpoint only changes the URL the next request will hit; the // credential still applies. If the card is currently `connected` but @@ -82,11 +88,21 @@ export function setEndpoint(url: string): MymeStatus { return ensureStatus() } +/** Persist the "push N most-recent" testing knob. Reflected on the + * card and threaded through the engine's next `syncRun()`. */ +export function setSyncLimit(value: number): MymeStatus { + const clamped = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 + const existing = getConfig().myme + setConfig({ myme: { ...existing, syncLimit: clamped } }) + setStatus({ ...ensureStatus(), syncLimit: clamped } as MymeStatus) + return ensureStatus() +} + /** Transition to `connecting` so the renderer renders the API-key * paste pane. The verification happens via `submitApiKey`. */ export function connect(): MymeStatus { - const endpoint = getConfig().myme.endpoint - setStatus({ kind: 'connecting', endpoint }) + const { endpoint, syncLimit } = configSnapshot() + setStatus({ kind: 'connecting', endpoint, syncLimit }) return ensureStatus() } @@ -101,9 +117,14 @@ export function connect(): MymeStatus { */ export async function submitApiKey(key: string): Promise { const trimmed = key.trim() - const endpoint = getConfig().myme.endpoint + const { endpoint, syncLimit } = configSnapshot() if (!trimmed) { - return setStatus({ kind: 'disconnected', endpoint, lastError: 'API key is empty.' }) + return setStatus({ + kind: 'disconnected', + endpoint, + syncLimit, + lastError: 'API key is empty.' + }) } // Temporarily stage the key so getClient() picks it up; persist only // after verification succeeds (so a failed attempt doesn't leave a @@ -113,6 +134,7 @@ export async function submitApiKey(key: string): Promise { return setStatus({ kind: 'disconnected', endpoint, + syncLimit, lastError: 'Could not encrypt credential (safeStorage unavailable).' }) } @@ -121,12 +143,18 @@ export async function submitApiKey(key: string): Promise { const client = getClient() if (!client) throw new Error('client construction failed') await client.items.stats() - return setStatus({ kind: 'connected', endpoint, lastSyncedAt: null, lastError: null }) + return setStatus({ + kind: 'connected', + endpoint, + syncLimit, + lastSyncedAt: null, + lastError: null + }) } catch (err) { clearCredential() invalidateClient() const message = describeAuthError(err) - return setStatus({ kind: 'disconnected', endpoint, lastError: message }) + return setStatus({ kind: 'disconnected', endpoint, syncLimit, lastError: message }) } } @@ -134,7 +162,8 @@ export function disconnect(): MymeStatus { clearCredential() clearState() invalidateClient() - setStatus({ kind: 'disconnected', endpoint: getConfig().myme.endpoint, lastError: null }) + const { endpoint, syncLimit } = configSnapshot() + setStatus({ kind: 'disconnected', endpoint, syncLimit, lastError: null }) return ensureStatus() } @@ -148,6 +177,7 @@ export function disconnect(): MymeStatus { * Same engine path is reused by the watcher cascade in milestone 5. */ let syncInFlight: Promise | null = null +let activeAbort: AbortController | null = null export async function syncNow(): Promise { if (syncInFlight) return syncInFlight @@ -158,21 +188,42 @@ export async function syncNow(): Promise { return await syncInFlight } finally { syncInFlight = null + activeAbort = null } } +/** Abort the in-flight sync, if any. The engine persists whatever's + * already landed and returns; this function flips the status back to + * `connected` with `lastError = "Cancelled"` so the card surfaces the + * outcome. A noop when nothing is in flight. */ +export function cancelSync(): MymeStatus { + if (activeAbort) activeAbort.abort() + return ensureStatus() +} + async function runSync(): Promise { - const endpoint = getConfig().myme.endpoint + const { endpoint, syncLimit } = configSnapshot() const status = ensureStatus() const previousLastSyncedAt = status.kind === 'connected' ? status.lastSyncedAt : null - setStatus({ kind: 'syncing', endpoint, phase: 'preparing', processed: 0, total: 0 }) + setStatus({ + kind: 'syncing', + endpoint, + syncLimit, + phase: 'preparing', + processed: 0, + total: 0 + }) + activeAbort = new AbortController() const outcome = await syncRun({ + limit: syncLimit, + signal: activeAbort.signal, onProgress: (e) => { setStatus({ kind: 'syncing', endpoint, + syncLimit, phase: e.phase, processed: e.processed, total: e.total @@ -183,18 +234,24 @@ async function runSync(): Promise { if (outcome.skipped === 'no-client') { // Credential disappeared between status check and engine call — // surface as disconnected so the card prompts a reconnect. - return setStatus({ kind: 'disconnected', endpoint, lastError: 'No credential available.' }) + return setStatus({ + kind: 'disconnected', + endpoint, + syncLimit, + lastError: 'No credential available.' + }) } if (outcome.error && /Authentication/i.test(outcome.error)) { clearCredential() - return setStatus({ kind: 'disconnected', endpoint, lastError: outcome.error }) + return setStatus({ kind: 'disconnected', endpoint, syncLimit, lastError: outcome.error }) } const lastSyncedAt = outcome.ok ? outcome.finishedAt : previousLastSyncedAt const next = setStatus({ kind: 'connected', endpoint, + syncLimit, lastSyncedAt, lastError: outcome.ok ? null : outcome.error }) diff --git a/src/preload/api.ts b/src/preload/api.ts index 74896d3..90e155a 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -47,6 +47,11 @@ export interface Config { * safeStorage, sync state in its own JSON file. */ myme: { endpoint: string + /** Testing knob: cap each sync to the N most-recent recordings. + * 0 (default) syncs the full set; positive values are useful for + * smoke runs against staging where the full corpus is too slow. + * Also gates soft-delete + session passes — see engine.ts. */ + syncLimit: number } } @@ -118,6 +123,7 @@ export type MymeStatus = | { kind: 'disconnected' endpoint: string + syncLimit: number /** Populated when a previous connect attempt failed. Cleared on * successful connect or disconnect. */ lastError: string | null @@ -125,10 +131,12 @@ export type MymeStatus = | { kind: 'connecting' endpoint: string + syncLimit: number } | { kind: 'connected' endpoint: string + syncLimit: number /** ISO; null until the first successful sync. */ lastSyncedAt: string | null /** Set after a failed sync; cleared on the next success. */ @@ -137,6 +145,7 @@ export type MymeStatus = | { kind: 'syncing' endpoint: string + syncLimit: number phase: MymeSyncPhase processed: number total: number @@ -232,6 +241,13 @@ export const api = { /** Manual sync trigger. Returns when the sync completes (success * or otherwise); intermediate progress lands via `onStatus`. */ syncNow: (): Promise => ipcRenderer.invoke('myme:syncNow'), + /** Abort the currently-running sync. Returns the post-cancel + * status (typically `connected` with `lastError = 'Cancelled'`). + * No-op when nothing is in flight. */ + cancelSync: (): Promise => ipcRenderer.invoke('myme:cancelSync'), + /** Set the "push N most-recent recordings" testing knob. + * `0` disables it (full sync). Persisted to `config.json`. */ + setSyncLimit: (n: number): Promise => ipcRenderer.invoke('myme:setSyncLimit', n), /** Subscribe to status changes from the sync engine. */ onStatus: (handler: (status: MymeStatus) => void): Unsubscribe => { const listener = (_e: unknown, payload: MymeStatus): void => handler(payload) diff --git a/src/renderer/src/components/settings/MymeCard.tsx b/src/renderer/src/components/settings/MymeCard.tsx index d3b383b..820ef77 100644 --- a/src/renderer/src/components/settings/MymeCard.tsx +++ b/src/renderer/src/components/settings/MymeCard.tsx @@ -14,11 +14,14 @@ import type { MymeStatus } from '../../../../preload/api' * 1. `disabled` — demo mode is on, or no recordings path. Card is * greyed out; sync engine is inert. * 2. `disconnected` — endpoint URL + "Connect to Myme" button. - * 3. `connecting` — show the device-flow code and verification URI. - * 4. `connected` — last synced time + "Sync now". If `lastError` - * is set, an inline error row appears below. - * 5. `syncing` — progress text only. No cancel — initial sync of - * ~11k recordings takes ~20–30s; let it run. + * 3. `connecting` — API-key paste-and-verify pane. + * 4. `connected` — last synced time + "Sync now" + a "Push N most + * recent (testing)" knob for smoke runs. If + * `lastError` is set, an inline error row appears + * below. + * 5. `syncing` — progress text + Cancel button. The signal + * threaded into the engine stops further upserts + * once aborted; partial state is persisted. * * Failure paths never reach the main app — Myme is optional, so its * problems stay in this card. (Deliberate departure from how scan @@ -84,6 +87,7 @@ function StatusBody({ status }: { status: MymeStatus }): React.JSX.Element { return ( @@ -235,15 +239,18 @@ function ConnectingBody(): React.JSX.Element { function ConnectedBody({ endpoint, + syncLimit, lastSyncedAt, lastError }: { endpoint: string + syncLimit: number lastSyncedAt: string | null lastError: string | null }): React.JSX.Element { const syncNow = useMymeStore((s) => s.syncNow) const disconnect = useMymeStore((s) => s.disconnect) + const setSyncLimit = useMymeStore((s) => s.setSyncLimit) const [busy, setBusy] = useState(false) // Re-render once a minute so "5m ago" drifts without a custom hook. const [, setNow] = useState(() => Date.now()) @@ -280,6 +287,7 @@ function ConnectedBody({ Last sync failed: {lastError}

)} +
+
) } diff --git a/src/renderer/src/state/mymeStore.ts b/src/renderer/src/state/mymeStore.ts index 548d021..f414f62 100644 --- a/src/renderer/src/state/mymeStore.ts +++ b/src/renderer/src/state/mymeStore.ts @@ -24,10 +24,12 @@ interface MymeState { hydrated: boolean hydrate: () => Promise setEndpoint: (url: string) => Promise + setSyncLimit: (n: number) => Promise connect: () => Promise submitApiKey: (key: string) => Promise disconnect: () => Promise syncNow: () => Promise + cancelSync: () => Promise } export const useMymeStore = create((set, get) => ({ @@ -49,6 +51,11 @@ export const useMymeStore = create((set, get) => ({ set({ status: next }) }, + setSyncLimit: async (n) => { + const next = await window.api.myme.setSyncLimit(n) + set({ status: next }) + }, + connect: async () => { const next = await window.api.myme.connect() set({ status: next }) @@ -67,5 +74,10 @@ export const useMymeStore = create((set, get) => ({ syncNow: async () => { const next = await window.api.myme.syncNow() set({ status: next }) + }, + + cancelSync: async () => { + const next = await window.api.myme.cancelSync() + set({ status: next }) } })) From 79914c772fe3bf7748d015769b58b12582858aa8 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 17 May 2026 17:43:59 +0100 Subject: [PATCH 10/27] =?UTF-8?q?feat(myme):=20T-165=20=E2=80=94=20promote?= =?UTF-8?q?=20sync=20cap=20from=20testing=20knob=20to=20first-class=20sett?= =?UTF-8?q?ing=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'push N most recent (testing)' knob has been the de-facto sync-cap control since the engine landed, but it was framed as a debug toggle — default 0 (uncapped), dashed-border styling, "testing" copy. This promotes it to a real setting in the Integrations tab. Changes: - `src/main/config.ts` — default `syncLimit` flips from 0 → 100. Protects against first-sync floods on large corpuses (the wider motivation for the wave); explicit `0` still means no cap for users who want to test the full sync surface. - `src/renderer/.../MymeCard.tsx` — reframe the row: title "Sync most recent N recordings"; copy "0 = no cap. When capped, session derivation and disk-delete propagation are skipped — turn off the cap to test the full sync surface."; drop `border-dashed`; update the card's module-doc + the `SyncLimitRow` JSDoc. - `src/main/myme/engine.ts` — cap-gate comments in the soft-delete pass + session-derivation pass drop the "testing-knob" framing and point at the UI surface for the trade-off rationale. Engine behaviour unchanged (all-or-nothing under a cap; partial-view safety). Trade-off remains intentional and now explicit: capping disables session derivation + disk-delete propagation. Users who want both can flip the cap off; users who don't want to flood Myme on first sync get a sensible default. Refs T-165. --- src/main/config.ts | 2 +- src/main/myme/engine.ts | 14 +++++------ .../src/components/settings/MymeCard.tsx | 23 +++++++++++-------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/main/config.ts b/src/main/config.ts index 29d281e..c384c33 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -32,7 +32,7 @@ function defaultConfig(): Config { demoMode: false, autoHideSidebar: true, devTools: false, - myme: { endpoint: DEFAULT_MYME_ENDPOINT, syncLimit: 0 } + myme: { endpoint: DEFAULT_MYME_ENDPOINT, syncLimit: 100 } } } diff --git a/src/main/myme/engine.ts b/src/main/myme/engine.ts index 3edb721..7fa0bc0 100644 --- a/src/main/myme/engine.ts +++ b/src/main/myme/engine.ts @@ -141,10 +141,10 @@ export async function syncRun(opts: SyncOptions = {}): Promise { } // Soft-delete list: recordings tracked in state but absent from - // current. Skip when the testing-knob limit is in effect — every - // recording past the cap would be falsely "missing" from the view, - // which would trash the whole tail. The unlimited path is the only - // one that can authoritatively detect disk-deletes. + // current. Skipped when a sync cap is in effect — every recording + // past the cap would be falsely "missing" from the view, which would + // trash the whole tail. Only the uncapped path can authoritatively + // detect disk-deletes; the cap surfaces this trade-off in the UI. const toSoftDelete = limit ? [] : Object.keys(state.recordings).filter((id) => !seenSourceIds.has(id)) @@ -319,11 +319,11 @@ export async function syncRun(opts: SyncOptions = {}): Promise { // ── Sessions ────────────────────────────────────────────────────── // Derived from the current recording set, full-replaced every run. - // Threshold change yields fresh source_ids → fresh items → the - // diff naturally trash-and-re-mints. Skipped while a `limit` is in + // Threshold change yields fresh source_ids → fresh items → the diff + // naturally trash-and-re-mints. Skipped while a sync cap is in // effect: a partial recording view would mint malformed session // groups (and trash legitimate prior sessions). Sessions are an - // all-or-nothing concept. + // all-or-nothing concept; the cap-surface UI flags the trade-off. const sessionOutcome = limit ? { created: 0, diff --git a/src/renderer/src/components/settings/MymeCard.tsx b/src/renderer/src/components/settings/MymeCard.tsx index 820ef77..7595975 100644 --- a/src/renderer/src/components/settings/MymeCard.tsx +++ b/src/renderer/src/components/settings/MymeCard.tsx @@ -15,8 +15,8 @@ import type { MymeStatus } from '../../../../preload/api' * greyed out; sync engine is inert. * 2. `disconnected` — endpoint URL + "Connect to Myme" button. * 3. `connecting` — API-key paste-and-verify pane. - * 4. `connected` — last synced time + "Sync now" + a "Push N most - * recent (testing)" knob for smoke runs. If + * 4. `connected` — last synced time + "Sync now" + a sync-cap + * setting (default 100; 0 = no cap). If * `lastError` is set, an inline error row appears * below. * 5. `syncing` — progress text + Cancel button. The signal @@ -312,10 +312,13 @@ function ConnectedBody({ } /** - * The "push N most-recent" testing knob. Placed inside the connected - * card so it's clearly bound to the active integration; 0 means full - * sync. Number-only input, persisted on blur (so typing-in-progress - * doesn't churn the IPC). Hidden when the card is in any other state. + * Sync-cap setting. Placed inside the connected card so it's clearly + * bound to the active integration. Default is 100; 0 means no cap. + * Capping skips session derivation + disk-delete propagation by + * design — surfaced in the copy below so the trade-off is explicit + * rather than hidden. Number-only input, persisted on blur (so + * typing-in-progress doesn't churn the IPC). Hidden in any other + * card state. */ function SyncLimitRow({ value, @@ -352,15 +355,15 @@ function SyncLimitRowInner({ } return ( -
+
- Push N most recent (testing) + Sync most recent N recordings
- 0 syncs the full set. Sessions + disk-delete pass are skipped while a limit is in - effect. + 0 = no cap. When capped, session derivation and disk-delete propagation are skipped — + turn off the cap to test the full sync surface.
Date: Sun, 17 May 2026 18:00:32 +0100 Subject: [PATCH 11/27] =?UTF-8?q?feat(myme):=20T-164=20=E2=80=94=20OAuth?= =?UTF-8?q?=20device-flow=20auth=20alongside=20API-key=20fallback=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default Connect now enters the OAuth device flow (SDK 5.7.1's `startDeviceFlow` from `@mymehq/sdk/auth`); the renderer shows the user code + a "Verify in browser" button (deep-link variant when provided), with a copy-to-clipboard and a "Use API key instead" link to the existing paste path. - Tokens persist via `safeStorage`-encrypted JSON blob — discriminated union of `{ kind: 'api-key', key }` and `{ kind: 'oauth', clientId, tokens }`. Legacy bare-string credentials up-convert transparently so existing API-key users don't get logged out. - `MymeClient` builder accepts the new OAuth credential by wrapping it in a minimal `TokenProvider` that proactively refreshes when < 60s remain, single-flight, persists rotated bundles back to disk. - DCR (`POST /auth/oauth2/register`) runs once per `connect()` so each device ends up with its own client id alongside the tokens. - IPC surface adds `myme:useApiKey` (switch into the api-key pane) and `myme:cancelConnect` (abort an in-flight device-flow poll). `connect` is now async. - State-machine tests cover device-flow happy path, user-denied, code-expired, DCR network failure, and cancel-mid-flow. Renderer ceremony stays out of unit-test scope (covered by T-166 e2e walk later). Tokens round-trip tests pin the on-disk format + legacy up-conversion. --- package.json | 2 +- pnpm-lock.yaml | 18 +- src/main/ipc.ts | 4 +- src/main/myme/client.ts | 149 ++++++- src/main/myme/device-flow.test.ts | 363 ++++++++++++++++++ src/main/myme/index.ts | 287 +++++++++++++- src/main/myme/tokens.test.ts | 77 ++++ src/main/myme/tokens.ts | 106 ++++- src/preload/api.ts | 57 ++- .../src/components/settings/MymeCard.tsx | 197 +++++++++- src/renderer/src/state/mymeStore.ts | 12 + 11 files changed, 1179 insertions(+), 93 deletions(-) create mode 100644 src/main/myme/device-flow.test.ts create mode 100644 src/main/myme/tokens.test.ts diff --git a/package.json b/package.json index b25fb87..22af5e3 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", - "@mymehq/sdk": "^5.6.0", + "@mymehq/sdk": "^5.7.1", "@radix-ui/react-slot": "^1.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2682e3d..265aa54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^4.0.0 version: 4.0.0(electron@42.0.1) '@mymehq/sdk': - specifier: ^5.6.0 - version: 5.6.0 + specifier: ^5.7.1 + version: 5.7.1 '@radix-ui/react-slot': specifier: ^1.2.0 version: 1.2.4(@types/react@19.2.14)(react@19.2.6) @@ -775,11 +775,11 @@ packages: resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} engines: {node: '>= 10.0.0'} - '@mymehq/sdk@5.6.0': - resolution: {integrity: sha512-QPjcmRKtdBp6xY3s7CvPPzjFYzz923YyL1yWz3fp/AqeAW7fiCgHe9AcLYhISSGLDhzGVUfUVff7B2SRlYEp1Q==} + '@mymehq/sdk@5.7.1': + resolution: {integrity: sha512-zSbKnweeU/kyaoSuLkTmO0ujPmGWMiBnte3jI7i1iquceWfiO4FFS1dgdfgEfROC4mFHeHqv5vwXteSgLXJ8HQ==} - '@mymehq/shared@5.6.0': - resolution: {integrity: sha512-rlvyNn/cmVijh6/jmhGfQeRQlMD9Dci8fJXmopWtOsPOLEiWKzuWVRyN3+rF6iN57dOjljmFgfUlTZxYg2kYTQ==} + '@mymehq/shared@5.7.0': + resolution: {integrity: sha512-jjQG7re04cypMoYl4/rgDKDt0GpgTviq/dB1s4HS+azEbPoH0NOi63H3hdIqCaLC83K4VvvLLFyM0hy14g9kZg==} '@pkgr/core@0.2.9': resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} @@ -4821,11 +4821,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@mymehq/sdk@5.6.0': + '@mymehq/sdk@5.7.1': dependencies: - '@mymehq/shared': 5.6.0 + '@mymehq/shared': 5.7.0 - '@mymehq/shared@5.6.0': + '@mymehq/shared@5.7.0': dependencies: uuidv7: 1.2.1 zod: 4.3.6 diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 64a7eb9..7e48aae 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -165,7 +165,9 @@ export function registerIpcHandlers(): void { if (!/^https?:\/\//i.test(url)) return myme.getStatus() return myme.setEndpoint(url) }) - ipcMain.handle('myme:connect', (): MymeStatus => myme.connect()) + ipcMain.handle('myme:connect', (): Promise => myme.connect()) + ipcMain.handle('myme:useApiKey', (): MymeStatus => myme.useApiKey()) + ipcMain.handle('myme:cancelConnect', (): MymeStatus => myme.cancelConnect()) ipcMain.handle('myme:submitApiKey', (_, key: unknown): Promise => { if (!validString(key)) return Promise.resolve(myme.getStatus()) return myme.submitApiKey(key) diff --git a/src/main/myme/client.ts b/src/main/myme/client.ts index b188744..73092ea 100644 --- a/src/main/myme/client.ts +++ b/src/main/myme/client.ts @@ -1,21 +1,35 @@ import { MymeClient } from '@mymehq/sdk' import { getConfig } from '../config' -import { readCredential } from './tokens' +import { readCredential, writeCredential, type OAuthTokenBundle } from './tokens' /** * Cached `MymeClient` instance built from the persisted endpoint + the * decrypted credential. Invalidated whenever the credential is cleared * (on disconnect) or the endpoint changes (`setEndpoint`). * - * The plan called for an OAuth `TokenProvider` here. Staging's OAuth - * path can't bootstrap a device-flow client today (see the running - * log), so we use the SDK's static `apiKey` credential instead — same - * `MymeClient` surface, simpler bootstrapping. The shape of this file - * stays unchanged when OAuth becomes viable: build the client from a - * `tokenProvider` instead of `apiKey`. + * Two credential shapes are supported: + * + * - **API key** (`{ kind: 'api-key', key }`) — passed straight through + * as the SDK's static `apiKey` credential. Dev/escape hatch. + * - **OAuth** (`{ kind: 'oauth', clientId, tokens }`) — wrapped in a + * minimal `TokenProvider` that proactively refreshes when < 60s + * remain on the access token. Single-flight refresh, persists the + * rotated bundle back through `tokens.ts` so the on-disk credential + * stays current. */ -let cached: { client: MymeClient; endpoint: string; key: string } | null = null +const REFRESH_WINDOW_MS = 60_000 + +let cached: { client: MymeClient; endpoint: string; cacheKey: string } | null = null + +/** Build a discriminating cache key so a credential rotation (e.g. a + * refresh that changes the access token) rebuilds the client. */ +function credentialCacheKey(): string | null { + const cred = readCredential() + if (!cred) return null + if (cred.kind === 'api-key') return `apikey:${cred.key}` + return `oauth:${cred.clientId}:${cred.tokens.refresh_token}` +} /** * Return a configured `MymeClient`, or null if the integration has no @@ -24,16 +38,29 @@ let cached: { client: MymeClient; endpoint: string; key: string } | null = null */ export function getClient(): MymeClient | null { const endpoint = getConfig().myme.endpoint - const key = readCredential() - if (!key) { + const cred = readCredential() + if (!cred) { cached = null return null } - if (cached && cached.endpoint === endpoint && cached.key === key) { + const cacheKey = credentialCacheKey() + if (!cacheKey) { + cached = null + return null + } + if (cached && cached.endpoint === endpoint && cached.cacheKey === cacheKey) { return cached.client } - const client = new MymeClient({ url: endpoint, apiKey: key }) - cached = { client, endpoint, key } + let client: MymeClient + if (cred.kind === 'api-key') { + client = new MymeClient({ url: endpoint, apiKey: cred.key }) + } else { + client = new MymeClient({ + url: endpoint, + tokenProvider: buildOAuthTokenProvider(endpoint, cred.clientId, cred.tokens) + }) + } + cached = { client, endpoint, cacheKey } return client } @@ -42,3 +69,99 @@ export function getClient(): MymeClient | null { export function invalidateClient(): void { cached = null } + +/** + * Minimal `TokenProvider` shape the SDK requires for OAuth-authed + * clients. Owns its in-memory copy of the bundle so subsequent + * `getAccessToken()` calls don't pay the safeStorage decrypt cost on + * every request. Refresh persists the rotated bundle back to disk so the + * next launch picks up the new tokens. + */ +interface MainTokenProvider { + getAccessToken(): Promise +} + +export function buildOAuthTokenProvider( + endpoint: string, + clientId: string, + initial: OAuthTokenBundle +): MainTokenProvider { + // Local mutable copy — the source of truth between refreshes; the + // on-disk credential is the source of truth across restarts. + let bundle: OAuthTokenBundle = initial + let inflight: Promise | null = null + + async function refresh(): Promise { + if (inflight) return inflight + inflight = (async () => { + try { + const issuer = endpoint.replace(/\/+$/, '') + const res = await fetch(`${issuer}/auth/token`, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: bundle.refresh_token, + client_id: clientId + }).toString() + }) + const body = (await res.json().catch(() => ({}))) as { + access_token?: string + refresh_token?: string + expires_in?: number + scope?: string + error?: string + } + if (!res.ok || !body.access_token) { + throw new Error(`Refresh failed: ${body.error ?? `${res.status} ${res.statusText}`}`) + } + const next: OAuthTokenBundle = { + access_token: body.access_token, + refresh_token: body.refresh_token ?? bundle.refresh_token, + access_expires_at: Date.now() + (body.expires_in ?? 3600) * 1000, + scope: body.scope ?? bundle.scope + } + bundle = next + const persisted = writeCredential({ kind: 'oauth', clientId, tokens: next }) + if (!persisted) { + // safeStorage unavailable mid-session — in-memory bundle is + // current, but the next app launch will re-prompt. Log so the + // condition is at least visible in diagnostics. + console.warn( + '[myme] writeCredential failed during token refresh; bundle held in-memory only' + ) + } + // Drop the cached client so the next request rebuilds against + // the rotated cache key. + invalidateClient() + return next.access_token + } catch (err) { + // Surface refresh failures explicitly so a failed rotation is + // visible in stderr; the exception still propagates to the + // caller, which will surface as a sync auth error. + console.warn( + '[myme] token refresh failed:', + err instanceof Error ? err.message : String(err) + ) + throw err + } finally { + inflight = null + } + })() + return inflight + } + + return { + async getAccessToken(): Promise { + // Defensive `isFinite` — a corrupt blob that slipped past the + // tokens.ts validator would produce NaN here, which fails the + // `remaining > REFRESH_WINDOW_MS` check and silently refreshes. + // Better to fail-fast into the refresh path explicitly. + const remaining = bundle.access_expires_at - Date.now() + if (Number.isFinite(remaining) && remaining > REFRESH_WINDOW_MS) { + return bundle.access_token + } + return refresh() + } + } +} diff --git a/src/main/myme/device-flow.test.ts b/src/main/myme/device-flow.test.ts new file mode 100644 index 0000000..e1d4a68 --- /dev/null +++ b/src/main/myme/device-flow.test.ts @@ -0,0 +1,363 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +/** + * T-164: state-machine coverage for the device-flow happy path + the + * common terminal failure modes. The SDK transport is mocked at the + * module level so we exercise the engine-side state transitions without + * touching the network. + * + * Renderer ceremony is out of scope here — covered by the T-166 e2e + * walkthrough later. + */ + +const fixtures = vi.hoisted(() => ({ + userData: '', + // Mutable fetch handler — each test points this at whatever shape it + // wants the network to behave as. + fetch: vi.fn() as unknown as typeof globalThis.fetch, + // Stub for the SDK client's items.stats — flipped per-test. + statsResponse: { ok: true as const, value: {} as Record } +})) + +// --------------------------------------------------------------------------- +// Electron + filesystem stubs +// --------------------------------------------------------------------------- + +vi.mock('electron', () => ({ + app: { + getPath: () => fixtures.userData + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (s: string) => Buffer.from(s, 'utf-8'), + decryptString: (b: Buffer) => b.toString('utf-8') + }, + BrowserWindow: { + getAllWindows: () => [] + } +})) + +// --------------------------------------------------------------------------- +// SDK transport stubs +// --------------------------------------------------------------------------- + +// The MymeClient constructor accepts either { apiKey } or +// { tokenProvider }. Both paths land on the same minimal mock — what +// matters for these tests is that the verification probe (items.stats) +// returns ok / throws per the fixture flag. +vi.mock('@mymehq/sdk', async () => { + const actual = await vi.importActual('@mymehq/sdk') + class FakeMymeClient { + items = { + stats: vi.fn(async (): Promise> => { + if (!fixtures.statsResponse.ok) { + throw new actual.UnauthorizedError('Invalid credential', undefined) + } + return fixtures.statsResponse.value + }) + } + constructor(_config: unknown) { + void _config + } + } + return { ...actual, MymeClient: FakeMymeClient } +}) + +// SDK auth — only `startDeviceFlow` + the storage class are imported by +// the engine. We hand back a `handle` whose `pollForToken` resolves or +// rejects per the test's wiring; the storage gets the same JSON shape +// the real SDK would persist so `persistTokensFromStorage` round-trips. +vi.mock('@mymehq/sdk/auth', async () => { + const actual = await vi.importActual('@mymehq/sdk/auth') + + // Replaceable across tests so we can swap happy / denied / expired + // paths without re-mocking the module. + return { + ...actual, + startDeviceFlow: vi.fn() + } +}) + +// --------------------------------------------------------------------------- +// Module under test — re-imported per test so state resets cleanly +// --------------------------------------------------------------------------- + +let mymeModule: typeof import('./index') +let authModule: typeof import('@mymehq/sdk/auth') +let tokensModule: typeof import('./tokens') + +beforeEach(async () => { + fixtures.userData = mkdtempSync(join(tmpdir(), 'sw-myme-deviceflow-')) + fixtures.statsResponse = { ok: true, value: {} } + // Reset modules so the lazy `currentStatus` cache in index.ts doesn't + // leak between tests. + vi.resetModules() + authModule = await import('@mymehq/sdk/auth') + tokensModule = await import('./tokens') + mymeModule = await import('./index') + + // Default fetch — DCR returns a client_id; anything else 404s. Tests + // override this directly when they need richer behaviour. + fixtures.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + if (url.endsWith('/auth/oauth2/register')) { + return new Response(JSON.stringify({ client_id: 'test-client-id-0001' }), { + status: 201, + headers: { 'content-type': 'application/json' } + }) + } + return new Response('', { status: 404 }) + }) as unknown as typeof globalThis.fetch + vi.stubGlobal('fetch', fixtures.fetch) +}) + +afterEach(() => { + rmSync(fixtures.userData, { recursive: true, force: true }) + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface MockHandleConfig { + userCode?: string + verificationUri?: string + verificationUriComplete?: string + expiresIn?: number + // `pollForToken` resolves with the SDK persisting this bundle to its + // injected storage; provide null to make it throw an OAuthError. + tokenBundle?: { + access_token: string + refresh_token: string + access_expires_at: number + scope: string + } | null + rejectWith?: import('@mymehq/sdk/auth').OAuthError +} + +function installDeviceFlowMock(config: MockHandleConfig): void { + const mock = authModule.startDeviceFlow as unknown as ReturnType + mock.mockImplementation((cfg: import('@mymehq/sdk/auth').StartDeviceFlowConfig) => { + return Promise.resolve({ + user_code: config.userCode ?? 'TEST-0001', + verification_uri: config.verificationUri ?? 'https://staging.myme.so/device', + verification_uri_complete: + config.verificationUriComplete ?? 'https://staging.myme.so/device?user_code=TEST-0001', + expires_in: config.expiresIn ?? 600, + interval: 5, + pollForToken: async () => { + if (config.rejectWith) throw config.rejectWith + if (!config.tokenBundle) { + throw new authModule.OAuthError('invalid_grant', 'No bundle configured') + } + // Mirror the SDK's persist-to-storage step so + // `persistTokensFromStorage` can find the JSON when we read it + // out. Key shape is fixed by `device-flow.ts` in the SDK. + const origin = new URL(cfg.issuer).origin + const key = `myme.auth.tokens:${origin}:${cfg.clientId}` + await cfg.storage!.set(key, JSON.stringify(config.tokenBundle)) + // Return a minimal TokenProvider-shaped object; the engine + // doesn't actually use this — it pulls tokens out of storage. + return { + getAccessToken: async () => config.tokenBundle!.access_token, + onSignOut: () => () => undefined, + signOut: async () => undefined, + refresh: async () => config.tokenBundle!.access_token + } + } + }) + }) +} + +// --------------------------------------------------------------------------- +// Happy path +// --------------------------------------------------------------------------- + +describe('device flow — happy path', () => { + it('transitions through preparing → connecting(device) → connected', async () => { + installDeviceFlowMock({ + tokenBundle: { + access_token: 'access-1', + refresh_token: 'refresh-1', + access_expires_at: Date.now() + 3600_000, + scope: '*:read *:write' + } + }) + + const final = await mymeModule.connect() + expect(final.kind).toBe('connected') + if (final.kind !== 'connected') return + expect(final.lastError).toBeNull() + + // The credential blob on disk should now carry the OAuth bundle. + const stored = tokensModule.readCredential() + expect(stored).not.toBeNull() + if (stored === null) return + expect(stored.kind).toBe('oauth') + if (stored.kind !== 'oauth') return + expect(stored.clientId).toBe('test-client-id-0001') + expect(stored.tokens.access_token).toBe('access-1') + expect(stored.tokens.refresh_token).toBe('refresh-1') + }) + + it('exposes the user code + verification URI mid-flow', async () => { + // Use a controllable poll so we can inspect the connecting status + // before it resolves. We swap startDeviceFlow inline to hold the + // promise open until we release it. + let release: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const mock = authModule.startDeviceFlow as unknown as ReturnType + mock.mockImplementation((cfg: import('@mymehq/sdk/auth').StartDeviceFlowConfig) => + Promise.resolve({ + user_code: 'WXYZ-1234', + verification_uri: 'https://staging.myme.so/device', + verification_uri_complete: 'https://staging.myme.so/device?user_code=WXYZ-1234', + expires_in: 600, + interval: 5, + pollForToken: async () => { + await gate + const origin = new URL(cfg.issuer).origin + await cfg.storage!.set( + `myme.auth.tokens:${origin}:${cfg.clientId}`, + JSON.stringify({ + access_token: 'a', + refresh_token: 'r', + access_expires_at: Date.now() + 3600_000, + scope: '*:read *:write' + }) + ) + return { + getAccessToken: async () => 'a', + onSignOut: () => () => undefined, + signOut: async () => undefined, + refresh: async () => 'a' + } + } + }) + ) + + const connectP = mymeModule.connect() + // Yield a few microtasks so the DCR + initiate-flow setState run. + await new Promise((r) => setTimeout(r, 10)) + const mid = mymeModule.getStatus() + expect(mid.kind).toBe('connecting') + if (mid.kind !== 'connecting') return + expect(mid.mode).toBe('device') + if (mid.mode !== 'device') return + expect(mid.userCode).toBe('WXYZ-1234') + expect(mid.verificationUri).toBe('https://staging.myme.so/device') + expect(mid.verificationUriComplete).toBe('https://staging.myme.so/device?user_code=WXYZ-1234') + + release!() + const final = await connectP + expect(final.kind).toBe('connected') + }) +}) + +// --------------------------------------------------------------------------- +// Failure paths +// --------------------------------------------------------------------------- + +describe('device flow — terminal failures', () => { + it('user denial returns to disconnected with a readable error', async () => { + installDeviceFlowMock({ + rejectWith: new authModule.OAuthError('access_denied', 'User denied'), + tokenBundle: null + }) + + const final = await mymeModule.connect() + expect(final.kind).toBe('disconnected') + if (final.kind !== 'disconnected') return + expect(final.lastError).toMatch(/cancelled/i) + // No credential should have been persisted. + expect(tokensModule.readCredential()).toBeNull() + }) + + it('code expired returns to disconnected with an expired-code message', async () => { + installDeviceFlowMock({ + rejectWith: new authModule.OAuthError('expired_token', 'Device code expired'), + tokenBundle: null + }) + + const final = await mymeModule.connect() + expect(final.kind).toBe('disconnected') + if (final.kind !== 'disconnected') return + expect(final.lastError).toMatch(/expired/i) + }) + + it('DCR network failure surfaces as a connect error', async () => { + // Make fetch reject for the registration URL. + fixtures.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + if (url.endsWith('/auth/oauth2/register')) { + throw new Error('ECONNREFUSED') + } + return new Response('', { status: 404 }) + }) as unknown as typeof globalThis.fetch + vi.stubGlobal('fetch', fixtures.fetch) + installDeviceFlowMock({ tokenBundle: null }) + + const final = await mymeModule.connect() + expect(final.kind).toBe('disconnected') + if (final.kind !== 'disconnected') return + expect(final.lastError).toMatch(/ECONNREFUSED/) + }) + + it('cancelConnect mid-flow aborts the poll and lands on disconnected', async () => { + // Hold the poll open indefinitely so we can hit the cancel path. + const mock = authModule.startDeviceFlow as unknown as ReturnType + mock.mockImplementation(() => + Promise.resolve({ + user_code: 'A', + verification_uri: 'https://x', + verification_uri_complete: 'https://x?a', + expires_in: 600, + interval: 5, + pollForToken: ({ signal }: { signal?: AbortSignal } = {}) => + new Promise((_, reject) => { + signal?.addEventListener('abort', () => { + reject(new authModule.OAuthError('invalid_request', 'Aborted')) + }) + }) + }) + ) + + const connectP = mymeModule.connect() + await new Promise((r) => setTimeout(r, 10)) + expect(mymeModule.getStatus().kind).toBe('connecting') + const after = mymeModule.cancelConnect() + expect(after.kind).toBe('disconnected') + // The original connect promise resolves once the abort surfaces; + // the final status should still be disconnected. + const final = await connectP + expect(final.kind).toBe('disconnected') + }) +}) + +// --------------------------------------------------------------------------- +// API-key fallback path +// --------------------------------------------------------------------------- + +describe('device flow — API key fallback', () => { + it('useApiKey transitions into connecting(mode: api-key)', () => { + const next = mymeModule.useApiKey() + expect(next.kind).toBe('connecting') + if (next.kind !== 'connecting') return + expect(next.mode).toBe('api-key') + }) + + it('submitApiKey on a valid key still reaches connected', async () => { + fixtures.statsResponse = { ok: true, value: {} } + const final = await mymeModule.submitApiKey('myme_k1_test') + expect(final.kind).toBe('connected') + const stored = tokensModule.readCredential() + expect(stored?.kind).toBe('api-key') + }) +}) diff --git a/src/main/myme/index.ts b/src/main/myme/index.ts index 9c28cc5..90ef02a 100644 --- a/src/main/myme/index.ts +++ b/src/main/myme/index.ts @@ -1,25 +1,35 @@ import { BrowserWindow } from 'electron' import { MymeError, UnauthorizedError } from '@mymehq/sdk' +import { startDeviceFlow, OAuthError, InMemoryTokenStorage } from '@mymehq/sdk/auth' +import type { DeviceFlowHandle, TokenStorage } from '@mymehq/sdk/auth' import type { MymeStatus } from '../../preload/api' import { onReindexed } from '../cache' import { getConfig, setConfig } from '../config' import { getClient, invalidateClient } from './client' import { syncRun } from './engine' import { clearState } from './state' -import { clearCredential, credentialExists, readCredential, writeCredential } from './tokens' +import { + clearCredential, + credentialExists, + readCredential, + writeCredential, + type OAuthTokenBundle +} from './tokens' /** * Public surface of the Myme integration module — used by `ipc.ts`. * * State machine (mirrors `MymeStatus`): * - * disconnected ── connect ──────▶ connecting - * │ submitApiKey - * ▼ (verify success) - * disconnected ◀── disconnect ── connected ◀──┐ - * │ │ (sync complete) - * ▼ │ - * syncing ───┘ + * disconnected ── connect ─────▶ connecting(device) ─poll approve─▶ connected + * │ │ useApiKey ▲ + * │ ▼ │ + * │ connecting(api-key) ── submitApiKey ───┤ + * │ │ + * └──────────────── disconnect ◀──────── connected ◀─┐ │ + * │ │ (sync │ + * ▼ │ complete)│ + * syncing ─┘ │ * * The `disabled` UX state (demo mode on, or no recordings path) is * composed in the renderer from `configStore`; main always reports the @@ -30,20 +40,36 @@ import { clearCredential, credentialExists, readCredential, writeCredential } fr * decryptable, the initial status is `connected` (no last-synced time * until the engine actually runs); otherwise it's `disconnected`. * - * v1 uses an API key rather than the device flow the spec mandates — - * see [[Myme issues — running log]] for why. The state machine here - * is identical to what device flow would need; the difference is what - * the user types into the connecting pane. + * Default auth is the OAuth device flow: `startDeviceFlow` from the SDK + * (`@mymehq/sdk/auth`) initiates `/auth/device`, returns the user-code + + * verification URI, and `pollForToken()` blocks until the user approves. + * On approval we persist the token bundle through `tokens.ts` and the + * existing `connected` terminal state takes over. + * + * The API-key path stays as a dev/escape hatch — linked from the + * device-flow connecting pane. Both terminate at `connected` and share + * the rest of the engine. */ const STATUS_CHANNEL = 'myme:status' +/** OAuth client metadata used for DCR + device-flow. Stored in the + * credential blob alongside the tokens. */ +const CLIENT_NAME = 'SuperWhisper Analytics' +const DEFAULT_SCOPES = ['*:read', '*:write'] + +const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code' + // Lazy-initialised so the boot-time probe runs after `app.ready` — // `safeStorage.isEncryptionAvailable()` returns false until then on // macOS, which would mis-classify a connected user as disconnected on // every launch. let currentStatus: MymeStatus | null = null +// In-flight device-flow polling — abort signal lets `cancelConnect` +// short-circuit a long-running poll. +let activeDeviceFlowAbort: AbortController | null = null + function configSnapshot(): { endpoint: string; syncLimit: number } { const c = getConfig() return { endpoint: c.myme.endpoint, syncLimit: c.myme.syncLimit } @@ -98,12 +124,128 @@ export function setSyncLimit(value: number): MymeStatus { return ensureStatus() } -/** Transition to `connecting` so the renderer renders the API-key - * paste pane. The verification happens via `submitApiKey`. */ -export function connect(): MymeStatus { +/** + * Default connect path — initiate the OAuth device flow against the + * configured endpoint. Registers a fresh OAuth client via DCR + * (`POST /auth/oauth2/register`) so each device has its own client id, + * then calls SDK's `startDeviceFlow` and surfaces the resulting + * user-code + verification URI on the connecting pane. A background + * poll waits for user approval; success transitions to `connected`, + * failure to `disconnected` with a sensible `lastError`. + */ +export async function connect(): Promise { const { endpoint, syncLimit } = configSnapshot() - setStatus({ kind: 'connecting', endpoint, syncLimit }) - return ensureStatus() + // Optimistic transition so the renderer can show a "preparing…" UX if + // it wants; the real device-flow payload arrives a moment later. + setStatus({ + kind: 'connecting', + mode: 'device', + endpoint, + syncLimit, + userCode: '', + verificationUri: '', + verificationUriComplete: null, + expiresAt: 0 + }) + + let handle: DeviceFlowHandle + let clientId: string + // Inject our own storage so we can pull the persisted bundle back out + // after `pollForToken` resolves — the SDK persists the bundle as JSON + // under a key it owns; we re-read that JSON and write it to our own + // safeStorage-backed credential file. + const flowStorage = new InMemoryTokenStorage() + try { + clientId = await registerOAuthClient(endpoint) + handle = await startDeviceFlow({ + issuer: endpoint, + clientId, + scopes: DEFAULT_SCOPES, + storage: flowStorage + }) + } catch (err) { + return setStatus({ + kind: 'disconnected', + endpoint, + syncLimit, + lastError: describeAuthError(err) + }) + } + + setStatus({ + kind: 'connecting', + mode: 'device', + endpoint, + syncLimit, + userCode: handle.user_code, + verificationUri: handle.verification_uri, + verificationUriComplete: handle.verification_uri_complete || null, + expiresAt: Date.now() + handle.expires_in * 1000 + }) + + activeDeviceFlowAbort = new AbortController() + const signal = activeDeviceFlowAbort.signal + + try { + await handle.pollForToken({ signal }) + // The SDK persists the bundle as JSON under a key shaped + // `myme.auth.tokens::` in whatever storage we + // passed in. Pull it back out, copy across to our safeStorage-backed + // credential file, and discard the in-memory copy. + await persistTokensFromStorage(flowStorage, endpoint, clientId) + invalidateClient() + + // Verify the new credentials against the server before flipping to + // `connected` — same probe shape as the API-key path. + const client = getClient() + if (!client) throw new Error('client construction failed after device-flow approval') + await client.items.stats() + + return setStatus({ + kind: 'connected', + endpoint, + syncLimit, + lastSyncedAt: null, + lastError: null + }) + } catch (err) { + clearCredential() + invalidateClient() + return setStatus({ + kind: 'disconnected', + endpoint, + syncLimit, + lastError: describeAuthError(err) + }) + } finally { + activeDeviceFlowAbort = null + } +} + +/** + * Dev/escape hatch — transition into the API-key paste pane. Linked + * from the device-flow connecting pane via "use API key instead". + * If a device-flow poll is in flight, cancel it first. + */ +export function useApiKey(): MymeStatus { + if (activeDeviceFlowAbort) { + activeDeviceFlowAbort.abort() + activeDeviceFlowAbort = null + } + const { endpoint, syncLimit } = configSnapshot() + return setStatus({ kind: 'connecting', mode: 'api-key', endpoint, syncLimit }) +} + +/** Cancel an in-progress connect attempt. Aborts a device-flow poll if + * one is running, then falls back to `disconnected`. Safe to call from + * either connecting variant. */ +export function cancelConnect(): MymeStatus { + if (activeDeviceFlowAbort) { + activeDeviceFlowAbort.abort() + activeDeviceFlowAbort = null + } + const { endpoint, syncLimit } = configSnapshot() + return setStatus({ kind: 'disconnected', endpoint, syncLimit, lastError: null }) } /** @@ -129,7 +271,7 @@ export async function submitApiKey(key: string): Promise { // Temporarily stage the key so getClient() picks it up; persist only // after verification succeeds (so a failed attempt doesn't leave a // bad credential on disk). - const persisted = writeCredential(trimmed) + const persisted = writeCredential({ kind: 'api-key', key: trimmed }) if (!persisted) { return setStatus({ kind: 'disconnected', @@ -159,6 +301,10 @@ export async function submitApiKey(key: string): Promise { } export function disconnect(): MymeStatus { + if (activeDeviceFlowAbort) { + activeDeviceFlowAbort.abort() + activeDeviceFlowAbort = null + } clearCredential() clearState() invalidateClient() @@ -266,6 +412,21 @@ async function runSync(): Promise { /** Translate an arbitrary error from the SDK or transport into the * single-line message that surfaces on the card. */ function describeAuthError(err: unknown): string { + if (err instanceof OAuthError) { + // RFC 8628 terminal codes — give the user something they can act on. + switch (err.code) { + case 'access_denied': + return 'Connection cancelled in browser.' + case 'expired_token': + return 'The verification code expired. Try connecting again.' + case 'invalid_grant': + return 'Device-flow approval was rejected by the server.' + case 'invalid_request': + return err.message || 'Device-flow request was invalid.' + default: + return err.message || `OAuth error: ${err.code}` + } + } if (err instanceof UnauthorizedError) { return 'Invalid API key. Generate a fresh one in your Myme client and try again.' } @@ -297,3 +458,93 @@ export function registerReindexHook(): void { void syncNow().catch((err) => console.warn('[myme] reindex-triggered sync failed:', err)) }) } + +// --------------------------------------------------------------------------- +// OAuth bootstrap helpers — DCR + token persistence +// --------------------------------------------------------------------------- + +interface DcrResponse { + client_id: string +} + +/** Register a fresh OAuth client via Dynamic Client Registration. The + * client_id is persisted alongside the tokens — each install ends up + * with its own client_id, so revoking one device doesn't cascade to + * the others. */ +async function registerOAuthClient(endpoint: string): Promise { + const issuer = endpoint.replace(/\/+$/, '') + const res = await fetch(`${issuer}/auth/oauth2/register`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + grant_types: [DEVICE_CODE_GRANT_TYPE, 'refresh_token'], + client_name: CLIENT_NAME, + scope: DEFAULT_SCOPES.join(' ') + }) + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: unknown } + const code = + typeof body.error === 'string' + ? body.error + : typeof body.error === 'object' && body.error !== null + ? ((body.error as { code?: string }).code ?? 'invalid_request') + : 'invalid_request' + throw new Error(`Client registration failed: ${code} (${res.status})`) + } + const body = (await res.json()) as DcrResponse + if (typeof body.client_id !== 'string' || body.client_id.length === 0) { + throw new Error('Client registration returned no client_id') + } + return body.client_id +} + +/** + * Lift the just-issued token bundle out of the SDK's flow storage and + * persist through our safeStorage-backed `tokens.ts`. The SDK persists + * a JSON `PersistedTokens` blob under a key shaped + * `myme.auth.tokens::` (see `device-flow.ts` in the + * SDK); we read that key out of the storage instance we passed in to + * `startDeviceFlow`, then write to our own credential file. + */ +async function persistTokensFromStorage( + storage: TokenStorage, + endpoint: string, + clientId: string +): Promise { + const origin = new URL(endpoint).origin + const storageKey = `myme.auth.tokens:${origin}:${clientId}` + const raw = await storage.get(storageKey) + if (!raw) { + throw new Error('Device-flow tokens were not persisted by the SDK') + } + let parsed: Partial + try { + parsed = JSON.parse(raw) as Partial + } catch (err) { + throw new Error( + `Could not parse device-flow tokens: ${err instanceof Error ? err.message : String(err)}` + ) + } + if ( + typeof parsed.access_token !== 'string' || + typeof parsed.refresh_token !== 'string' || + typeof parsed.access_expires_at !== 'number' || + typeof parsed.scope !== 'string' + ) { + throw new Error('Device-flow tokens are missing required fields') + } + const ok = writeCredential({ + kind: 'oauth', + clientId, + tokens: { + access_token: parsed.access_token, + refresh_token: parsed.refresh_token, + access_expires_at: parsed.access_expires_at, + scope: parsed.scope + } + }) + if (!ok) { + throw new Error('Could not persist tokens (safeStorage unavailable).') + } +} diff --git a/src/main/myme/tokens.test.ts b/src/main/myme/tokens.test.ts new file mode 100644 index 0000000..7859064 --- /dev/null +++ b/src/main/myme/tokens.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +/** + * Credential-blob round-trip coverage. The JSON shape changed in T-164 + * (previously: single string; now: discriminated union). These tests + * pin the on-disk format + the legacy-string up-conversion path so an + * existing API-key user doesn't get logged out by the format change. + */ + +const fixtures = vi.hoisted(() => ({ userData: '' })) + +vi.mock('electron', () => ({ + app: { getPath: () => fixtures.userData }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (s: string) => Buffer.from(s, 'utf-8'), + decryptString: (b: Buffer) => b.toString('utf-8') + } +})) + +let tokensModule: typeof import('./tokens') + +beforeEach(async () => { + fixtures.userData = mkdtempSync(join(tmpdir(), 'sw-myme-tokens-')) + vi.resetModules() + tokensModule = await import('./tokens') +}) + +afterEach(() => { + rmSync(fixtures.userData, { recursive: true, force: true }) +}) + +describe('credential round-trip', () => { + it('persists and reads back an API-key credential', () => { + const ok = tokensModule.writeCredential({ kind: 'api-key', key: 'myme_k1_abc' }) + expect(ok).toBe(true) + const back = tokensModule.readCredential() + expect(back).toEqual({ kind: 'api-key', key: 'myme_k1_abc' }) + }) + + it('persists and reads back an OAuth credential', () => { + const bundle = { + access_token: 'at-1', + refresh_token: 'rt-1', + access_expires_at: 1735689600000, + scope: '*:read *:write' + } + tokensModule.writeCredential({ kind: 'oauth', clientId: 'cli-1', tokens: bundle }) + const back = tokensModule.readCredential() + expect(back).toEqual({ kind: 'oauth', clientId: 'cli-1', tokens: bundle }) + }) + + it('up-converts a legacy bare-string payload to api-key shape', () => { + // Pre-T-164 the file held the raw API key as a string. + const path = join(fixtures.userData, 'myme-credential.enc') + writeFileSync(path, Buffer.from('myme_k1_legacy', 'utf-8')) + const back = tokensModule.readCredential() + expect(back).toEqual({ kind: 'api-key', key: 'myme_k1_legacy' }) + }) + + it('returns null on a malformed credential blob', () => { + const path = join(fixtures.userData, 'myme-credential.enc') + // JSON, but not the expected shape. + writeFileSync(path, Buffer.from(JSON.stringify({ kind: 'nope' }), 'utf-8')) + expect(tokensModule.readCredential()).toBeNull() + }) + + it('clearCredential removes the persisted file', () => { + tokensModule.writeCredential({ kind: 'api-key', key: 'k' }) + expect(tokensModule.credentialExists()).toBe(true) + tokensModule.clearCredential() + expect(tokensModule.credentialExists()).toBe(false) + }) +}) diff --git a/src/main/myme/tokens.ts b/src/main/myme/tokens.ts index 355f53a..b175e2f 100644 --- a/src/main/myme/tokens.ts +++ b/src/main/myme/tokens.ts @@ -5,29 +5,43 @@ import { dirname, join } from 'path' /** * Encrypted-on-disk storage for the Myme credential. * - * First introduction of `safeStorage` in the repo. `safeStorage` is - * Keychain-backed on macOS (the only platform this app targets) — the - * blob written to disk is opaque ciphertext that can only be decrypted - * on the machine that wrote it, under the same user. If the Keychain is - * unavailable (extremely unlikely on macOS) the helpers degrade - * gracefully — read returns null, write returns false — so the rest of - * the integration stays optional. + * `safeStorage` is Keychain-backed on macOS (the only platform this app + * targets) — the blob written to disk is opaque ciphertext that can only + * be decrypted on the machine that wrote it, under the same user. If the + * Keychain is unavailable (extremely unlikely on macOS) the helpers + * degrade gracefully — read returns null, write returns false — so the + * rest of the integration stays optional. * * On disk: `/myme-credential.enc` — a single binary file - * containing the encrypted API key bytes. Written via the + * containing the encrypted JSON-encoded credential blob. Written via the * write-temp + rename idiom so a crash mid-write doesn't corrupt the * file. * - * The credential shape today is a single API key. The plan called for - * device-flow tokens (access + refresh) but staging's OAuth path - * doesn't actually expose a way to register a device-flow client — see - * the Myme issues running log. The encryption surface here is generic - * enough that swapping to a JSON `{accessToken, refreshToken, - * expiresAt}` blob later is a one-line change. + * The credential is a discriminated union of: + * - `{ kind: 'api-key', key }` — user-pasted API key (dev/escape hatch) + * - `{ kind: 'oauth', clientId, tokens: { access_token, refresh_token, + * access_expires_at, scope } }` — device-flow tokens + * + * Older installs that pre-date device-flow stored a single string here. + * On read we transparently up-convert a raw string to the `api-key` + * shape so an existing API-key connection survives the format change + * without forcing a reconnect. */ const CREDENTIAL_FILE = 'myme-credential.enc' +export interface OAuthTokenBundle { + access_token: string + refresh_token: string + /** Unix ms. */ + access_expires_at: number + scope: string +} + +export type StoredCredential = + | { kind: 'api-key'; key: string } + | { kind: 'oauth'; clientId: string; tokens: OAuthTokenBundle } + function filePath(): string { return join(app.getPath('userData'), CREDENTIAL_FILE) } @@ -41,8 +55,11 @@ export function credentialExists(): boolean { /** Read and decrypt the stored credential. Returns null on any failure * (missing file, safeStorage unavailable, decryption error). Never - * throws — Myme is optional and shouldn't break the rest of the app. */ -export function readCredential(): string | null { + * throws — Myme is optional and shouldn't break the rest of the app. + * + * A legacy raw-string payload is up-converted to the `api-key` shape + * transparently so existing installs don't need to reconnect. */ +export function readCredential(): StoredCredential | null { try { if (!safeStorage.isEncryptionAvailable()) { console.warn('[myme] safeStorage unavailable — cannot decrypt credential') @@ -51,7 +68,21 @@ export function readCredential(): string | null { const path = filePath() if (!existsSync(path)) return null const buf = readFileSync(path) - return safeStorage.decryptString(buf) + const decoded = safeStorage.decryptString(buf) + // Try JSON first. If it parses, the on-disk file was written by the + // current code path — validate the shape and return (or null on a + // shape mismatch; we never silently fall back to legacy in that case + // because the only way to land here with valid JSON is the new + // format). If JSON parsing fails, the file is from the pre-T-164 + // bare-string era — up-convert it. + let parsed: unknown + try { + parsed = JSON.parse(decoded) + } catch { + if (decoded.length > 0) return { kind: 'api-key', key: decoded } + return null + } + return validate(parsed) } catch (err) { console.warn('[myme] failed to read credential:', err) return null @@ -60,7 +91,7 @@ export function readCredential(): string | null { /** Encrypt and persist the credential. Returns true on success, false * on any failure (safeStorage unavailable, write error). */ -export function writeCredential(value: string): boolean { +export function writeCredential(value: StoredCredential): boolean { try { if (!safeStorage.isEncryptionAvailable()) { console.warn('[myme] safeStorage unavailable — cannot encrypt credential') @@ -69,7 +100,7 @@ export function writeCredential(value: string): boolean { const path = filePath() mkdirSync(dirname(path), { recursive: true }) const tmp = path + '.tmp' - writeFileSync(tmp, safeStorage.encryptString(value)) + writeFileSync(tmp, safeStorage.encryptString(JSON.stringify(value))) renameSync(tmp, path) return true } catch (err) { @@ -90,3 +121,40 @@ export function clearCredential(): void { } } } + +/** Defensive parse — accept either credential variant; reject anything + * else so a corrupted file forces a fresh reconnect rather than wedging + * on bad data. */ +function validate(parsed: unknown): StoredCredential | null { + if (typeof parsed !== 'object' || parsed === null) return null + const obj = parsed as Record + if (obj.kind === 'api-key' && typeof obj.key === 'string' && obj.key.length > 0) { + return { kind: 'api-key', key: obj.key } + } + if ( + obj.kind === 'oauth' && + typeof obj.clientId === 'string' && + typeof obj.tokens === 'object' && + obj.tokens !== null + ) { + const t = obj.tokens as Record + if ( + typeof t.access_token === 'string' && + typeof t.refresh_token === 'string' && + typeof t.access_expires_at === 'number' && + typeof t.scope === 'string' + ) { + return { + kind: 'oauth', + clientId: obj.clientId, + tokens: { + access_token: t.access_token, + refresh_token: t.refresh_token, + access_expires_at: t.access_expires_at, + scope: t.scope + } + } + } + } + return null +} diff --git a/src/preload/api.ts b/src/preload/api.ts index 90e155a..b43d038 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -105,17 +105,19 @@ export type UpdaterStatus = * recordings path is set) from `configStore` — main always reports the * sync engine's actual state. Card states the user sees: * - * - `disconnected` → endpoint + "Connect to Myme" CTA - * - `connecting` → API-key paste-and-verify pane - * - `connected` (no error) → "last synced …" + "Sync now" - * - `connected` (with error) → as above + inline error row - * - `syncing` → progress phase + processed/total + * - `disconnected` → endpoint + "Connect to Myme" CTA + * - `connecting` (mode: device) → user-code + "Verify in browser" pane, + * with a "use API key instead" link + * - `connecting` (mode: api-key)→ API-key paste-and-verify pane + * - `connected` (no error) → "last synced …" + "Sync now" + * - `connected` (with error) → as above + inline error row + * - `syncing` → progress phase + processed/total * - * The OAuth device flow [[Myme integration — May 2026]] specifies isn't - * actually wired end-to-end on staging today (see the running log). - * v1 uses an API key the user generates in their Myme client and - * pastes into the connecting pane; encrypted via `safeStorage` and - * persisted to `/myme-credential.enc`. + * Default auth is the OAuth device-flow (`startDeviceFlow` from the SDK). + * The API-key paste path stays as a dev/escape hatch — surfaced via the + * "use API key instead" link on the device-flow connecting pane. + * Tokens are encrypted via `safeStorage` and persisted to + * `/myme-credential.enc`. */ export type MymeSyncPhase = 'preparing' | 'recordings' | 'sessions' @@ -130,9 +132,29 @@ export type MymeStatus = } | { kind: 'connecting' + mode: 'api-key' endpoint: string syncLimit: number } + | { + kind: 'connecting' + mode: 'device' + endpoint: string + syncLimit: number + /** Short user-readable code (XXXX-XXXX shape) the user types into + * the verification page. */ + userCode: string + /** Plain verification URL — show as fallback if `verificationUriComplete` + * is absent or the user wants to type the code manually. */ + verificationUri: string + /** Deep-link variant with the code pre-filled. When present, the + * "Verify in browser" button opens this so the user doesn't have + * to type the code at all. */ + verificationUriComplete: string | null + /** Unix ms — used by the renderer to show a "code expires in …" + * countdown if we want one later. */ + expiresAt: number + } | { kind: 'connected' endpoint: string @@ -226,16 +248,25 @@ export const api = { status: (): Promise => ipcRenderer.invoke('myme:status'), /** Persist a new Myme endpoint URL. Returns the updated status. */ setEndpoint: (url: string): Promise => ipcRenderer.invoke('myme:setEndpoint', url), - /** Transition the card into the "paste your API key" state. Until - * staging's OAuth device flow is viable end-to-end, the integration - * authenticates via a user-supplied API key (see the running log). */ + /** Default connect path — initiates the OAuth device flow against + * the configured endpoint. Returns a `connecting` (mode: 'device') + * status carrying the user code + verification URI; the renderer + * shows that, and a background poll resolves to `connected` on + * user approval (push lands via `onStatus`). */ connect: (): Promise => ipcRenderer.invoke('myme:connect'), + /** Dev/escape-hatch path: transition the card into the API-key + * paste pane. Linked from the device-flow connecting pane via + * "use API key instead". */ + useApiKey: (): Promise => ipcRenderer.invoke('myme:useApiKey'), /** Verify the supplied API key against the current endpoint and, on * success, encrypt + persist it. Resolves with the new status — * `connected` on success, `disconnected` with `lastError` set on * failure. */ submitApiKey: (key: string): Promise => ipcRenderer.invoke('myme:submitApiKey', key), + /** Cancel an in-progress connect attempt (device-flow polling or + * API-key pane). Falls back to `disconnected`. */ + cancelConnect: (): Promise => ipcRenderer.invoke('myme:cancelConnect'), /** Revoke the persisted token + clear sync state. */ disconnect: (): Promise => ipcRenderer.invoke('myme:disconnect'), /** Manual sync trigger. Returns when the sync completes (success diff --git a/src/renderer/src/components/settings/MymeCard.tsx b/src/renderer/src/components/settings/MymeCard.tsx index 7595975..05ec52d 100644 --- a/src/renderer/src/components/settings/MymeCard.tsx +++ b/src/renderer/src/components/settings/MymeCard.tsx @@ -2,26 +2,32 @@ import { SettingsCard } from './SettingsCard' import { cn } from '@renderer/lib/cn' import { useConfigStore } from '@renderer/state/configStore' import { useMymeStore } from '@renderer/state/mymeStore' -import { Cloud, CloudOff, RefreshCw } from 'lucide-react' +import { Check, Cloud, CloudOff, Copy, ExternalLink, RefreshCw } from 'lucide-react' import { useEffect, useState } from 'react' import type { MymeStatus } from '../../../../preload/api' /** * Settings → Integrations → Myme card. * - * One card, five effective states: + * One card, six effective states: * - * 1. `disabled` — demo mode is on, or no recordings path. Card is - * greyed out; sync engine is inert. - * 2. `disconnected` — endpoint URL + "Connect to Myme" button. - * 3. `connecting` — API-key paste-and-verify pane. - * 4. `connected` — last synced time + "Sync now" + a sync-cap - * setting (default 100; 0 = no cap). If - * `lastError` is set, an inline error row appears - * below. - * 5. `syncing` — progress text + Cancel button. The signal - * threaded into the engine stops further upserts - * once aborted; partial state is persisted. + * 1. `disabled` — demo mode is on, or no recordings path. + * Card is greyed out; sync engine is inert. + * 2. `disconnected` — endpoint URL + "Connect to Myme" button. + * 3. `connecting (device)` — default. User-code + "Verify in browser" + * button; opens the verification URI in + * the system browser. Includes a + * "Use API key instead" fallback link. + * 4. `connecting (api-key)` — dev/escape hatch. API-key paste-and-verify + * pane. + * 5. `connected` — last synced time + "Sync now" + a sync-cap + * setting (default 100; 0 = no cap). If + * `lastError` is set, an inline error row + * appears below. + * 6. `syncing` — progress text + Cancel button. The signal + * threaded into the engine stops further + * upserts once aborted; partial state is + * persisted. * * Failure paths never reach the main app — Myme is optional, so its * problems stay in this card. (Deliberate departure from how scan @@ -82,7 +88,16 @@ function StatusBody({ status }: { status: MymeStatus }): React.JSX.Element { case 'disconnected': return case 'connecting': - return + if (status.mode === 'device') { + return ( + + ) + } + return case 'connected': return ( s.useApiKey) + const cancelConnect = useMymeStore((s) => s.cancelConnect) + const [copied, setCopied] = useState(false) + const [busy, setBusy] = useState(false) + + const ready = userCode.length > 0 && verificationUri.length > 0 + const openHref = verificationUriComplete ?? verificationUri + + async function copyCode(): Promise { + try { + await navigator.clipboard.writeText(userCode) + setCopied(true) + window.setTimeout(() => setCopied(false), 1500) + } catch { + // clipboard refused — silent + } + } + + function openVerify(): void { + void window.api.openExternal(openHref) + } + + async function cancel(): Promise { + setBusy(true) + try { + await cancelConnect() + } finally { + setBusy(false) + } + } + + async function fallback(): Promise { + setBusy(true) + try { + await switchToApiKey() + } finally { + setBusy(false) + } + } + + if (!ready) { + return ( +
+

+ + Preparing device-flow request… +

+
+ +
+
+ ) + } + + return ( +
+

+ Approve this device in your browser to connect. The verification page will ask for the code + below. +

+
+
Your code
+
+
+ {userCode} +
+ +
+
+
+ +
+ + +
+
+
+ ) +} + +function ApiKeyConnectingBody(): React.JSX.Element { const submitApiKey = useMymeStore((s) => s.submitApiKey) - const disconnect = useMymeStore((s) => s.disconnect) + const cancelConnect = useMymeStore((s) => s.cancelConnect) const [key, setKey] = useState('') const [busy, setBusy] = useState(false) @@ -198,13 +358,12 @@ function ConnectingBody(): React.JSX.Element { } } function cancel(): void { - void disconnect() + void cancelConnect() } return (

- Paste your Myme API key to connect. Generate one in your Myme client — it stays on this Mac, - encrypted via Keychain. + Dev path. Paste your Myme API key to connect — it stays on this Mac, encrypted via Keychain.

+ ) +} + +function Row({ + label, + value, + mono +}: { + label: string + value: string + mono?: boolean +}): React.JSX.Element { + return ( +
+
{label}
+
+ {value} +
+
+ ) +} + +function StatusRow({ + probe, + probing +}: { + probe: ProbeResult | null + probing: boolean +}): React.JSX.Element { + if (probing && !probe) { + return ( +
+
Status
+
Checking…
+
+ ) + } + if (!probe) { + return ( +
+
Status
+
+
+ ) + } + if (probe.ok) { + return ( +
+
Status
+
+ + Reachable + +
+
+ ) + } + return ( +
+
Status
+
+ + {probe.error} +
+
+ ) +} + +function describeIdentity(probe: ProbeResult | null, probing: boolean): string { + if (probing && !probe) return 'Resolving…' + if (!probe) return '—' + if (!probe.ok) return '—' + if (probe.displayName && probe.email) return `${probe.displayName} (${probe.email})` + if (probe.displayName) return probe.displayName + if (probe.email) return probe.email + return 'Signed in' +} diff --git a/src/renderer/src/components/settings/MymeCard.tsx b/src/renderer/src/components/settings/MymeCard.tsx index c0e7ae4..edb23ac 100644 --- a/src/renderer/src/components/settings/MymeCard.tsx +++ b/src/renderer/src/components/settings/MymeCard.tsx @@ -1,6 +1,7 @@ +import { ConnectionPanel } from './ConnectionPanel' import { MappingPanel } from './MappingPanel' import { SettingsCard } from './SettingsCard' -import { cn } from '@renderer/lib/cn' +import { SyncControlsPanel } from './SyncControlsPanel' import { useConfigStore } from '@renderer/state/configStore' import { useMymeStore } from '@renderer/state/mymeStore' import { Check, Cloud, CloudOff, Copy, ExternalLink, RefreshCw } from 'lucide-react' @@ -409,16 +410,9 @@ function ConnectedBody({ lastError: string | null }): React.JSX.Element { const syncNow = useMymeStore((s) => s.syncNow) - const disconnect = useMymeStore((s) => s.disconnect) const setSyncLimit = useMymeStore((s) => s.setSyncLimit) const mapping = useMymeStore((s) => s.mapping) const [busy, setBusy] = useState(false) - // Re-render once a minute so "5m ago" drifts without a custom hook. - const [, setNow] = useState(() => Date.now()) - useEffect(() => { - const t = window.setInterval(() => setNow(Date.now()), 60_000) - return () => window.clearInterval(t) - }, []) async function doSync(): Promise { setBusy(true) @@ -428,117 +422,19 @@ function ConnectedBody({ setBusy(false) } } - async function doDisconnect(): Promise { - setBusy(true) - try { - await disconnect() - } finally { - setBusy(false) - } - } return ( -
-
- - -
- {lastError && ( -

- Last sync failed: {lastError} -

- )} +
+ {mapping && } - -
- - -
-
- ) -} - -/** - * Sync-cap setting. Placed inside the connected card so it's clearly - * bound to the active integration. Default is 100; 0 means no cap. - * Capping skips session derivation + disk-delete propagation by - * design — surfaced in the copy below so the trade-off is explicit - * rather than hidden. Number-only input, persisted on blur (so - * typing-in-progress doesn't churn the IPC). Hidden in any other - * card state. - */ -function SyncLimitRow({ - value, - onCommit, - disabled -}: { - value: number - onCommit: (n: number) => Promise - disabled: boolean -}): React.JSX.Element { - // Key the row by the committed value so the input resets to the new - // canonical value whenever it changes externally (e.g. disconnect → - // reconnect resets back to whatever was persisted). Avoids the - // setState-in-effect anti-pattern. - return -} - -function SyncLimitRowInner({ - value, - onCommit, - disabled -}: { - value: number - onCommit: (n: number) => Promise - disabled: boolean -}): React.JSX.Element { - const [draft, setDraft] = useState(String(value)) - - async function commit(): Promise { - const n = Number.parseInt(draft, 10) - const next = Number.isFinite(n) && n > 0 ? n : 0 - if (next === value) return - await onCommit(next) - } - - return ( -
-
-
-
- Sync most recent N recordings -
-
- 0 = no cap. When capped, session derivation and disk-delete propagation are skipped — - turn off the cap to test the full sync surface. -
-
- setDraft(e.target.value)} - onBlur={() => void commit()} - disabled={disabled} - className="w-20 rounded-md border border-border bg-card px-2 py-1 text-right text-[12.5px] tabular-nums text-foreground focus:border-foreground/30 focus:outline-none disabled:opacity-50" - /> -
+
) } @@ -596,27 +492,3 @@ function SyncingBody({
) } - -function Row({ k, v }: { k: string; v: string }): React.JSX.Element { - return ( -
-
{k}
-
- {v} -
-
- ) -} - -function formatRelative(iso: string): string { - const then = new Date(iso).getTime() - if (!Number.isFinite(then)) return iso - const diffSec = Math.floor((Date.now() - then) / 1000) - if (diffSec < 60) return 'Just now' - const diffMin = Math.floor(diffSec / 60) - if (diffMin < 60) return `${diffMin}m ago` - const diffHr = Math.floor(diffMin / 60) - if (diffHr < 24) return `${diffHr}h ago` - const diffD = Math.floor(diffHr / 24) - return `${diffD}d ago` -} diff --git a/src/renderer/src/components/settings/SyncControlsPanel.tsx b/src/renderer/src/components/settings/SyncControlsPanel.tsx new file mode 100644 index 0000000..81dc443 --- /dev/null +++ b/src/renderer/src/components/settings/SyncControlsPanel.tsx @@ -0,0 +1,224 @@ +import { cn } from '@renderer/lib/cn' +import { useDataStore } from '@renderer/state/dataStore' +import { useMymeStore } from '@renderer/state/mymeStore' +import { RefreshCw } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' + +/** + * Sync controls — bottom section of the connected card. Carries the + * sync-cap (reframed copy), the mode filter (chips derived from + * observed recording modes), the manual sync trigger, last-sync drift + * display, and a recent-error row. + */ +export function SyncControlsPanel({ + syncLimit, + lastSyncedAt, + lastError, + busy, + onSync, + onSyncLimit +}: { + syncLimit: number + lastSyncedAt: string | null + lastError: string | null + busy: boolean + onSync: () => Promise + onSyncLimit: (n: number) => Promise +}): React.JSX.Element { + // Re-render once a minute so the "5m ago" drift updates without a + // custom hook. The same trick the previous ConnectedBody used. + const [, setNow] = useState(() => Date.now()) + useEffect(() => { + const t = window.setInterval(() => setNow(Date.now()), 60_000) + return () => window.clearInterval(t) + }, []) + + return ( +
+
Sync
+ + +
+
Last synced
+
{lastSyncedAt ? formatRelative(lastSyncedAt) : 'Never'}
+
+ {lastError && ( +

+ Last sync failed: {lastError} +

+ )} +
+ +
+
+ ) +} + +function ModeFilterRow({ disabled }: { disabled: boolean }): React.JSX.Element { + const recordings = useDataStore((s) => s.recordings) + const modeFilter = useMymeStore((s) => s.modeFilter) + const setModeFilter = useMymeStore((s) => s.setModeFilter) + + const modes = useMemo(() => { + const counts = new Map() + for (const r of recordings) { + const name = r.modeName || '(unknown)' + counts.set(name, (counts.get(name) ?? 0) + 1) + } + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([name, count]) => ({ name, count })) + }, [recordings]) + + const selected = new Set(modeFilter ?? []) + + async function toggleMode(name: string): Promise { + if (modeFilter === null) { + // Currently "all" — clicking a chip narrows to that single mode. + await setModeFilter([name]) + return + } + const next = new Set(modeFilter) + if (next.has(name)) next.delete(name) + else next.add(name) + await setModeFilter(next.size === 0 ? null : Array.from(next)) + } + + async function selectAll(): Promise { + await setModeFilter(null) + } + + if (modes.length === 0) { + return ( +
+
Mode filter
+
No recordings indexed yet.
+
+ ) + } + + return ( +
+
+
+
Mode filter
+
+ {modeFilter === null + ? 'Syncing recordings from every mode.' + : `Syncing only from ${modeFilter.length} of ${modes.length} modes.`} +
+
+ {modeFilter !== null && ( + + )} +
+
+ {modes.map(({ name, count }) => { + const active = modeFilter === null || selected.has(name) + return ( + + ) + })} +
+
+ ) +} + +function SyncLimitRow({ + value, + onCommit, + disabled +}: { + value: number + onCommit: (n: number) => Promise + disabled: boolean +}): React.JSX.Element { + return +} + +function SyncLimitRowInner({ + value, + onCommit, + disabled +}: { + value: number + onCommit: (n: number) => Promise + disabled: boolean +}): React.JSX.Element { + const [draft, setDraft] = useState(String(value)) + + async function commit(): Promise { + const n = Number.parseInt(draft, 10) + const next = Number.isFinite(n) && n > 0 ? n : 0 + if (next === value) return + await onCommit(next) + } + + return ( +
+
+
+
+ Sync most recent N recordings +
+
+ 0 = no cap. While capped, session derivation and disk-delete propagation are skipped — + turn the cap off to test the full sync surface. +
+
+ setDraft(e.target.value)} + onBlur={() => void commit()} + disabled={disabled} + className="w-20 rounded-md border border-border bg-card px-2 py-1 text-right text-[12.5px] tabular-nums text-foreground focus:border-foreground/30 focus:outline-none disabled:opacity-50" + /> +
+
+ ) +} + +function formatRelative(iso: string): string { + const then = new Date(iso).getTime() + if (!Number.isFinite(then)) return iso + const diffSec = Math.floor((Date.now() - then) / 1000) + if (diffSec < 60) return 'Just now' + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + const diffHr = Math.floor(diffMin / 60) + if (diffHr < 24) return `${diffHr}h ago` + const diffD = Math.floor(diffHr / 24) + return `${diffD}d ago` +} From f9af0b928956d393caa9be523c0be881f86acd14 Mon Sep 17 00:00:00 2001 From: August Cayzer Date: Sun, 17 May 2026 21:11:05 +0100 Subject: [PATCH 18/27] feat(sw-app): redesign the connected MymeCard layout (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sw-app): redesign the connected card layout The original layout buried the type mapping behind an Edit / Close gate, opened with Connection (the least frequently touched part), and gave every subsection the same border treatment — flat and patchwork. This rework treats the card as three clearly-separated sections with a consistent header pattern (uppercase tracked label + subtitle): - Type mapping (top — the headline decision) - Sync (middle — what lands, when) - Connection (bottom — compact footer) Type mapping is now always-visible: each binding card shows the segmented [Bundled | Existing | Authored] control inline, the type id in monospace, and the field map open. Bundled fields render read-only with a 'Fixed by the bundled type.' note; existing and authored modes expose the field-picker UI. Apply only appears when the draft differs from the persisted binding, and still gates the trash-and-re-mint warning before committing. Resetting the binding is one click rather than 'Cancel + re-open Edit.' Connection shrinks to a compact footer: endpoint + identity on the left, a status pill on the right, Disconnect / Test connection in a small button row below. Sync gains an inline Sync-now button next to the Last-synced row so the common action sits next to its status. Renderer-only — no engine, IPC, or main-process changes. * refactor(sw-app): split the Myme tab into three Settings cards Replaces the single MymeCard with three sibling SettingsCards that match the rest of the Settings tabs (Indexing / Filler words shape): Connection · Sync · Type mapping. The integration tab in the segmented strip renames from 'Integrations' to 'Myme'. Changes: - Connection (top, Cloud icon) — endpoint, account identity, status pill, Disconnect + Test connection. Pill moves into the header via the SettingsCard.headerExtra slot. - Sync (middle, RefreshCw icon) — mode filter rendered as toggle rows per observed Superwhisper mode (matches Watch folder pattern), sync cap, Last-synced display with inline Sync now / Cancel buttons. During syncing, the header pill shows progress and the body switches to phase + processed/total. - Type mapping (bottom, Layers icon) — binding panels for recording and session. Field map laid out with CSS subgrid so target / arrow / source columns line up across rows regardless of name length. Authored / Existing modes expose the field-picker inline; bundled renders the canonical layout read-only with a fixed-source label. Buttons are unified on the standard chrome treatment (no 'primary' variant). The disconnect/connect flow lives in its own Connect-to-Myme card during disconnected / connecting states, so the empty-state copy matches the connected hierarchy. Drops the three superseded panel files (MappingPanel, ConnectionPanel, SyncControlsPanel) — fully replaced by the new SettingsCard-wrapped components. * refactor(sw-app): drop sync-cap UI, add test-sync button, polish copy The sync-cap had nasty semantics — skipped soft-delete, skipped session derivation, orphaned items beyond the cap — and it was a testing knob that leaked into the user surface. Drop it. - Remove `syncLimit` from Config.myme, MymeStatus, the setSyncLimit IPC + store action. Engine's `limit` option stays for tests but no production code path sets it. - New `testSync` IPC + store action: runs `syncRun` with limit=5 for a quick sanity-check pass. Surfaced in the Sync card as a secondary 'Test sync' button next to 'Sync now'. Field-map grid: target sits flush left, source cluster (arrow + value + remove) sits flush right. A spacer column between the two halves keeps that split stable regardless of target-name length. Subgrid carries the column widths across rows. Copy pass: tighten the card subtitles, retire 'Local-only by default' for 'Off by default — local-only until you connect', rewrite the trash-and-re-mint warning, replace generic 'No fields mapped' with action-oriented prompts. All buttons unified on the chrome treatment. * fix(sw-app): inline arrow next to source on field rows Drop the cross-row arrow alignment. Each row uses flex justify-between so the target sits on the left and the arrow+source cluster sits on the right, trailing the row. Same spacing for both bundled (read-only) and editable variants. * fix(sw-app): align arrows in a fixed centre column on the field map Target column is max-content (locks to the longest target name); arrow column auto; source column 1fr right-aligned. Result: arrows sit at the same x across every row, source labels hang flush right. * fix(sw-app): drop the arrow from field rows The arrow column added noise without clarifying the relationship. Target flush left, source flush right reads as the mapping just fine on its own. --- src/main/config.ts | 6 - src/main/ipc.ts | 5 +- src/main/myme/index.ts | 93 +-- src/preload/api.ts | 18 +- .../components/settings/ConnectionCard.tsx | 185 ++++++ .../components/settings/ConnectionPanel.tsx | 169 ----- .../src/components/settings/MymeCard.tsx | 265 +++----- .../src/components/settings/SyncCard.tsx | 260 ++++++++ .../components/settings/SyncControlsPanel.tsx | 224 ------- .../{MappingPanel.tsx => TypeMappingCard.tsx} | 578 +++++++++--------- src/renderer/src/screens/Settings.tsx | 6 +- src/renderer/src/state/mymeStore.ts | 12 +- 12 files changed, 904 insertions(+), 917 deletions(-) create mode 100644 src/renderer/src/components/settings/ConnectionCard.tsx delete mode 100644 src/renderer/src/components/settings/ConnectionPanel.tsx create mode 100644 src/renderer/src/components/settings/SyncCard.tsx delete mode 100644 src/renderer/src/components/settings/SyncControlsPanel.tsx rename src/renderer/src/components/settings/{MappingPanel.tsx => TypeMappingCard.tsx} (59%) diff --git a/src/main/config.ts b/src/main/config.ts index 3e670aa..4c02c88 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -35,7 +35,6 @@ function defaultConfig(): Config { devTools: false, myme: { endpoint: DEFAULT_MYME_ENDPOINT, - syncLimit: 100, mapping: defaultMapping(), modeFilter: null } @@ -80,10 +79,6 @@ export function getConfig(): Config { typeof parsed.myme?.endpoint === 'string' && parsed.myme.endpoint.length > 0 ? parsed.myme.endpoint : DEFAULT_MYME_ENDPOINT - const mymeSyncLimit = - typeof parsed.myme?.syncLimit === 'number' && Number.isFinite(parsed.myme.syncLimit) - ? Math.max(0, Math.floor(parsed.myme.syncLimit)) - : 0 // Additive migration: configs pre-dating the mapping work have no // `mapping` block. Drop in the bundled default so the engine has a // working mapping to project against from the very first sync. @@ -106,7 +101,6 @@ export function getConfig(): Config { devTools: parsed.devTools === true, myme: { endpoint: mymeEndpoint, - syncLimit: mymeSyncLimit, mapping: mymeMapping, modeFilter: mymeModeFilter } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index be7077f..adab31f 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -181,10 +181,7 @@ export function registerIpcHandlers(): void { ipcMain.handle('myme:disconnect', (): MymeStatus => myme.disconnect()) ipcMain.handle('myme:syncNow', (): Promise => myme.syncNow()) ipcMain.handle('myme:cancelSync', (): MymeStatus => myme.cancelSync()) - ipcMain.handle('myme:setSyncLimit', (_, n: unknown): MymeStatus => { - if (typeof n !== 'number' || !Number.isFinite(n)) return myme.getStatus() - return myme.setSyncLimit(n) - }) + ipcMain.handle('myme:testSync', (): Promise => myme.testSync()) ipcMain.handle('myme:getMapping', (): MymeMapping => myme.getMapping()) ipcMain.handle('myme:setMapping', (_, mapping: unknown): MymeMapping => { // Light validation: the structure must have `recording` and diff --git a/src/main/myme/index.ts b/src/main/myme/index.ts index 5f56aaf..0bf4e89 100644 --- a/src/main/myme/index.ts +++ b/src/main/myme/index.ts @@ -97,17 +97,23 @@ let currentStatus: MymeStatus | null = null // short-circuit a long-running poll. let activeDeviceFlowAbort: AbortController | null = null -function configSnapshot(): { endpoint: string; syncLimit: number } { - const c = getConfig() - return { endpoint: c.myme.endpoint, syncLimit: c.myme.syncLimit } +// In-flight sync state. Coalesces concurrent `syncNow` / `testSync` +// calls; the single-promise guard means a click on either while a sync +// is running returns the in-flight promise rather than spawning a +// second pass. +let syncInFlight: Promise | null = null +let activeAbort: AbortController | null = null + +function endpointSnapshot(): string { + return getConfig().myme.endpoint } function buildInitialStatus(): MymeStatus { - const { endpoint, syncLimit } = configSnapshot() + const endpoint = endpointSnapshot() if (credentialExists() && readCredential() !== null) { - return { kind: 'connected', endpoint, syncLimit, lastSyncedAt: null, lastError: null } + return { kind: 'connected', endpoint, lastSyncedAt: null, lastError: null } } - return { kind: 'disconnected', endpoint, syncLimit, lastError: null } + return { kind: 'disconnected', endpoint, lastError: null } } function ensureStatus(): MymeStatus { @@ -141,14 +147,22 @@ export function setEndpoint(url: string): MymeStatus { return ensureStatus() } -/** Persist the "push N most-recent" testing knob. Reflected on the - * card and threaded through the engine's next `syncRun()`. */ -export function setSyncLimit(value: number): MymeStatus { - const clamped = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0 - const existing = getConfig().myme - setConfig({ myme: { ...existing, syncLimit: clamped } }) - setStatus({ ...ensureStatus(), syncLimit: clamped } as MymeStatus) - return ensureStatus() +/** Run a small dry-run sync against the 5 most recent recordings. + * Useful for sanity-checking the integration without touching the + * full corpus. Skips soft-delete and session derivation while the + * cap is in effect, so the test run never trashes existing items + * beyond the cap. */ +export async function testSync(): Promise { + if (syncInFlight) return syncInFlight + const status = ensureStatus() + if (status.kind !== 'connected') return status + syncInFlight = runSync({ limit: 5 }) + try { + return await syncInFlight + } finally { + syncInFlight = null + activeAbort = null + } } /** Read the persisted mapping config. The renderer hydrates this on @@ -267,14 +281,14 @@ export async function registerType(schema: TypeSchema): Promise { - const { endpoint, syncLimit } = configSnapshot() + const endpoint = endpointSnapshot() // Optimistic transition so the renderer can show a "preparing…" UX if // it wants; the real device-flow payload arrives a moment later. setStatus({ kind: 'connecting', mode: 'device', endpoint, - syncLimit, + userCode: '', verificationUri: '', verificationUriComplete: null, @@ -300,7 +314,7 @@ export async function connect(): Promise { return setStatus({ kind: 'disconnected', endpoint, - syncLimit, + lastError: describeAuthError(err) }) } @@ -309,7 +323,7 @@ export async function connect(): Promise { kind: 'connecting', mode: 'device', endpoint, - syncLimit, + userCode: handle.user_code, verificationUri: handle.verification_uri, verificationUriComplete: handle.verification_uri_complete || null, @@ -337,7 +351,7 @@ export async function connect(): Promise { return setStatus({ kind: 'connected', endpoint, - syncLimit, + lastSyncedAt: null, lastError: null }) @@ -347,7 +361,7 @@ export async function connect(): Promise { return setStatus({ kind: 'disconnected', endpoint, - syncLimit, + lastError: describeAuthError(err) }) } finally { @@ -365,8 +379,8 @@ export function useApiKey(): MymeStatus { activeDeviceFlowAbort.abort() activeDeviceFlowAbort = null } - const { endpoint, syncLimit } = configSnapshot() - return setStatus({ kind: 'connecting', mode: 'api-key', endpoint, syncLimit }) + const endpoint = endpointSnapshot() + return setStatus({ kind: 'connecting', mode: 'api-key', endpoint }) } /** Cancel an in-progress connect attempt. Aborts a device-flow poll if @@ -377,8 +391,8 @@ export function cancelConnect(): MymeStatus { activeDeviceFlowAbort.abort() activeDeviceFlowAbort = null } - const { endpoint, syncLimit } = configSnapshot() - return setStatus({ kind: 'disconnected', endpoint, syncLimit, lastError: null }) + const endpoint = endpointSnapshot() + return setStatus({ kind: 'disconnected', endpoint, lastError: null }) } /** @@ -392,12 +406,12 @@ export function cancelConnect(): MymeStatus { */ export async function submitApiKey(key: string): Promise { const trimmed = key.trim() - const { endpoint, syncLimit } = configSnapshot() + const endpoint = endpointSnapshot() if (!trimmed) { return setStatus({ kind: 'disconnected', endpoint, - syncLimit, + lastError: 'API key is empty.' }) } @@ -409,7 +423,7 @@ export async function submitApiKey(key: string): Promise { return setStatus({ kind: 'disconnected', endpoint, - syncLimit, + lastError: 'Could not encrypt credential (safeStorage unavailable).' }) } @@ -421,7 +435,7 @@ export async function submitApiKey(key: string): Promise { return setStatus({ kind: 'connected', endpoint, - syncLimit, + lastSyncedAt: null, lastError: null }) @@ -429,7 +443,7 @@ export async function submitApiKey(key: string): Promise { clearCredential() invalidateClient() const message = describeAuthError(err) - return setStatus({ kind: 'disconnected', endpoint, syncLimit, lastError: message }) + return setStatus({ kind: 'disconnected', endpoint, lastError: message }) } } @@ -441,8 +455,8 @@ export function disconnect(): MymeStatus { clearCredential() clearState() invalidateClient() - const { endpoint, syncLimit } = configSnapshot() - setStatus({ kind: 'disconnected', endpoint, syncLimit, lastError: null }) + const endpoint = endpointSnapshot() + setStatus({ kind: 'disconnected', endpoint, lastError: null }) return ensureStatus() } @@ -455,9 +469,6 @@ export function disconnect(): MymeStatus { * flight returns the current status without spawning a parallel run. * Same engine path is reused by the watcher cascade in milestone 5. */ -let syncInFlight: Promise | null = null -let activeAbort: AbortController | null = null - export async function syncNow(): Promise { if (syncInFlight) return syncInFlight const status = ensureStatus() @@ -480,15 +491,14 @@ export function cancelSync(): MymeStatus { return ensureStatus() } -async function runSync(): Promise { - const { endpoint, syncLimit } = configSnapshot() +async function runSync(opts: { limit?: number } = {}): Promise { + const endpoint = endpointSnapshot() const status = ensureStatus() const previousLastSyncedAt = status.kind === 'connected' ? status.lastSyncedAt : null setStatus({ kind: 'syncing', endpoint, - syncLimit, phase: 'preparing', processed: 0, total: 0 @@ -497,7 +507,7 @@ async function runSync(): Promise { activeAbort = new AbortController() const cfg = getConfig().myme const outcome = await syncRun({ - limit: syncLimit, + limit: opts.limit ?? 0, mapping: cfg.mapping, modeFilter: cfg.modeFilter, signal: activeAbort.signal, @@ -505,7 +515,6 @@ async function runSync(): Promise { setStatus({ kind: 'syncing', endpoint, - syncLimit, phase: e.phase, processed: e.processed, total: e.total @@ -519,21 +528,21 @@ async function runSync(): Promise { return setStatus({ kind: 'disconnected', endpoint, - syncLimit, + lastError: 'No credential available.' }) } if (outcome.error && /Authentication/i.test(outcome.error)) { clearCredential() - return setStatus({ kind: 'disconnected', endpoint, syncLimit, lastError: outcome.error }) + return setStatus({ kind: 'disconnected', endpoint, lastError: outcome.error }) } const lastSyncedAt = outcome.ok ? outcome.finishedAt : previousLastSyncedAt const next = setStatus({ kind: 'connected', endpoint, - syncLimit, + lastSyncedAt, lastError: outcome.ok ? null : outcome.error }) diff --git a/src/preload/api.ts b/src/preload/api.ts index 2ae6794..e2fb8e6 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -50,11 +50,6 @@ export interface Config { * safeStorage, sync state in its own JSON file. */ myme: { endpoint: string - /** Testing knob: cap each sync to the N most-recent recordings. - * 0 (default) syncs the full set; positive values are useful for - * smoke runs against staging where the full corpus is too slow. - * Also gates soft-delete + session passes — see engine.ts. */ - syncLimit: number /** Active mapping config — which Myme type each source kind binds * to, plus the field map for projection. Defaults to the bundled * superwhisper.* types. Older configs without a `mapping` block @@ -167,7 +162,6 @@ export type MymeStatus = | { kind: 'disconnected' endpoint: string - syncLimit: number /** Populated when a previous connect attempt failed. Cleared on * successful connect or disconnect. */ lastError: string | null @@ -176,13 +170,11 @@ export type MymeStatus = kind: 'connecting' mode: 'api-key' endpoint: string - syncLimit: number } | { kind: 'connecting' mode: 'device' endpoint: string - syncLimit: number /** Short user-readable code (XXXX-XXXX shape) the user types into * the verification page. */ userCode: string @@ -200,7 +192,6 @@ export type MymeStatus = | { kind: 'connected' endpoint: string - syncLimit: number /** ISO; null until the first successful sync. */ lastSyncedAt: string | null /** Set after a failed sync; cleared on the next success. */ @@ -209,7 +200,6 @@ export type MymeStatus = | { kind: 'syncing' endpoint: string - syncLimit: number phase: MymeSyncPhase processed: number total: number @@ -318,9 +308,11 @@ export const api = { * status (typically `connected` with `lastError = 'Cancelled'`). * No-op when nothing is in flight. */ cancelSync: (): Promise => ipcRenderer.invoke('myme:cancelSync'), - /** Set the "push N most-recent recordings" testing knob. - * `0` disables it (full sync). Persisted to `config.json`. */ - setSyncLimit: (n: number): Promise => ipcRenderer.invoke('myme:setSyncLimit', n), + /** Run a small dry-run sync against the 5 most recent recordings. + * Useful for sanity-checking the integration without touching the + * full corpus. Skips soft-delete and session derivation while the + * cap is in effect. */ + testSync: (): Promise => ipcRenderer.invoke('myme:testSync'), /** Read the persisted mapping config. */ getMapping: (): Promise => ipcRenderer.invoke('myme:getMapping'), /** Persist a new mapping. Resets the sync state so the next pass diff --git a/src/renderer/src/components/settings/ConnectionCard.tsx b/src/renderer/src/components/settings/ConnectionCard.tsx new file mode 100644 index 0000000..f32cec5 --- /dev/null +++ b/src/renderer/src/components/settings/ConnectionCard.tsx @@ -0,0 +1,185 @@ +import { SettingsCard } from './SettingsCard' +import { useMymeStore } from '@renderer/state/mymeStore' +import { Check, CircleAlert, Cloud, RefreshCw } from 'lucide-react' +import { useEffect, useState } from 'react' +import type { ProbeResult } from '../../../../preload/api' + +/** + * Connection card — the connected-state "who am I signed in as" view. + * Sits at the top of the Myme tab when the integration is up. + * + * Body: + * • Endpoint + signed-in account (resolved via `profile.get`) + * • Status pill (Connected / probe error) + * • Disconnect + Test connection actions + */ +export function ConnectionCard({ + endpoint, + syncing +}: { + endpoint: string + syncing: boolean + syncProgress: { + phase: 'preparing' | 'recordings' | 'sessions' + processed: number + total: number + } | null +}): React.JSX.Element { + const probeConnection = useMymeStore((s) => s.probeConnection) + const disconnect = useMymeStore((s) => s.disconnect) + const [probe, setProbe] = useState(null) + const [probing, setProbing] = useState(false) + const [disconnecting, setDisconnecting] = useState(false) + + // Initial probe on mount. + useEffect(() => { + let cancelled = false + void (async () => { + setProbing(true) + try { + const result = await probeConnection() + if (!cancelled) setProbe(result) + } finally { + if (!cancelled) setProbing(false) + } + })() + return () => { + cancelled = true + } + }, [probeConnection]) + + async function runProbe(): Promise { + setProbing(true) + try { + const result = await probeConnection() + setProbe(result) + } finally { + setProbing(false) + } + } + + async function doDisconnect(): Promise { + setDisconnecting(true) + try { + await disconnect() + } finally { + setDisconnecting(false) + } + } + + return ( + } + > +
+
+ + +
+
+ + +
+
+
+ ) +} + +function Row({ + label, + value, + mono +}: { + label: string + value: string + mono?: boolean +}): React.JSX.Element { + return ( +
+
{label}
+
+ {value} +
+
+ ) +} + +function StatusPill({ + probe, + probing, + syncing +}: { + probe: ProbeResult | null + probing: boolean + syncing: boolean +}): React.JSX.Element { + if (syncing) { + return ( + + + Syncing + + ) + } + if (probing && !probe) { + return ( + + Checking… + + ) + } + if (!probe) { + return ( + + Unknown + + ) + } + if (probe.ok) { + return ( + + + Connected + + ) + } + return ( + + + {probe.error} + + ) +} + +function describeIdentity(probe: ProbeResult | null, probing: boolean): string { + if (probing && !probe) return 'Resolving…' + if (!probe) return '—' + if (!probe.ok) return '—' + if (probe.displayName && probe.email) return `${probe.displayName} · ${probe.email}` + if (probe.displayName) return probe.displayName + if (probe.email) return probe.email + return 'Signed in' +} diff --git a/src/renderer/src/components/settings/ConnectionPanel.tsx b/src/renderer/src/components/settings/ConnectionPanel.tsx deleted file mode 100644 index 70f007b..0000000 --- a/src/renderer/src/components/settings/ConnectionPanel.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { useMymeStore } from '@renderer/state/mymeStore' -import { Check, CircleAlert, ShieldCheck } from 'lucide-react' -import { useEffect, useState } from 'react' -import type { ProbeResult } from '../../../../preload/api' - -/** - * Connection panel — top section of the connected card. Shows the - * endpoint, who's signed in (resolved on demand via `profile.get`), - * and offers a Test connection button + a Disconnect action. - * - * The probe runs on mount and on click. Result lives in local state; - * no store churn because the value isn't useful elsewhere in the - * renderer. - */ -export function ConnectionPanel({ - endpoint, - busy -}: { - endpoint: string - busy: boolean -}): React.JSX.Element { - const probeConnection = useMymeStore((s) => s.probeConnection) - const disconnect = useMymeStore((s) => s.disconnect) - const [probe, setProbe] = useState(null) - const [probing, setProbing] = useState(false) - const [disconnecting, setDisconnecting] = useState(false) - - useEffect(() => { - let cancelled = false - void (async () => { - setProbing(true) - try { - const result = await probeConnection() - if (!cancelled) setProbe(result) - } finally { - if (!cancelled) setProbing(false) - } - })() - return () => { - cancelled = true - } - }, [probeConnection]) - - async function runProbe(): Promise { - setProbing(true) - try { - const result = await probeConnection() - setProbe(result) - } finally { - setProbing(false) - } - } - - async function doDisconnect(): Promise { - setDisconnecting(true) - try { - await disconnect() - } finally { - setDisconnecting(false) - } - } - - return ( -
-
Connection
-
- - - -
-
- - -
-
- ) -} - -function Row({ - label, - value, - mono -}: { - label: string - value: string - mono?: boolean -}): React.JSX.Element { - return ( -
-
{label}
-
- {value} -
-
- ) -} - -function StatusRow({ - probe, - probing -}: { - probe: ProbeResult | null - probing: boolean -}): React.JSX.Element { - if (probing && !probe) { - return ( -
-
Status
-
Checking…
-
- ) - } - if (!probe) { - return ( -
-
Status
-
-
- ) - } - if (probe.ok) { - return ( -
-
Status
-
- - Reachable - -
-
- ) - } - return ( -
-
Status
-
- - {probe.error} -
-
- ) -} - -function describeIdentity(probe: ProbeResult | null, probing: boolean): string { - if (probing && !probe) return 'Resolving…' - if (!probe) return '—' - if (!probe.ok) return '—' - if (probe.displayName && probe.email) return `${probe.displayName} (${probe.email})` - if (probe.displayName) return probe.displayName - if (probe.email) return probe.email - return 'Signed in' -} diff --git a/src/renderer/src/components/settings/MymeCard.tsx b/src/renderer/src/components/settings/MymeCard.tsx index edb23ac..92b64f0 100644 --- a/src/renderer/src/components/settings/MymeCard.tsx +++ b/src/renderer/src/components/settings/MymeCard.tsx @@ -1,7 +1,7 @@ -import { ConnectionPanel } from './ConnectionPanel' -import { MappingPanel } from './MappingPanel' +import { ConnectionCard } from './ConnectionCard' import { SettingsCard } from './SettingsCard' -import { SyncControlsPanel } from './SyncControlsPanel' +import { SyncCard } from './SyncCard' +import { TypeMappingCard } from './TypeMappingCard' import { useConfigStore } from '@renderer/state/configStore' import { useMymeStore } from '@renderer/state/mymeStore' import { Check, Cloud, CloudOff, Copy, ExternalLink, RefreshCw } from 'lucide-react' @@ -9,31 +9,18 @@ import { useEffect, useState } from 'react' import type { MymeStatus } from '../../../../preload/api' /** - * Settings → Integrations → Myme card. + * Settings → Myme tab. Renders one or more SettingsCards depending on + * the integration's lifecycle state: * - * One card, six effective states: + * • disabled → single "Myme" card explaining why. + * • disconnected / connecting → single "Connection" card carrying the + * endpoint + connect flow (device-flow code / API-key paste). + * • connected / syncing → three sibling cards in order: + * Connection · Sync · Type mapping. * - * 1. `disabled` — demo mode is on, or no recordings path. - * Card is greyed out; sync engine is inert. - * 2. `disconnected` — endpoint URL + "Connect to Myme" button. - * 3. `connecting (device)` — default. User-code + "Verify in browser" - * button; opens the verification URI in - * the system browser. Includes a - * "Use API key instead" fallback link. - * 4. `connecting (api-key)` — dev/escape hatch. API-key paste-and-verify - * pane. - * 5. `connected` — last synced time + "Sync now" + a sync-cap - * setting (default 100; 0 = no cap). If - * `lastError` is set, an inline error row - * appears below. - * 6. `syncing` — progress text + Cancel button. The signal - * threaded into the engine stops further - * upserts once aborted; partial state is - * persisted. - * - * Failure paths never reach the main app — Myme is optional, so its - * problems stay in this card. (Deliberate departure from how scan - * errors surface elsewhere.) + * The tab strip lives in `Settings.tsx`; this module knows nothing + * about it. Each connected-state card lives in its own file + * (`ConnectionCard.tsx`, `SyncCard.tsx`, `TypeMappingCard.tsx`). */ export function MymeCard(): React.JSX.Element { const path = useConfigStore((s) => s.path) @@ -52,40 +39,88 @@ export function MymeCard(): React.JSX.Element { ? 'no-recordings-path' : null - return ( - - {disabledReason ? ( - - ) : !hydrated || !status ? ( - - ) : ( - - )} - - ) + if (disabledReason) { + return + } + if (!hydrated || !status) { + return ( + +

Loading…

+
+ ) + } + + if (status.kind === 'connected' || status.kind === 'syncing') { + return ( + <> + + + + + ) + } + + // disconnected / connecting — single card carrying the connect flow. + return } -function DisabledBody({ +function DisabledCard({ reason }: { reason: 'demo-mode' | 'no-recordings-path' }): React.JSX.Element { const message = reason === 'demo-mode' - ? 'Disabled while demo mode is on. Toggle demo off to enable.' - : 'Disabled until a recordings folder is configured.' - return

{message}

+ ? 'Turn demo mode off to push real recordings into Myme.' + : 'Pick a recordings folder under General before connecting to Myme.' + return ( + +

{message}

+
+ ) } -function PendingBody(): React.JSX.Element { - return

Loading…

+const CHROME_BUTTON = + 'inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-floating px-3 text-[12px] text-foreground transition-colors hover:bg-foreground/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-floating' + +function ConnectFlowCard({ status }: { status: MymeStatus }): React.JSX.Element { + return ( + + + + ) } -function StatusBody({ status }: { status: MymeStatus }): React.JSX.Element { +function ConnectFlowBody({ status }: { status: MymeStatus }): React.JSX.Element { switch (status.kind) { case 'disconnected': return @@ -101,22 +136,13 @@ function StatusBody({ status }: { status: MymeStatus }): React.JSX.Element { } return case 'connected': - return ( - - ) case 'syncing': - return + // Unreachable — handled by the orchestrator. Render nothing + // rather than throwing. + return

} } -const CHROME_BUTTON = - 'inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-floating px-3 text-[12px] text-foreground transition-colors hover:bg-foreground/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-floating' - function DisconnectedBody({ endpoint, lastError @@ -146,9 +172,6 @@ function DisconnectedBody({ async function doConnect(): Promise { setBusy(true) try { - // Make sure the endpoint is persisted before kicking off the flow - // so the connect path uses the user's intended URL even if they - // didn't blur out of the input first. if (changed) await setEndpoint(trimmed) await connect() } finally { @@ -189,27 +212,13 @@ function DisconnectedBody({ disabled={busy || !looksValid} className={CHROME_BUTTON} > - Connect to Myme + Connect
) } -/** - * Default connecting pane — shows the user code + a "Verify in browser" - * button that opens the verification URI (deep-link variant if the - * server provided one). A "Use API key instead" link drops down to the - * legacy paste path for development. - * - * The user code is shown prominently with a copy-to-clipboard button so - * the user can paste it on the verification page if the deep-link - * variant isn't available or they prefer to type the code by hand. - * - * Empty `userCode` / `verificationUri` means we're still mid-DCR (the - * status payload arrives in two ticks — preparing, then with the real - * code). Show a spinner placeholder during that window. - */ function DeviceConnectingBody({ userCode, verificationUri, @@ -283,8 +292,7 @@ function DeviceConnectingBody({ return (

- Approve this device in your browser to connect. The verification page will ask for the code - below. + Approve this device in your browser. The Myme verification page will ask for the code below.

Your code
@@ -365,7 +373,7 @@ function ApiKeyConnectingBody(): React.JSX.Element { return (

- Dev path. Paste your Myme API key to connect — it stays on this Mac, encrypted via Keychain. + Paste a Myme API key. Stored locally in Keychain — never leaves your Mac.

) } - -function ConnectedBody({ - endpoint, - syncLimit, - lastSyncedAt, - lastError -}: { - endpoint: string - syncLimit: number - lastSyncedAt: string | null - lastError: string | null -}): React.JSX.Element { - const syncNow = useMymeStore((s) => s.syncNow) - const setSyncLimit = useMymeStore((s) => s.setSyncLimit) - const mapping = useMymeStore((s) => s.mapping) - const [busy, setBusy] = useState(false) - - async function doSync(): Promise { - setBusy(true) - try { - await syncNow() - } finally { - setBusy(false) - } - } - - return ( -
- - {mapping && } - -
- ) -} - -function SyncingBody({ - phase, - processed, - total -}: { - phase: 'preparing' | 'recordings' | 'sessions' - processed: number - total: number -}): React.JSX.Element { - const cancelSync = useMymeStore((s) => s.cancelSync) - const [cancelling, setCancelling] = useState(false) - const label = - phase === 'preparing' - ? 'Preparing…' - : phase === 'recordings' - ? 'Syncing recordings' - : 'Syncing sessions' - - async function doCancel(): Promise { - setCancelling(true) - try { - await cancelSync() - } finally { - setCancelling(false) - } - } - - return ( -
-
- - - {label} - - {total > 0 && ( - - {processed.toLocaleString()} / {total.toLocaleString()} - - )} -
-
- -
-
- ) -} diff --git a/src/renderer/src/components/settings/SyncCard.tsx b/src/renderer/src/components/settings/SyncCard.tsx new file mode 100644 index 0000000..f155cf0 --- /dev/null +++ b/src/renderer/src/components/settings/SyncCard.tsx @@ -0,0 +1,260 @@ +import { SettingsCard } from './SettingsCard' +import { Switch } from '@renderer/components/ui/Switch' +import { cn } from '@renderer/lib/cn' +import { useDataStore } from '@renderer/state/dataStore' +import { useMymeStore } from '@renderer/state/mymeStore' +import { FlaskConical, RefreshCw } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' + +/** + * Sync card — sits below Connection in the Myme tab. Carries: + * + * • Mode filter (toggle row per observed Superwhisper mode). + * • Last-synced display with the manual Sync-now trigger. + * • A small Test-sync button alongside it that runs a 5-item dry + * pass — handy for sanity-checking the integration without + * touching the full corpus. + * + * During `syncing` state, the body shows phase + processed/total and + * swaps the action buttons for a Cancel button. + */ +export function SyncCard({ + lastSyncedAt, + lastError, + syncing, + syncProgress +}: { + lastSyncedAt: string | null + lastError: string | null + syncing: boolean + syncProgress: { + phase: 'preparing' | 'recordings' | 'sessions' + processed: number + total: number + } | null +}): React.JSX.Element { + const syncNow = useMymeStore((s) => s.syncNow) + const testSync = useMymeStore((s) => s.testSync) + const cancelSync = useMymeStore((s) => s.cancelSync) + const [busy, setBusy] = useState(false) + + // Re-render once a minute so the "5m ago" drift updates without a + // custom hook. + const [, setNow] = useState(() => Date.now()) + useEffect(() => { + const t = window.setInterval(() => setNow(Date.now()), 60_000) + return () => window.clearInterval(t) + }, []) + + async function doSync(): Promise { + setBusy(true) + try { + await syncNow() + } finally { + setBusy(false) + } + } + + async function doTest(): Promise { + setBusy(true) + try { + await testSync() + } finally { + setBusy(false) + } + } + + async function doCancel(): Promise { + setBusy(true) + try { + await cancelSync() + } finally { + setBusy(false) + } + } + + return ( + +
+ +
+
+
+ {syncing ? phaseLabel(syncProgress?.phase ?? 'preparing') : 'Last synced'} +
+
+ {syncing + ? syncProgress && syncProgress.total > 0 + ? `${syncProgress.processed.toLocaleString()} of ${syncProgress.total.toLocaleString()}` + : 'Preparing the diff…' + : lastSyncedAt + ? formatRelative(lastSyncedAt) + : 'Never. Your first sync will set the baseline.'} +
+
+ {syncing ? ( + + ) : ( +
+ + +
+ )} +
+ {lastError && !syncing && ( +

+ Last sync failed: {lastError} +

+ )} +
+
+ ) +} + +function phaseLabel(phase: 'preparing' | 'recordings' | 'sessions'): string { + switch (phase) { + case 'preparing': + return 'Preparing…' + case 'recordings': + return 'Syncing recordings' + case 'sessions': + return 'Syncing sessions' + } +} + +function ModeFilter({ disabled }: { disabled: boolean }): React.JSX.Element { + const recordings = useDataStore((s) => s.recordings) + const modeFilter = useMymeStore((s) => s.modeFilter) + const setModeFilter = useMymeStore((s) => s.setModeFilter) + + const modes = useMemo(() => { + const counts = new Map() + for (const r of recordings) { + const name = r.modeName || '(unknown)' + counts.set(name, (counts.get(name) ?? 0) + 1) + } + return Array.from(counts.entries()) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([name, count]) => ({ name, count })) + }, [recordings]) + + const allSelected = modeFilter === null + + async function setEnabled(name: string, next: boolean): Promise { + if (allSelected) { + if (!next) { + const remaining = modes.filter((m) => m.name !== name).map((m) => m.name) + await setModeFilter(remaining.length === 0 ? null : remaining) + } + return + } + const current = new Set(modeFilter ?? []) + if (next) current.add(name) + else current.delete(name) + if (current.size === 0) { + await setModeFilter(null) + return + } + if (current.size === modes.length) { + await setModeFilter(null) + return + } + await setModeFilter(Array.from(current)) + } + + if (modes.length === 0) { + return ( +
+
Mode filter
+
+ No recordings indexed yet — modes will appear here once your folder is scanned. +
+
+ ) + } + + const selected = new Set(modeFilter ?? modes.map((m) => m.name)) + + return ( +
+
+
+
Mode filter
+
+ {allSelected + ? 'Syncing recordings from every mode.' + : `Syncing ${selected.size} of ${modes.length} modes.`} +
+
+ {!allSelected && ( + + )} +
+
    + {modes.map(({ name, count }) => ( +
  • +
    + {name} + + {count.toLocaleString()} + +
    + void setEnabled(name, next)} + ariaLabel={`Sync recordings from ${name}`} + /> +
  • + ))} +
+
+ ) +} + +function formatRelative(iso: string): string { + const then = new Date(iso).getTime() + if (!Number.isFinite(then)) return iso + const diffSec = Math.floor((Date.now() - then) / 1000) + if (diffSec < 60) return 'Just now' + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + const diffHr = Math.floor(diffMin / 60) + if (diffHr < 24) return `${diffHr}h ago` + const diffD = Math.floor(diffHr / 24) + return `${diffD}d ago` +} diff --git a/src/renderer/src/components/settings/SyncControlsPanel.tsx b/src/renderer/src/components/settings/SyncControlsPanel.tsx deleted file mode 100644 index 81dc443..0000000 --- a/src/renderer/src/components/settings/SyncControlsPanel.tsx +++ /dev/null @@ -1,224 +0,0 @@ -import { cn } from '@renderer/lib/cn' -import { useDataStore } from '@renderer/state/dataStore' -import { useMymeStore } from '@renderer/state/mymeStore' -import { RefreshCw } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' - -/** - * Sync controls — bottom section of the connected card. Carries the - * sync-cap (reframed copy), the mode filter (chips derived from - * observed recording modes), the manual sync trigger, last-sync drift - * display, and a recent-error row. - */ -export function SyncControlsPanel({ - syncLimit, - lastSyncedAt, - lastError, - busy, - onSync, - onSyncLimit -}: { - syncLimit: number - lastSyncedAt: string | null - lastError: string | null - busy: boolean - onSync: () => Promise - onSyncLimit: (n: number) => Promise -}): React.JSX.Element { - // Re-render once a minute so the "5m ago" drift updates without a - // custom hook. The same trick the previous ConnectedBody used. - const [, setNow] = useState(() => Date.now()) - useEffect(() => { - const t = window.setInterval(() => setNow(Date.now()), 60_000) - return () => window.clearInterval(t) - }, []) - - return ( -
-
Sync
- - -
-
Last synced
-
{lastSyncedAt ? formatRelative(lastSyncedAt) : 'Never'}
-
- {lastError && ( -

- Last sync failed: {lastError} -

- )} -
- -
-
- ) -} - -function ModeFilterRow({ disabled }: { disabled: boolean }): React.JSX.Element { - const recordings = useDataStore((s) => s.recordings) - const modeFilter = useMymeStore((s) => s.modeFilter) - const setModeFilter = useMymeStore((s) => s.setModeFilter) - - const modes = useMemo(() => { - const counts = new Map() - for (const r of recordings) { - const name = r.modeName || '(unknown)' - counts.set(name, (counts.get(name) ?? 0) + 1) - } - return Array.from(counts.entries()) - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .map(([name, count]) => ({ name, count })) - }, [recordings]) - - const selected = new Set(modeFilter ?? []) - - async function toggleMode(name: string): Promise { - if (modeFilter === null) { - // Currently "all" — clicking a chip narrows to that single mode. - await setModeFilter([name]) - return - } - const next = new Set(modeFilter) - if (next.has(name)) next.delete(name) - else next.add(name) - await setModeFilter(next.size === 0 ? null : Array.from(next)) - } - - async function selectAll(): Promise { - await setModeFilter(null) - } - - if (modes.length === 0) { - return ( -
-
Mode filter
-
No recordings indexed yet.
-
- ) - } - - return ( -
-
-
-
Mode filter
-
- {modeFilter === null - ? 'Syncing recordings from every mode.' - : `Syncing only from ${modeFilter.length} of ${modes.length} modes.`} -
-
- {modeFilter !== null && ( - - )} -
-
- {modes.map(({ name, count }) => { - const active = modeFilter === null || selected.has(name) - return ( - - ) - })} -
-
- ) -} - -function SyncLimitRow({ - value, - onCommit, - disabled -}: { - value: number - onCommit: (n: number) => Promise - disabled: boolean -}): React.JSX.Element { - return -} - -function SyncLimitRowInner({ - value, - onCommit, - disabled -}: { - value: number - onCommit: (n: number) => Promise - disabled: boolean -}): React.JSX.Element { - const [draft, setDraft] = useState(String(value)) - - async function commit(): Promise { - const n = Number.parseInt(draft, 10) - const next = Number.isFinite(n) && n > 0 ? n : 0 - if (next === value) return - await onCommit(next) - } - - return ( -
-
-
-
- Sync most recent N recordings -
-
- 0 = no cap. While capped, session derivation and disk-delete propagation are skipped — - turn the cap off to test the full sync surface. -
-
- setDraft(e.target.value)} - onBlur={() => void commit()} - disabled={disabled} - className="w-20 rounded-md border border-border bg-card px-2 py-1 text-right text-[12.5px] tabular-nums text-foreground focus:border-foreground/30 focus:outline-none disabled:opacity-50" - /> -
-
- ) -} - -function formatRelative(iso: string): string { - const then = new Date(iso).getTime() - if (!Number.isFinite(then)) return iso - const diffSec = Math.floor((Date.now() - then) / 1000) - if (diffSec < 60) return 'Just now' - const diffMin = Math.floor(diffSec / 60) - if (diffMin < 60) return `${diffMin}m ago` - const diffHr = Math.floor(diffMin / 60) - if (diffHr < 24) return `${diffHr}h ago` - const diffD = Math.floor(diffHr / 24) - return `${diffD}d ago` -} diff --git a/src/renderer/src/components/settings/MappingPanel.tsx b/src/renderer/src/components/settings/TypeMappingCard.tsx similarity index 59% rename from src/renderer/src/components/settings/MappingPanel.tsx rename to src/renderer/src/components/settings/TypeMappingCard.tsx index f53ba7e..22b62ac 100644 --- a/src/renderer/src/components/settings/MappingPanel.tsx +++ b/src/renderer/src/components/settings/TypeMappingCard.tsx @@ -1,6 +1,7 @@ +import { SettingsCard } from './SettingsCard' import { useMymeStore } from '@renderer/state/mymeStore' -import { ChevronDown, Pencil, X } from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' +import { ChevronDown, Layers, X } from 'lucide-react' +import { useEffect, useState } from 'react' import type { FieldMap, MappingBinding, @@ -10,15 +11,15 @@ import type { } from '../../../../preload/api' /** - * Type-mapping panel. Renders two rows (recordings + sessions); each - * row shows the current binding and an Edit button. Edit expands an - * inline editor with mode picker (bundled / existing / authored), - * type picker (for existing), inline authoring form (for authored), - * and a field-map override list. Apply confirms with a trash-and-re- - * mint warning before persisting. + * Type-mapping card — bottom of the Myme tab when connected. Renders + * a binding panel per source kind (recording, session). Each panel: * - * The panel is read-only safe — no destructive action runs until the - * user clicks Apply. Cancel discards the local draft. + * • Segmented [Bundled · Existing · Authored] mode control. + * • Type id label, with the inline authoring inputs when authored. + * • Field map, with columns aligned via CSS subgrid so target / arrow + * / source line up across rows regardless of name length. + * • Reset / Apply, only when the draft differs from the persisted + * binding. Apply gates the trash-and-re-mint confirmation. */ const RECORDING_FIELD_OPTIONS: Array<{ value: string; label: string }> = [ @@ -48,78 +49,55 @@ const SESSION_FIELD_OPTIONS: Array<{ value: string; label: string }> = [ { value: 'session.gapThresholdMinutes', label: 'gap threshold (min)' } ] -const UNMAPPED = '__unmapped__' +const SOURCE_LABEL: Record = Object.fromEntries( + [...RECORDING_FIELD_OPTIONS, ...SESSION_FIELD_OPTIONS].map((o) => [o.value, o.label]) +) -interface Props { - mapping: MymeMapping - disabled?: boolean -} +const UNMAPPED = '__unmapped__' -export function MappingPanel({ mapping, disabled }: Props): React.JSX.Element { - return ( -
-
Type mapping
-
- - -
-
- ) -} +const CHROME_BUTTON = + 'inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-floating px-3 text-[12px] text-foreground transition-colors hover:bg-foreground/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-floating' -function MappingRow({ - kind, - binding, - disabled -}: { - kind: 'recording' | 'session' - binding: MappingBinding - disabled?: boolean -}): React.JSX.Element { - const [editing, setEditing] = useState(false) +export function TypeMappingCard({ disabled }: { disabled?: boolean }): React.JSX.Element { + const mapping = useMymeStore((s) => s.mapping) + if (!mapping) { + return ( + +

Loading…

+
+ ) + } return ( -
-
-
-
{kind}s
-
- {describeBinding(binding)} -
-
- + /> +
+
- {editing && ( -
- setEditing(false)} - disabled={disabled} - /> -
- )} -
+ ) } -function describeBinding(binding: MappingBinding): string { - const mode = - binding.mode === 'bundled' ? 'Bundled' : binding.mode === 'existing' ? 'Existing' : 'Authored' - return `${mode}: ${binding.typeId}` -} - interface DraftState { mode: 'bundled' | 'existing' | 'authored' typeId: string @@ -127,15 +105,15 @@ interface DraftState { authoredLabel: string } -function MappingEditor({ +function BindingPanel({ kind, - current, - onClose, + binding, + mapping, disabled }: { kind: 'recording' | 'session' - current: MappingBinding - onClose: () => void + binding: MappingBinding + mapping: MymeMapping disabled?: boolean }): React.JSX.Element { const typeList = useMymeStore((s) => s.typeList) @@ -143,26 +121,27 @@ function MappingEditor({ const refreshTypeList = useMymeStore((s) => s.refreshTypeList) const setMapping = useMymeStore((s) => s.setMapping) const registerType = useMymeStore((s) => s.registerType) - const currentMapping = useMymeStore((s) => s.mapping) const [draft, setDraft] = useState({ - mode: current.mode, - typeId: current.typeId, - fieldMap: { ...current.fieldMap }, + mode: binding.mode, + typeId: binding.typeId, + fieldMap: { ...binding.fieldMap }, authoredLabel: '' }) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) const [confirming, setConfirming] = useState(false) + // Fetch the type list lazily — only when the user reaches for the + // existing-type picker for the first time. useEffect(() => { - if (typeList === null && !typeListLoading) { + if (draft.mode === 'existing' && typeList === null && !typeListLoading) { void refreshTypeList() } - }, [typeList, typeListLoading, refreshTypeList]) + }, [draft.mode, typeList, typeListLoading, refreshTypeList]) const bundledTypeId = kind === 'recording' ? 'superwhisper.recording' : 'superwhisper.session' - const bundledFieldMap = useMemo(() => buildBundledFieldMap(kind), [kind]) + const bundledFieldMap = buildBundledFieldMap(kind) const sourceOptions = kind === 'recording' ? RECORDING_FIELD_OPTIONS : SESSION_FIELD_OPTIONS function selectMode(mode: DraftState['mode']): void { @@ -177,10 +156,11 @@ function MappingEditor({ return } if (mode === 'existing') { + const first = typeList?.[0] setDraft({ mode: 'existing', - typeId: typeList?.[0]?.id ?? '', - fieldMap: typeList?.[0] ? autoFieldMapFor(kind, typeList[0]) : {}, + typeId: first?.id ?? '', + fieldMap: first ? autoFieldMapFor(kind, first) : {}, authoredLabel: '' }) return @@ -238,6 +218,17 @@ function MappingEditor({ setDraft((d) => ({ ...d, authoredLabel: label })) } + function reset(): void { + setDraft({ + mode: binding.mode, + typeId: binding.typeId, + fieldMap: { ...binding.fieldMap }, + authoredLabel: '' + }) + setError(null) + setConfirming(false) + } + function validate(): string | null { if (draft.mode === 'authored') { if (!/^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$/i.test(draft.typeId)) { @@ -259,26 +250,18 @@ function MappingEditor({ setError(v) return } - if (!currentMapping) return setBusy(true) setError(null) try { - // For authored types, register first so the schema lands on the - // server. If registration fails (e.g. id collides), surface that - // before we mutate the mapping locally. let authoredSchema: MappingBinding['authoredSchema'] if (draft.mode === 'authored') { const schema = buildAuthoredSchema(kind, draft) const registered = await registerType(schema) if (!registered) { - setError('Failed to register the new type. Check the type id and try again.') + setError('Couldn’t register the new type. Check the id and try again.') setBusy(false) return } - // The renderer-side draft uses widened types (string literal - // FieldType doesn't propagate through the picker), so the - // schema we just registered comes back through the IPC layer - // as TypeSchema-shaped; safe to cast. authoredSchema = schema as unknown as MappingBinding['authoredSchema'] } const nextBinding: MappingBinding = { @@ -288,63 +271,37 @@ function MappingEditor({ ...(authoredSchema ? { authoredSchema } : {}) } const nextMapping: MymeMapping = { - ...currentMapping, + ...mapping, [kind]: nextBinding } await setMapping(nextMapping) - onClose() + setConfirming(false) } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to apply mapping.') + setError(err instanceof Error ? err.message : 'Couldn’t apply the mapping.') } finally { setBusy(false) } } const dirty = - draft.mode !== current.mode || - draft.typeId !== current.typeId || - !sameFieldMap(draft.fieldMap, current.fieldMap) + draft.mode !== binding.mode || + draft.typeId !== binding.typeId || + !sameFieldMap(draft.fieldMap, binding.fieldMap) - if (confirming) { - return ( -
-

- Applying this mapping will trash existing items under the old binding and re-mint them - under the new one on next sync. The old items are soft-deleted, not purged — you can - recover them via your Myme client. -

-
- - -
-
- ) - } + const editableFieldMap = draft.mode !== 'bundled' + const titleLabel = kind === 'recording' ? 'Recordings' : 'Sessions' return ( -
- - - {draft.mode === 'bundled' && ( -

- Uses the app’s built-in {bundledTypeId} type. - Field mapping is fixed. -

- )} +
+
+
+

{titleLabel}

+

+ {draft.typeId || '—'} +

+
+ +
{draft.mode === 'existing' && ( )} - {draft.mode !== 'bundled' && ( - - )} + {error && (

@@ -384,29 +341,59 @@ function MappingEditor({

)} -
- - -
+ {dirty && !confirming && ( +
+ + +
+ )} + + {confirming && ( +
+

+ Changing the mapping re-mints your items under a new type id on the next sync. Items + from the previous binding move to the trash — recover them from your Myme client if you + change your mind. +

+
+ + +
+
+ )}
) } -function ModePicker({ +function SegmentedModePicker({ mode, onChange, disabled @@ -415,34 +402,39 @@ function ModePicker({ onChange: (mode: DraftState['mode']) => void disabled?: boolean }): React.JSX.Element { - const options: Array<{ value: DraftState['mode']; label: string; hint: string }> = [ - { value: 'bundled', label: 'Bundled', hint: 'App-defined default.' }, - { value: 'existing', label: 'Existing', hint: 'Pick a type already in your Myme tenant.' }, - { value: 'authored', label: 'Authored', hint: 'Define a new type from scratch.' } + const options: Array<{ value: DraftState['mode']; label: string }> = [ + { value: 'bundled', label: 'Bundled' }, + { value: 'existing', label: 'Existing' }, + { value: 'authored', label: 'Authored' } ] return ( -
- Mode -
- {options.map((opt) => ( +
+ {options.map((opt) => { + const active = mode === opt.value + return ( - ))} -
-
+ ) + })} +
) } @@ -462,17 +454,17 @@ function ExistingTypePicker({ disabled?: boolean }): React.JSX.Element { if (loading && !types) { - return

Loading types…

+ return

Loading types from Myme…

} if (!types || types.length === 0) { return ( -
-

No types loaded.

+
+ No types found on the server. @@ -482,9 +474,7 @@ function ExistingTypePicker({ const selected = types.find((t) => t.id === value) return (
- +