From 37471978963b56eccb473987a93e49b610be1c5a Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 16 Jun 2026 22:38:05 -0700 Subject: [PATCH 01/15] chore(sweep): record deferred-work decisions --- .automator/policy.toml | 3 +++ _bmad-output/implementation-artifacts/deferred-work.md | 2 ++ 2 files changed, 5 insertions(+) diff --git a/.automator/policy.toml b/.automator/policy.toml index 5b085f6..4a03180 100644 --- a/.automator/policy.toml +++ b/.automator/policy.toml @@ -45,3 +45,6 @@ auto = "per-epic" # never | per-epic | run-end (auto-triggered swe max_bundles = 5 # bundles executed per sweep; triage excess is truncated max_triage_attempts = 2 # triage validation retries before escalating repeat = true + +[scm] +isolation = "worktree" diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 5ee8141..1969ca2 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -707,6 +707,7 @@ origin: code review of spec-singleflight-guard-helper.md (edge-case + blind revi location: src/lib/singleflight.ts:50 reason: The shared `singleflight` helper only clears an in-flight key in `started.finally(...)`. If the wrapped operation never settles (e.g. a Tauri IPC/dialog promise that hangs), the key stays in the `inFlight` map forever and every later same-key call coalesces onto the dead promise, so that command (createNewNote/toggleTheme/export/note-list refresh) is disabled until reload. This is a PRE-EXISTING gap inherited from the hand-rolled boolean guards it replaced (they likewise never reset on a hung promise), surfaced incidentally by this refactor — it is NOT a regression. Adding a timeout/abort to the helper is explicitly out of scope per the spec's "Never" boundary (no timers/retries in the helper), so it needs a deliberate design call: whether to add an optional abort/timeout to the helper or to push timeouts to the IPC layer (note Story 6.7 already added a 5s timeout on the CLI side). status: open +decision: 2026-06-16 Timeout at the IPC layer — Add a timeout/abort wrapper around the Tauri invoke layer (mirroring the CLI's 5s timeout from Story 6.7) so wrapped operations always settle, keeping singleflight timer-free and respecting its 'Never' boundary. ### DW-91: First-feature E2E leaves orphaned marker notes if it fails mid-cycle @@ -745,3 +746,4 @@ origin: local verification of epic-6 retro item-1 (E2E), 2026-06-16 location: e2e/run.mjs (cliLiveSyncTests, waitForAppSocket / realInstancePresent guard) reason: The CLI live-sync suite originally pinned a unique per-run `NOTEY_SOCKET_PATH` so the test app and CLI rendezvous on their own endpoint, isolated from any real notey instance. This relies on the env var propagating to the tauri-driver-launched app. It holds on CI's older Ubuntu `webkit2gtk-driver`, but a modern WebKitWebDriver (verified locally on `webkit2gtk-4.1 2.52.4`) **resets the launched app's environment** — stripping `NOTEY_SOCKET_PATH` and forcing `XDG_RUNTIME_DIR` back to the real session value — and `tauri-driver 2.0.6` forwards only `{binary, args}` (no env passthrough). So locally the app always binds the default `$XDG_RUNTIME_DIR/notey.sock`, diverging from the per-run path the CLI used → `notey add` failed exit-2 ("not running") and the suite was 14/16 locally while green on CI. Fixed test-only by discovering the socket the app actually bound (probe configured path + `$XDG_RUNTIME_DIR/notey.sock` for liveness) and pointing the CLI there — local now 16/16, CI unchanged (it matches the first candidate). Residual limitation: because the test app falls back to the *default* socket locally, the suite cannot isolate from a real running notey; it now detects a live pre-existing instance and skips the live-sync suite with an actionable message rather than hijacking it. Proper isolation would need either an env-passthrough in a newer tauri-driver or an app-level `--socket-path` arg routed via `tauri:options.args` (app source change; deferred). Test-harness robustness, not a product defect. status: open +decision: 2026-06-16 App-level --socket-path arg — Add a --socket-path CLI arg to the desktop app that overrides the socket path, and route a per-run unique path through tauri:options.args in e2e/run.mjs so each E2E run binds an isolated endpoint independent of env propagation. From 058e3633d4c296d289c4d8292bae06077ef8c75c Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 16 Jun 2026 23:05:47 -0700 Subject: [PATCH 02/15] sweep dw-decision-dw-90: DW-90 via bmad-auto --- .../implementation-artifacts/deferred-work.md | 3 +- .../spec-dw-decision-dw-90.md | 92 +++++++++++ src/features/command-palette/actions.ts | 148 +++++++++++------- src/features/editor/hooks/useAutoSave.ts | 27 +++- src/features/editor/store.ts | 47 +++--- src/features/export/exportJson.ts | 6 +- src/features/export/exportMarkdown.ts | 6 +- src/features/workspace/store.ts | 41 +++-- src/lib/withTimeout.test.ts | 110 +++++++++++++ src/lib/withTimeout.ts | 86 ++++++++++ 10 files changed, 468 insertions(+), 98 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-dw-decision-dw-90.md create mode 100644 src/lib/withTimeout.test.ts create mode 100644 src/lib/withTimeout.ts diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 1969ca2..2bac429 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -706,7 +706,8 @@ resolution: bindingsFromConfig now applies first-binding-wins dedupe at load (re origin: code review of spec-singleflight-guard-helper.md (edge-case + blind review), 2026-06-14 location: src/lib/singleflight.ts:50 reason: The shared `singleflight` helper only clears an in-flight key in `started.finally(...)`. If the wrapped operation never settles (e.g. a Tauri IPC/dialog promise that hangs), the key stays in the `inFlight` map forever and every later same-key call coalesces onto the dead promise, so that command (createNewNote/toggleTheme/export/note-list refresh) is disabled until reload. This is a PRE-EXISTING gap inherited from the hand-rolled boolean guards it replaced (they likewise never reset on a hung promise), surfaced incidentally by this refactor — it is NOT a regression. Adding a timeout/abort to the helper is explicitly out of scope per the spec's "Never" boundary (no timers/retries in the helper), so it needs a deliberate design call: whether to add an optional abort/timeout to the helper or to push timeouts to the IPC layer (note Story 6.7 already added a 5s timeout on the CLI side). -status: open +status: done 2026-06-16 +resolution: Added `src/lib/withTimeout.ts` (IpcTimeoutError + 5s default / 60s export bound) and wrapped the Tauri `commands.*` invokes inside every singleflight-wrapped flight (create-note, toggle-theme, toggle-layout-mode, trash-active-note, note-list-refresh, export-json, export-markdown), including helper IPC reached through `flushSave()` and `loadNote()`; a hung invoke now rejects and releases its singleflight key through existing error paths. singleflight.ts left timer-free (unchanged); native dialogs left unwrapped. decision: 2026-06-16 Timeout at the IPC layer — Add a timeout/abort wrapper around the Tauri invoke layer (mirroring the CLI's 5s timeout from Story 6.7) so wrapped operations always settle, keeping singleflight timer-free and respecting its 'Never' boundary. ### DW-91: First-feature E2E leaves orphaned marker notes if it fails mid-cycle diff --git a/_bmad-output/implementation-artifacts/spec-dw-decision-dw-90.md b/_bmad-output/implementation-artifacts/spec-dw-decision-dw-90.md new file mode 100644 index 0000000..28468db --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-dw-decision-dw-90.md @@ -0,0 +1,92 @@ +--- +title: "Timeout-bound the Tauri IPC layer so a hung invoke can't wedge a singleflight key (DW-90)" +type: "bugfix" +created: "2026-06-16" +status: "done" +context: ["{project-root}/_bmad-output/project-context.md"] +baseline_commit: "37471978963b56eccb473987a93e49b610be1c5a" +--- + + + +## Intent + +**Problem:** The shared `singleflight` helper only clears an in-flight key in `started.finally(...)`. If a wrapped operation never settles — e.g. a Tauri IPC promise that hangs — the key stays in the `inFlight` map forever, so every later same-key call (`create-note`, `toggle-theme`, `toggle-layout-mode`, `trash-active-note`, `export-json`, `export-markdown`, `note-list-refresh`) coalesces onto the dead promise and that command is disabled until the app reloads. `singleflight` is deliberately timer-free (its "Never" boundary), so the bound must live elsewhere. + +**Approach:** Per the human decision (DW-90, option 1 — *Timeout at the IPC layer*), add a small `withTimeout` wrapper around the Tauri invoke calls, mirroring the CLI's 5s round-trip timeout from Story 6.7. Every IPC call made inside a singleflight-wrapped operation is raced against a timeout that rejects if the invoke never settles; the rejection flows back through the operation, so `singleflight`'s `finally` always runs and the key is released. `singleflight` itself is not touched. + +## Boundaries & Constraints + +**Always:** +- The bound lives at the IPC invoke layer (frontend), not inside `singleflight` — `singleflight` stays timer-free. +- A timed-out invoke rejects with a distinct `IpcTimeoutError` so callers' existing `try/catch` / `result.status` paths log and recover exactly as they do for any other invoke failure. +- The timer is always cleared once the invoke settles (win or lose the race) — no leaked `setTimeout`, no late unhandled rejection from the losing branch. +- Keep mirroring Story 6.7: a quick local-SQLite round-trip is bounded at 5s by default. + +**Never:** +- Do NOT add a timeout, timer, retry, or abort to `src/lib/singleflight.ts` — its "Never" boundary stands. +- Do NOT wrap the native dialog promises (`save`/`open` from `@tauri-apps/plugin-dialog`): those legitimately wait on the user and must not be killed by a 5s bound. +- Do NOT hand-edit `src/generated/bindings.ts` (tauri-specta output) and do NOT introduce raw `invoke(...)` calls — keep using the generated `commands`, wrapping the promise they return. +- Do NOT change the resolved-value contract of the `commands.*` calls on the success path. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +| -------- | ------------- | -------------------------- | -------------- | +| Invoke settles fast | `commands.x()` resolves before timeout | `withTimeout` resolves with the same value; timer cleared | N/A | +| Invoke rejects fast | `commands.x()` rejects before timeout | `withTimeout` rejects with that same reason; timer cleared | propagate original error | +| Invoke hangs | promise never settles, timeout elapses | `withTimeout` rejects with `IpcTimeoutError`; singleflight key released so the next call runs fresh | caller's existing catch/log path runs | +| Late settle after timeout | invoke settles *after* the timeout already won | no unhandled rejection, no double-settle, timer already cleared | swallowed (race already lost) | +| Custom bound | `withTimeout(p, { timeoutMs })` | uses the supplied bound instead of the 5s default | N/A | + + + +## Code Map + +- `src/lib/withTimeout.ts` -- NEW. The IPC timeout primitive: `withTimeout`, `IpcTimeoutError`, `IPC_TIMEOUT_MS` (5000), `EXPORT_IPC_TIMEOUT_MS` (generous bound for the long-running exports). No `singleflight` dependency. +- `src/lib/withTimeout.test.ts` -- NEW. Unit tests for the I/O matrix above (fake timers). +- `src/lib/singleflight.ts` -- UNCHANGED reference: the helper whose key gets wedged; confirms the "timer-free" boundary it must keep. +- `src/features/command-palette/actions.ts` -- wrap the IPC calls behind `create-note` / `toggle-theme` / `toggle-layout-mode`: `commands.createNote`, `commands.getConfig`, plus the shared `persistSettingsUpdate` (`commands.updateConfig`) and `applyLayoutModeToWindow` (`commands.applyLayoutMode`) helpers. +- `src/features/workspace/store.ts` -- wrap `commands.trashNote` (backs `trash-active-note`) and `commands.listNotes` in `loadFilteredNotes` (backs `note-list-refresh` and trash's follow-up reload). +- `src/features/export/exportJson.ts` -- wrap `commands.exportJson` with the export bound (dialog left unwrapped). +- `src/features/export/exportMarkdown.ts` -- wrap `commands.exportMarkdown` with the export bound (dialog/progress listener left unwrapped). + +## Tasks & Acceptance + +**Execution:** + +- [x] `src/lib/withTimeout.ts` -- create `withTimeout(operation: Promise, opts?: { timeoutMs?: number; label?: string }): Promise` that races `operation` against a `setTimeout`, rejecting with `IpcTimeoutError` on elapse and clearing the timer once `operation` settles; export `IpcTimeoutError`, `IPC_TIMEOUT_MS = 5000`, `EXPORT_IPC_TIMEOUT_MS` (generous, e.g. 60000). JSDoc on all exports. -- the IPC bound that guarantees every wrapped invoke settles. +- [x] `src/lib/withTimeout.test.ts` -- cover the I/O & Edge-Case Matrix with `vi.useFakeTimers()`: fast resolve, fast reject, hang→`IpcTimeoutError`, late settle (no unhandled rejection / no double-settle), custom `timeoutMs`. -- proves the primitive and prevents timer leaks. +- [x] `src/features/command-palette/actions.ts` -- wrap `commands.createNote`, `commands.getConfig` (both toggle flows), the inline `commands.updateConfig` awaits in `toggleTheme`/`toggleLayoutMode`, the `commands.updateConfig` await in the shared `persistSettingsUpdate`, and the `commands.applyLayoutMode` await in `applyLayoutModeToWindow` with `withTimeout(...)` (default bound). -- bounds the `create-note` / `toggle-theme` / `toggle-layout-mode` flights (and the shared settings-panel setters). +- [x] `src/features/workspace/store.ts` -- wrap `commands.trashNote` and the `commands.listNotes` await in `loadFilteredNotes` with `withTimeout(...)` (default bound). -- bounds the `trash-active-note` and `note-list-refresh` flights. +- [x] `src/features/export/exportJson.ts` -- wrap `commands.exportJson(path)` with `withTimeout(..., { timeoutMs: EXPORT_IPC_TIMEOUT_MS })`; leave the `save` dialog unwrapped. -- bounds the `export-json` flight without killing the picker. +- [x] `src/features/export/exportMarkdown.ts` -- wrap `commands.exportMarkdown(directory)` with `withTimeout(..., { timeoutMs: EXPORT_IPC_TIMEOUT_MS })`; leave the `open` dialog and progress listener unwrapped. -- bounds the `export-markdown` flight without killing the picker. + +**Acceptance Criteria:** + +- Given a `singleflight`-wrapped operation whose underlying invoke never settles, when the timeout elapses, then the wrapper rejects with `IpcTimeoutError`, the operation's existing error path runs, the `singleflight` key is released, and a subsequent same-key call invokes `fn` afresh (no permanent wedge). +- Given an invoke that resolves or rejects before the timeout, when it settles, then `withTimeout` settles with the identical value/reason and the pending timer is cleared (verified: no leaked timers, no late unhandled rejection). +- Given the native file dialogs (`save`/`open`), when an export runs, then the dialog promise is NOT timeout-bounded and the user can take arbitrarily long to choose a path/folder. +- Given `src/lib/singleflight.ts`, when this change is complete, then it contains no timer/timeout/retry/abort logic (diff shows it unchanged). + +## Verification + +**Commands:** + +- `npm run test -- src/lib/withTimeout.test.ts` -- expected: all new unit tests pass. +- `npm run test` -- expected: full Vitest suite green (existing actions/export/workspace/singleflight tests unaffected). +- `npm run build` -- expected: `tsc` typechecks clean (strict mode) and Vite build succeeds. +- `git diff --stat src/lib/singleflight.ts` -- expected: empty (singleflight untouched). + +### Review Findings + +- [x] [Review][Patch] Unbounded helper IPC could still wedge guarded create/trash flows [src/features/editor/hooks/useAutoSave.ts:46] — fixed by timeout-bounding `flushSave`'s create/update IPC and `loadNote`'s `getNote` IPC, so helper calls awaited inside `create-note` / `trash-active-note` singleflight operations now settle through `withTimeout`. +- [x] [Review][Patch] Timeout rejections bypassed existing recovery paths [src/features/workspace/store.ts:96] — fixed by catching timeout rejections in command-palette toggles, workspace trash/list loads, editor save, and note load paths so failures log and update existing failure state instead of escaping. + +#### Review Ledger (2026-06-16) + +- patch: Unbounded helper IPC could still wedge guarded create/trash flows [src/features/editor/hooks/useAutoSave.ts:46] — fixed by wrapping `commands.createNote`, `commands.updateNote`, and `commands.getNote` helper IPC with `withTimeout`. +- patch: Timeout rejections bypassed existing recovery paths [src/features/workspace/store.ts:96] — fixed by routing thrown `IpcTimeoutError` through existing log/error-state paths for toggles, trash, note-list refresh, save, and load. +- dismiss: Timed-out mutating IPC can still complete after retry [src/lib/withTimeout.ts:41] — frozen spec chose a frontend IPC timeout and explicitly documents that it cannot cancel backend commands. +- dismiss: Dialog hang path remains unresolved [src/features/export/exportJson.ts:25] — frozen spec explicitly says native `save`/`open` dialog promises must not be timeout-bounded. +- dismiss: Fixed timeout bounds may false-timeout slow valid work [src/lib/withTimeout.ts:7] — frozen spec mandates the 5s Story 6.7 mirror and a generous export bound; changing these is outside review scope. diff --git a/src/features/command-palette/actions.ts b/src/features/command-palette/actions.ts index cec5207..bb9c119 100644 --- a/src/features/command-palette/actions.ts +++ b/src/features/command-palette/actions.ts @@ -8,6 +8,7 @@ import { flushSave } from '../editor/hooks/useAutoSave'; import { normalizeLayoutMode, nextLayoutMode } from '../settings/layoutMode'; import { useSettingsStore } from '../settings/store'; import { singleflight } from '../../lib/singleflight'; +import { withTimeout } from '../../lib/withTimeout'; /** * Create a new note, open it in a tab, and load it into the editor. @@ -25,23 +26,30 @@ export async function createNewNote(): Promise { const format = useEditorStore.getState().format; const workspaceId = useWorkspaceStore.getState().activeWorkspaceId; - const result = await commands.createNote(format, workspaceId); - if (result.status === 'error') { - console.error('createNote failed:', result.error); - return; - } - - const note = result.data; - useTabStore.getState().openTab(note.id, 'New note'); + try { + const result = await withTimeout(commands.createNote(format, workspaceId), { + label: 'create_note', + }); + if (result.status === 'error') { + console.error('createNote failed:', result.error); + return; + } - await useEditorStore.getState().loadNote(note.id); - // loadNote sets saveStatus to 'failed' on error — close the orphaned tab - if (useEditorStore.getState().saveStatus === 'failed') { - const { tabs } = useTabStore.getState(); - const tabIndex = tabs.findIndex((t) => t.noteId === note.id); - if (tabIndex !== -1) { - useTabStore.getState().closeTab(tabIndex); + const note = result.data; + useTabStore.getState().openTab(note.id, 'New note'); + + await useEditorStore.getState().loadNote(note.id); + // loadNote sets saveStatus to 'failed' on error — close the orphaned tab + if (useEditorStore.getState().saveStatus === 'failed') { + const { tabs } = useTabStore.getState(); + const tabIndex = tabs.findIndex((t) => t.noteId === note.id); + if (tabIndex !== -1) { + useTabStore.getState().closeTab(tabIndex); + } } + } catch (error) { + console.error('createNote threw:', error); + return; } }); } @@ -247,7 +255,9 @@ function bindSystemThemeListener(): void { async function applyLayoutModeToWindow(layoutMode: string): Promise { const mode = normalizeLayoutMode(layoutMode); try { - const result = await commands.applyLayoutMode(mode); + const result = await withTimeout(commands.applyLayoutMode(mode), { + label: 'apply_layout_mode', + }); if (result.status === 'error') { console.error('applyLayoutMode failed:', result.error); return false; @@ -298,7 +308,9 @@ function applyFontFamily(family: string): void { async function persistSettingsUpdate(partial: PartialAppConfig, context: string): Promise { const save = async () => { try { - const result = await commands.updateConfig(partial); + const result = await withTimeout(commands.updateConfig(partial), { + label: 'update_config', + }); if (result.status === 'error') { console.error(`updateConfig failed in ${context}:`, result.error); return false; @@ -370,32 +382,42 @@ export async function applyStartupConfig(): Promise { */ export async function toggleTheme(): Promise { await singleflight('toggle-theme', async () => { - const configResult = await commands.getConfig(); - if (configResult.status === 'error') { - console.error('getConfig failed:', configResult.error); - return; - } + try { + const configResult = await withTimeout(commands.getConfig(), { + label: 'get_config', + }); + if (configResult.status === 'error') { + console.error('getConfig failed:', configResult.error); + return; + } - const current = configResult.data.general?.theme ?? 'dark'; - const currentIsDark = current === 'dark' || (current === 'system' && systemPrefersDark()); - const next = currentIsDark ? 'light' : 'dark'; + const current = configResult.data.general?.theme ?? 'dark'; + const currentIsDark = current === 'dark' || (current === 'system' && systemPrefersDark()); + const next = currentIsDark ? 'light' : 'dark'; + + const updateResult = await withTimeout( + commands.updateConfig({ + general: { theme: next, layoutMode: null }, + editor: null, + hotkey: null, + shortcuts: null, + }), + { label: 'update_config' }, + ); + if (updateResult.status === 'error') { + console.error('updateConfig failed:', updateResult.error); + return; + } - const updateResult = await commands.updateConfig({ - general: { theme: next, layoutMode: null }, - editor: null, - hotkey: null, - shortcuts: null, - }); - if (updateResult.status === 'error') { - console.error('updateConfig failed:', updateResult.error); + // Mark theme as user-controlled before applying, so a concurrent startup + // application (still awaiting its getConfig) will skip it. Set only on the + // success path — a failed toggle must not suppress startup application. + userToggled.theme = true; + applyThemeClass(next); + } catch (error) { + console.error('toggleTheme threw:', error); return; } - - // Mark theme as user-controlled before applying, so a concurrent startup - // application (still awaiting its getConfig) will skip it. Set only on the - // success path — a failed toggle must not suppress startup application. - userToggled.theme = true; - applyThemeClass(next); }); } @@ -414,29 +436,39 @@ export function toggleFormat(): void { */ export async function toggleLayoutMode(): Promise { await singleflight('toggle-layout-mode', async () => { - const configResult = await commands.getConfig(); - if (configResult.status === 'error') { - console.error('getConfig failed:', configResult.error); - return; - } + try { + const configResult = await withTimeout(commands.getConfig(), { + label: 'get_config', + }); + if (configResult.status === 'error') { + console.error('getConfig failed:', configResult.error); + return; + } - const next = nextLayoutMode(configResult.data.general?.layoutMode); + const next = nextLayoutMode(configResult.data.general?.layoutMode); + + const updateResult = await withTimeout( + commands.updateConfig({ + general: { theme: null, layoutMode: next }, + editor: null, + hotkey: null, + shortcuts: null, + }), + { label: 'update_config' }, + ); + if (updateResult.status === 'error') { + console.error('updateConfig failed:', updateResult.error); + return; + } - const updateResult = await commands.updateConfig({ - general: { theme: null, layoutMode: next }, - editor: null, - hotkey: null, - shortcuts: null, - }); - if (updateResult.status === 'error') { - console.error('updateConfig failed:', updateResult.error); + // Mark layout as user-controlled before applying (see toggleTheme). Success + // path only, so a failed toggle does not suppress startup application. + userToggled.layoutMode = true; + await applyLayoutModeToWindow(next); + } catch (error) { + console.error('toggleLayoutMode threw:', error); return; } - - // Mark layout as user-controlled before applying (see toggleTheme). Success - // path only, so a failed toggle does not suppress startup application. - userToggled.layoutMode = true; - await applyLayoutModeToWindow(next); }); } diff --git a/src/features/editor/hooks/useAutoSave.ts b/src/features/editor/hooks/useAutoSave.ts index f5ba4a4..0b64021 100644 --- a/src/features/editor/hooks/useAutoSave.ts +++ b/src/features/editor/hooks/useAutoSave.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'; import { commands } from '../../../generated/bindings'; import { useEditorStore } from '../store'; import { useWorkspaceStore } from '../../workspace/store'; +import { withTimeout } from '../../../lib/withTimeout'; /** Module-level reference to the active hook instance's flush function. */ let registeredFlush: (() => Promise) | null = null; @@ -42,7 +43,9 @@ async function performSave( if (isCreatingRef) isCreatingRef.current = true; try { const workspaceId = useWorkspaceStore.getState().activeWorkspaceId; - const createResult = await commands.createNote(format, workspaceId); + const createResult = await withTimeout(commands.createNote(format, workspaceId), { + label: 'create_note', + }); if (createResult.status === 'error') { setSaveStatus('failed'); console.error('createNote failed:', createResult.error); @@ -50,6 +53,10 @@ async function performSave( } noteId = createResult.data.id; setActiveNote(noteId); + } catch (error) { + setSaveStatus('failed'); + console.error('createNote threw:', error); + return; } finally { if (isCreatingRef) isCreatingRef.current = false; } @@ -58,14 +65,22 @@ async function performSave( const firstLine = content.split('\n')[0].trim(); const title = firstLine.slice(0, 100) || 'Untitled'; - const updateResult = await commands.updateNote(noteId, title, content, null); - if (updateResult.status === 'error') { + try { + const updateResult = await withTimeout(commands.updateNote(noteId, title, content, null), { + label: 'update_note', + }); + if (updateResult.status === 'error') { + setSaveStatus('failed'); + console.error('updateNote failed:', updateResult.error); + return; + } + + markSaved(updateResult.data.updatedAt); + } catch (error) { setSaveStatus('failed'); - console.error('updateNote failed:', updateResult.error); + console.error('updateNote threw:', error); return; } - - markSaved(updateResult.data.updatedAt); } /** diff --git a/src/features/editor/store.ts b/src/features/editor/store.ts index 7fc1f5e..db623fe 100644 --- a/src/features/editor/store.ts +++ b/src/features/editor/store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { commands } from '../../generated/bindings'; +import { withTimeout } from '../../lib/withTimeout'; /** Format of a note's content. */ export type NoteFormat = 'markdown' | 'plaintext'; @@ -64,28 +65,36 @@ export const useEditorStore = create((set) => ({ isHydrating: false, }), loadNote: async (id) => { - const result = await commands.getNote(id); - if (result.status === 'error') { - console.error('loadNote failed:', result.error); + try { + const result = await withTimeout(commands.getNote(id), { + label: 'get_note', + }); + if (result.status === 'error') { + console.error('loadNote failed:', result.error); + set({ saveStatus: 'failed', isHydrating: false }); + return; + } + const note = result.data; + const validFormats: NoteFormat[] = ['markdown', 'plaintext']; + if (!validFormats.includes(note.format as NoteFormat)) { + console.warn(`loadNote: unknown format "${note.format}" for note ${note.id}, defaulting to markdown`); + } + const format = validFormats.includes(note.format as NoteFormat) + ? (note.format as NoteFormat) + : 'markdown'; + set({ + activeNoteId: note.id, + content: note.content, + format, + saveStatus: 'idle', + lastSavedAt: note.updatedAt, + isHydrating: true, + }); + } catch (error) { + console.error('loadNote threw:', error); set({ saveStatus: 'failed', isHydrating: false }); return; } - const note = result.data; - const validFormats: NoteFormat[] = ['markdown', 'plaintext']; - if (!validFormats.includes(note.format as NoteFormat)) { - console.warn(`loadNote: unknown format "${note.format}" for note ${note.id}, defaulting to markdown`); - } - const format = validFormats.includes(note.format as NoteFormat) - ? (note.format as NoteFormat) - : 'markdown'; - set({ - activeNoteId: note.id, - content: note.content, - format, - saveStatus: 'idle', - lastSavedAt: note.updatedAt, - isHydrating: true, - }); }, clearHydrating: () => set({ isHydrating: false }), })); diff --git a/src/features/export/exportJson.ts b/src/features/export/exportJson.ts index 6f813b3..5f5fdba 100644 --- a/src/features/export/exportJson.ts +++ b/src/features/export/exportJson.ts @@ -2,6 +2,7 @@ import { save } from "@tauri-apps/plugin-dialog"; import { commands } from "../../generated/bindings"; import { useToastStore } from "../toast/store"; import { singleflight, resetSingleflight } from "../../lib/singleflight"; +import { withTimeout, EXPORT_IPC_TIMEOUT_MS } from "../../lib/withTimeout"; /** Auto-dismiss duration for the final "Exported N notes" toast. */ const RESULT_TOAST_DURATION_MS = 5000; @@ -29,7 +30,10 @@ export async function exportToJson(): Promise { // Cancelled dialog → null. if (path === null) return; - const result = await commands.exportJson(path); + const result = await withTimeout(commands.exportJson(path), { + timeoutMs: EXPORT_IPC_TIMEOUT_MS, + label: "export_json", + }); if (result.status === "ok") { useToastStore diff --git a/src/features/export/exportMarkdown.ts b/src/features/export/exportMarkdown.ts index 0f940b0..6e7d8dc 100644 --- a/src/features/export/exportMarkdown.ts +++ b/src/features/export/exportMarkdown.ts @@ -3,6 +3,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { commands } from "../../generated/bindings"; import { useToastStore } from "../toast/store"; import { singleflight, resetSingleflight } from "../../lib/singleflight"; +import { withTimeout, EXPORT_IPC_TIMEOUT_MS } from "../../lib/withTimeout"; /** Payload of the backend `export-markdown-progress` event. */ interface ExportProgress { @@ -68,7 +69,10 @@ export async function exportToMarkdown(): Promise { } }, PROGRESS_TOAST_DELAY_MS); - const result = await commands.exportMarkdown(directory); + const result = await withTimeout(commands.exportMarkdown(directory), { + timeoutMs: EXPORT_IPC_TIMEOUT_MS, + label: "export_markdown", + }); if (result.status === "ok") { useToastStore diff --git a/src/features/workspace/store.ts b/src/features/workspace/store.ts index 091641b..1687562 100644 --- a/src/features/workspace/store.ts +++ b/src/features/workspace/store.ts @@ -3,6 +3,7 @@ import { commands } from '../../generated/bindings'; import type { WorkspaceInfo, Note } from '../../generated/bindings'; import { useSearchStore } from '../search/store'; import { useTabStore } from '../tabs/store'; +import { withTimeout } from '../../lib/withTimeout'; /** Workspace state for tracking the active workspace context. */ interface WorkspaceState { @@ -91,13 +92,21 @@ export const useWorkspaceStore = create((set, }, trashNote: async (noteId) => { - const result = await commands.trashNote(noteId); - if (result.status === 'ok') { - useTabStore.getState().closeTabByNoteId(noteId); - await get().loadFilteredNotes(); - return result.data; - } else { - console.error('trashNote failed:', result.error); + try { + const result = await withTimeout(commands.trashNote(noteId), { + label: 'trash_note', + }); + if (result.status === 'ok') { + useTabStore.getState().closeTabByNoteId(noteId); + await get().loadFilteredNotes(); + return result.data; + } else { + console.error('trashNote failed:', result.error); + set({ notesError: 'Failed to move note to trash' }); + return null; + } + } catch (error) { + console.error('trashNote threw:', error); set({ notesError: 'Failed to move note to trash' }); return null; } @@ -107,12 +116,20 @@ export const useWorkspaceStore = create((set, set({ isLoadingNotes: true, notesError: null }); const { activeWorkspaceId, isAllWorkspaces } = get(); const workspaceId = isAllWorkspaces ? null : activeWorkspaceId; - const result = await commands.listNotes(workspaceId); - if (result.status === 'ok') { - set({ filteredNotes: result.data, isLoadingNotes: false, notesError: null }); - } else { - console.error('listNotes failed:', result.error); + try { + const result = await withTimeout(commands.listNotes(workspaceId), { + label: 'list_notes', + }); + if (result.status === 'ok') { + set({ filteredNotes: result.data, isLoadingNotes: false, notesError: null }); + } else { + console.error('listNotes failed:', result.error); + set({ isLoadingNotes: false, notesError: 'Failed to load notes \u2014 switch workspace to retry' }); + } + } catch (error) { + console.error('listNotes threw:', error); set({ isLoadingNotes: false, notesError: 'Failed to load notes \u2014 switch workspace to retry' }); + return; } }, diff --git a/src/lib/withTimeout.test.ts b/src/lib/withTimeout.test.ts new file mode 100644 index 0000000..e99725c --- /dev/null +++ b/src/lib/withTimeout.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + withTimeout, + IpcTimeoutError, + IPC_TIMEOUT_MS, +} from './withTimeout'; + +/** A promise whose settlement the test controls. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('withTimeout', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('resolves with the operation value when it settles before the timeout', async () => { + const d = deferred(); + const wrapped = withTimeout(d.promise); + + d.resolve(42); + await expect(wrapped).resolves.toBe(42); + }); + + it('rejects with the operation reason when it rejects before the timeout', async () => { + const d = deferred(); + const wrapped = withTimeout(d.promise); + const boom = new Error('invoke failed'); + + d.reject(boom); + await expect(wrapped).rejects.toBe(boom); + }); + + it('rejects with IpcTimeoutError when the operation never settles', async () => { + const d = deferred(); + const wrapped = withTimeout(d.promise, { label: 'create_note' }); + // Surface the eventual rejection before advancing timers so the rejection + // is observed (avoids an unhandled-rejection warning under fake timers). + const assertion = expect(wrapped).rejects.toBeInstanceOf(IpcTimeoutError); + + await vi.advanceTimersByTimeAsync(IPC_TIMEOUT_MS); + await assertion; + // d.promise is intentionally left pending — it models a hung invoke. + }); + + it('clears the timer once the operation resolves (no leaked timeout)', async () => { + const d = deferred(); + const wrapped = withTimeout(d.promise); + + d.resolve(1); + await wrapped; + + expect(vi.getTimerCount()).toBe(0); + }); + + it('does not double-settle or throw when the operation settles after the timeout', async () => { + const d = deferred(); + const wrapped = withTimeout(d.promise, { timeoutMs: 1000 }); + const assertion = expect(wrapped).rejects.toBeInstanceOf(IpcTimeoutError); + + await vi.advanceTimersByTimeAsync(1000); + await assertion; + + // A late settlement of the (already-lost) operation must be a harmless + // no-op: no unhandled rejection, no second settlement of `wrapped`. + d.reject(new Error('late failure')); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('honors a custom timeoutMs', async () => { + const d = deferred(); + const wrapped = withTimeout(d.promise, { timeoutMs: 250 }); + const assertion = expect(wrapped).rejects.toBeInstanceOf(IpcTimeoutError); + + // Not yet elapsed at the default bound's earlier point... + await vi.advanceTimersByTimeAsync(249); + expect(vi.getTimerCount()).toBe(1); + + await vi.advanceTimersByTimeAsync(1); + await assertion; + }); + + it('includes the label and bound in the timeout message', async () => { + const d = deferred(); + const wrapped = withTimeout(d.promise, { label: 'export_json', timeoutMs: 500 }); + const assertion = wrapped.then( + () => { + throw new Error('expected a timeout rejection'); + }, + (e: unknown) => e as Error, + ); + + await vi.advanceTimersByTimeAsync(500); + const err = await assertion; + expect(err.message).toContain('export_json'); + expect(err.message).toContain('500'); + }); +}); diff --git a/src/lib/withTimeout.ts b/src/lib/withTimeout.ts new file mode 100644 index 0000000..b2748ff --- /dev/null +++ b/src/lib/withTimeout.ts @@ -0,0 +1,86 @@ +/** + * Default bound on a single Tauri IPC round-trip, mirroring the CLI's 5s + * connect/round-trip timeout (Story 6.7). Notey commands are quick local-SQLite + * operations, so a round-trip that has not settled in 5s is treated as a hung + * IPC promise rather than slow work. + */ +export const IPC_TIMEOUT_MS = 5000; + +/** + * Generous bound for the long-running export commands (`export_markdown` / + * `export_json`), which write one file per note and can legitimately run for + * many seconds on a large workspace. The bound is not about latency — it exists + * only so a genuinely hung export promise still settles and releases its + * `singleflight` key instead of disabling export until reload. + */ +export const EXPORT_IPC_TIMEOUT_MS = 60_000; + +/** + * Rejection raised by {@link withTimeout} when the wrapped operation does not + * settle within its bound. Distinct from any backend `NoteyError` so callers' + * existing error paths log it as just another invoke failure. + */ +export class IpcTimeoutError extends Error { + constructor(label: string, timeoutMs: number) { + super(`IPC operation "${label}" timed out after ${timeoutMs}ms`); + this.name = 'IpcTimeoutError'; + } +} + +/** + * Race a Tauri IPC promise against a timeout so it always settles. + * + * The shared `singleflight` helper only releases an in-flight key once its + * wrapped operation settles; a Tauri invoke promise that never settles would + * therefore wedge that key forever (see DW-90). Wrapping the invoke with + * `withTimeout` guarantees the operation settles — either with the invoke's own + * result/rejection, or with an {@link IpcTimeoutError} once the bound elapses — + * so the `singleflight` `finally` always runs. `singleflight` itself stays + * timer-free. + * + * This is a frontend-side bound: it stops the UI awaiting a dead promise, but it + * cannot cancel the backend command, which may still complete. The timer is + * always cleared once `operation` settles, win or lose the race, so no timer + * leaks and a late settlement after the timeout neither double-settles the + * returned promise nor surfaces an unhandled rejection. + * + * Do NOT wrap native dialog promises (`save`/`open`): those legitimately wait on + * the user and must not be bounded. + * + * @param operation The IPC promise to bound (typically a generated `commands.*` call). + * @param opts.timeoutMs Bound in milliseconds; defaults to {@link IPC_TIMEOUT_MS}. + * @param opts.label Human-readable name for the operation, used in the timeout message. + * @returns A promise that settles with `operation`'s result, or rejects with + * {@link IpcTimeoutError} if the bound elapses first. + */ +export function withTimeout( + operation: Promise, + opts?: { timeoutMs?: number; label?: string }, +): Promise { + const timeoutMs = opts?.timeoutMs ?? IPC_TIMEOUT_MS; + const label = opts?.label ?? 'ipc'; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new IpcTimeoutError(label, timeoutMs)); + }, timeoutMs); + + // Attaching handlers here (rather than racing a separate promise) means a + // rejection from `operation` is always observed — never an unhandled + // rejection — even if the timeout already won the race. The timer is cleared + // synchronously with settlement, so it is gone the moment the returned + // promise settles. Once the outer promise has settled, these resolve/reject + // calls are inert no-ops, so a late settlement neither double-settles nor + // throws. + operation.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (reason) => { + clearTimeout(timer); + reject(reason); + }, + ); + }); +} From a8a951bdb84521c150b6bc70cfa672a6ae67e50d Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 16 Jun 2026 23:37:32 -0700 Subject: [PATCH 03/15] sweep dw-decision-dw-95: DW-95 via bmad-auto --- .../implementation-artifacts/deferred-work.md | 3 +- .../spec-dw-decision-dw-95.md | 138 ++++++++++++++++ e2e/driver.mjs | 17 +- e2e/run.mjs | 116 +++++++------- src-tauri/src/ipc/socket_server.rs | 150 +++++++++++++++++- 5 files changed, 350 insertions(+), 74 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-dw-decision-dw-95.md diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 2bac429..45d714c 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -746,5 +746,6 @@ status: open origin: local verification of epic-6 retro item-1 (E2E), 2026-06-16 location: e2e/run.mjs (cliLiveSyncTests, waitForAppSocket / realInstancePresent guard) reason: The CLI live-sync suite originally pinned a unique per-run `NOTEY_SOCKET_PATH` so the test app and CLI rendezvous on their own endpoint, isolated from any real notey instance. This relies on the env var propagating to the tauri-driver-launched app. It holds on CI's older Ubuntu `webkit2gtk-driver`, but a modern WebKitWebDriver (verified locally on `webkit2gtk-4.1 2.52.4`) **resets the launched app's environment** — stripping `NOTEY_SOCKET_PATH` and forcing `XDG_RUNTIME_DIR` back to the real session value — and `tauri-driver 2.0.6` forwards only `{binary, args}` (no env passthrough). So locally the app always binds the default `$XDG_RUNTIME_DIR/notey.sock`, diverging from the per-run path the CLI used → `notey add` failed exit-2 ("not running") and the suite was 14/16 locally while green on CI. Fixed test-only by discovering the socket the app actually bound (probe configured path + `$XDG_RUNTIME_DIR/notey.sock` for liveness) and pointing the CLI there — local now 16/16, CI unchanged (it matches the first candidate). Residual limitation: because the test app falls back to the *default* socket locally, the suite cannot isolate from a real running notey; it now detects a live pre-existing instance and skips the live-sync suite with an actionable message rather than hijacking it. Proper isolation would need either an env-passthrough in a newer tauri-driver or an app-level `--socket-path` arg routed via `tauri:options.args` (app source change; deferred). Test-harness robustness, not a product defect. -status: open +status: done 2026-06-16 +resolution: Added a permissive `--socket-path` parser to `socket_server::socket_path()` (precedence: CLI arg > `NOTEY_SOCKET_PATH` > default) with unit tests, and routed a per-run `--socket-path` through `tauri:options.args` in `e2e/run.mjs` (via `createSession(application, args)`); the live-sync suite now binds an isolated endpoint regardless of env stripping, so the `realInstancePresent` skip guard and multi-candidate socket discovery were removed. decision: 2026-06-16 App-level --socket-path arg — Add a --socket-path CLI arg to the desktop app that overrides the socket path, and route a per-run unique path through tauri:options.args in e2e/run.mjs so each E2E run binds an isolated endpoint independent of env propagation. diff --git a/_bmad-output/implementation-artifacts/spec-dw-decision-dw-95.md b/_bmad-output/implementation-artifacts/spec-dw-decision-dw-95.md new file mode 100644 index 0000000..8fa3f62 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-dw-decision-dw-95.md @@ -0,0 +1,138 @@ +--- +title: "App --socket-path CLI arg for per-run E2E IPC isolation (DW-95)" +type: "feature" +created: "2026-06-16" +status: "done" +context: [] +baseline_commit: "e03877932bcab1d48b470adfd41c8df4b7b2784e" +--- + + + +## Intent + +**Problem:** Local E2E cannot isolate its IPC socket per run. A modern WebKitWebDriver (`webkit2gtk-4.1 2.52.4`) resets the launched app's environment — stripping `NOTEY_SOCKET_PATH` and forcing `XDG_RUNTIME_DIR` back to the real session value — and `tauri-driver 2.0.6` has no env passthrough. So the test app always binds the default `$XDG_RUNTIME_DIR/notey.sock`, colliding with any real notey instance; the live-sync suite currently can only *skip* when a real instance is present rather than isolate from it. + +**Approach:** Give the desktop app a `--socket-path ` CLI argument that overrides the resolved IPC socket path, taking precedence over the `NOTEY_SOCKET_PATH` env var and the default. Route a per-run unique path through `tauri:options.args` in `e2e/run.mjs` (the tauri-driver `args` field is forwarded to the spawned binary as process args, surviving the env reset). Each E2E session then binds its own isolated endpoint regardless of env propagation, so the live-sync suite no longer needs the "real instance present → skip" guard. + +## Boundaries & Constraints + +**Always:** +- CLI-arg precedence: `--socket-path` > `NOTEY_SOCKET_PATH` env > platform default. The arg is the most explicit channel and must win. +- Parse the arg permissively from `std::env::args()` — accept both `--socket-path ` and `--socket-path=`; ignore unknown/extra args (the launcher may inject others) and never abort/exit the process on a parse miss. +- The CLI (`notey`, spawned directly by the E2E node process) keeps reading `NOTEY_SOCKET_PATH`; the app reads `--socket-path`. Both must resolve to the *same* per-run path so app and CLI rendezvous. +- Behavior must stay identical on CI (older driver where env survives) and locally — both now bind the per-run path via the arg. + +**Ask First:** +- Adding a heavyweight CLI-parsing dependency (e.g. `clap`) to the desktop crate — the single arg should be hand-parsed; do not pull in a new dep without approval. + +**Never:** +- Do not change the default/`NOTEY_SOCKET_PATH` resolution behavior for normal (no-arg) app launches. +- Do not isolate the E2E *database* — only the socket is in scope (DB sharing with a real instance is pre-existing and out of scope). +- Do not touch the IPC framing, worker, or security (`0600`) logic. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +| -------- | ------------- | -------------------------- | -------------- | +| Arg with space form | argv contains `--socket-path /tmp/x.sock` | `socket_path()` returns `/tmp/x.sock` | N/A | +| Arg with equals form | argv contains `--socket-path=/tmp/x.sock` | `socket_path()` returns `/tmp/x.sock` | N/A | +| Arg precedence over env | argv has `--socket-path /tmp/a.sock` AND `NOTEY_SOCKET_PATH=/tmp/b.sock` | returns `/tmp/a.sock` | N/A | +| Arg absent | argv has no `--socket-path`, env set | falls back to `NOTEY_SOCKET_PATH`, then default | N/A | +| Flag present, value missing | argv ends with `--socket-path` (no following token) | treated as absent → env/default | no panic | +| Empty value | `--socket-path=` or `--socket-path ""` | treated as absent → env/default | no panic | +| Unknown extra args | argv has other flags around `--socket-path` | extra flags ignored; arg still found | no abort | + + + +## Code Map + +- `src-tauri/src/ipc/socket_server.rs` -- `socket_path()` resolves the IPC path. Add a permissive `--socket-path` argv parser, give it top precedence, and add a unit-test module (none exists today). +- `src-tauri/src/lib.rs` -- calls `ipc::socket_server::socket_path()` at server start (line ~304). No change needed — it consumes whatever `socket_path()` returns. +- `e2e/driver.mjs` -- `createSession(application)` builds the `tauri:options` capability. Extend to forward an optional `args` array. +- `e2e/run.mjs` -- top-of-file per-run socket setup, the 5 `createSession(APP_PATH)` calls, `appSocketCandidates()`/`waitForAppSocket()` discovery, and the `realInstancePresent` skip guard in `cliLiveSyncTests()`/`main()`. + +## Tasks & Acceptance + +**Execution:** + +- [x] `src-tauri/src/ipc/socket_server.rs` -- Add a pure helper `socket_path_arg_from>(args: I) -> Option` that scans for `--socket-path ` / `--socket-path=`, returning the first non-empty value (else `None`); add `socket_path_arg()` wrapping `std::env::args()`. In `socket_path()`, check `socket_path_arg()` first, before the `NOTEY_SOCKET_PATH` env branch. Update the rustdoc on `socket_path()` to document the new precedence. +- [x] `src-tauri/src/ipc/socket_server.rs` -- Add a `#[cfg(test)] mod tests` covering every I/O & Edge-Case Matrix row for `socket_path_arg_from` (space form, equals form, missing value, empty value, unknown extra args, absent). +- [x] `e2e/driver.mjs` -- `createSession(application, args = [])`: include `args` in `tauri:options` only when non-empty. Update the JSDoc. +- [x] `e2e/run.mjs` -- Keep the per-run `NOTEY_SOCKET_PATH` assignment (for the CLI) and capture it as a constant; pass `['--socket-path', ]` to every `createSession` call (a small wrapper is fine). Simplify `appSocketCandidates()`/`waitForAppSocket()` to the now-deterministic per-run path. Remove the `realInstancePresent` detection and the live-sync skip block now that isolation is guaranteed via the arg; update the surrounding comments to reflect the arg-based isolation. + +**Acceptance Criteria:** + +- Given the app binary is launched with `--socket-path /tmp/notey-e2e.sock`, when the IPC server starts, then it binds `/tmp/notey-e2e.sock` even if `NOTEY_SOCKET_PATH` is unset/stripped and `XDG_RUNTIME_DIR` points elsewhere. +- Given the app is launched with both `--socket-path` and `NOTEY_SOCKET_PATH` set to different paths, when `socket_path()` resolves, then the `--socket-path` value wins. +- Given the app is launched with no `--socket-path`, when `socket_path()` resolves, then behavior is unchanged (env → default), so normal app launch and existing tests are unaffected. +- Given an E2E run, when each WebDriver session is created, then `tauri:options.args` carries the per-run `--socket-path`, the app and the `notey` CLI rendezvous on that path, and the live-sync suite runs (no longer skipped) even with a real notey instance on the default socket. +- Given `cargo test -p tauri-app` (or the crate's test command), when the new `socket_path_arg_from` tests run, then all matrix rows pass. + +## Design Notes + +Permissive hand-parse (no `clap` in the desktop crate) — robust to launcher-injected args and never exits the process: + +```rust +fn socket_path_arg_from>(args: I) -> Option { + let mut args = args; + while let Some(arg) = args.next() { + if let Some(v) = arg.strip_prefix("--socket-path=") { + return (!v.is_empty()).then(|| PathBuf::from(v)); + } + if arg == "--socket-path" { + return args.next().filter(|v| !v.is_empty()).map(PathBuf::from); + } + } + None +} +``` + +`socket_path()` gains one branch ahead of the env check: + +```rust +pub fn socket_path() -> PathBuf { + if let Some(custom) = socket_path_arg() { return custom; } + if let Ok(custom) = std::env::var("NOTEY_SOCKET_PATH") { return PathBuf::from(custom); } + // …existing default… +} +``` + +E2E side — both channels carry the same path; the CLI is not env-stripped (spawned directly by node), the app gets the arg: + +```js +if (!process.env.NOTEY_SOCKET_PATH) { + process.env.NOTEY_SOCKET_PATH = path.join(os.tmpdir(), `notey-e2e-${process.pid}.sock`); +} +const E2E_SOCKET_PATH = process.env.NOTEY_SOCKET_PATH; +// every session: +sessionId = await createSession(APP_PATH, ['--socket-path', E2E_SOCKET_PATH]); +``` + +## Verification + +**Commands:** + +- `cargo test -p tauri-app socket_path` -- expected: the new arg-parser unit tests pass (run from `src-tauri/`; adjust package name to the crate's actual name if different). +- `cargo build` (in `src-tauri/`) -- expected: app compiles with the new parser. +- `cargo clippy --all-targets -- -D warnings` (in `src-tauri/`) -- expected: no new warnings. +- `node --check e2e/run.mjs && node --check e2e/driver.mjs` -- expected: both parse clean. + +**Manual checks (if no CLI):** + +- Full E2E (`node e2e/run.mjs`) requires `tauri-driver` + built debug binary + xvfb; if unavailable in this environment, confirm by inspection that `tauri:options.args` carries `--socket-path` for every session and that the app's `socket_path()` returns the arg value. Note in the result if the live E2E run could not be executed here. + +### Review Findings + +- [x] [Review][Patch] Guard `--socket-path` parser against empty/malformed values while continuing to scan [src-tauri/src/ipc/socket_server.rs:116] +- [x] [Review][Patch] Normalize empty `NOTEY_SOCKET_PATH` before sharing the E2E socket path with app and CLI [e2e/run.mjs:51] +- [x] [Review][Patch] Reject a pre-existing live E2E socket before launching the test app [e2e/run.mjs:785] + +#### Review Ledger (2026-06-16) + +- patch: Guard `--socket-path` parser against empty/malformed values while continuing to scan [src-tauri/src/ipc/socket_server.rs:116] - merged blind/edge/auditor finding; malformed `--socket-path --flag` and earlier empty values must fall through instead of becoming a path or stopping the scan. +- patch: Normalize empty `NOTEY_SOCKET_PATH` before sharing the E2E socket path with app and CLI [e2e/run.mjs:51] - empty caller env split app fallback from CLI env resolution; generate a per-run path when the env var is unset or empty. +- patch: Reject a pre-existing live E2E socket before launching the test app [e2e/run.mjs:785] - prevents `waitForAppSocket()` and `notey add` from accepting an already-running listener at the chosen test path. +- dismiss: Windows custom paths are not unique by full path [src-tauri/src/ipc/socket_server.rs:201] - Windows local-socket transport already maps namespaced pipes from the final component; DW-95 uses unique per-run basenames and changing arbitrary path identity would require a coordinated CLI transport change outside this spec. + + diff --git a/e2e/driver.mjs b/e2e/driver.mjs index dfd4f87..40435ec 100644 --- a/e2e/driver.mjs +++ b/e2e/driver.mjs @@ -20,12 +20,25 @@ async function request(method, path, body) { return json.value; } -export async function createSession(application) { +/** + * Create a WebDriver session that launches the Tauri app. + * + * @param {string} application - Absolute path to the app binary. + * @param {string[]} [args] - Command-line arguments forwarded to the launched + * binary via `tauri:options.args` (tauri-driver passes them through as process + * args). Used to route a per-run `--socket-path`, which survives a modern + * WebKitWebDriver resetting the app's environment. Omitted from the capability + * when empty. + * @returns {Promise} The created session id. + */ +export async function createSession(application, args = []) { + const tauriOptions = { application }; + if (args.length > 0) tauriOptions.args = args; const value = await request('POST', '/session', { capabilities: { alwaysMatch: { browserName: 'wry', - 'tauri:options': { application }, + 'tauri:options': tauriOptions, }, }, }); diff --git a/e2e/run.mjs b/e2e/run.mjs index 3126d35..ff064d2 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -35,11 +35,23 @@ process.env.LIBGL_ALWAYS_SOFTWARE ??= '1'; // Pin a unique per-run IPC socket so the CLI live-sync suite rendezvous's the // test app and the `notey` CLI on their own endpoint — never colliding with a // real notey instance the developer may have running on the default -// `$XDG_RUNTIME_DIR/notey.sock`. Set before the driver spawns so it inherits -// down node → tauri-driver → app (same chain as the WEBKIT vars above), and the -// CLI (spawned from this process) reads the same value. `??=` lets a caller -// override it. -process.env.NOTEY_SOCKET_PATH ??= path.join(os.tmpdir(), `notey-e2e-${process.pid}.sock`); +// `$XDG_RUNTIME_DIR/notey.sock`. A non-empty caller-provided NOTEY_SOCKET_PATH +// can still override it. +// +// The same path is carried over two independent channels because a modern +// WebKitWebDriver (webkit2gtk ≥2.52) resets the launched app's environment — +// stripping NOTEY_SOCKET_PATH — and tauri-driver has no env passthrough: +// - The APP receives it as a `--socket-path` CLI arg via `tauri:options.args` +// (see `createSession`), which survives the env reset and binds the app to +// this exact endpoint regardless of env propagation. +// - The CLI (spawned directly by this process, so its env is intact) reads +// NOTEY_SOCKET_PATH. +// Both resolve to the same path, so every run is fully isolated — even with a +// real notey instance listening on the default socket. +if (!process.env.NOTEY_SOCKET_PATH) { + process.env.NOTEY_SOCKET_PATH = path.join(os.tmpdir(), `notey-e2e-${process.pid}.sock`); +} +const E2E_SOCKET_PATH = process.env.NOTEY_SOCKET_PATH; import { createSession, @@ -70,9 +82,15 @@ let tauriDriver; let sessionId; let passed = 0; let failed = 0; -// Set once, before any test app launches: whether a real notey instance already -// owns the default IPC socket (see the live-sync suite's guard for why it matters). -let realInstancePresent = false; + +/** + * Launch a fresh WebDriver session for the test app, routing the per-run IPC + * socket through `--socket-path` so the app binds the isolated endpoint even + * when the WebKitWebDriver build resets its environment. + */ +function launchSession() { + return createSession(APP_PATH, ['--socket-path', E2E_SOCKET_PATH]); +} async function test(name, fn) { try { @@ -354,22 +372,6 @@ async function waitForMarkerInNoteList(marker, timeoutMs = 5000) { throw new Error(`Note list never showed CLI-added marker "${marker}" within ${timeoutMs}ms`); } -/** - * Paths the app's IPC server may have bound, in priority order. The app resolves - * its socket as NOTEY_SOCKET_PATH → `$XDG_RUNTIME_DIR/notey.sock` → temp fallback - * (mirrors `socket_server::socket_path`). We export a unique per-run - * NOTEY_SOCKET_PATH, but a modern WebKitWebDriver (webkit2gtk ≥2.52) resets the - * launched app's environment — stripping NOTEY_SOCKET_PATH and forcing - * XDG_RUNTIME_DIR back to the real session value — and tauri-driver has no env - * passthrough, so the harness cannot steer the app's path: it must discover it. - * On CI's older driver the env survives, so the first candidate wins and behavior - * is unchanged. - */ -function appSocketCandidates() { - const xdg = process.env.XDG_RUNTIME_DIR; - return [process.env.NOTEY_SOCKET_PATH, xdg ? path.join(xdg, 'notey.sock') : null].filter(Boolean); -} - /** * Whether a Unix socket path is *accepting connections* (a live server), as * opposed to a leftover stale socket file. A probe connect that immediately @@ -393,19 +395,19 @@ function isSocketLive(p, timeoutMs = 500) { } /** - * Poll until one of the app's candidate IPC sockets is *live*, and return that - * path. The app's IPC server binds during Tauri's setup hook, which can lag a - * freshly-created WebDriver session on a cold runner — without this gate the first - * `notey add` races the bind and fails exit-2 ("not running"). Liveness (not mere - * file existence) is checked so a leftover stale socket never wins over the path - * the app actually bound. Returns null after the deadline, leaving the CLI's own - * connect timeout to report a genuine absence. + * Poll until the app's per-run IPC socket (`E2E_SOCKET_PATH`, routed via + * `--socket-path`) is *live*, then return it. The app's IPC server binds during + * Tauri's setup hook, which can lag a freshly-created WebDriver session on a cold + * runner — without this gate the first `notey add` races the bind and fails + * exit-2 ("not running"). Liveness (not mere file existence) is checked so a + * leftover stale socket file never counts as ready. Returns null after the + * deadline, leaving the CLI's own connect timeout to report a genuine absence. */ async function waitForAppSocket(timeoutMs = 5000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - for (const c of appSocketCandidates()) { - if (fs.existsSync(c) && (await isSocketLive(c))) return c; + if (fs.existsSync(E2E_SOCKET_PATH) && (await isSocketLive(E2E_SOCKET_PATH))) { + return E2E_SOCKET_PATH; } await pause(150); } @@ -632,20 +634,9 @@ async function purgeCliNote(marker) { async function cliLiveSyncTests() { console.log('\nP1-E2E-003: CLI Live Sync'); - // A modern WebKitWebDriver resets the test app's env, forcing it onto the - // default `$XDG_RUNTIME_DIR/notey.sock` — the same endpoint a real desktop notey - // uses. If a real instance was already listening there before we launched, the - // CLI would talk to IT (polluting the real DB) instead of the test app. Skip - // rather than hijack; CI never trips this (no real instance, env survives). - if (realInstancePresent) { - console.log( - ' ⚠ skipped: a running notey instance owns the default IPC socket. ' + - 'Close it to run the live-sync suite locally — this WebKitWebDriver build ' + - 'resets the app env, preventing per-run socket isolation.', - ); - return; - } - + // The app binds the per-run socket via its `--socket-path` arg (see + // `launchSession`), so this suite is fully isolated from any real notey + // instance on the default `$XDG_RUNTIME_DIR/notey.sock` — no skip guard needed. // Unique per run so the marker note is identifiable in a non-isolated dev DB. const marker = `E2E-CLISYNC-${Date.now()}`; let addSucceeded = false; @@ -672,12 +663,12 @@ async function cliLiveSyncTests() { }); await test('notey add (CLI, separate process) exits 0', async () => { - // Discover where the app actually bound (its env may not survive the - // WebDriver launch) and point the CLI at that exact socket. The CLI reads - // NOTEY_SOCKET_PATH at spawn, so updating it here steers `runCli` below. + // Gate on the app's IPC server actually being live before the CLI connects + // (the bind lags session creation on a cold runner). The app and CLI share + // E2E_SOCKET_PATH — the app via `--socket-path`, the CLI via the inherited + // NOTEY_SOCKET_PATH env — so no path reassignment is needed. const appSocket = await waitForAppSocket(); - assert(appSocket, `app IPC socket never came up; tried: ${appSocketCandidates().join(', ')}`); - process.env.NOTEY_SOCKET_PATH = appSocket; + assert(appSocket, `app IPC socket never came up at ${E2E_SOCKET_PATH}`); const res = await runCli(['add', marker]); assert(res.code === 0, `CLI exited ${res.code}; stderr: "${res.stderr.trim()}"`); addSucceeded = true; @@ -791,19 +782,18 @@ async function exportTests() { // --- Main --- async function main() { - // Detect a real notey instance on the default socket BEFORE launching the test - // app (which, under a modern WebKitWebDriver, binds that same default path). Once - // the test app is up we can't tell its socket apart from a pre-existing one. - const xdg = process.env.XDG_RUNTIME_DIR; - const defaultSock = xdg ? path.join(xdg, 'notey.sock') : null; - realInstancePresent = !!(defaultSock && fs.existsSync(defaultSock) && (await isSocketLive(defaultSock))); + if (fs.existsSync(E2E_SOCKET_PATH) && (await isSocketLive(E2E_SOCKET_PATH))) { + throw new Error( + `E2E IPC socket path is already live before launching the test app: ${E2E_SOCKET_PATH}`, + ); + } console.log('Starting tauri-driver...'); await startDriver(); try { console.log('Creating session...'); - sessionId = await createSession(APP_PATH); + sessionId = await launchSession(); console.log(`Session: ${sessionId}`); // Give the app time to fully initialize @@ -815,7 +805,7 @@ async function main() { await deleteSession(sessionId); await pause(2000); - sessionId = await createSession(APP_PATH); + sessionId = await launchSession(); await pause(3000); await windowManagementTests(); @@ -824,7 +814,7 @@ async function main() { await deleteSession(sessionId); await pause(2000); - sessionId = await createSession(APP_PATH); + sessionId = await launchSession(); await pause(3000); await trashLifecycleTests(); @@ -833,7 +823,7 @@ async function main() { await deleteSession(sessionId); await pause(2000); - sessionId = await createSession(APP_PATH); + sessionId = await launchSession(); await pause(3000); await cliLiveSyncTests(); @@ -842,7 +832,7 @@ async function main() { await deleteSession(sessionId); await pause(2000); - sessionId = await createSession(APP_PATH); + sessionId = await launchSession(); await pause(3000); await exportTests(); diff --git a/src-tauri/src/ipc/socket_server.rs b/src-tauri/src/ipc/socket_server.rs index 9441205..1f95e83 100644 --- a/src-tauri/src/ipc/socket_server.rs +++ b/src-tauri/src/ipc/socket_server.rs @@ -106,12 +106,52 @@ type WorkerRegistry = Arc>>; /// it ultimately calls [`handle_request`]. pub type Handler = Arc IpcResponse + Send + Sync>; +/// Scan an argument iterator for a `--socket-path ` (or +/// `--socket-path=`) override, returning the first non-empty value. +/// +/// Parsed permissively so the desktop app never aborts on flags injected by its +/// launcher (e.g. a WebDriver harness forwarding `tauri:options.args`): unknown +/// arguments are ignored, and a flag with a missing or empty value is treated as +/// absent (falls through to env/default) rather than an error. +fn socket_path_arg_from>(args: I) -> Option { + let mut args = args; + while let Some(arg) = args.next() { + if let Some(value) = arg.strip_prefix("--socket-path=") { + if !value.is_empty() { + return Some(PathBuf::from(value)); + } + continue; + } + if arg == "--socket-path" { + let Some(value) = args.next() else { + continue; + }; + if !value.is_empty() && !value.starts_with("--") { + return Some(PathBuf::from(value)); + } + } + } + None +} + +/// Read the `--socket-path` override from this process's actual arguments. +fn socket_path_arg() -> Option { + socket_path_arg_from(std::env::args()) +} + /// Resolve the per-user socket path. /// -/// Honors the `NOTEY_SOCKET_PATH` override first (the testability seam). On Unix, -/// prefers `$XDG_RUNTIME_DIR/notey.sock`, falling back to a user-scoped temp-dir -/// path. On Windows, returns a user-scoped namespaced pipe name. +/// Precedence: an explicit `--socket-path` CLI argument wins (the channel the +/// E2E harness routes through `tauri:options.args`, since it survives a launcher +/// that resets the environment), then the `NOTEY_SOCKET_PATH` env override (the +/// testability seam). Otherwise, on Unix, prefers `$XDG_RUNTIME_DIR/notey.sock`, +/// falling back to a user-scoped temp-dir path; on Windows, returns a +/// user-scoped namespaced pipe name. pub fn socket_path() -> PathBuf { + if let Some(custom) = socket_path_arg() { + return custom; + } + if let Ok(custom) = std::env::var("NOTEY_SOCKET_PATH") { return PathBuf::from(custom); } @@ -166,16 +206,15 @@ fn user_scope_token() -> Option { fn to_name(path: &Path) -> io::Result> { #[cfg(windows)] { - let raw = path - .file_name() - .unwrap_or(path.as_os_str()) - .to_owned(); + let raw = path.file_name().unwrap_or(path.as_os_str()).to_owned(); raw.to_ns_name::() } #[cfg(not(windows))] { - path.as_os_str().to_owned().to_fs_name::() + path.as_os_str() + .to_owned() + .to_fs_name::() } } @@ -587,3 +626,98 @@ fn reaper_loop(shutdown: Arc, registry: WorkerRegistry) { drop(reg); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Build an owned-`String` argument iterator from string literals, mirroring + /// what `std::env::args()` yields (the program name is included as argv[0]). + fn argv(items: &[&str]) -> std::vec::IntoIter { + items + .iter() + .map(|s| s.to_string()) + .collect::>() + .into_iter() + } + + #[test] + fn socket_path_arg_space_form() { + let got = socket_path_arg_from(argv(&["notey", "--socket-path", "/tmp/x.sock"])); + assert_eq!(got, Some(PathBuf::from("/tmp/x.sock"))); + } + + #[test] + fn socket_path_arg_equals_form() { + let got = socket_path_arg_from(argv(&["notey", "--socket-path=/tmp/x.sock"])); + assert_eq!(got, Some(PathBuf::from("/tmp/x.sock"))); + } + + #[test] + fn socket_path_arg_missing_value_is_absent() { + // Flag is the final token with no value following it. + let got = socket_path_arg_from(argv(&["notey", "--socket-path"])); + assert_eq!(got, None); + } + + #[test] + fn socket_path_arg_missing_value_before_unknown_flag_is_absent() { + let got = socket_path_arg_from(argv(&["notey", "--socket-path", "--unrelated"])); + assert_eq!(got, None); + } + + #[test] + fn socket_path_arg_empty_value_is_absent() { + assert_eq!( + socket_path_arg_from(argv(&["notey", "--socket-path="])), + None + ); + assert_eq!( + socket_path_arg_from(argv(&["notey", "--socket-path", ""])), + None + ); + } + + #[test] + fn socket_path_arg_skips_empty_value_and_keeps_scanning() { + let got = socket_path_arg_from(argv(&[ + "notey", + "--socket-path=", + "--socket-path", + "/tmp/x.sock", + ])); + assert_eq!(got, Some(PathBuf::from("/tmp/x.sock"))); + } + + #[test] + fn socket_path_arg_ignores_unknown_extra_args() { + let got = socket_path_arg_from(argv(&[ + "notey", + "--unrelated", + "value", + "--socket-path", + "/tmp/x.sock", + "--another", + ])); + assert_eq!(got, Some(PathBuf::from("/tmp/x.sock"))); + } + + #[test] + fn socket_path_arg_absent() { + let got = socket_path_arg_from(argv(&["notey", "--unrelated", "value"])); + assert_eq!(got, None); + } + + #[test] + fn socket_path_arg_first_occurrence_wins() { + // Defensive: a duplicated flag resolves to the first value, not the last. + let got = socket_path_arg_from(argv(&[ + "notey", + "--socket-path", + "/tmp/first.sock", + "--socket-path", + "/tmp/second.sock", + ])); + assert_eq!(got, Some(PathBuf::from("/tmp/first.sock"))); + } +} From d5a65b67397e98daa4a90fc1e2d3ca6020eed796 Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 16 Jun 2026 23:37:33 -0700 Subject: [PATCH 04/15] chore(sweep): drop consumed deferred-work pre-answers --- .automator/decisions.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.automator/decisions.json b/.automator/decisions.json index 7d6ea4f..60cfda7 100644 --- a/.automator/decisions.json +++ b/.automator/decisions.json @@ -7,14 +7,5 @@ "key": "1", "label": "Keep monitoring specta", "resolution": "" - }, - "DW-85": { - "answered_at": "2026-06-13", - "bundle_name": "", - "effect": "keep-open", - "intent": "Keep tracking until a cross-platform timeout + worker-budget design (covering the Windows named-pipe no-timeout constraint) is decided.", - "key": "1", - "label": "Defer until cross-platform design agreed", - "resolution": "" } } \ No newline at end of file From 76c67ceccccc7062e4018e159e457f8cd0d2590b Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 17 Jun 2026 00:56:55 -0700 Subject: [PATCH 05/15] story 8-1-first-run-detection-onboarding-overlay: implemented and reviewed via bmad-auto --- .../epic-8-context.md | 53 +++++ ...-first-run-detection-onboarding-overlay.md | 135 +++++++++++ .../sprint-status.yaml | 6 +- src-tauri/capabilities/default.json | 3 + .../autogenerated/complete_onboarding.toml | 11 + .../autogenerated/get_onboarding_state.toml | 11 + .../increment_onboarding_session.toml | 11 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/onboarding.rs | 34 +++ src-tauri/src/ipc/events.rs | 23 +- src-tauri/src/lib.rs | 90 ++++--- src-tauri/src/services/onboarding.rs | 118 ++++++++-- src-tauri/tests/acl_tests.rs | 3 + src-tauri/tests/onboarding_tests.rs | 42 ++-- src/App.tsx | 6 + .../editor/components/CaptureWindow.tsx | 2 + .../editor/components/StatusBar.test.tsx | 94 ++++++-- src/features/editor/components/StatusBar.tsx | 16 ++ src/features/onboarding/api.ts | 42 ++-- src/features/onboarding/bootstrap.ts | 56 +++++ .../components/OnboardingOverlay.test.tsx | 38 ++- .../components/OnboardingOverlay.tsx | 220 ++++++++++++++++-- src/features/onboarding/store.test.ts | 41 +++- src/features/onboarding/store.ts | 50 ++-- src/generated/bindings.ts | 40 ++++ 25 files changed, 992 insertions(+), 154 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/epic-8-context.md create mode 100644 _bmad-output/implementation-artifacts/spec-8-1-first-run-detection-onboarding-overlay.md create mode 100644 src-tauri/permissions/autogenerated/complete_onboarding.toml create mode 100644 src-tauri/permissions/autogenerated/get_onboarding_state.toml create mode 100644 src-tauri/permissions/autogenerated/increment_onboarding_session.toml create mode 100644 src-tauri/src/commands/onboarding.rs create mode 100644 src/features/onboarding/bootstrap.ts diff --git a/_bmad-output/implementation-artifacts/epic-8-context.md b/_bmad-output/implementation-artifacts/epic-8-context.md new file mode 100644 index 0000000..bfa0630 --- /dev/null +++ b/_bmad-output/implementation-artifacts/epic-8-context.md @@ -0,0 +1,53 @@ +# Epic 8 Context: Onboarding & Platform Integration + + + +## Goal + +New users get a fast, single-instruction first-run experience and the app behaves like a reliable native citizen on every supported OS. The epic delivers a first-launch onboarding overlay that teaches the one thing that matters (the capture shortcut), guides macOS users through the accessibility permission grant the global hotkey depends on, lets users customize the hotkey before they ever leave onboarding, configures auto-start on login so Notey is always ready, enforces per-user data/socket isolation on shared machines, and verifies that all functionality works across Windows, macOS, and Linux (X11, with a Wayland fallback path). The north star is the "60-second test": a brand-new user captures their first note within a minute, with no account, email, or feature tour. + +## Stories + +- Story 8.1: First-Run Detection & Onboarding Overlay +- Story 8.2: macOS Accessibility Permission Guidance +- Story 8.3: Hotkey Customization During Onboarding +- Story 8.4: Auto-Start on Login +- Story 8.5: Per-User Data Isolation +- Story 8.6: Cross-Platform Verification & Wayland Fallback + +## Requirements & Constraints + +- First-run is detected by the absence of an `onboarding_complete` flag in config; the overlay shows only on first launch and is never shown again once dismissed (by pressing the hotkey or Esc, both of which set the flag). +- Onboarding is a single screen, not a multi-step tour: product name, key-cap visualization of the configured hotkey, "Press it now to try", and muted skip/customize affordances. No "Step X of Y". +- macOS accessibility permission is the only expected friction point; the global hotkey will not work without it. Detect the grant state, guide the user to System Settings > Privacy & Security > Accessibility, and offer a skip-with-warning. This step is skipped entirely on non-macOS platforms. +- Hotkey customization during onboarding must capture a new key combination, detect conflicts (warn and retry on conflict), and on success persist to config and re-register immediately so the rest of onboarding reflects the new shortcut. +- Auto-start must be user-toggleable, persisted in config, and survive reboot so the app launches as a tray daemon with no user action. On macOS use the LaunchAgent mechanism. +- Per-user isolation is mandatory: data, config, logs, and IPC socket all live under the current user's platform-standard directories. The Unix socket uses a user-scoped path and 0600 (owner-only) permissions; socket-file permissions are the sole isolation mechanism for v1 (no auth token). Two users on one machine must have fully independent state and cannot reach each other's socket. +- No system-wide shared directories anywhere. No network calls of any kind. +- Cross-platform parity: every functional requirement must work on Windows 10/11, macOS 12+, and Linux X11, using platform-standard paths throughout. CI must produce artifacts for all 5 targets (Windows x64, macOS x64, macOS ARM64, Linux x64, Linux ARM64). +- Wayland: if the standard global-shortcut plugin fails to register, attempt a fallback (D-Bus portal via the `ashpd` crate); if no fallback works, notify the user that the global shortcut is unavailable on their compositor. XWayland is the baseline fallback for v1. + +## Technical Decisions + +- **Platform abstraction:** A `Platform` trait centralizes all OS-divergent behavior — `data_dir()`, `config_dir()`, `log_dir()`, `socket_path()`, `register_hotkey()`, `autostart_enable/disable()`, and accessibility checks. Concrete `#[cfg(target_os)]` implementations live in `platform/linux.rs`, `platform/macos.rs`, `platform/windows.rs`. Linux handles XDG paths, Wayland portal integration, and Unix socket paths; macOS handles accessibility permissions, macOS paths, NSPanel behavior; Windows handles named pipes, registry autostart, and Windows paths. +- **Config model:** Human-readable TOML. Onboarding state lives as an `onboarding_complete` boolean; auto-start lives under `[general] auto_start = true|false`. First-run detection keys off config/flag absence — treat it as an implementation detail, not an architectural concern. +- **Auto-start plugin:** Use `tauri-plugin-autostart` (verify the current version before pinning), registered with the macOS LaunchAgent launcher. Required capability ACL permissions: `autostart:allow-enable`, `autostart:allow-disable`, `autostart:allow-is-enabled`. +- **Window lifecycle:** The main window is created hidden at startup and toggled show/hide (never destroyed) to meet the hotkey-latency target; onboarding renders as an overlay within that single window. Views (editor, search, settings, onboarding) are managed by Zustand state, not routing. +- **Frontend structure:** Onboarding components live in `src/features/onboarding/` — `OnboardingOverlay.tsx` (welcome + hotkey setup) and `AccessibilityGuide.tsx` (macOS permission guidance). Follow project conventions: feature-based dirs, no barrel files, per-feature Zustand stores, typed IPC via tauri-specta bindings only. +- **Accessibility markup:** OnboardingOverlay uses `role="dialog"`, `aria-label="Welcome to Notey"`, `aria-modal="true"`, with focus on the instruction text and Esc to dismiss. Use Radix/shadcn primitives for focus trapping and keyboard handling rather than custom logic. +- **data-testid:** Onboarding overlay must expose `data-testid="onboarding-overlay"` and the hotkey key-cap visualization `data-testid="hotkey-display"`. +- **Quality gates:** P0 tests 100%, P1 tests ≥95%, no open high-severity bugs. Favor unit/integration tests; E2E is rationed — the first-run onboarding flow (P1-E2E-002: overlay display, hotkey-press dismissal, config persistence) is one of only 3 permitted E2E journeys across the whole product. Cross-platform work also carries visual-regression expectations on CI builds for all 3 platforms. + +## UX & Interaction Patterns + +- **First-run flow:** App detects first run -> main window opens with onboarding overlay -> (macOS only) accessibility guidance if permission not granted -> user presses the hotkey to dismiss the overlay and start their first capture; pressing Esc instead also completes onboarding (app minimizes to tray, user discovers the hotkey later via the tray menu). Onboarding never reappears. +- **Hotkey customization in onboarding:** A muted "Customize" affordance below the key caps enters a capture mode ("Press your preferred shortcut..."), updates the key caps live, validates for conflicts, and continues onboarding with the new shortcut once saved. +- **Progressive disclosure:** For the user's first 5 sessions, the StatusBar shows a "Ctrl+P for commands" hint in `var(--text-muted)`; it disappears permanently after the 5th session. (Track session count to drive this.) +- **Visual style:** Onboarding text and chrome may use the secondary proportional sans-serif stack (editor/note content stays monospace). Minimal, dense, single-screen presentation consistent with the rest of the app's tokens. + +## Cross-Story Dependencies + +- The configured hotkey shown and registered here is the same global-shortcut/window-summon machinery built in Epic 1 (Story 1.11); onboarding reuses and reconfigures it rather than reimplementing it. Hotkey conflict detection overlaps with Epic 7's hotkey configuration work — share the validation/registration path. +- Story 8.2 (macOS permission) and Story 8.3 (customization) are conditional enhancements layered onto the Story 8.1 overlay; 8.1 should expose the overlay states (`visible`, `macos-permission`, `dismissed`) those stories plug into. +- Story 8.5 (per-user paths/socket) and Story 8.6 (platform trait) are foundational to features built earlier: the data/config/log paths underpin Epic 1's database location and the socket path underpins Epic 6's CLI-to-app IPC. Coordinate so path/socket resolution has a single source of truth in the `Platform` abstraction. +- Story 8.6 is the integration capstone for the epic and the product — it verifies that FRs from all prior epics behave correctly per platform and that CI emits all 5 target artifacts. diff --git a/_bmad-output/implementation-artifacts/spec-8-1-first-run-detection-onboarding-overlay.md b/_bmad-output/implementation-artifacts/spec-8-1-first-run-detection-onboarding-overlay.md new file mode 100644 index 0000000..2428a7c --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-8-1-first-run-detection-onboarding-overlay.md @@ -0,0 +1,135 @@ +--- +title: "Story 8.1 — First-Run Detection & Onboarding Overlay" +type: "feature" +created: "2026-06-17" +status: "done" +baseline_commit: "d5a65b67397e98daa4a90fc1e2d3ca6020eed796" +context: + - "{project-root}/_bmad-output/implementation-artifacts/epic-8-context.md" + - "{project-root}/_bmad-output/project-context.md" +--- + + + +## Intent + +**Problem:** A brand-new user launches Notey to a blank editor with no indication of how to summon the app. The capture global-shortcut — the single thing a new user must learn — is invisible, and the codebase has only RED-PHASE stubs for onboarding (backend service with `todo!`, frontend store/api/component shells, and `#[ignore]`/`describe.skip` acceptance tests). + +**Approach:** Implement the green phase of the first-run onboarding slice. Persist onboarding state (`complete`, `sessions_seen`) to `onboarding.toml` in the platform config dir; on first run, show the main window with a centered accessible overlay that teaches the configured capture shortcut as key caps; dismiss-and-persist-completion on Esc or hotkey press; and show a transient "Ctrl+P for commands" status-bar hint for the user's first 5 sessions. The overlay is the **complete visual shell** for the whole epic — it renders the Customize (8.3) and macOS-accessibility (8.2) states so those stories only wire their backends — but this story implements no macOS-permission detection and no hotkey capture/conflict/re-registration logic. + +## Boundaries & Constraints + +**Always:** +- Persist onboarding state atomically to `onboarding.toml` (temp-file + rename), sibling to `config.toml`, mirroring `services::config::save`. Missing/corrupt file → default state, never an error. +- First-run detection keys solely off `onboarding.complete == false`. Onboarding is shown once and never again once `complete` is set. +- All IPC via tauri-specta generated `commands.*` bindings — never raw `invoke()`. New commands need `#[serde(rename_all = "camelCase")]` boundary types, a permission TOML, a `capabilities/default.json` entry, and an `EXPECTED_COMMANDS` entry in `acl_tests.rs`. +- Tauri command handlers stay thin & synchronous; all logic lives in `services::onboarding`. +- The overlay must expose `data-testid="onboarding-overlay"` and `data-testid="hotkey-display"`, with `role="dialog"`, `aria-modal="true"`, `aria-label="Welcome to Notey"`, focus-trapped via the existing `useFocusTrap` hook. +- The command-hint gate is exactly `sessions_seen < COMMAND_HINT_SESSION_LIMIT` (=5); `sessions_seen` is the count of *prior* sessions (incremented once per launch AFTER the overlay reads it), so the hint shows for the first 5 launches. +- Zero network access; reuse the existing global-shortcut machinery (no reimplementation). + +**Ask First:** +- Changing the frozen `OnboardingState` field names/shape, the `onboarding.toml` filename/location, or the `COMMAND_HINT_SESSION_LIMIT` value. +- Introducing any new runtime dependency. + +**Never:** +- Do NOT implement Story 8.2 backend (macOS accessibility permission detection / "open System Settings" command) or Story 8.3 backend (hotkey capture parsing, conflict detection, persistence, live re-registration). Render their overlay states as inert shell only; wire the "System Settings" button and the customize-capture to documented `TODO(Story 8.2/8.3)` handlers. +- Do NOT add a new E2E test (P1-E2E-002 is owned by a later cross-platform pass; component/store/integration layers cover this story). +- Do NOT mutate `AppConfig` / `config.toml` for onboarding state — onboarding has its own file. +- No raw `app.emit` with stringly names — the hotkey-press event must be a typed `tauri_specta::Event`. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +| --- | --- | --- | --- | +| First run | no `onboarding.toml` | `load` → `{complete:false, sessions_seen:0}`; window shown with overlay | missing/corrupt file → default state, no error | +| Mark complete + reload | `mark_complete` then fresh `is_complete` | `true`; `onboarding.toml` exists on disk | atomic temp+rename write | +| Idempotent completion | `increment_session` then `mark_complete` twice | `complete==true`, `sessions_seen==1` (not reset) | N/A | +| Session counting | `increment_session` ×2, reload | returns `1` then `2`; persists across reload | N/A | +| Command-hint gate | `sessions_seen` 0..4 vs ≥5 | `should_show_command_hint` true for `<5`, false for `≥5` (permanently) | N/A | +| Dismiss (Esc or hotkey) | overlay visible | `dismiss()` persists `complete=true`, hides overlay, never reshown on re-show | `completeOnboarding` failure logged; overlay still hides | +| Already onboarded | `complete==true` at launch | overlay stays hidden (`isVisible=false`) | N/A | + + + +## Code Map + +- `src-tauri/src/services/onboarding.rs` -- service: replace 5 `todo!` stubs (`load`, `is_complete`, `mark_complete`, `increment_session`, `should_show_command_hint`) with atomic TOML persistence; update the RED-PHASE module doc. +- `src-tauri/tests/onboarding_tests.rs` -- 5 `#[ignore]` integration tests encoding the service contract; remove the `#[ignore]` attributes to activate. +- `src-tauri/src/commands/onboarding.rs` -- NEW thin commands: `get_onboarding_state`, `complete_onboarding`, `increment_onboarding_session`, all over `State`. +- `src-tauri/src/commands/mod.rs` -- add `pub mod onboarding;`. +- `src-tauri/src/ipc/events.rs` -- add typed `HotkeyPressed` event (kebab `hotkey-pressed`), mirroring `NoteCreated`. +- `src-tauri/src/lib.rs` -- register the 3 commands + `HotkeyPressed` event in `specta_builder`; on first run (onboarding not complete) show+center+focus the main window; emit `HotkeyPressed` in the global-shortcut `Pressed` handler. +- `src-tauri/permissions/autogenerated/*.toml` + `src-tauri/capabilities/default.json` + `src-tauri/tests/acl_tests.rs` -- ACL wiring for the 3 new commands. +- `src/features/onboarding/api.ts` -- wire the 3 functions to generated bindings (replace `not implemented` throws). +- `src/features/onboarding/store.ts` -- already orchestrates correctly; no change expected beyond verification. +- `src/features/onboarding/components/OnboardingOverlay.tsx` -- implement the full overlay shell markup + Esc/hotkey-event dismissal + focus trap. +- `src/features/onboarding/bootstrap.ts` -- NEW: `initOnboarding()` — read config hotkey, `store.init(hotkey)`, then record the session once (StrictMode-guarded). +- `src/App.tsx` -- call `initOnboarding()` in the startup effect. +- `src/features/editor/components/CaptureWindow.tsx` -- render `` in the relative container. +- `src/features/editor/components/StatusBar.tsx` -- render the "Ctrl+P for commands" hint in `var(--text-muted)` when `shouldShowCommandHint()`. +- `src/features/onboarding/store.test.ts` & `OnboardingOverlay.test.tsx` -- flip `describe.skip` → `describe` to activate. + +## Tasks & Acceptance + +**Execution:** + +- [x] `src-tauri/src/services/onboarding.rs` -- Implement `load` (read+parse TOML, default on missing/corrupt), `is_complete`, `mark_complete` (set flag, atomic write, idempotent — preserve `sessions_seen`), `increment_session` (load, bump, persist, return new count), `should_show_command_hint` (`state.sessions_seen < COMMAND_HINT_SESSION_LIMIT`); refresh the module doc to drop the stub language. -- Backend persistence core. +- [x] `src-tauri/tests/onboarding_tests.rs` -- Remove all 5 `#[ignore]` attributes. -- Activate the red-phase contract. +- [x] `src-tauri/src/commands/onboarding.rs` + `commands/mod.rs` -- Add thin sync commands delegating to the service via `State`; return `OnboardingState` / `()` / `u32`. -- IPC surface. +- [x] `src-tauri/src/ipc/events.rs` -- Add `HotkeyPressed` typed event (empty/marker payload) + a name unit test (`hotkey-pressed`). -- Reliable hotkey-press signal to the webview. +- [x] `src-tauri/src/lib.rs` -- Register the 3 commands + `HotkeyPressed` in `specta_builder`; after managing `ConfigDir`, show the main window when `!onboarding::is_complete(...)`; emit `HotkeyPressed` from the shortcut `Pressed` handler. -- Wiring + first-run window show. +- [x] `src-tauri/permissions/autogenerated/{get_onboarding_state,complete_onboarding,increment_onboarding_session}.toml`, `capabilities/default.json`, `tests/acl_tests.rs` -- Add allow-permissions + `EXPECTED_COMMANDS` entries. -- Default-deny ACL. +- [x] `src/features/onboarding/api.ts` -- Replace throws with `commands.getOnboardingState()`, `commands.completeOnboarding()`, `commands.incrementOnboardingSession()` (unwrap `Result`). -- Frontend bridge. +- [x] `src/features/onboarding/components/OnboardingOverlay.tsx` -- Render centered `role="dialog"` overlay: "Your capture shortcut is" + key caps split from the hotkey string (`data-testid="hotkey-display"`) + "Press it now to try"; muted Customize control → `startCustomize()` showing "Press your preferred shortcut…"; macOS guidance block (shell) shown when `accessibilityNeeded`, with the permission text, a "System Settings" button (`TODO(8.2)`), and the skip-with-warning text; Esc keydown and `events.hotkeyPressed` listener → `dismiss()`; focus-trap via `useFocusTrap`. -- The overlay UI. +- [x] `src/features/onboarding/bootstrap.ts` + `src/App.tsx` -- `initOnboarding()`: `getConfig()` → hotkey → `store.init(hotkey)` → record session once (module-flag guarded). Call from the App startup effect. -- First-run wiring + session counting. +- [x] `src/features/editor/components/CaptureWindow.tsx` -- Mount ``. -- Show the overlay. +- [x] `src/features/editor/components/StatusBar.tsx` -- Add the `var(--text-muted)` "Ctrl+P for commands" hint gated on `useOnboardingStore(s => s.shouldShowCommandHint())`. -- Progressive disclosure. +- [x] `src/features/onboarding/store.test.ts` & `OnboardingOverlay.test.tsx` -- Activate (`describe.skip` → `describe`). -- Lock the contract. + +### Review Findings + +- [x] [Review][Patch] Completion can be lost by a startup/dismiss race [src-tauri/src/services/onboarding.rs:76] +- [x] [Review][Patch] Dismiss failure leaves overlay visible [src/features/onboarding/store.ts:72] +- [x] [Review][Patch] Onboarding can show an empty shortcut instruction [src/features/onboarding/bootstrap.ts:28] +- [x] [Review][Patch] Retired command hint can flash before onboarding init resolves [src/features/editor/components/StatusBar.tsx:18] +- [x] [Review][Patch] Persisted onboarding TOML uses `sessionsSeen` instead of frozen `sessions_seen` [src-tauri/src/services/onboarding.rs:30] +- [x] [Review][Patch] Dialog focus is not moved into the instruction/focus trap on open [src/features/onboarding/components/OnboardingOverlay.tsx:23] + +#### Review Ledger (2026-06-17) + +- patch: Completion can be lost by a startup/dismiss race [src-tauri/src/services/onboarding.rs:76] -- concurrent session increment and completion both read-modify-write `onboarding.toml`, so a stale increment can clear completion. +- patch: Dismiss failure leaves overlay visible [src/features/onboarding/store.ts:72] -- `dismiss()` hides only after `completeOnboarding()` succeeds, contrary to the required failure behavior. +- patch: Onboarding can show an empty shortcut instruction [src/features/onboarding/bootstrap.ts:28] -- config load failure leaves `hotkey` empty while the overlay still renders the try prompt. +- patch: Retired command hint can flash before onboarding init resolves [src/features/editor/components/StatusBar.tsx:18] -- the store defaults `sessionsSeen` to 0, so already-retired users can briefly see the hint before persisted state loads. +- patch: Persisted onboarding TOML uses `sessionsSeen` instead of frozen `sessions_seen` [src-tauri/src/services/onboarding.rs:30] -- the IPC struct's camelCase serde naming is reused for TOML persistence. +- patch: Dialog focus is not moved into the instruction/focus trap on open [src/features/onboarding/components/OnboardingOverlay.tsx:23] -- the current hook traps tabbing only and the component explicitly leaves focus on the editor. + +**Acceptance Criteria:** + +- Given a fresh config dir, when the app starts, then the main window is shown with the onboarding overlay rendered (dialog markup + key caps + try prompt) and `onboarding.toml` reports not-complete. +- Given the overlay is visible, when the user presses Esc or the configured hotkey, then `onboarding_complete` is persisted, the overlay hides, and it never reappears (including after a window hide/show cycle). +- Given onboarding is already complete, when the app starts, then no overlay is shown. +- Given the user is within their first 5 sessions, when the StatusBar renders, then the "Ctrl+P for commands" hint appears in `var(--text-muted)`; from the 6th session it is gone permanently. +- Given the build, when bindings regenerate and the suites run, then `cargo test`, `vitest`, clippy, and tsc all pass with the previously-ignored/skipped onboarding tests now active. + +## Design Notes + +**Session-count off-by-one:** `store.init` reads the *prior* `sessions_seen` and seeds the hint; `bootstrap` then increments for the next launch. So launch 1 reads 0 (shows), … launch 5 reads 4 (shows), launch 6 reads 5 (hidden) — exactly "first 5 sessions." Guard the increment with a module-level boolean so React StrictMode's double-mount doesn't double-count. + +**Hotkey-press dismissal:** the OS global shortcut hides the window via the existing `toggle_main_window`, but the React tree is *not* reloaded, so the frontend must learn of the press to flip `isVisible` and persist completion. The shortcut handler emits the typed `HotkeyPressed` event; the visible overlay's `events.hotkeyPressed.listen(...)` calls `dismiss()`. Relying on a webview keydown for a registered global shortcut is unreliable cross-platform — the event is the source of truth. + +**Key-cap split (golden):** +```tsx +{hotkey.split('+').map((k) => {k.trim()})} +``` + +## Verification + +**Commands:** + +- `cd src-tauri && cargo test --test onboarding_tests` -- expected: all 5 (de-ignored) pass. +- `cd src-tauri && cargo test` -- expected: full backend suite green (incl. `acl_tests`, `export_bindings` regen of `bindings.ts`). +- `cd src-tauri && cargo clippy --all-targets -- -D warnings` -- expected: no warnings. +- `npm test -- src/features/onboarding src/features/editor/components/StatusBar.test` -- expected: activated onboarding store/overlay tests + StatusBar pass. +- `npm run build` -- expected: `tsc` + vite build succeed (bindings include the 3 new commands + `hotkeyPressed`). diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index c3d6237..189d285 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -40,7 +40,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: 2026-04-03 -last_updated: 2026-06-16 # epic-6-retro item-1 (E2E) note corrected + local live-sync E2E fixed (DW-95; 16/16 local); item 5 (DW-85) → done; items 1+2 previously → done +last_updated: 2026-06-17 # story 8-1 → done (epic-8 in-progress) # Note: epic-2-retrospective rewritten fresh on 2026-04-04 project: notey project_key: NOKEY @@ -132,8 +132,8 @@ development_status: epic-7-retrospective: optional # Epic 8: Onboarding & Platform Integration - epic-8: backlog - 8-1-first-run-detection-onboarding-overlay: backlog + epic-8: in-progress + 8-1-first-run-detection-onboarding-overlay: done 8-2-macos-accessibility-permission-guidance: backlog 8-3-hotkey-customization-during-onboarding: backlog 8-4-auto-start-on-login: backlog diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8f1ca32..7a7754a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -21,6 +21,9 @@ "allow-list-notes", "allow-get-config", "allow-update-config", + "allow-get-onboarding-state", + "allow-complete-onboarding", + "allow-increment-onboarding-session", "allow-dismiss-window", "allow-apply-layout-mode", "allow-create-workspace", diff --git a/src-tauri/permissions/autogenerated/complete_onboarding.toml b/src-tauri/permissions/autogenerated/complete_onboarding.toml new file mode 100644 index 0000000..47e92b1 --- /dev/null +++ b/src-tauri/permissions/autogenerated/complete_onboarding.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-complete-onboarding" +description = "Enables the complete_onboarding command without any pre-configured scope." +commands.allow = ["complete_onboarding"] + +[[permission]] +identifier = "deny-complete-onboarding" +description = "Denies the complete_onboarding command without any pre-configured scope." +commands.deny = ["complete_onboarding"] diff --git a/src-tauri/permissions/autogenerated/get_onboarding_state.toml b/src-tauri/permissions/autogenerated/get_onboarding_state.toml new file mode 100644 index 0000000..72d5f1f --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_onboarding_state.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-onboarding-state" +description = "Enables the get_onboarding_state command without any pre-configured scope." +commands.allow = ["get_onboarding_state"] + +[[permission]] +identifier = "deny-get-onboarding-state" +description = "Denies the get_onboarding_state command without any pre-configured scope." +commands.deny = ["get_onboarding_state"] diff --git a/src-tauri/permissions/autogenerated/increment_onboarding_session.toml b/src-tauri/permissions/autogenerated/increment_onboarding_session.toml new file mode 100644 index 0000000..25bb122 --- /dev/null +++ b/src-tauri/permissions/autogenerated/increment_onboarding_session.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-increment-onboarding-session" +description = "Enables the increment_onboarding_session command without any pre-configured scope." +commands.allow = ["increment_onboarding_session"] + +[[permission]] +identifier = "deny-increment-onboarding-session" +description = "Denies the increment_onboarding_session command without any pre-configured scope." +commands.deny = ["increment_onboarding_session"] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 17a1358..f53d157 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod config; pub mod export; pub mod notes; +pub mod onboarding; pub mod search; pub mod system; pub mod window; diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs new file mode 100644 index 0000000..c314b07 --- /dev/null +++ b/src-tauri/src/commands/onboarding.rs @@ -0,0 +1,34 @@ +//! Thin Tauri command handlers for first-run onboarding state. All logic lives +//! in [`crate::services::onboarding`]; these handlers only resolve the managed +//! config directory and delegate. Synchronous (filesystem-only work) per the +//! project's command convention. + +use tauri::State; + +use crate::errors::NoteyError; +use crate::services::onboarding::{self, OnboardingState}; + +use super::config::ConfigDir; + +/// Returns the persisted onboarding state (completion flag + session count). +#[tauri::command] +#[specta::specta] +pub fn get_onboarding_state( + config_dir: State<'_, ConfigDir>, +) -> Result { + onboarding::load(&config_dir.0) +} + +/// Marks onboarding complete and persists it. Idempotent. +#[tauri::command] +#[specta::specta] +pub fn complete_onboarding(config_dir: State<'_, ConfigDir>) -> Result<(), NoteyError> { + onboarding::mark_complete(&config_dir.0) +} + +/// Increments the persisted session counter and returns the new count. +#[tauri::command] +#[specta::specta] +pub fn increment_onboarding_session(config_dir: State<'_, ConfigDir>) -> Result { + onboarding::increment_session(&config_dir.0) +} diff --git a/src-tauri/src/ipc/events.rs b/src-tauri/src/ipc/events.rs index c00932c..761326a 100644 --- a/src-tauri/src/ipc/events.rs +++ b/src-tauri/src/ipc/events.rs @@ -47,10 +47,28 @@ impl NoteCreated { } } +/// The `hotkey-pressed` event — emitted whenever the registered global capture +/// shortcut fires. +/// +/// First-run onboarding (Story 8.1) dismisses its overlay when the user presses +/// the hotkey, but the OS-level shortcut hides the window without reloading the +/// webview, and a registered global shortcut does not reliably deliver a keydown +/// to the focused page across platforms. The shortcut handler emits this typed +/// event; the visible overlay listens via the generated `events.hotkeyPressed` +/// binding and completes onboarding. It is a marker event with no payload. +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +#[serde(rename_all = "camelCase")] +pub struct HotkeyPressed; + #[cfg(test)] mod tests { use super::*; + #[test] + fn hotkey_pressed_event_name_is_kebab_case() { + assert_eq!(HotkeyPressed::NAME, "hotkey-pressed"); + } + #[test] fn note_created_serializes_camel_case_shape() { let value = serde_json::to_value(NoteCreated::now(42)).expect("serialize"); @@ -58,7 +76,10 @@ mod tests { assert_eq!(value["data"]["noteId"].as_i64(), Some(42)); // Timestamp is present and non-empty (an RFC 3339 string). let ts = value["timestamp"].as_str().expect("timestamp string"); - assert!(!ts.is_empty(), "timestamp must be a non-empty ISO 8601 string"); + assert!( + !ts.is_empty(), + "timestamp must be a non-empty ISO 8601 string" + ); // No snake_case leakage. assert!(value["data"].get("note_id").is_none()); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index acc861d..038c452 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,32 +18,38 @@ use crate::commands::config::ConfigDir; fn specta_builder() -> tauri_specta::Builder { tauri_specta::Builder::::new() - .events(collect_events![ipc::events::NoteCreated]) + .events(collect_events![ + ipc::events::NoteCreated, + ipc::events::HotkeyPressed + ]) .commands(collect_commands![ - commands::notes::create_note, - commands::notes::get_note, - commands::notes::update_note, - commands::notes::trash_note, - commands::notes::restore_note, - commands::notes::list_trashed_notes, - commands::notes::delete_note_permanently, - commands::notes::list_notes, - commands::notes::reassign_note_workspace, - commands::notes::rebuild_fts_index, - commands::config::get_config, - commands::config::update_config, - commands::window::dismiss_window, - commands::window::apply_layout_mode, - commands::workspace::create_workspace, - commands::workspace::list_workspaces, - commands::workspace::get_workspace, - commands::workspace::detect_workspace, - commands::workspace::resolve_workspace, - commands::system::get_current_dir, - commands::search::search_notes, - commands::export::export_markdown, - commands::export::export_json, - ]) + commands::notes::create_note, + commands::notes::get_note, + commands::notes::update_note, + commands::notes::trash_note, + commands::notes::restore_note, + commands::notes::list_trashed_notes, + commands::notes::delete_note_permanently, + commands::notes::list_notes, + commands::notes::reassign_note_workspace, + commands::notes::rebuild_fts_index, + commands::config::get_config, + commands::config::update_config, + commands::onboarding::get_onboarding_state, + commands::onboarding::complete_onboarding, + commands::onboarding::increment_onboarding_session, + commands::window::dismiss_window, + commands::window::apply_layout_mode, + commands::workspace::create_workspace, + commands::workspace::list_workspaces, + commands::workspace::get_workspace, + commands::workspace::detect_workspace, + commands::workspace::resolve_workspace, + commands::system::get_current_dir, + commands::search::search_notes, + commands::export::export_markdown, + commands::export::export_json, + ]) } /// Toggles the main window: shows + centers + focuses if hidden, hides if visible. @@ -186,6 +192,20 @@ pub fn run() { } } + // --- First-run onboarding: reveal the window so the overlay shows --- + // The main window is created hidden and is normally summoned via the + // global shortcut. On first run (onboarding not yet completed) we show + // it at startup so the OnboardingOverlay greets the user. A read failure + // is non-fatal — fall back to the default hidden-until-summoned behavior. + let first_run = !services::onboarding::is_complete(&config_dir).unwrap_or(false); + if first_run { + if let Some(window) = app.get_webview_window("main") { + let _ = window.center(); + let _ = window.show(); + let _ = window.set_focus(); + } + } + app.manage(Mutex::new(config)); app.manage(ConfigDir(config_dir)); @@ -200,8 +220,7 @@ pub fn run() { "Warning: invalid shortcut '{}', falling back to {}", shortcut_str, default_shortcut ); - parse_shortcut(&default_shortcut) - .expect("platform default shortcut must parse") + parse_shortcut(&default_shortcut).expect("platform default shortcut must parse") }); let app_handle = app.handle().clone(); @@ -213,6 +232,10 @@ pub fn run() { // value when the hotkey is re-registered via update_config. if event.state() == ShortcutState::Pressed { toggle_main_window(&app_handle); + // Notify the webview so the first-run onboarding overlay + // can complete on hotkey press (Story 8.1). Best-effort: + // a failed emit must not affect window toggling. + let _ = ipc::events::HotkeyPressed.emit(&app_handle); } }) .build(), @@ -285,8 +308,7 @@ pub fn run() { std::sync::Arc::new(move |raw: &[u8]| { let response = { let state = app_handle.state::>(); - let conn = - state.lock().unwrap_or_else(commands::recover_poisoned_db); + let conn = state.lock().unwrap_or_else(commands::recover_poisoned_db); ipc::protocol::handle_request(&conn, raw) // conn guard dropped here, before emitting. }; @@ -349,7 +371,15 @@ mod tests { #[test] fn parse_shortcut_accepts_modifier_aliases() { // Each spelling of a modifier resolves; same combo parses every way. - for s in ["Ctrl+N", "Control+N", "Cmd+N", "Command+N", "Super+N", "Meta+N", "Alt+N"] { + for s in [ + "Ctrl+N", + "Control+N", + "Cmd+N", + "Command+N", + "Super+N", + "Meta+N", + "Alt+N", + ] { assert!(parse_shortcut(s).is_some(), "expected '{s}' to parse"); } } diff --git a/src-tauri/src/services/onboarding.rs b/src-tauri/src/services/onboarding.rs index ed14948..b57dc31 100644 --- a/src-tauri/src/services/onboarding.rs +++ b/src-tauri/src/services/onboarding.rs @@ -1,20 +1,17 @@ //! First-run onboarding state: persistence of the "onboarding complete" flag and //! the early-session counter that drives progressive command-palette hints. //! -//! **RED-PHASE STUB (Epic 8 — Stories 8.1 & 8.3).** Every function below is an -//! unimplemented scaffold (`todo!`). The acceptance tests in -//! `tests/onboarding_tests.rs` are written against this contract but marked -//! `#[ignore = "red-phase: Story 8.x"]` so the suite stays green until a developer -//! implements a function and activates its tests (TDD red → green). +//! State is serialized to `onboarding.toml` in the platform config dir (sibling +//! to `config.toml`; see [`crate::services::config::config_dir`]) using the same +//! atomic temp-file + rename write the config service uses. A missing or corrupt +//! file resolves to the default state — onboarding never fails the app. //! -//! ## Green-phase wiring (do this when implementing) -//! - Persist `OnboardingState` to `onboarding.toml` in the platform config dir -//! (sibling to `config.toml`; see [`crate::services::config::config_dir`]). -//! - Expose `get_onboarding_state` / `complete_onboarding` Tauri commands, register -//! them in `lib.rs::specta_builder`, add their permission TOMLs + capabilities, -//! and extend `EXPECTED_COMMANDS` in `tests/acl_tests.rs`. +//! The `get_onboarding_state` / `complete_onboarding` / `increment_onboarding_session` +//! Tauri commands (in `commands::onboarding`) expose this service to the frontend. +use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Mutex; use serde::{Deserialize, Serialize}; use specta::Type; @@ -25,6 +22,8 @@ use crate::errors::NoteyError; /// is shown before it disappears permanently (Story 8.1 progressive disclosure). pub const COMMAND_HINT_SESSION_LIMIT: u32 = 5; +static ONBOARDING_WRITE_LOCK: Mutex<()> = Mutex::new(()); + /// Persisted first-run onboarding state. /// /// Serialized to `onboarding.toml`. `complete` gates the one-time onboarding @@ -43,37 +42,114 @@ pub struct OnboardingState { pub sessions_seen: u32, } +#[derive(Debug, Default, Serialize, Deserialize)] +struct PersistedOnboardingState { + #[serde(default)] + complete: bool, + #[serde(default, alias = "sessionsSeen")] + sessions_seen: u32, +} + +impl From for OnboardingState { + fn from(state: PersistedOnboardingState) -> Self { + Self { + complete: state.complete, + sessions_seen: state.sessions_seen, + } + } +} + +impl From<&OnboardingState> for PersistedOnboardingState { + fn from(state: &OnboardingState) -> Self { + Self { + complete: state.complete, + sessions_seen: state.sessions_seen, + } + } +} + /// Full path to `onboarding.toml` within the given config directory. pub fn state_file_path(config_dir: &Path) -> PathBuf { config_dir.join("onboarding.toml") } /// Load onboarding state from disk, returning the default (not-complete, zero -/// sessions) when the file is missing or unreadable. +/// sessions) when the file is missing or corrupt — onboarding state must never +/// fail the app. pub fn load(config_dir: &Path) -> Result { - todo!("Story 8.1: read onboarding.toml under {config_dir:?}, default on missing/corrupt") + let path = state_file_path(config_dir); + if !path.exists() { + return Ok(OnboardingState::default()); + } + match fs::read_to_string(&path) { + Ok(contents) => Ok( + toml::from_str::(&contents).map_or_else( + |e| { + eprintln!( + "Warning: onboarding.toml is corrupt ({e}), falling back to defaults" + ); + OnboardingState::default() + }, + OnboardingState::from, + ), + ), + Err(e) => { + eprintln!("Warning: failed to read onboarding.toml ({e}), falling back to defaults"); + Ok(OnboardingState::default()) + } + } } /// Returns whether onboarding has been completed. pub fn is_complete(config_dir: &Path) -> Result { - todo!("Story 8.1: return load(config_dir)?.complete for {config_dir:?}") + Ok(load(config_dir)?.complete) } -/// Mark onboarding as complete and persist atomically. Idempotent. +/// Mark onboarding as complete and persist atomically. Idempotent — preserves the +/// existing session counter. pub fn mark_complete(config_dir: &Path) -> Result<(), NoteyError> { - todo!("Story 8.1: set complete=true and atomically write onboarding.toml under {config_dir:?}") + let _guard = ONBOARDING_WRITE_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut state = load(config_dir)?; + state.complete = true; + save(config_dir, &state) } /// Increment the session counter, persist, and return the new count. pub fn increment_session(config_dir: &Path) -> Result { - todo!("Story 8.1: load, bump sessions_seen, persist, return new count for {config_dir:?}") + let _guard = ONBOARDING_WRITE_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut state = load(config_dir)?; + state.sessions_seen = state.sessions_seen.saturating_add(1); + save(config_dir, &state)?; + Ok(state.sessions_seen) } /// Whether the early "Ctrl+P for commands" status-bar hint should still be shown /// for the given state (i.e. within the first [`COMMAND_HINT_SESSION_LIMIT`] -/// sessions and onboarding done). +/// sessions). pub fn should_show_command_hint(state: &OnboardingState) -> bool { - todo!( - "Story 8.1: true when sessions_seen < COMMAND_HINT_SESSION_LIMIT (state: {state:?})" - ) + state.sessions_seen < COMMAND_HINT_SESSION_LIMIT +} + +/// Writes onboarding state to `onboarding.toml` using an atomic temp-file + rename, +/// mirroring [`crate::services::config::save`]. Cleans up the temp file on failure. +fn save(config_dir: &Path, state: &OnboardingState) -> Result<(), NoteyError> { + fs::create_dir_all(config_dir)?; + let path = state_file_path(config_dir); + let tmp_path = config_dir.join("onboarding.toml.tmp"); + let persisted = PersistedOnboardingState::from(state); + let contents = toml::to_string_pretty(&persisted) + .map_err(|e| NoteyError::Config(format!("Failed to serialize onboarding state: {e}")))?; + if let Err(e) = fs::write(&tmp_path, &contents) { + let _ = fs::remove_file(&tmp_path); + return Err(e.into()); + } + if let Err(e) = fs::rename(&tmp_path, &path) { + let _ = fs::remove_file(&tmp_path); + return Err(e.into()); + } + Ok(()) } diff --git a/src-tauri/tests/acl_tests.rs b/src-tauri/tests/acl_tests.rs index 1ac72a6..152f4a5 100644 --- a/src-tauri/tests/acl_tests.rs +++ b/src-tauri/tests/acl_tests.rs @@ -14,6 +14,9 @@ const EXPECTED_COMMANDS: &[&str] = &[ "allow-list-notes", "allow-get-config", "allow-update-config", + "allow-get-onboarding-state", + "allow-complete-onboarding", + "allow-increment-onboarding-session", "allow-dismiss-window", "allow-apply-layout-mode", "allow-create-workspace", diff --git a/src-tauri/tests/onboarding_tests.rs b/src-tauri/tests/onboarding_tests.rs index dca4cc5..3427b3d 100644 --- a/src-tauri/tests/onboarding_tests.rs +++ b/src-tauri/tests/onboarding_tests.rs @@ -1,24 +1,16 @@ //! ATDD red-phase acceptance tests — Story 8.1 (First-Run Detection & Onboarding //! Overlay), backend persistence slice. //! -//! Every test is `#[ignore = "red-phase: Story 8.1"]`: the assertions encode the -//! *expected* behavior of [`tauri_app_lib::services::onboarding`], whose functions -//! are unimplemented (`todo!`) scaffolds. The suite stays green until a developer -//! implements a function and removes the matching `#[ignore]` (TDD red → green). -//! -//! Activate one test at a time: -//! cargo test --test onboarding_tests -- --ignored +//! These tests lock the persisted onboarding contract now that the Story 8.1 +//! green phase is active. use tempfile::TempDir; -use tauri_app_lib::services::onboarding::{ - self, OnboardingState, COMMAND_HINT_SESSION_LIMIT, -}; +use tauri_app_lib::services::onboarding::{self, OnboardingState, COMMAND_HINT_SESSION_LIMIT}; /// AC: "the application starts for the first time (no `onboarding_complete` flag)". /// A fresh config dir yields the default state — not complete, zero sessions. #[test] -#[ignore = "red-phase: Story 8.1"] fn load_returns_default_state_on_first_run() { let tmp = TempDir::new().unwrap(); let state = onboarding::load(tmp.path()).expect("load must succeed on a fresh dir"); @@ -35,7 +27,6 @@ fn load_returns_default_state_on_first_run() { /// `onboarding_complete = true` is set in config". After marking complete, /// `is_complete` reports true on a fresh read from disk. #[test] -#[ignore = "red-phase: Story 8.1"] fn mark_complete_persists_and_is_observed_on_reload() { let tmp = TempDir::new().unwrap(); assert!(!onboarding::is_complete(tmp.path()).unwrap()); @@ -56,7 +47,6 @@ fn mark_complete_persists_and_is_observed_on_reload() { /// AC: "the overlay is never shown again". mark_complete is idempotent — calling /// it twice keeps `complete = true` and does not reset the session counter. #[test] -#[ignore = "red-phase: Story 8.1"] fn mark_complete_is_idempotent() { let tmp = TempDir::new().unwrap(); onboarding::increment_session(tmp.path()).unwrap(); @@ -65,13 +55,19 @@ fn mark_complete_is_idempotent() { let state = onboarding::load(tmp.path()).unwrap(); assert!(state.complete); - assert_eq!(state.sessions_seen, 1, "idempotent completion must not reset sessions"); + assert_eq!( + state.sessions_seen, 1, + "idempotent completion must not reset sessions" + ); + + let contents = std::fs::read_to_string(onboarding::state_file_path(tmp.path())).unwrap(); + assert!(contents.contains("sessions_seen = 1")); + assert!(!contents.contains("sessionsSeen")); } /// AC: "the user is within their first 5 sessions". The session counter /// increments and persists across reloads. #[test] -#[ignore = "red-phase: Story 8.1"] fn increment_session_counts_and_persists() { let tmp = TempDir::new().unwrap(); assert_eq!(onboarding::increment_session(tmp.path()).unwrap(), 1); @@ -86,11 +82,19 @@ fn increment_session_counts_and_persists() { /// AC: "after 5 sessions, the hint disappears permanently". The hint shows for the /// first `COMMAND_HINT_SESSION_LIMIT` sessions, then never again. #[test] -#[ignore = "red-phase: Story 8.1"] fn command_hint_retires_after_session_limit() { - let within = OnboardingState { complete: true, sessions_seen: 0 }; - let last = OnboardingState { complete: true, sessions_seen: COMMAND_HINT_SESSION_LIMIT - 1 }; - let past = OnboardingState { complete: true, sessions_seen: COMMAND_HINT_SESSION_LIMIT }; + let within = OnboardingState { + complete: true, + sessions_seen: 0, + }; + let last = OnboardingState { + complete: true, + sessions_seen: COMMAND_HINT_SESSION_LIMIT - 1, + }; + let past = OnboardingState { + complete: true, + sessions_seen: COMMAND_HINT_SESSION_LIMIT, + }; assert!(onboarding::should_show_command_hint(&within)); assert!(onboarding::should_show_command_hint(&last)); diff --git a/src/App.tsx b/src/App.tsx index dde3ad6..6030c15 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { Toaster } from './features/toast/components/Toaster'; import { useWorkspaceStore } from './features/workspace/store'; import { restoreSession, startSessionAutoSave } from './features/session/persistence'; import { startNoteCreatedSync } from './features/note-list/realtimeSync'; +import { initOnboarding } from './features/onboarding/bootstrap'; /** Application root — renders the main CaptureWindow and the toast overlay. */ function App() { @@ -13,6 +14,11 @@ function App() { let stopNoteSync: (() => void) | null = null; const noteSyncReady = startNoteCreatedSync(); + // First-run onboarding: load persisted state (shows the overlay on first run) + // and record this session for the progressive command hint. Independent of + // the workspace/session chain below — failures must not block startup. + void initOnboarding(); + // Attempt workspace init first, then restore the saved session. Auto-save // still starts if either step fails so session persistence keeps working. void (async () => { diff --git a/src/features/editor/components/CaptureWindow.tsx b/src/features/editor/components/CaptureWindow.tsx index 4062c0e..05c153a 100644 --- a/src/features/editor/components/CaptureWindow.tsx +++ b/src/features/editor/components/CaptureWindow.tsx @@ -13,6 +13,7 @@ import { TrashPanel } from '../../trash/components/TrashPanel'; import { useTrashStore } from '../../trash/store'; import { SettingsPanel } from '../../settings/components/SettingsPanel'; import { useSettingsStore } from '../../settings/store'; +import { OnboardingOverlay } from '../../onboarding/components/OnboardingOverlay'; import { matchesShortcut } from '../../settings/shortcuts'; import { createNewNote, toggleTheme } from '../../command-palette/actions'; @@ -136,6 +137,7 @@ export function CaptureWindow() { {isNoteListOpen && } {isTrashOpen && } {isSettingsOpen && } + diff --git a/src/features/editor/components/StatusBar.test.tsx b/src/features/editor/components/StatusBar.test.tsx index 8109370..a41c116 100644 --- a/src/features/editor/components/StatusBar.test.tsx +++ b/src/features/editor/components/StatusBar.test.tsx @@ -1,25 +1,49 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import { useWorkspaceStore } from '../../workspace/store'; +import { useOnboardingStore } from '../../onboarding/store'; import { StatusBar } from './StatusBar'; const MOCK_WORKSPACES = [ - { id: 1, name: 'my-project', path: '/p', createdAt: '2026-01-01T00:00:00+00:00', noteCount: 4 }, - { id: 2, name: 'other', path: '/o', createdAt: '2026-01-02T00:00:00+00:00', noteCount: 6 }, + { + id: 1, + name: 'my-project', + path: '/p', + createdAt: '2026-01-01T00:00:00+00:00', + noteCount: 4, + }, + { + id: 2, + name: 'other', + path: '/o', + createdAt: '2026-01-02T00:00:00+00:00', + noteCount: 6, + }, ]; describe('StatusBar', () => { + beforeEach(() => { + useOnboardingStore.getState().reset(); + }); it('renders "No workspace" when no workspace is active', () => { render(); - expect(screen.getByTestId('workspace-name')).toHaveTextContent('No workspace'); + expect(screen.getByTestId('workspace-name')).toHaveTextContent( + 'No workspace', + ); }); it('renders workspace name with note count in "[name] \u00b7 [N] notes" format', () => { const mockNotes = Array.from({ length: 4 }, (_, i) => ({ - id: i + 1, title: `Note ${i}`, content: '', format: 'markdown', - workspaceId: 1, createdAt: '2026-01-01T00:00:00+00:00', - updatedAt: '2026-01-01T00:00:00+00:00', deletedAt: null, isTrashed: false, + id: i + 1, + title: `Note ${i}`, + content: '', + format: 'markdown', + workspaceId: 1, + createdAt: '2026-01-01T00:00:00+00:00', + updatedAt: '2026-01-01T00:00:00+00:00', + deletedAt: null, + isTrashed: false, })); useWorkspaceStore.setState({ activeWorkspaceId: 1, @@ -28,14 +52,22 @@ describe('StatusBar', () => { filteredNotes: mockNotes, }); render(); - expect(screen.getByTestId('workspace-name')).toHaveTextContent('my-project \u00b7 4 notes'); + expect(screen.getByTestId('workspace-name')).toHaveTextContent( + 'my-project \u00b7 4 notes', + ); }); it('renders "All Workspaces" with total note count when isAllWorkspaces is true', () => { const mockNotes = Array.from({ length: 10 }, (_, i) => ({ - id: i + 1, title: `Note ${i}`, content: '', format: 'markdown', - workspaceId: i < 4 ? 1 : 2, createdAt: '2026-01-01T00:00:00+00:00', - updatedAt: '2026-01-01T00:00:00+00:00', deletedAt: null, isTrashed: false, + id: i + 1, + title: `Note ${i}`, + content: '', + format: 'markdown', + workspaceId: i < 4 ? 1 : 2, + createdAt: '2026-01-01T00:00:00+00:00', + updatedAt: '2026-01-01T00:00:00+00:00', + deletedAt: null, + isTrashed: false, })); useWorkspaceStore.setState({ isAllWorkspaces: true, @@ -45,14 +77,22 @@ describe('StatusBar', () => { filteredNotes: mockNotes, }); render(); - expect(screen.getByTestId('workspace-name')).toHaveTextContent('All Workspaces \u00b7 10 notes'); + expect(screen.getByTestId('workspace-name')).toHaveTextContent( + 'All Workspaces \u00b7 10 notes', + ); }); it('renders singular "1 note" for single note count', () => { const singleNote = { - id: 1, title: 'Solo', content: '', format: 'markdown', - workspaceId: 1, createdAt: '2026-01-01T00:00:00+00:00', - updatedAt: '2026-01-01T00:00:00+00:00', deletedAt: null, isTrashed: false, + id: 1, + title: 'Solo', + content: '', + format: 'markdown', + workspaceId: 1, + createdAt: '2026-01-01T00:00:00+00:00', + updatedAt: '2026-01-01T00:00:00+00:00', + deletedAt: null, + isTrashed: false, }; useWorkspaceStore.setState({ activeWorkspaceId: 1, @@ -61,9 +101,13 @@ describe('StatusBar', () => { filteredNotes: [singleNote], }); render(); - expect(screen.getByTestId('workspace-name')).toHaveTextContent('my-project \u00b7 1 note'); + expect(screen.getByTestId('workspace-name')).toHaveTextContent( + 'my-project \u00b7 1 note', + ); // Verify it's NOT "1 notes" (plural) - expect(screen.getByTestId('workspace-name').textContent).not.toContain('1 notes'); + expect(screen.getByTestId('workspace-name').textContent).not.toContain( + '1 notes', + ); }); it('workspace trigger is clickable (button element)', () => { @@ -94,4 +138,20 @@ describe('StatusBar', () => { expect(toggle.style.display).toBe('flex'); expect(toggle.parentElement?.style.gap).toBe(''); }); + + it('renders the command hint only after onboarding state loads within the session limit', () => { + useOnboardingStore.setState({ initialized: false, sessionsSeen: 0 }); + const { rerender } = render(); + expect(screen.queryByTestId('command-hint')).not.toBeInTheDocument(); + + useOnboardingStore.setState({ initialized: true, sessionsSeen: 4 }); + rerender(); + expect(screen.getByTestId('command-hint')).toHaveTextContent( + 'Ctrl+P for commands', + ); + + useOnboardingStore.setState({ sessionsSeen: 5 }); + rerender(); + expect(screen.queryByTestId('command-hint')).not.toBeInTheDocument(); + }); }); diff --git a/src/features/editor/components/StatusBar.tsx b/src/features/editor/components/StatusBar.tsx index e5d84cf..689d0b3 100644 --- a/src/features/editor/components/StatusBar.tsx +++ b/src/features/editor/components/StatusBar.tsx @@ -2,6 +2,7 @@ import { commands } from '../../../generated/bindings'; import { useEditorStore } from '../store'; import { SaveIndicator } from './SaveIndicator'; import { WorkspaceSelector } from '../../workspace/components/WorkspaceSelector'; +import { useOnboardingStore } from '../../onboarding/store'; import type { NoteFormat } from '../store'; /** @@ -12,6 +13,9 @@ export function StatusBar() { const format = useEditorStore((s) => s.format); const activeNoteId = useEditorStore((s) => s.activeNoteId); const setFormat = useEditorStore((s) => s.setFormat); + // Progressive disclosure (Story 8.1): show a "Ctrl+P for commands" hint during + // the user's first 5 sessions, then retire it permanently. + const showCommandHint = useOnboardingStore((s) => s.shouldShowCommandHint()); async function handleFormatToggle() { const newFormat: NoteFormat = format === 'markdown' ? 'plaintext' : 'markdown'; @@ -43,6 +47,18 @@ export function StatusBar() { >
+ {showCommandHint && ( + + Ctrl+P for commands + + )} +

+ You can skip this, but the shortcut may not work without this + permission. +

+
+ ) : customizing ? ( +

+ Press your preferred shortcut… +

+ ) : ( +
+

+ Your capture shortcut is +

+
+ {keyCaps.map((cap) => ( + + {cap} + + ))} +
+

+ Press it now to try +

+ +
+ )} + + + ); } + +const onboardingButtonStyle = { + cursor: 'pointer', + color: 'var(--text-secondary)', + background: 'var(--bg-primary)', + border: '1px solid var(--border-default)', + borderRadius: '4px', + padding: '6px 12px', + fontSize: '13px', +} as const; diff --git a/src/features/onboarding/store.test.ts b/src/features/onboarding/store.test.ts index 880670d..8027061 100644 --- a/src/features/onboarding/store.test.ts +++ b/src/features/onboarding/store.test.ts @@ -9,14 +9,17 @@ import * as api from './api'; * stubbed `./api`, which throws until the green phase). Activate by switching * `describe.skip` → `describe` once `./api` is wired to the generated bindings. */ -describe.skip('useOnboardingStore (red-phase: Stories 8.1, 8.3)', () => { +describe('useOnboardingStore (red-phase: Stories 8.1, 8.3)', () => { beforeEach(() => { useOnboardingStore.getState().reset(); vi.restoreAllMocks(); }); it('shows the overlay on first run and seeds the hotkey (8.1)', async () => { - vi.spyOn(api, 'loadOnboardingState').mockResolvedValue({ complete: false, sessionsSeen: 0 }); + vi.spyOn(api, 'loadOnboardingState').mockResolvedValue({ + complete: false, + sessionsSeen: 0, + }); await useOnboardingStore.getState().init('Ctrl+Shift+N'); @@ -26,7 +29,10 @@ describe.skip('useOnboardingStore (red-phase: Stories 8.1, 8.3)', () => { }); it('keeps the overlay hidden when onboarding is already complete (8.1)', async () => { - vi.spyOn(api, 'loadOnboardingState').mockResolvedValue({ complete: true, sessionsSeen: 3 }); + vi.spyOn(api, 'loadOnboardingState').mockResolvedValue({ + complete: true, + sessionsSeen: 3, + }); await useOnboardingStore.getState().init('Ctrl+Shift+N'); @@ -43,6 +49,25 @@ describe.skip('useOnboardingStore (red-phase: Stories 8.1, 8.3)', () => { expect(useOnboardingStore.getState().isVisible).toBe(false); }); + it('dismiss logs persistence failure but still hides the overlay (8.1)', async () => { + vi.spyOn(api, 'completeOnboarding').mockRejectedValue( + new Error('disk full'), + ); + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + useOnboardingStore.setState({ isVisible: true, customizing: true }); + + await useOnboardingStore.getState().dismiss(); + + expect(consoleError).toHaveBeenCalledWith( + 'completeOnboarding failed during dismiss:', + expect.any(Error), + ); + expect(useOnboardingStore.getState().isVisible).toBe(false); + expect(useOnboardingStore.getState().customizing).toBe(false); + }); + it('startCustomize enters capture mode (8.3)', () => { useOnboardingStore.getState().startCustomize(); expect(useOnboardingStore.getState().customizing).toBe(true); @@ -59,13 +84,21 @@ describe.skip('useOnboardingStore (red-phase: Stories 8.1, 8.3)', () => { }); it('shows the command hint only within the first 5 sessions (8.1)', () => { - useOnboardingStore.setState({ sessionsSeen: COMMAND_HINT_SESSION_LIMIT - 1 }); + useOnboardingStore.setState({ + initialized: true, + sessionsSeen: COMMAND_HINT_SESSION_LIMIT - 1, + }); expect(useOnboardingStore.getState().shouldShowCommandHint()).toBe(true); useOnboardingStore.setState({ sessionsSeen: COMMAND_HINT_SESSION_LIMIT }); expect(useOnboardingStore.getState().shouldShowCommandHint()).toBe(false); }); + it('does not show the command hint before onboarding state loads (8.1)', () => { + useOnboardingStore.setState({ initialized: false, sessionsSeen: 0 }); + expect(useOnboardingStore.getState().shouldShowCommandHint()).toBe(false); + }); + it('flags macOS accessibility guidance when required (8.2)', () => { useOnboardingStore.getState().setAccessibilityNeeded(true); expect(useOnboardingStore.getState().accessibilityNeeded).toBe(true); diff --git a/src/features/onboarding/store.ts b/src/features/onboarding/store.ts index d9ea6a7..13bed94 100644 --- a/src/features/onboarding/store.ts +++ b/src/features/onboarding/store.ts @@ -20,6 +20,8 @@ interface OnboardingStateShape { accessibilityNeeded: boolean; /** Persisted session count, used to retire the command hint. */ sessionsSeen: number; + /** Whether persisted onboarding state has been loaded for this session. */ + initialized: boolean; } /** Onboarding actions. */ @@ -49,6 +51,7 @@ const INITIAL: OnboardingStateShape = { customizing: false, accessibilityNeeded: false, sessionsSeen: 0, + initialized: false, }; /** @@ -58,25 +61,32 @@ const INITIAL: OnboardingStateShape = { * stubbed {@link import('./api')} bridge, which throws until the green phase. The * `describe.skip` tests in `store.test.ts` assert these transitions. */ -export const useOnboardingStore = create( - (set, get) => ({ - ...INITIAL, - init: async (hotkey) => { - const state = await loadOnboardingState(); - set({ - hotkey, - sessionsSeen: state.sessionsSeen, - isVisible: !state.complete, - }); - }, - dismiss: async () => { +export const useOnboardingStore = create< + OnboardingStateShape & OnboardingActions +>((set, get) => ({ + ...INITIAL, + init: async (hotkey) => { + const state = await loadOnboardingState(); + set({ + hotkey, + sessionsSeen: state.sessionsSeen, + isVisible: !state.complete, + initialized: true, + }); + }, + dismiss: async () => { + try { await completeOnboarding(); + } catch (e) { + console.error('completeOnboarding failed during dismiss:', e); + } finally { set({ isVisible: false, customizing: false }); - }, - startCustomize: () => set({ customizing: true }), - applyCustomHotkey: (combo) => set({ hotkey: combo, customizing: false }), - setAccessibilityNeeded: (needed) => set({ accessibilityNeeded: needed }), - shouldShowCommandHint: () => get().sessionsSeen < COMMAND_HINT_SESSION_LIMIT, - reset: () => set({ ...INITIAL }), - }), -); + } + }, + startCustomize: () => set({ customizing: true }), + applyCustomHotkey: (combo) => set({ hotkey: combo, customizing: false }), + setAccessibilityNeeded: (needed) => set({ accessibilityNeeded: needed }), + shouldShowCommandHint: () => + get().initialized && get().sessionsSeen < COMMAND_HINT_SESSION_LIMIT, + reset: () => set({ ...INITIAL }), +})); diff --git a/src/generated/bindings.ts b/src/generated/bindings.ts index d6835cb..f389a49 100644 --- a/src/generated/bindings.ts +++ b/src/generated/bindings.ts @@ -33,6 +33,12 @@ export const commands = { * cannot clobber each other from stale snapshots. */ updateConfig: (partial: PartialAppConfig) => typedError(__TAURI_INVOKE("update_config", { partial })), + // Returns the persisted onboarding state (completion flag + session count). + getOnboardingState: () => typedError(__TAURI_INVOKE("get_onboarding_state")), + // Marks onboarding complete and persists it. Idempotent. + completeOnboarding: () => typedError(__TAURI_INVOKE("complete_onboarding")), + // Increments the persisted session counter and returns the new count. + incrementOnboardingSession: () => typedError(__TAURI_INVOKE("increment_onboarding_session")), // Hides the calling window (dismiss without destroy). dismissWindow: () => typedError(__TAURI_INVOKE("dismiss_window")), /** @@ -83,6 +89,7 @@ export const commands = { /** Events */ export const events = { + hotkeyPressed: makeEvent("hotkey-pressed"), noteCreated: makeEvent("note-created"), }; @@ -134,6 +141,19 @@ export type HotkeyConfig = { globalShortcut: string, }; +/** + * The `hotkey-pressed` event — emitted whenever the registered global capture + * shortcut fires. + * + * First-run onboarding (Story 8.1) dismisses its overlay when the user presses + * the hotkey, but the OS-level shortcut hides the window without reloading the + * webview, and a registered global shortcut does not reliably deliver a keydown + * to the focused page across platforms. The shortcut handler emits this typed + * event; the visible overlay listens via the generated `events.hotkeyPressed` + * binding and completes onboarding. It is a marker event with no payload. + */ +export type HotkeyPressed = null; + export type Note = { id: number, title: string, @@ -166,6 +186,26 @@ export type NoteCreatedData = { export type NoteyError = { type: "Database" } | { type: "NotFound" } | { type: "Workspace" } | { type: "Io" } | { type: "Validation"; message: string } | { type: "Config"; message: string }; +/** + * Persisted first-run onboarding state. + * + * Serialized to `onboarding.toml`. `complete` gates the one-time onboarding + * overlay; `sessions_seen` counts app launches so the early command hint can + * retire itself after [`COMMAND_HINT_SESSION_LIMIT`] sessions. + */ +export type OnboardingState = { + /** + * `true` once the user has dismissed the onboarding overlay (by pressing the + * hotkey or Esc). The overlay is never shown again once this is set. + */ + complete?: boolean, + /** + * How many sessions the user has started. Drives the progressive + * command-palette hint in the status bar. + */ + sessionsSeen?: number, +}; + // Partial config for updates — all fields optional. export type PartialAppConfig = { general: PartialGeneralConfig | null, From e1bef81c031c874e9f5e2183c68ab7b2256a3427 Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 17 Jun 2026 01:23:38 -0700 Subject: [PATCH 06/15] story 8-2-macos-accessibility-permission-guidance: implemented and reviewed via bmad-auto --- ...macos-accessibility-permission-guidance.md | 126 ++++++++++++++++++ .../sprint-status.yaml | 4 +- src-tauri/capabilities/default.json | 2 + .../check_accessibility_permission.toml | 11 ++ .../open_accessibility_settings.toml | 11 ++ src-tauri/src/commands/accessibility.rs | 25 ++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 2 + src-tauri/src/platform/macos.rs | 35 ++++- src-tauri/tests/acl_tests.rs | 2 + src-tauri/tests/platform_tests.rs | 13 +- src/features/onboarding/api.ts | 25 ++++ src/features/onboarding/bootstrap.ts | 15 ++- .../components/OnboardingOverlay.test.tsx | 42 +++++- .../components/OnboardingOverlay.tsx | 63 +++++++-- src/generated/bindings.ts | 10 ++ 16 files changed, 364 insertions(+), 23 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-8-2-macos-accessibility-permission-guidance.md create mode 100644 src-tauri/permissions/autogenerated/check_accessibility_permission.toml create mode 100644 src-tauri/permissions/autogenerated/open_accessibility_settings.toml create mode 100644 src-tauri/src/commands/accessibility.rs diff --git a/_bmad-output/implementation-artifacts/spec-8-2-macos-accessibility-permission-guidance.md b/_bmad-output/implementation-artifacts/spec-8-2-macos-accessibility-permission-guidance.md new file mode 100644 index 0000000..6d6a888 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-8-2-macos-accessibility-permission-guidance.md @@ -0,0 +1,126 @@ +--- +title: "Story 8.2 — macOS Accessibility Permission Guidance" +type: "feature" +created: "2026-06-17" +status: "done" +baseline_commit: "a722759a78999fb1c9ddc9720656de2e378dc085" +context: + - "{project-root}/_bmad-output/implementation-artifacts/epic-8-context.md" + - "{project-root}/_bmad-output/project-context.md" +--- + + + +## Intent + +**Problem:** On macOS the global capture hotkey silently fails until the user grants the OS *Accessibility* permission, yet Story 8.1 left the onboarding guidance state as an inert shell — no permission detection, the "Open System Settings" button is a `TODO(Story 8.2)`, and there is no skip affordance. A macOS first-run user has no path from "the shortcut doesn't work" to a working capture hotkey. + +**Approach:** Implement the green phase of the macOS accessibility flow behind the existing `Platform` trait: a real `accessibility_permission_granted()` (via `AXIsProcessTrusted`) and `open_accessibility_settings()` (open the Privacy → Accessibility pane), exposed through two thin Tauri commands. Wire onboarding to detect a missing grant on first run, render the existing guidance with a working Settings button plus a Skip affordance, and poll so that granting permission auto-dismisses the guidance and onboarding continues. Non-macOS platforms report the permission as granted, so the step is skipped entirely with no OS branching in the frontend. + +## Boundaries & Constraints + +**Always:** +- macOS detection uses `AXIsProcessTrusted()` linked from the system `ApplicationServices` framework via a small `extern "C"` FFI block — no new crate. The settings pane is opened with the `x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility` URL. +- All OS-divergent logic stays behind the existing `Platform` trait (`accessibility_permission_granted`, `open_accessibility_settings`). The two new Tauri commands are thin **synchronous** handlers that delegate to `platform::current()` — no business logic in handlers. +- The frontend never branches on OS. "Skip entirely on non-macOS" is derived from the backend returning `Ok(true)` (granted) — the existing Linux/Windows impls already do this; keep them. +- New commands require: a permission TOML in `permissions/autogenerated/`, an `allow-*` entry in `capabilities/default.json`, an `EXPECTED_COMMANDS` entry in `acl_tests.rs`, and registration in `specta_builder`. `bindings.ts` is regenerated by `cargo test` (never hand-edited). +- All IPC via generated `commands.*` bindings — never raw `invoke()`. +- Accessibility detection runs only when the onboarding overlay is shown (first run). Granting permission auto-dismisses the guidance by calling `setAccessibilityNeeded(false)`; onboarding then shows the normal hotkey instruction. +- Every accessibility IPC call on the frontend is guarded (try/catch or `.catch`) so a missing Tauri runtime (unit tests) or a failed check cannot crash render or block startup — a failed check is treated as "granted" so onboarding is never blocked. + +**Ask First:** +- Changing the settings-pane URL, the FFI symbol (`AXIsProcessTrusted`), or the linked framework (`ApplicationServices`). +- Changing any `Platform` trait method signature (shared with the still-stubbed Stories 8.4/8.5/8.6 methods). +- Introducing any new runtime dependency (the FFI approach needs none). + +**Never:** +- Do NOT implement Story 8.3 (hotkey capture/conflict/re-registration) or the 8.4/8.5/8.6 `Platform` methods — leave those `todo!` stubs intact; only fill the two accessibility methods on macOS. +- Do NOT gate Esc/hotkey dismissal on the permission state — skipping must always remain possible. +- Do NOT add a new E2E test (P1-E2E-002 is owned by the later cross-platform pass). +- Do NOT block startup on the accessibility check (best-effort, like the Story 8.1 session increment). +- No raw `invoke`, no network calls, no broad FS permissions. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +| --- | --- | --- | --- | +| macOS, granted | first run, `AXIsProcessTrusted` true | `accessibilityNeeded=false`; normal hotkey instruction shown | N/A | +| macOS, denied | first run, `AXIsProcessTrusted` false | guidance shown: permission message + "Open System Settings" + "Skip for now" + warning text | N/A | +| Open settings click | guidance visible | `open_accessibility_settings` spawns the settings URL | spawn failure → `Err(Io)` logged; button remains | +| Grant detected | guidance visible, poll/focus re-check sees granted | `setAccessibilityNeeded(false)`; guidance dismissed; normal instruction shown | check error → keep guidance, retry next tick | +| Skip | guidance visible, user clicks "Skip for now" | `accessibilityNeeded=false`; normal instruction shown (onboarding continues) | N/A | +| Non-macOS | first run, `granted() == Ok(true)` | guidance never shown; onboarding shows hotkey instruction | N/A | +| Check fails / no runtime | first run, IPC throws | treated as granted; onboarding proceeds; error logged | swallow + log, never block | +| Already onboarded | `complete == true` | overlay hidden; no accessibility check performed | N/A | + + + +## Code Map + +- `src-tauri/src/platform/macos.rs` -- Implement `accessibility_permission_granted` (FFI `AXIsProcessTrusted`) and `open_accessibility_settings` (open the Privacy→Accessibility URL via `open`). Leave the 8.4/8.5/8.6 `todo!` methods untouched. +- `src-tauri/src/platform/linux.rs` / `windows.rs` -- No change; already return `Ok(true)` / no-op for the two methods (non-macOS skip path). +- `src-tauri/src/commands/accessibility.rs` -- NEW thin sync commands `check_accessibility_permission` (→ `Result`) and `open_accessibility_settings` (→ `Result<()>`), each delegating to `platform::current()`. +- `src-tauri/src/commands/mod.rs` -- Add `pub mod accessibility;`. +- `src-tauri/src/lib.rs` -- Register the 2 commands in `specta_builder`'s `collect_commands!`. +- `src-tauri/permissions/autogenerated/check_accessibility_permission.toml` + `open_accessibility_settings.toml` -- NEW allow/deny permission sets (mirror existing onboarding TOMLs). +- `src-tauri/capabilities/default.json` -- Add `allow-check-accessibility-permission`, `allow-open-accessibility-settings`. +- `src-tauri/tests/acl_tests.rs` -- Add the same two identifiers to `EXPECTED_COMMANDS`. +- `src-tauri/tests/platform_tests.rs` -- Remove `#[ignore]` from the three Story 8.2 tests (`macos_reports_accessibility_permission`, `macos_can_open_accessibility_settings`, `non_macos_skips_accessibility_gate`). +- `src/features/onboarding/api.ts` -- Add `checkAccessibilityPermission(): Promise` and `openAccessibilitySettings(): Promise` over the generated bindings. +- `src/features/onboarding/bootstrap.ts` -- After `store.init`, if the overlay is visible, best-effort check permission and `setAccessibilityNeeded(!granted)`. +- `src/features/onboarding/components/OnboardingOverlay.tsx` -- Wire the Settings button to `openAccessibilitySettings`; add a "Skip for now" button → `setAccessibilityNeeded(false)`; poll (interval + window `focus`) while the guidance is shown to auto-dismiss on grant. +- `src/features/onboarding/components/OnboardingOverlay.test.tsx` -- Add tests: Settings button invokes the open command; Skip clears `accessibilityNeeded`; a focus/poll re-check that sees a grant dismisses the guidance. +- `src/generated/bindings.ts` -- Regenerated by `cargo test` (the `export_bindings` test); do not hand-edit. + +## Tasks & Acceptance + +**Execution:** + +- [x] `src-tauri/src/platform/macos.rs` -- Replace the two `todo!` accessibility methods: `accessibility_permission_granted` calls a `#[link(name = "ApplicationServices", kind = "framework")] extern "C" { fn AXIsProcessTrusted() -> u8; }` and returns `Ok(unsafe { AXIsProcessTrusted() } != 0)`; `open_accessibility_settings` spawns `open ` mapping spawn failure to `NoteyError::Io`. -- Real macOS permission detection + settings deep-link. +- [x] `src-tauri/src/commands/accessibility.rs` + `commands/mod.rs` -- Add the two thin `#[tauri::command] #[specta::specta]` sync handlers delegating to `platform::current()`. -- IPC surface. +- [x] `src-tauri/src/lib.rs` -- Register both commands in `collect_commands!`. -- Wiring + bindings regen. +- [x] `src-tauri/permissions/autogenerated/{check_accessibility_permission,open_accessibility_settings}.toml`, `capabilities/default.json`, `tests/acl_tests.rs` -- Add allow/deny permission sets, capability entries, and `EXPECTED_COMMANDS` entries. -- Default-deny ACL stays consistent. +- [x] `src-tauri/tests/platform_tests.rs` -- Remove `#[ignore]` from the three Story 8.2 tests. -- Activate the red-phase contract (`non_macos_skips_accessibility_gate` runs on Linux CI now). +- [x] `src/features/onboarding/api.ts` -- Add `checkAccessibilityPermission` / `openAccessibilitySettings` wrapping `commands.checkAccessibilityPermission()` / `commands.openAccessibilitySettings()` (unwrap `Result`). -- Frontend bridge. +- [x] `src/features/onboarding/bootstrap.ts` -- After `store.init(hotkey)`, when `isVisible`, best-effort `checkAccessibilityPermission()` → `setAccessibilityNeeded(!granted)`; swallow+log errors. -- First-run detection. +- [x] `src/features/onboarding/components/OnboardingOverlay.tsx` -- Settings button → `void openAccessibilitySettings()`; add "Skip for now" button → `setAccessibilityNeeded(false)` (keeping the warning text); while `isVisible && accessibilityNeeded`, poll (interval + window `focus`) `checkAccessibilityPermission()` and call `setAccessibilityNeeded(false)` on a grant; guard all calls. -- Working guidance + auto-dismiss-on-grant + skip. +- [x] `src/features/onboarding/components/OnboardingOverlay.test.tsx` -- Add tests for the Settings-button command call, the Skip control, and grant-detected auto-dismiss. -- Lock the 8.2 UI contract. + +**Acceptance Criteria:** + +- Given the app runs on macOS and the overlay is shown, when accessibility permission is NOT granted, then the overlay shows the guidance message, a working "Open System Settings" button, and a skip option with the "shortcut may not work without this permission" warning. +- Given the macOS guidance is visible, when the user grants permission (detected by the re-check), then `accessibilityNeeded` clears and onboarding shows the normal hotkey instruction without a reload. +- Given the macOS guidance is visible, when the user clicks "Skip for now", then the guidance is dismissed, onboarding continues, and Esc/hotkey dismissal still works throughout. +- Given the app is NOT running on macOS, when onboarding runs, then `accessibility_permission_granted()` returns `Ok(true)`, no guidance is shown, and the accessibility step is skipped entirely with no OS branching in the frontend. +- Given the build, when bindings regenerate and the suites run, then `cargo test` (incl. `acl_tests`, de-ignored `platform_tests` 8.2 cases), `cargo clippy`, `vitest`, and `tsc` all pass. + +## Design Notes + +**FFI return type:** Apple's `AXIsProcessTrusted` returns `Boolean` (`typedef unsigned char`). Declare it `-> u8` and compare `!= 0` rather than `-> bool` — a Rust `bool` is UB for any byte other than 0/1, and `u8`+compare is the safe idiom. The `extern` block and call site are the only `unsafe` in the change; keep them minimal and documented. + +**Non-macOS "skip" is data, not a branch:** Because `LinuxPlatform`/`WindowsPlatform::accessibility_permission_granted` already return `Ok(true)`, the same `checkAccessibilityPermission()` call yields `granted=true` everywhere but macOS, so `setAccessibilityNeeded(!granted)` is a no-op off macOS. The frontend stays OS-agnostic and the test `non_macos_skips_accessibility_gate` pins this on Linux CI. + +**Auto-dismiss on grant:** macOS emits no event when permission changes, so the overlay polls while the guidance is shown — a short interval plus a window `focus` listener (the user returns from System Settings) — re-checking `checkAccessibilityPermission()`. On the first `true`, `setAccessibilityNeeded(false)` swaps the guidance for the normal hotkey instruction. The effect is keyed on `isVisible && accessibilityNeeded` so it stops the moment guidance closes. + +## Verification + +**Commands:** + +- `cd src-tauri && cargo test` -- expected: full backend suite green, incl. `acl_tests`, the de-ignored `platform_tests::non_macos_skips_accessibility_gate` (runs on Linux), and `export_bindings` regenerating `bindings.ts` with the 2 new commands. +- `cd src-tauri && cargo clippy --all-targets -- -D warnings` -- expected: no warnings. +- `npm test -- src/features/onboarding` -- expected: existing + new onboarding store/overlay tests pass. +- `npm run build` -- expected: `tsc` + vite build succeed (bindings include `checkAccessibilityPermission` + `openAccessibilitySettings`). + +**Manual checks (macOS, not runnable on this Linux host):** + +- On a macOS build with permission revoked, first run shows the guidance; "Open System Settings" opens Privacy → Accessibility; granting it dismisses the guidance and reveals the hotkey instruction. + +### Review Findings + +- [x] [Review][Patch] Reap and validate macOS settings opener [src-tauri/src/platform/macos.rs:70] + +#### Review Ledger (2026-06-17) + +- patch: Reap and validate macOS settings opener [src-tauri/src/platform/macos.rs:70] — fixed by waiting for the short-lived `open` helper, reporting nonzero exit, and reaping the child process. +- dismiss: macOS settings-opening test has a desktop side effect [src-tauri/tests/platform_tests.rs:105] — the approved story explicitly de-ignored this macOS-only acceptance test, so the side effect is an accepted platform-test behavior rather than an implementation bug. +- dismiss: Failed permission re-check can leave guidance stuck [src/features/onboarding/components/OnboardingOverlay.tsx:82] — the approved matrix says re-check errors keep guidance visible and retry; the first-run bootstrap failure path starts from `accessibilityNeeded=false`, so onboarding proceeds. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 189d285..a3ee38f 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -40,7 +40,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: 2026-04-03 -last_updated: 2026-06-17 # story 8-1 → done (epic-8 in-progress) +last_updated: 2026-06-17 # story 8-2 → done # Note: epic-2-retrospective rewritten fresh on 2026-04-04 project: notey project_key: NOKEY @@ -134,7 +134,7 @@ development_status: # Epic 8: Onboarding & Platform Integration epic-8: in-progress 8-1-first-run-detection-onboarding-overlay: done - 8-2-macos-accessibility-permission-guidance: backlog + 8-2-macos-accessibility-permission-guidance: done 8-3-hotkey-customization-during-onboarding: backlog 8-4-auto-start-on-login: backlog 8-5-per-user-data-isolation: backlog diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 7a7754a..5fe5635 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -24,6 +24,8 @@ "allow-get-onboarding-state", "allow-complete-onboarding", "allow-increment-onboarding-session", + "allow-check-accessibility-permission", + "allow-open-accessibility-settings", "allow-dismiss-window", "allow-apply-layout-mode", "allow-create-workspace", diff --git a/src-tauri/permissions/autogenerated/check_accessibility_permission.toml b/src-tauri/permissions/autogenerated/check_accessibility_permission.toml new file mode 100644 index 0000000..4eff8d6 --- /dev/null +++ b/src-tauri/permissions/autogenerated/check_accessibility_permission.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-check-accessibility-permission" +description = "Enables the check_accessibility_permission command without any pre-configured scope." +commands.allow = ["check_accessibility_permission"] + +[[permission]] +identifier = "deny-check-accessibility-permission" +description = "Denies the check_accessibility_permission command without any pre-configured scope." +commands.deny = ["check_accessibility_permission"] diff --git a/src-tauri/permissions/autogenerated/open_accessibility_settings.toml b/src-tauri/permissions/autogenerated/open_accessibility_settings.toml new file mode 100644 index 0000000..e8c340f --- /dev/null +++ b/src-tauri/permissions/autogenerated/open_accessibility_settings.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-open-accessibility-settings" +description = "Enables the open_accessibility_settings command without any pre-configured scope." +commands.allow = ["open_accessibility_settings"] + +[[permission]] +identifier = "deny-open-accessibility-settings" +description = "Denies the open_accessibility_settings command without any pre-configured scope." +commands.deny = ["open_accessibility_settings"] diff --git a/src-tauri/src/commands/accessibility.rs b/src-tauri/src/commands/accessibility.rs new file mode 100644 index 0000000..9c58528 --- /dev/null +++ b/src-tauri/src/commands/accessibility.rs @@ -0,0 +1,25 @@ +//! Thin Tauri command handlers for the macOS accessibility-permission flow +//! (Story 8.2 / FR54). All OS-divergent logic lives behind the [`crate::platform`] +//! abstraction; these handlers only resolve the active platform and delegate. +//! Synchronous (an FFI query / a process spawn) per the project's command +//! convention. On non-macOS platforms the underlying impls report the permission +//! as granted and the settings call is a no-op, so onboarding skips the step. + +use crate::errors::NoteyError; +use crate::platform; + +/// Whether the OS has granted the accessibility permission the global hotkey +/// depends on. Always `Ok(true)` off macOS (no such gate). +#[tauri::command] +#[specta::specta] +pub fn check_accessibility_permission() -> Result { + platform::current().accessibility_permission_granted() +} + +/// Open the OS settings pane where the user grants accessibility permission. +/// No-op off macOS. +#[tauri::command] +#[specta::specta] +pub fn open_accessibility_settings() -> Result<(), NoteyError> { + platform::current().open_accessibility_settings() +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index f53d157..6091572 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod accessibility; pub mod config; pub mod export; pub mod notes; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 038c452..686a0bf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,6 +38,8 @@ fn specta_builder() -> tauri_specta::Builder { commands::onboarding::get_onboarding_state, commands::onboarding::complete_onboarding, commands::onboarding::increment_onboarding_session, + commands::accessibility::check_accessibility_permission, + commands::accessibility::open_accessibility_settings, commands::window::dismiss_window, commands::window::apply_layout_mode, commands::workspace::create_workspace, diff --git a/src-tauri/src/platform/macos.rs b/src-tauri/src/platform/macos.rs index ee3ca17..4b31fba 100644 --- a/src-tauri/src/platform/macos.rs +++ b/src-tauri/src/platform/macos.rs @@ -1,6 +1,7 @@ -//! macOS [`Platform`] implementation. RED-PHASE STUB (Stories 8.2, 8.6). +//! macOS [`Platform`] implementation. Accessibility-permission methods (Story 8.2) +//! are implemented; paths/hotkey/auto-start remain RED-PHASE stubs (Stories 8.4–8.6). -use std::path::PathBuf; +use std::{io, path::PathBuf}; use crate::errors::NoteyError; use crate::platform::{HotkeyBackend, Platform}; @@ -54,12 +55,34 @@ impl Platform for MacosPlatform { } fn accessibility_permission_granted(&self) -> Result { - todo!("Story 8.2: query AXIsProcessTrusted()") + // `AXIsProcessTrusted` reports whether the app is allowed to use the + // Accessibility APIs the global hotkey depends on (Story 8.2 / FR54). It + // returns CoreFoundation's `Boolean` (an `unsigned char`), so bind it as + // `u8` and compare — a Rust `bool` would be UB for any byte other than + // 0/1. The call is side-effect-free and never blocks. + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrusted() -> u8; + } + Ok(unsafe { AXIsProcessTrusted() } != 0) } fn open_accessibility_settings(&self) -> Result<(), NoteyError> { - todo!( - "Story 8.2: open x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" - ) + // Deep-link straight to System Settings > Privacy & Security > + // Accessibility. `open` hands the URL to LaunchServices and exits + // quickly; wait for that short-lived helper so failures are reported and + // the child process is reaped. + let status = std::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") + .status() + .map_err(NoteyError::Io)?; + + if status.success() { + Ok(()) + } else { + Err(NoteyError::Io(io::Error::other(format!( + "open accessibility settings exited with {status}" + )))) + } } } diff --git a/src-tauri/tests/acl_tests.rs b/src-tauri/tests/acl_tests.rs index 152f4a5..2febe77 100644 --- a/src-tauri/tests/acl_tests.rs +++ b/src-tauri/tests/acl_tests.rs @@ -17,6 +17,8 @@ const EXPECTED_COMMANDS: &[&str] = &[ "allow-get-onboarding-state", "allow-complete-onboarding", "allow-increment-onboarding-session", + "allow-check-accessibility-permission", + "allow-open-accessibility-settings", "allow-dismiss-window", "allow-apply-layout-mode", "allow-create-workspace", diff --git a/src-tauri/tests/platform_tests.rs b/src-tauri/tests/platform_tests.rs index e36c5b8..506935c 100644 --- a/src-tauri/tests/platform_tests.rs +++ b/src-tauri/tests/platform_tests.rs @@ -35,7 +35,10 @@ fn data_dir_is_user_scoped_and_standard() { .data_dir() .expect("data_dir must resolve"); let s = dir.to_string_lossy(); - assert!(s.contains("notey"), "data dir must be namespaced to notey: {s}"); + assert!( + s.contains("notey"), + "data dir must be namespaced to notey: {s}" + ); #[cfg(unix)] { @@ -54,7 +57,10 @@ fn data_dir_is_user_scoped_and_standard() { fn socket_path_is_user_scoped() { let path = platform::current().socket_path(); let s = path.to_string_lossy(); - assert!(s.contains("notey"), "socket path must be namespaced to notey: {s}"); + assert!( + s.contains("notey"), + "socket path must be namespaced to notey: {s}" + ); let runtime = std::env::var("XDG_RUNTIME_DIR").unwrap_or_default(); if !runtime.is_empty() { assert!( @@ -94,7 +100,6 @@ fn linux_hotkey_falls_back_to_wayland_portal() { /// AC 8.2/FR54: on macOS the accessibility permission is queried during onboarding. #[cfg(target_os = "macos")] #[test] -#[ignore = "red-phase: Story 8.2"] fn macos_reports_accessibility_permission() { // Must not panic and must return a definite grant state. let _granted = platform::current() @@ -105,7 +110,6 @@ fn macos_reports_accessibility_permission() { /// AC 8.2: opening the accessibility settings pane is available on macOS. #[cfg(target_os = "macos")] #[test] -#[ignore = "red-phase: Story 8.2"] fn macos_can_open_accessibility_settings() { platform::current() .open_accessibility_settings() @@ -116,7 +120,6 @@ fn macos_can_open_accessibility_settings() { /// skipped entirely" — non-macOS platforms always report the permission as granted. #[cfg(not(target_os = "macos"))] #[test] -#[ignore = "red-phase: Story 8.2"] fn non_macos_skips_accessibility_gate() { assert!( platform::current() diff --git a/src/features/onboarding/api.ts b/src/features/onboarding/api.ts index 7a09d10..d70b9eb 100644 --- a/src/features/onboarding/api.ts +++ b/src/features/onboarding/api.ts @@ -47,3 +47,28 @@ export async function incrementSession(): Promise { } return result.data; } + +/** + * Whether the OS has granted the accessibility permission the global hotkey + * depends on (Story 8.2). Always resolves `true` off macOS, so callers need no + * platform branch — the macOS guidance step is skipped purely by this value. + */ +export async function checkAccessibilityPermission(): Promise { + const result = await commands.checkAccessibilityPermission(); + if (result.status === 'error') { + throw new Error( + `checkAccessibilityPermission failed: ${String(result.error)}`, + ); + } + return result.data; +} + +/** Open the macOS System Settings pane for granting accessibility permission. */ +export async function openAccessibilitySettings(): Promise { + const result = await commands.openAccessibilitySettings(); + if (result.status === 'error') { + throw new Error( + `openAccessibilitySettings failed: ${String(result.error)}`, + ); + } +} diff --git a/src/features/onboarding/bootstrap.ts b/src/features/onboarding/bootstrap.ts index 875c0f9..0f23823 100644 --- a/src/features/onboarding/bootstrap.ts +++ b/src/features/onboarding/bootstrap.ts @@ -1,6 +1,6 @@ import { commands } from '../../generated/bindings'; import { platformDefaultShortcut } from '../settings/shortcut'; -import { incrementSession } from './api'; +import { checkAccessibilityPermission, incrementSession } from './api'; import { useOnboardingStore } from './store'; /** @@ -45,6 +45,19 @@ export async function initOnboarding(): Promise { console.error('initOnboarding: store init failed:', e); } + // First-run only: detect a missing macOS accessibility grant so the overlay can + // show its guidance (Story 8.2). Best-effort — a failed check is treated as + // "granted" so onboarding is never blocked. Off macOS the backend reports + // granted, so this is a no-op and the step is skipped entirely. + if (useOnboardingStore.getState().isVisible) { + try { + const granted = await checkAccessibilityPermission(); + useOnboardingStore.getState().setAccessibilityNeeded(!granted); + } catch (e) { + console.error('initOnboarding: accessibility check failed:', e); + } + } + if (!sessionRecorded) { sessionRecorded = true; try { diff --git a/src/features/onboarding/components/OnboardingOverlay.test.tsx b/src/features/onboarding/components/OnboardingOverlay.test.tsx index 378c0fa..38aef8a 100644 --- a/src/features/onboarding/components/OnboardingOverlay.test.tsx +++ b/src/features/onboarding/components/OnboardingOverlay.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { useOnboardingStore } from '../store'; import * as api from '../api'; import { OnboardingOverlay } from './OnboardingOverlay'; @@ -105,4 +105,44 @@ describe('OnboardingOverlay (red-phase: Stories 8.1, 8.2, 8.3)', () => { screen.getByText(/shortcut may not work without this permission/i), ).toBeInTheDocument(); }); + + it('opens the accessibility settings pane from the guidance button (8.2)', () => { + const open = vi + .spyOn(api, 'openAccessibilitySettings') + .mockResolvedValue(); + showOverlay(); + useOnboardingStore.setState({ accessibilityNeeded: true }); + render(); + + fireEvent.click(screen.getByRole('button', { name: /system settings/i })); + + expect(open).toHaveBeenCalledOnce(); + }); + + it('lets the user skip the accessibility guidance and continue (8.2)', () => { + showOverlay(); + useOnboardingStore.setState({ accessibilityNeeded: true }); + render(); + + fireEvent.click(screen.getByRole('button', { name: /skip for now/i })); + + expect(useOnboardingStore.getState().accessibilityNeeded).toBe(false); + // Onboarding continues: the normal hotkey instruction is now shown. + expect(screen.getByText(/your capture shortcut is/i)).toBeInTheDocument(); + }); + + it('auto-dismisses the guidance when the grant is detected (8.2)', async () => { + vi.spyOn(api, 'checkAccessibilityPermission').mockResolvedValue(true); + showOverlay(); + useOnboardingStore.setState({ accessibilityNeeded: true }); + render(); + + // Simulate the user returning from System Settings after granting. + fireEvent.focus(window); + + await waitFor(() => + expect(useOnboardingStore.getState().accessibilityNeeded).toBe(false), + ); + expect(screen.getByText(/your capture shortcut is/i)).toBeInTheDocument(); + }); }); diff --git a/src/features/onboarding/components/OnboardingOverlay.tsx b/src/features/onboarding/components/OnboardingOverlay.tsx index b4a1530..e96bc9c 100644 --- a/src/features/onboarding/components/OnboardingOverlay.tsx +++ b/src/features/onboarding/components/OnboardingOverlay.tsx @@ -1,17 +1,21 @@ import { useEffect, useRef } from 'react'; import { events } from '../../../generated/bindings'; import { useFocusTrap } from '../../../lib/useFocusTrap'; +import { checkAccessibilityPermission, openAccessibilitySettings } from '../api'; import { useOnboardingStore } from '../store'; +/** How often to re-check the macOS accessibility grant while guidance is shown. */ +const ACCESSIBILITY_POLL_MS = 1500; + /** * First-run onboarding overlay (Epic 8). * - * Story 8.1 (this story) implements the complete visual shell and the first-run - * flow: a centered accessible dialog that teaches the configured capture shortcut - * as key caps, dismissing (and persisting completion) on Esc or a global-hotkey - * press. It also renders — but does not wire the backends for — the Customize - * capture state (Story 8.3) and the macOS accessibility-guidance state - * (Story 8.2), which those stories activate. + * The overlay teaches the configured capture shortcut as key caps and dismisses + * (persisting completion) on Esc or a global-hotkey press (Story 8.1). It also + * renders the macOS accessibility-guidance state (Story 8.2) — a permission + * message, a working "Open System Settings" button, and a "Skip for now" control — + * auto-dismissing the guidance when a grant is detected. The Customize capture + * state (Story 8.3) is still an inert shell. */ export function OnboardingOverlay() { const isVisible = useOnboardingStore((s) => s.isVisible); @@ -70,6 +74,32 @@ export function OnboardingOverlay() { }; }, [isVisible]); + // While the macOS accessibility guidance is shown, re-check the grant so the + // guidance auto-dismisses once the user enables the permission. macOS emits no + // change event, so we poll on an interval and on window focus (the user + // returning from System Settings). Guarded so a missing Tauri runtime (unit + // tests) cannot crash. Off macOS this branch never runs (guidance is not shown). + useEffect(() => { + if (!isVisible || !accessibilityNeeded) return; + let active = true; + const recheck = () => { + checkAccessibilityPermission() + .then((granted) => { + if (active && granted) { + useOnboardingStore.getState().setAccessibilityNeeded(false); + } + }) + .catch((e) => console.error('accessibility re-check failed:', e)); + }; + const interval = window.setInterval(recheck, ACCESSIBILITY_POLL_MS); + window.addEventListener('focus', recheck); + return () => { + active = false; + window.clearInterval(interval); + window.removeEventListener('focus', recheck); + }; + }, [isVisible, accessibilityNeeded]); + if (!isVisible) return null; const keyCaps = hotkey @@ -125,8 +155,9 @@ export function OnboardingOverlay() { ) : customizing ? (

diff --git a/src/generated/bindings.ts b/src/generated/bindings.ts index f389a49..bd59f86 100644 --- a/src/generated/bindings.ts +++ b/src/generated/bindings.ts @@ -39,6 +39,16 @@ export const commands = { completeOnboarding: () => typedError(__TAURI_INVOKE("complete_onboarding")), // Increments the persisted session counter and returns the new count. incrementOnboardingSession: () => typedError(__TAURI_INVOKE("increment_onboarding_session")), + /** + * Whether the OS has granted the accessibility permission the global hotkey + * depends on. Always `Ok(true)` off macOS (no such gate). + */ + checkAccessibilityPermission: () => typedError(__TAURI_INVOKE("check_accessibility_permission")), + /** + * Open the OS settings pane where the user grants accessibility permission. + * No-op off macOS. + */ + openAccessibilitySettings: () => typedError(__TAURI_INVOKE("open_accessibility_settings")), // Hides the calling window (dismiss without destroy). dismissWindow: () => typedError(__TAURI_INVOKE("dismiss_window")), /** From 935a0bde04fdecc64102c9018fa34febb2efe65f Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 17 Jun 2026 02:00:37 -0700 Subject: [PATCH 07/15] story 8-3-hotkey-customization-during-onboarding: implemented and reviewed via bmad-auto --- ...-hotkey-customization-during-onboarding.md | 115 ++++++++++ .../sprint-status.yaml | 4 +- .../components/OnboardingOverlay.test.tsx | 197 +++++++++++++++- .../components/OnboardingOverlay.tsx | 210 +++++++++++++++++- src/features/onboarding/store.test.ts | 36 ++- src/features/onboarding/store.ts | 32 ++- 6 files changed, 571 insertions(+), 23 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-8-3-hotkey-customization-during-onboarding.md diff --git a/_bmad-output/implementation-artifacts/spec-8-3-hotkey-customization-during-onboarding.md b/_bmad-output/implementation-artifacts/spec-8-3-hotkey-customization-during-onboarding.md new file mode 100644 index 0000000..55d94a6 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-8-3-hotkey-customization-during-onboarding.md @@ -0,0 +1,115 @@ +--- +title: "Story 8.3 — Hotkey Customization During Onboarding" +type: "feature" +created: "2026-06-17" +status: "done" +baseline_commit: "89e39fc3282e7dfdcd238ba355d8660e2d9f444a" +context: + - "{project-root}/_bmad-output/implementation-artifacts/epic-8-context.md" + - "{project-root}/_bmad-output/project-context.md" +--- + + + +## Intent + +**Problem:** Story 8.1 shipped the onboarding overlay's "Customize" affordance as an inert shell: clicking it flips `customizing` and shows the static "Press your preferred shortcut…" prompt, but there is no key capture, no conflict handling, and `applyCustomHotkey` only mutates the displayed string locally — it never persists or re-registers the global shortcut. A new user cannot actually change the capture hotkey before leaving onboarding. + +**Approach:** Implement the green phase of in-onboarding hotkey customization purely on the frontend by reusing Epic 7's existing register-before-commit path. In the overlay's `customizing` branch, capture a modifier+key combination with the existing `formatShortcutFromEvent` grammar, live-preview it as key caps, and on an explicit Save delegate to the shared `useSettingsStore.setGlobalShortcut()` (which calls `commands.updateConfig` — validate → register-new → unregister-old → persist). A conflict keeps the previous shortcut and surfaces an inline warning so the user can retry; success updates the displayed shortcut and continues onboarding. No backend, command, or dependency changes. + +## Boundaries & Constraints + +**Always:** +- Persist + register the new shortcut ONLY through `useSettingsStore.getState().setGlobalShortcut(combo)` — the single Epic 7 register-before-commit path (it already does conflict detection, unregister-old, atomic persist, and returns `true`/`false`). Never call `commands.updateConfig` directly from onboarding and never reimplement parsing/conflict logic. +- Derive captured combos with the existing `formatShortcutFromEvent` + supported-key set from `src/features/settings/shortcut.ts` (canonical `Ctrl|Cmd + Shift + Alt + KEY`). No new capture grammar. +- Capture uses a window keydown listener in the **capture phase** with `preventDefault` + `stopPropagation`, mirroring `HotkeyCaptureField`: ignore Tab and bare-modifier presses; an unsupported/modifier-less combo shows an inline warning rather than capturing. +- Register only on an explicit Save/confirm action — never auto-register on each keypress. +- On conflict/failure: keep the previously displayed-and-registered shortcut, show an inline warning (`role="alert"`), and stay in capture mode so the user can try again. On success: set the overlay `hotkey` to the captured combo, leave capture mode, and continue onboarding. +- While `customizing`, suppress the overlay's Esc-dismiss and `hotkey-pressed`-dismiss so capture is never interrupted; Esc cancels capture only. Outside capture, Story 8.1 Esc/hotkey dismissal is unchanged. +- All IPC stays via generated `commands.*` bindings (transitively through the settings store). Zero raw `invoke`, zero network, no broad FS. + +**Ask First:** +- Changing the shared `setGlobalShortcut` contract or the `formatShortcutFromEvent` grammar (both shared with Settings / Epic 7). +- Introducing any backend code, new Tauri command, or new runtime dependency (none expected — this story is frontend-only reuse). + +**Never:** +- Do NOT add Rust, a new Tauri command, ACL/permission entries, or a new `onboarding/api.ts` function — the registration + conflict + persistence path already exists in Epic 7's `update_config`. +- Do NOT auto-apply the shortcut on every captured keystroke (no register spam). +- Do NOT add a new E2E test (P1-E2E-002 is owned by the later cross-platform pass). +- Do NOT gate Story 8.1's Esc/hotkey onboarding dismissal outside capture mode. +- Do NOT mutate `onboarding.toml` or `AppConfig`/`config.toml` directly for the shortcut. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +| --- | --- | --- | --- | +| Enter capture | click "Customize" | `customizing=true`; "Press your preferred shortcut…" shown; capture listener active; Save disabled | N/A | +| Valid combo pressed | `Ctrl+Shift+J` in capture | live-preview key caps update to the combo; Save enabled; warning cleared | N/A | +| Invalid press | bare `J`, pure modifier, or unsupported key | nothing captured; inline warning to use a modifier + letter/number; Save stays disabled | swallow, warn inline | +| Save, no conflict | Save with captured combo | `setGlobalShortcut`→`true`; overlay `hotkey` = new combo; capture exits; onboarding continues with new caps | N/A | +| Save, conflict/failure | `setGlobalShortcut`→`false` | inline warning shown; remains in capture; displayed + registered shortcut unchanged | keep old, allow retry | +| Cancel capture | "Cancel" button | capture exits; original hotkey still displayed; onboarding NOT dismissed | N/A | +| Esc during capture | Esc keydown while `customizing` | cancels capture only; onboarding stays open | stopPropagation blocks dismiss | +| Global hotkey press during capture | backend `hotkey-pressed` while `customizing` | ignored — no dismiss until capture exits | listener gated off | + + + +## Code Map + +- `src/features/onboarding/store.ts` -- Make `applyCustomHotkey` async returning `Promise`: delegate to `useSettingsStore.getState().setGlobalShortcut(combo)`; on `true` `set({ hotkey: combo, customizing: false })` and return `true`; on `false` leave `customizing` true (retry) and return `false`. Add `cancelCustomize: () => set({ customizing: false })`. Update the `OnboardingActions` interface + JSDoc and drop the RED-PHASE note now that the slice is green. +- `src/features/onboarding/components/OnboardingOverlay.tsx` -- Implement the real `customizing` branch: local `captured`/`warning` state; a capture-phase window `keydown` handler using `formatShortcutFromEvent` (ignore Tab + bare modifiers, Esc→`cancelCustomize`, invalid→warning, valid→live preview); render the live key-cap preview + Save (disabled until captured) + Cancel + inline warning; Save → `await applyCustomHotkey(captured)` then warn-and-stay on `false`. Gate the existing Esc-dismiss and `hotkey-pressed` effects on `!customizing`. +- `src/features/settings/shortcut.ts` -- REUSE `formatShortcutFromEvent` (no change). +- `src/features/settings/store.ts` -- REUSE `setGlobalShortcut` (no change). +- `src/features/onboarding/store.test.ts` -- Replace the stub `applyCustomHotkey` test with async cases mocking `useSettingsStore.getState().setGlobalShortcut` (success updates hotkey + exits capture; conflict keeps hotkey + stays in capture + returns `false`); add a `cancelCustomize` test. +- `src/features/onboarding/components/OnboardingOverlay.test.tsx` -- Add capture-flow tests: valid press shows live preview + enables Save; invalid press shows the warning; Save (success) calls `setGlobalShortcut`, shows the new key caps, and exits capture; Save (conflict) shows the warning and stays in capture; Cancel and Esc cancel capture without dismissing onboarding. + +## Tasks & Acceptance + +**Execution:** + +- [x] `src/features/onboarding/store.ts` -- Convert `applyCustomHotkey` to the async delegating action and add `cancelCustomize` (interface + impl + JSDoc); refresh the store doc comment. -- Onboarding-side orchestration over the shared registration path. +- [x] `src/features/onboarding/components/OnboardingOverlay.tsx` -- Implement capture-mode keydown + live preview + Save/Cancel + inline warning in the `customizing` branch and gate the Esc/`hotkey-pressed` dismiss effects on `!customizing`. -- The customization UI. +- [x] `src/features/onboarding/store.test.ts` -- Update/add store tests for async `applyCustomHotkey` (success + conflict) and `cancelCustomize`, mocking the settings store's `setGlobalShortcut`. -- Lock the store contract. +- [x] `src/features/onboarding/components/OnboardingOverlay.test.tsx` -- Add the capture-flow component tests (live preview, invalid warning, Save success/conflict, Cancel/Esc). -- Lock the overlay contract. + +**Acceptance Criteria:** + +- Given the onboarding overlay shows the default capture shortcut, when the user clicks "Customize", presses a valid modifier+key combination, and clicks Save with no conflict, then the new shortcut is registered and persisted via the shared Epic 7 path and the overlay continues showing the new shortcut as key caps. +- Given the user is in capture mode and saves a combination that fails to register (conflict), when the backend rejects it, then an inline warning is shown, the previously registered shortcut is retained, and the user can capture another combination without leaving onboarding. +- Given the user is in capture mode, when they press Esc or click Cancel, then capture is abandoned, the original shortcut is still displayed, and onboarding is not dismissed. +- Given the build, when the suites run, then `vitest` (updated onboarding store + overlay tests), `tsc`, and `npm run build` all pass; no backend, clippy, or binding changes are introduced. + +## Design Notes + +**Why frontend-only:** Conflict detection, register-before-commit, unregister-old, and atomic persistence already live in Epic 7's `commands::config::update_config`, surfaced to the UI by `useSettingsStore.setGlobalShortcut` (returns `true`/`false`, raises its own conflict toast). Story 8.3 is simply the onboarding-time entry point into that path — reusing it satisfies the epic's "share the validation/registration path" directive and adds no Rust. + +**Esc precedence during capture:** The capture keydown listener is registered in the capture phase and calls `stopPropagation`, so the overlay's bubble-phase Esc-dismiss never fires while customizing; the dismiss effects are additionally gated on `!customizing` so a backend `hotkey-pressed` event can't complete onboarding mid-capture. This mirrors `HotkeyCaptureField` exactly. + +**Capture core (golden, mirrors HotkeyCaptureField):** +```ts +if (isModifierCode(e.code)) return; // wait for a main key +const combo = formatShortcutFromEvent(e); +if (combo === null) { setCaptured(null); setWarning(USE_MODIFIER_MSG); return; } +setCaptured(combo); setWarning(null); // live preview; Save → applyCustomHotkey +``` + +## Verification + +**Commands:** + +- `npm test -- src/features/onboarding` -- expected: updated onboarding store + overlay tests pass (capture, save, conflict, cancel). +- `npm run build` -- expected: `tsc` + vite build succeed (no binding changes). +- `cd src-tauri && cargo test` -- expected: backend suite stays green and unchanged (no backend edits in this story). + +### Review Findings + +- [x] [Review][Patch] Capture-mode Save/Cancel keyboard activation was swallowed by shortcut capture [src/features/onboarding/components/OnboardingOverlay.tsx:123] — fixed by allowing focused button Enter/Space keydown events to pass through instead of treating them as shortcut input. +- [x] [Review][Patch] Save could be submitted repeatedly or followed by Cancel while registration was still pending [src/features/onboarding/components/OnboardingOverlay.tsx:191] — fixed with a ref-backed in-flight guard plus disabled Save/Cancel controls during registration. +- [x] [Review][Patch] A stale `hotkey-pressed` listener could still dismiss onboarding after capture started [src/features/onboarding/components/OnboardingOverlay.tsx:91] — fixed by checking listener activity and current onboarding state inside the callback before dismissing. + +#### Review Ledger (2026-06-17) + +- patch: Capture-mode Save/Cancel keyboard activation was swallowed by shortcut capture [src/features/onboarding/components/OnboardingOverlay.tsx:123] — focused capture controls now receive Enter/Space normally. +- patch: Save could be submitted repeatedly or followed by Cancel while registration was still pending [src/features/onboarding/components/OnboardingOverlay.tsx:191] — in-flight Save is guarded and controls are disabled until registration settles. +- patch: A stale `hotkey-pressed` listener could still dismiss onboarding after capture started [src/features/onboarding/components/OnboardingOverlay.tsx:91] — stale callbacks now check listener activity and current store state before dismissing. +- dismiss: Save failure can reject without showing the retry warning [src/features/onboarding/store.ts:98] — false positive; `useSettingsStore.setGlobalShortcut` catches thrown `updateConfig` errors and resolves `false`. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index a3ee38f..21f48eb 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -40,7 +40,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: 2026-04-03 -last_updated: 2026-06-17 # story 8-2 → done +last_updated: 2026-06-17 # story 8-3 → done # Note: epic-2-retrospective rewritten fresh on 2026-04-04 project: notey project_key: NOKEY @@ -135,7 +135,7 @@ development_status: epic-8: in-progress 8-1-first-run-detection-onboarding-overlay: done 8-2-macos-accessibility-permission-guidance: done - 8-3-hotkey-customization-during-onboarding: backlog + 8-3-hotkey-customization-during-onboarding: done 8-4-auto-start-on-login: backlog 8-5-per-user-data-isolation: backlog 8-6-cross-platform-verification-wayland-fallback: backlog diff --git a/src/features/onboarding/components/OnboardingOverlay.test.tsx b/src/features/onboarding/components/OnboardingOverlay.test.tsx index 38aef8a..ffdabc4 100644 --- a/src/features/onboarding/components/OnboardingOverlay.test.tsx +++ b/src/features/onboarding/components/OnboardingOverlay.test.tsx @@ -1,6 +1,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; +import type { EventCallback } from '@tauri-apps/api/event'; +import { events, type HotkeyPressed } from '../../../generated/bindings'; import { useOnboardingStore } from '../store'; +import { useSettingsStore } from '../../settings/store'; import * as api from '../api'; import { OnboardingOverlay } from './OnboardingOverlay'; @@ -84,6 +87,198 @@ describe('OnboardingOverlay (red-phase: Stories 8.1, 8.2, 8.3)', () => { ).toBeInTheDocument(); }); + it('live-previews a captured combination and enables Save (8.3)', () => { + showOverlay(); + useOnboardingStore.setState({ customizing: true }); + render(); + + expect(screen.getByTestId('save-custom-hotkey')).toBeDisabled(); + + fireEvent.keyDown(window, { + code: 'KeyJ', + key: 'j', + ctrlKey: true, + shiftKey: true, + }); + + const preview = screen.getByTestId('hotkey-capture'); + expect(preview).toHaveTextContent('Ctrl'); + expect(preview).toHaveTextContent('Shift'); + expect(preview).toHaveTextContent('J'); + expect(screen.getByTestId('save-custom-hotkey')).toBeEnabled(); + }); + + it('warns on an unbindable combination and leaves Save disabled (8.3)', () => { + showOverlay(); + useOnboardingStore.setState({ customizing: true }); + render(); + + // No modifier — not a bindable global shortcut. + fireEvent.keyDown(window, { code: 'KeyJ', key: 'j' }); + + expect(screen.getByTestId('hotkey-warning')).toHaveTextContent( + /at least one modifier/i, + ); + expect(screen.getByTestId('save-custom-hotkey')).toBeDisabled(); + }); + + it('saves a captured shortcut via the shared path and shows the new caps (8.3)', async () => { + const setGlobalShortcut = vi + .spyOn(useSettingsStore.getState(), 'setGlobalShortcut') + .mockResolvedValue(true); + showOverlay('Ctrl+Shift+N'); + useOnboardingStore.setState({ customizing: true }); + render(); + + fireEvent.keyDown(window, { + code: 'KeyJ', + key: 'j', + ctrlKey: true, + shiftKey: true, + }); + fireEvent.click(screen.getByTestId('save-custom-hotkey')); + + await waitFor(() => + expect(useOnboardingStore.getState().customizing).toBe(false), + ); + expect(setGlobalShortcut).toHaveBeenCalledWith('Ctrl+Shift+J'); + expect(useOnboardingStore.getState().hotkey).toBe('Ctrl+Shift+J'); + const caps = screen.getByTestId('hotkey-display'); + expect(caps).toHaveTextContent('J'); + }); + + it('warns and stays in capture when the shortcut conflicts (8.3)', async () => { + vi.spyOn(useSettingsStore.getState(), 'setGlobalShortcut').mockResolvedValue( + false, + ); + showOverlay('Ctrl+Shift+N'); + useOnboardingStore.setState({ customizing: true }); + render(); + + fireEvent.keyDown(window, { + code: 'KeyJ', + key: 'j', + ctrlKey: true, + shiftKey: true, + }); + fireEvent.click(screen.getByTestId('save-custom-hotkey')); + + await waitFor(() => + expect(screen.getByTestId('hotkey-warning')).toHaveTextContent( + /unavailable/i, + ), + ); + expect(useOnboardingStore.getState().customizing).toBe(true); + expect(useOnboardingStore.getState().hotkey).toBe('Ctrl+Shift+N'); + }); + + it('does not submit duplicate saves while registration is pending (8.3)', async () => { + let resolveShortcut!: (value: boolean) => void; + const pending = new Promise((resolve) => { + resolveShortcut = resolve; + }); + const applyCustomHotkey = vi + .spyOn(useOnboardingStore.getState(), 'applyCustomHotkey') + .mockReturnValue(pending); + showOverlay('Ctrl+Shift+N'); + useOnboardingStore.setState({ customizing: true }); + render(); + + fireEvent.keyDown(window, { + code: 'KeyJ', + key: 'j', + ctrlKey: true, + shiftKey: true, + }); + const save = screen.getByTestId('save-custom-hotkey'); + fireEvent.click(save); + fireEvent.click(save); + + expect(applyCustomHotkey).toHaveBeenCalledTimes(1); + expect(save).toBeDisabled(); + expect(screen.getByTestId('cancel-custom-hotkey')).toBeDisabled(); + + resolveShortcut(false); + await waitFor(() => + expect(screen.getByTestId('hotkey-warning')).toHaveTextContent( + /unavailable/i, + ), + ); + expect(save).toBeDisabled(); + expect(screen.getByTestId('cancel-custom-hotkey')).toBeEnabled(); + }); + + it('lets focused capture controls receive Enter without treating it as a shortcut (8.3)', () => { + showOverlay(); + useOnboardingStore.setState({ customizing: true }); + render(); + + fireEvent.keyDown(window, { + code: 'KeyJ', + key: 'j', + ctrlKey: true, + shiftKey: true, + }); + const save = screen.getByTestId('save-custom-hotkey'); + save.focus(); + + fireEvent.keyDown(save, { key: 'Enter', code: 'Enter' }); + + expect(screen.queryByTestId('hotkey-warning')).not.toBeInTheDocument(); + expect(screen.getByTestId('hotkey-capture')).toHaveTextContent('J'); + }); + + it('cancels capture without dismissing onboarding (8.3)', () => { + showOverlay(); + useOnboardingStore.setState({ customizing: true }); + render(); + + fireEvent.click(screen.getByTestId('cancel-custom-hotkey')); + + // Capture is abandoned and the overlay returns to the normal instruction; + // onboarding is NOT completed (still visible). + expect(useOnboardingStore.getState().customizing).toBe(false); + expect(useOnboardingStore.getState().isVisible).toBe(true); + expect(screen.getByText(/your capture shortcut is/i)).toBeInTheDocument(); + }); + + it('Esc during capture cancels capture, not onboarding (8.3)', () => { + showOverlay(); + useOnboardingStore.setState({ customizing: true }); + render(); + + fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' }); + + // Esc exits capture but leaves onboarding open (the Esc-dismiss handler is + // suppressed while customizing). + expect(useOnboardingStore.getState().customizing).toBe(false); + expect(useOnboardingStore.getState().isVisible).toBe(true); + }); + + it('ignores stale global-hotkey events after capture starts (8.3)', async () => { + let captured: EventCallback | null = null; + vi.spyOn(events.hotkeyPressed, 'listen').mockImplementation((cb) => { + captured = cb; + return Promise.resolve(vi.fn()); + }); + const dismiss = vi + .spyOn(useOnboardingStore.getState(), 'dismiss') + .mockResolvedValue(); + showOverlay(); + render(); + await waitFor(() => expect(captured).not.toBeNull()); + + act(() => { + useOnboardingStore.setState({ isVisible: true, customizing: true }); + }); + expect(useOnboardingStore.getState().customizing).toBe(true); + dismiss.mockClear(); + captured!({ event: 'hotkey-pressed', id: 1, payload: null }); + + expect(dismiss).not.toHaveBeenCalled(); + expect(useOnboardingStore.getState().isVisible).toBe(true); + }); + it('shows macOS accessibility guidance with a settings link when required (8.2)', () => { vi.spyOn(api, 'loadOnboardingState').mockResolvedValue({ complete: false, diff --git a/src/features/onboarding/components/OnboardingOverlay.tsx b/src/features/onboarding/components/OnboardingOverlay.tsx index e96bc9c..9dd560e 100644 --- a/src/features/onboarding/components/OnboardingOverlay.tsx +++ b/src/features/onboarding/components/OnboardingOverlay.tsx @@ -1,12 +1,31 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { events } from '../../../generated/bindings'; import { useFocusTrap } from '../../../lib/useFocusTrap'; +import { formatShortcutFromEvent } from '../../settings/shortcut'; import { checkAccessibilityPermission, openAccessibilitySettings } from '../api'; import { useOnboardingStore } from '../store'; /** How often to re-check the macOS accessibility grant while guidance is shown. */ const ACCESSIBILITY_POLL_MS = 1500; +/** Physical `KeyboardEvent.code` values for the modifier keys. */ +const MODIFIER_CODES = new Set([ + 'ShiftLeft', + 'ShiftRight', + 'ControlLeft', + 'ControlRight', + 'AltLeft', + 'AltRight', + 'MetaLeft', + 'MetaRight', +]); + +/** Inline guidance shown when a captured combination is not a bindable shortcut. */ +const USE_MODIFIER_MSG = + 'Use at least one modifier (Ctrl/Cmd/Shift/Alt) with a letter or number.'; +/** Inline guidance shown when the backend rejects a captured shortcut. */ +const CONFLICT_MSG = 'That shortcut is unavailable. Try a different combination.'; + /** * First-run onboarding overlay (Epic 8). * @@ -15,7 +34,10 @@ const ACCESSIBILITY_POLL_MS = 1500; * renders the macOS accessibility-guidance state (Story 8.2) — a permission * message, a working "Open System Settings" button, and a "Skip for now" control — * auto-dismissing the guidance when a grant is detected. The Customize capture - * state (Story 8.3) is still an inert shell. + * state (Story 8.3) captures a new modifier+key combination, previews it live, + * and on Save registers + persists it through the shared Settings path + * ({@link useOnboardingStore.applyCustomHotkey}); a conflict warns inline and + * keeps capture open so the user can retry. */ export function OnboardingOverlay() { const isVisible = useOnboardingStore((s) => s.isVisible); @@ -24,6 +46,12 @@ export function OnboardingOverlay() { const accessibilityNeeded = useOnboardingStore((s) => s.accessibilityNeeded); const dialogRef = useRef(null); const instructionRef = useRef(null); + const saveInFlightRef = useRef(false); + // Story 8.3 capture state: the combo captured so far (null until a valid one) + // and any inline warning (invalid combination or a registration conflict). + const [captured, setCaptured] = useState(null); + const [warning, setWarning] = useState(null); + const [isSaving, setIsSaving] = useState(false); useFocusTrap(dialogRef, isVisible); @@ -34,9 +62,11 @@ export function OnboardingOverlay() { // Dismiss on Esc. A window-level listener (not an element handler) is used // because focus is on the editor when the overlay first appears, so the - // keydown does not originate inside the dialog subtree. + // keydown does not originate inside the dialog subtree. Disabled while + // customizing — there Esc cancels capture (the capture-phase handler below + // stops propagation so this never fires), not onboarding. useEffect(() => { - if (!isVisible) return; + if (!isVisible || customizing) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); @@ -45,20 +75,25 @@ export function OnboardingOverlay() { }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [isVisible]); + }, [isVisible, customizing]); // Dismiss on a global-hotkey press. The OS-level shortcut hides the window // without reloading the webview and does not reliably deliver a keydown to the // page, so the backend emits a typed `hotkey-pressed` event we listen for here. // Guarded so a missing Tauri runtime (unit tests) cannot crash the overlay. + // Suppressed while customizing so a stray press of the still-registered old + // shortcut cannot complete onboarding mid-capture. useEffect(() => { - if (!isVisible) return; + if (!isVisible || customizing) return; let active = true; let unlisten: (() => void) | null = null; try { void events.hotkeyPressed .listen(() => { - void useOnboardingStore.getState().dismiss(); + if (!active) return; + const state = useOnboardingStore.getState(); + if (!state.isVisible || state.customizing) return; + void state.dismiss(); }) .then((u) => { if (active) unlisten = u; @@ -72,7 +107,50 @@ export function OnboardingOverlay() { active = false; unlisten?.(); }; - }, [isVisible]); + }, [isVisible, customizing]); + + // Story 8.3 capture: while customizing, listen for the next modifier+key combo + // on the window in the CAPTURE phase, stopping propagation so Esc cancels + // capture (not onboarding) and the combo never leaks to the editor. Bare + // modifiers are ignored until a main key arrives; an unbindable combination + // surfaces an inline warning instead of being captured. + useEffect(() => { + if (!isVisible || !customizing) return; + saveInFlightRef.current = false; + setCaptured(null); + setWarning(null); + setIsSaving(false); + const handler = (e: KeyboardEvent) => { + if (e.key === 'Tab') return; + const target = e.target; + const targetButton = + target instanceof HTMLElement ? target.closest('button') : null; + if ( + targetButton !== null && + (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') + ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + if (saveInFlightRef.current) return; + if (e.key === 'Escape') { + useOnboardingStore.getState().cancelCustomize(); + return; + } + if (MODIFIER_CODES.has(e.code)) return; + const combo = formatShortcutFromEvent(e); + if (combo === null) { + setCaptured(null); + setWarning(USE_MODIFIER_MSG); + return; + } + setCaptured(combo); + setWarning(null); + }; + window.addEventListener('keydown', handler, true); + return () => window.removeEventListener('keydown', handler, true); + }, [isVisible, customizing]); // While the macOS accessibility guidance is shown, re-check the grant so the // guidance auto-dismisses once the user enables the permission. macOS emits no @@ -107,6 +185,25 @@ export function OnboardingOverlay() { .map((part) => part.trim()) .filter(Boolean); + // Register + persist the captured shortcut via the shared Epic 7 path. On a + // conflict the store keeps capture mode open; surface an inline warning so the + // user can try another combination. + const handleSaveCustom = async () => { + if (captured === null || saveInFlightRef.current) return; + saveInFlightRef.current = true; + setIsSaving(true); + try { + const ok = await useOnboardingStore.getState().applyCustomHotkey(captured); + if (!ok) { + setCaptured(null); + setWarning(CONFLICT_MSG); + } + } finally { + saveInFlightRef.current = false; + setIsSaving(false); + } + }; + return (

) : customizing ? ( -

- Press your preferred shortcut… -

+
+

+ Press your preferred shortcut… +

+
+ {captured === null ? ( + + Waiting for a key combination… + + ) : ( + captured + .split('+') + .map((part) => part.trim()) + .filter(Boolean) + .map((cap) => ( + + {cap} + + )) + )} +
+ {warning !== null && ( +

+ {warning} +

+ )} +
+ + +
+
) : (

{ expect(useOnboardingStore.getState().customizing).toBe(true); }); - it('applyCustomHotkey updates the displayed shortcut and exits capture mode (8.3)', () => { + it('cancelCustomize leaves capture mode without changing the shortcut (8.3)', () => { useOnboardingStore.setState({ customizing: true, hotkey: 'Ctrl+Shift+N' }); - useOnboardingStore.getState().applyCustomHotkey('Alt+Space'); + useOnboardingStore.getState().cancelCustomize(); const s = useOnboardingStore.getState(); - expect(s.hotkey).toBe('Alt+Space'); expect(s.customizing).toBe(false); + expect(s.hotkey).toBe('Ctrl+Shift+N'); + }); + + it('applyCustomHotkey registers via the shared path, then adopts the shortcut and exits capture (8.3)', async () => { + const setGlobalShortcut = vi + .spyOn(useSettingsStore.getState(), 'setGlobalShortcut') + .mockResolvedValue(true); + useOnboardingStore.setState({ customizing: true, hotkey: 'Ctrl+Shift+N' }); + + const ok = await useOnboardingStore.getState().applyCustomHotkey('Ctrl+Shift+J'); + + expect(setGlobalShortcut).toHaveBeenCalledWith('Ctrl+Shift+J'); + expect(ok).toBe(true); + const s = useOnboardingStore.getState(); + expect(s.hotkey).toBe('Ctrl+Shift+J'); + expect(s.customizing).toBe(false); + }); + + it('applyCustomHotkey keeps the old shortcut and stays in capture on a conflict (8.3)', async () => { + vi.spyOn(useSettingsStore.getState(), 'setGlobalShortcut').mockResolvedValue( + false, + ); + useOnboardingStore.setState({ customizing: true, hotkey: 'Ctrl+Shift+N' }); + + const ok = await useOnboardingStore.getState().applyCustomHotkey('Ctrl+Shift+J'); + + expect(ok).toBe(false); + const s = useOnboardingStore.getState(); + expect(s.hotkey).toBe('Ctrl+Shift+N'); + expect(s.customizing).toBe(true); }); it('shows the command hint only within the first 5 sessions (8.1)', () => { diff --git a/src/features/onboarding/store.ts b/src/features/onboarding/store.ts index 13bed94..ede7825 100644 --- a/src/features/onboarding/store.ts +++ b/src/features/onboarding/store.ts @@ -1,4 +1,5 @@ import { create } from 'zustand'; +import { useSettingsStore } from '../settings/store'; import { completeOnboarding, loadOnboardingState } from './api'; /** @@ -35,8 +36,16 @@ interface OnboardingActions { dismiss: () => Promise; /** Enter hotkey capture mode (Story 8.3 "Customize"). */ startCustomize: () => void; - /** Apply a captured shortcut and leave capture mode (Story 8.3). */ - applyCustomHotkey: (combo: string) => void; + /** Leave hotkey capture mode without changing the shortcut (Story 8.3). */ + cancelCustomize: () => void; + /** + * Register + persist a captured shortcut through the shared Epic 7 path and, + * on success, adopt it as the displayed hotkey and leave capture mode (Story + * 8.3). On a conflict/failure the previous shortcut stays registered and + * capture mode is kept so the user can retry. Resolves `true` on success, + * `false` on conflict/error. + */ + applyCustomHotkey: (combo: string) => Promise; /** Set whether macOS accessibility guidance should be shown (Story 8.2). */ setAccessibilityNeeded: (needed: boolean) => void; /** Whether the early command-palette hint should still be shown. */ @@ -57,9 +66,10 @@ const INITIAL: OnboardingStateShape = { /** * Per-feature store for the first-run onboarding flow. * - * RED-PHASE NOTE: the orchestration here is real; it delegates persistence to the - * stubbed {@link import('./api')} bridge, which throws until the green phase. The - * `describe.skip` tests in `store.test.ts` assert these transitions. + * Persistence is delegated to the {@link import('./api')} bridge (onboarding + * completion + session count) and, for hotkey customization (Story 8.3), to the + * shared Settings {@link useSettingsStore.setGlobalShortcut} register-before-commit + * path. The transitions are asserted by `store.test.ts`. */ export const useOnboardingStore = create< OnboardingStateShape & OnboardingActions @@ -84,7 +94,17 @@ export const useOnboardingStore = create< } }, startCustomize: () => set({ customizing: true }), - applyCustomHotkey: (combo) => set({ hotkey: combo, customizing: false }), + cancelCustomize: () => set({ customizing: false }), + applyCustomHotkey: async (combo) => { + // Reuse Settings' register-before-commit path (Epic 7): it validates, + // registers the new shortcut, unregisters the old, persists, and reports a + // conflict by resolving `false` — so onboarding never reimplements that logic. + const ok = await useSettingsStore.getState().setGlobalShortcut(combo); + if (ok) { + set({ hotkey: combo, customizing: false }); + } + return ok; + }, setAccessibilityNeeded: (needed) => set({ accessibilityNeeded: needed }), shouldShowCommandHint: () => get().initialized && get().sessionsSeen < COMMAND_HINT_SESSION_LIMIT, From be3f7870f098cf1bea6263012bbfdfb563e06315 Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 17 Jun 2026 02:48:32 -0700 Subject: [PATCH 08/15] story 8-4-auto-start-on-login: implemented and reviewed via bmad-auto --- .../spec-8-4-auto-start-on-login.md | 154 ++++++++++++++++++ .../sprint-status.yaml | 4 +- src-tauri/Cargo.lock | 59 ++++++- src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 5 + .../autogenerated/get_autostart.toml | 11 ++ .../autogenerated/set_autostart.toml | 11 ++ src-tauri/src/commands/autostart.rs | 104 ++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 36 ++++ src-tauri/src/models/config.rs | 40 ++++- src-tauri/src/platform/linux.rs | 8 +- src-tauri/src/platform/macos.rs | 8 +- src-tauri/src/platform/windows.rs | 8 +- src-tauri/src/services/autostart.rs | 60 ------- src-tauri/src/services/mod.rs | 1 - src-tauri/tests/acl_tests.rs | 2 + src-tauri/tests/autostart_tests.rs | 91 ++++++----- src/features/command-palette/actions.test.ts | 70 ++++++++ src/features/command-palette/actions.ts | 36 ++++ .../hooks/usePaletteCommands.test.ts | 9 +- .../hooks/usePaletteCommands.ts | 8 + .../components/SettingsPanel.test.tsx | 27 +++ .../settings/components/SettingsPanel.tsx | 24 +++ src/features/settings/store.test.ts | 82 ++++++++++ src/features/settings/store.ts | 48 +++++- src/generated/bindings.ts | 16 +- src/test-utils/factories.ts | 2 +- 28 files changed, 803 insertions(+), 123 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-8-4-auto-start-on-login.md create mode 100644 src-tauri/permissions/autogenerated/get_autostart.toml create mode 100644 src-tauri/permissions/autogenerated/set_autostart.toml create mode 100644 src-tauri/src/commands/autostart.rs delete mode 100644 src-tauri/src/services/autostart.rs diff --git a/_bmad-output/implementation-artifacts/spec-8-4-auto-start-on-login.md b/_bmad-output/implementation-artifacts/spec-8-4-auto-start-on-login.md new file mode 100644 index 0000000..2f40411 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-8-4-auto-start-on-login.md @@ -0,0 +1,154 @@ +--- +title: "Story 8.4 — Auto-Start on Login" +type: "feature" +created: "2026-06-17" +status: "done" +baseline_commit: "7260382c80eb5caf2e58a7b084f3c613158b69d1" +context: + - "{project-root}/_bmad-output/implementation-artifacts/epic-8-context.md" + - "{project-root}/_bmad-output/project-context.md" +--- + + + +## Intent + +**Problem:** Notey has no way to start itself when the user logs in. Epic 8 ships only a red-phase scaffold (`services/autostart.rs` + `tests/autostart_tests.rs` + `Platform` trait `autostart_*` stubs, all `todo!`/`#[ignore]`); the auto-start plugin is not even a dependency, there is no config key, no IPC command, no UI affordance, and no ACL entries. A user cannot enable auto-start, and the app never relaunches after a reboot. (FR41–FR43.) + +**Approach:** Adopt `tauri-plugin-autostart` (v2.5.x) as the single OS mechanism, registered with `MacosLauncher::LaunchAgent`. Persist the user's preference as the new boolean `[general] auto_start` in the existing `config.toml` (the app's single config source of truth). Add two thin tauri-specta commands — `set_autostart(enabled)` (register/unregister via the plugin AND persist) and `get_autostart()` (live OS state) — surface a toggle in the Settings panel and a Command Palette action, and reconcile the OS to the persisted preference on every startup so a reboot relaunches the tray daemon. Add the required ACL permissions. + +## Boundaries & Constraints + +**Always:** +- OS registration goes through `tauri-plugin-autostart` only, via `app.autolaunch()` (`ManagerExt`): `enable()` / `disable()` / `is_enabled()`. Register the plugin with `MacosLauncher::LaunchAgent`. +- The persisted preference lives at `config.toml` `[general] auto_start` (serde key `autoStart`), a `bool` defaulting to `false`, on the existing `GeneralConfig` / `AppConfig`. It is the source of truth; on startup the OS state is reconciled to match it. +- `set_autostart` must do BOTH halves atomically under the existing `Mutex`: call the plugin to register/unregister, then persist the new value with `services::config::save` and update the in-memory config — mirroring `update_config`'s lock discipline. It returns the updated `AppConfig`. +- Both `set_autostart` and `get_autostart` are idempotent and guard plugin calls behind `#[cfg(desktop)]` (mirror `update_config`); map plugin errors to `NoteyError::Config`. +- All frontend IPC goes through generated tauri-specta bindings (`commands.setAutostart` / `commands.getAutostart` / `commands.getConfig`) — never raw `invoke`, never the plugin's JS package, no network, no broad FS. +- Capability ACL `default.json` must include `autostart:allow-enable`, `autostart:allow-disable`, `autostart:allow-is-enabled` (the plugin's commands, per AC) AND `allow-set-autostart`, `allow-get-autostart` (our commands). New `allow-*` commands are added to `EXPECTED_COMMANDS` in `tests/acl_tests.rs`, with autogenerated permission TOMLs created manually if the build skips them (known Tauri v2 gotcha). +- Re-enabling/disabling when already in that state is a no-op (check `is_enabled()` first). + +**Ask First:** +- Changing the persisted location away from `config.toml [general] auto_start` (e.g. back to a separate `autostart.toml`). +- Adding the `@tauri-apps/plugin-autostart` JS package or any other new runtime dependency beyond the Rust plugin crate. +- Implementing the `Platform` trait `autostart_*` methods as a second mechanism (they remain deferred — see Never). + +**Never:** +- Do NOT implement the `Platform` trait `autostart_enable/disable/is_enabled` methods as the mechanism for this story — the plugin is the single mechanism. Leave those stubs deferred to the Story 8.6 platform-abstraction capstone (only update their `todo!` text so they no longer claim Story 8.4). +- Do NOT introduce a second persistence file/section for auto-start; reuse `config.toml`. +- Do NOT add `auto_start` to `PartialGeneralConfig`/`merge_update` (avoids a `PartialGeneralConfig` ripple across existing settings update sites) — `set_autostart` sets the field directly under the lock. +- Do NOT call the plugin from non-desktop targets; do NOT add a new E2E journey (the first-run flow E2E is owned by the later cross-platform pass). +- Do NOT change `update_config`, the global-shortcut path, or any other unrelated config field. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +| --- | --- | --- | --- | +| Enable | `set_autostart(true)`, currently disabled | plugin `enable()`; `config.general.auto_start=true` saved; returns updated `AppConfig` | plugin error → `NoteyError::Config`, nothing persisted | +| Disable | `set_autostart(false)`, currently enabled | plugin `disable()`; `auto_start=false` saved; returns updated config | plugin error → `NoteyError::Config` | +| Enable when already on | `set_autostart(true)`, already enabled | no duplicate registration; `auto_start=true` persisted; success | N/A | +| Query live state | `get_autostart()` | returns `app.autolaunch().is_enabled()` | plugin error → `NoteyError::Config` | +| Fresh install | no `config.toml`, or `[general]` missing `autoStart` | `auto_start` defaults to `false`; auto-start off | missing key tolerated via serde default | +| Startup reconcile | `auto_start=true` but OS not registered (e.g. artifact removed) | startup best-effort `enable()` so reboot relaunches | failure logged, non-fatal — never blocks startup | +| Toggle in Settings | user clicks the auto-start toggle | `commands.setAutostart(!current)`; snapshot adopts returned config; toast on success | failure → error toast, displayed state unchanged | +| Palette toggle | "Toggle Auto-Start on Login" invoked | flips current state via the settings store (singleflight-guarded) | concurrent invokes coalesced | + + + +## Code Map + +- `src-tauri/Cargo.toml` -- add `tauri-plugin-autostart = "2"` (resolves to 2.5.x; match the existing caret-pin style of the other tauri plugins). +- `src-tauri/src/models/config.rs` -- add `#[serde(default)] pub auto_start: bool` to `GeneralConfig`; set `auto_start: false` in its `Default` impl. Generated bindings gain `GeneralConfig.autoStart: boolean`. +- `src-tauri/src/commands/autostart.rs` -- NEW. `set_autostart(app, config_state, config_dir_state, enabled: bool) -> Result` and `get_autostart(app) -> Result`. Thin handlers: plugin call under `#[cfg(desktop)]` + persist via `services::config::save` under the `Mutex` lock. `#[tauri::command] #[specta::specta]`. +- `src-tauri/src/commands/mod.rs` -- add `pub mod autostart;`. +- `src-tauri/src/lib.rs` -- (1) `.plugin(tauri_plugin_autostart::init(MacosLauncher::LaunchAgent, Some(vec![])))` in the builder chain; (2) add `commands::autostart::set_autostart` + `get_autostart` to `collect_commands!`; (3) in `setup`, after config load and before `app.manage(config)`, best-effort reconcile OS state to `config.general.auto_start` (`#[cfg(desktop)]`, non-fatal). +- `src-tauri/src/services/autostart.rs` -- DELETE (superseded: preference now lives in `config.toml`; OS handled by the plugin). Remove its line from `src-tauri/src/services/mod.rs`. +- `src-tauri/src/platform/{linux,macos,windows}.rs` -- update only the `autostart_*` `todo!` messages to reference Story 8.6 (plugin is the 8.4 mechanism); no behavior change. +- `src-tauri/capabilities/default.json` -- add the 5 permission identifiers listed in Always. +- `src-tauri/permissions/autogenerated/{set_autostart,get_autostart}.toml` -- create manually if `cargo build` doesn't generate them (project gotcha). +- `src-tauri/tests/acl_tests.rs` -- add `allow-set-autostart`, `allow-get-autostart` to `EXPECTED_COMMANDS`. +- `src-tauri/tests/autostart_tests.rs` -- rewrite to pin the config-backed `auto_start` persistence + idempotence contract via `services::config` + `models::config` (hermetic `TempDir`); remove `#[ignore]` and the obsolete `services::autostart` import. Platform launch-agent behavior stays QA-verified (keep that note). +- `src/features/settings/store.ts` -- add async `setAutostart(enabled: boolean)` (persist via `commands.setAutostart`, adopt returned config into snapshot, toast on success / revert+toast on failure); in `open()`, best-effort reconcile the toggle to `commands.getAutostart()`. +- `src/features/settings/components/SettingsPanel.tsx` -- add a General-section toggle row, `data-testid="autostart-toggle"`, `aria-pressed`, bound to `config.general.autoStart`, `onClick` → `setAutostart(!current)` (mirror the existing theme/layout row pattern). +- `src/features/command-palette/actions.ts` -- add `toggleAutostart()` (wrap in `singleflight('toggle-autostart', …)`, flip current state via the settings store). +- `src/features/command-palette/hooks/usePaletteCommands.ts` -- register a "Settings"-group command `{ id: 'toggle-autostart', label: 'Toggle Auto-Start on Login', action: toggleAutostart }`. +- Tests: `src/features/settings/store.test.ts`, `src/features/settings/components/SettingsPanel.test.tsx`, and the command-palette tests -- add coverage mirroring the existing theme-toggle cases (assert `set_autostart` invoked with `{ enabled }`, snapshot adoption, toast). Update `buildConfig` test factories to include `autoStart`. + +## Tasks & Acceptance + +**Execution:** + +- [x] `src-tauri/Cargo.toml` -- add the `tauri-plugin-autostart` dependency. +- [x] `src-tauri/src/models/config.rs` -- add `auto_start: bool` (serde default false) to `GeneralConfig` + `Default`; add/extend a unit test asserting the default is false and round-trips through TOML. +- [x] `src-tauri/src/commands/autostart.rs` + `commands/mod.rs` -- implement `set_autostart` + `get_autostart` (plugin + lock-disciplined persist), registered. +- [x] `src-tauri/src/lib.rs` -- register the plugin (LaunchAgent), wire both commands, add the non-fatal startup OS↔config reconcile. +- [x] `src-tauri/src/services/{autostart.rs,mod.rs}` -- delete the superseded scaffold service + its `mod` line. +- [x] `src-tauri/src/platform/{linux,macos,windows}.rs` -- retarget the `autostart_*` `todo!` messages to Story 8.6 (no behavior change). +- [x] `src-tauri/capabilities/default.json` + `permissions/autogenerated/*` + `tests/acl_tests.rs` -- add the 5 ACL identifiers, create permission TOMLs if needed, extend `EXPECTED_COMMANDS`. +- [x] `src-tauri/tests/autostart_tests.rs` -- rewrite to the config-backed persistence/idempotence contract; un-ignore. +- [x] `src/features/settings/store.ts` + `SettingsPanel.tsx` -- add `setAutostart` action + the toggle row + open() reconcile. +- [x] `src/features/command-palette/{actions.ts,hooks/usePaletteCommands.ts}` -- add the singleflight-guarded `toggleAutostart` action + palette entry. +- [x] Frontend tests -- add store + SettingsPanel + palette coverage; update `buildConfig` factories for `autoStart`. + +**Acceptance Criteria:** + +- Given the desktop app is running, when the user enables auto-start via the Settings toggle or the Command Palette action, then `tauri-plugin-autostart` registers the login launcher (LaunchAgent on macOS), `config.toml` persists `[general] auto_start = true`, and the toggle reflects the enabled state. +- Given auto-start is enabled, when the user disables it, then the plugin unregisters the launcher and `config.toml` persists `auto_start = false`. +- Given `auto_start = true` is persisted, when the app starts (e.g. after a reboot/login), then startup reconciles the OS registration to the preference (best-effort, non-fatal) so Notey relaunches as the tray daemon without user action. +- Given the capability ACL, when permissions are checked, then `autostart:allow-enable`, `autostart:allow-disable`, `autostart:allow-is-enabled`, `allow-set-autostart`, and `allow-get-autostart` are all present, and `acl_tests` passes. +- Given the build, when the suites run, then `cargo test` (incl. the un-ignored `autostart_tests` + `acl_tests`), `cargo clippy -- -D warnings`, `vitest`, `tsc`, and `npm run build` all pass with the regenerated bindings. + +### Review Findings + +- [x] [Review][Patch] `set_autostart` was not atomic on config save failure [src-tauri/src/commands/autostart.rs:50] — fixed by saving a cloned config first, committing the in-memory config only after save succeeds, and rolling back the OS registration best-effort when persistence fails. +- [x] [Review][Patch] Settings open reconciliation could overwrite a completed user toggle [src/features/settings/store.ts:128] — fixed by applying the live OS reconciliation only while the config snapshot still matches the one loaded by `open()`. +- [x] [Review][Patch] Palette toggle could flip stale persisted state instead of live OS state [src/features/command-palette/actions.ts:441] — fixed by querying `get_autostart` before toggling and falling back to persisted config only when the live query cannot return an `ok` result. +- [x] [Review][Patch] Generated bindings contained trailing whitespace [src/generated/bindings.ts:54] — fixed by tightening the Rust command rustdoc and regenerating bindings through the Rust export path. +- [x] [Review][Patch] Spec artifact had stray closing tags [_bmad-output/implementation-artifacts/spec-8-4-auto-start-on-login.md:134] — fixed by removing the malformed `` / `` suffix. +- [x] [Review][Patch] Hand-edited `auto_start` config spelling was not accepted [src-tauri/src/models/config.rs:55] — fixed by accepting `auto_start` as a serde alias while preserving the existing generated `autoStart` IPC/config shape. + +#### Review Ledger (2026-06-17) + +- patched: `set_autostart` was not atomic on config save failure [src-tauri/src/commands/autostart.rs:50] — save now commits through a cloned config and rolls back OS registration best-effort on persistence failure. +- patched: Settings open reconciliation could overwrite a completed user toggle [src/features/settings/store.ts:128] — stale live-state responses are ignored once the loaded config snapshot has been replaced. +- patched: Palette toggle could flip stale persisted state instead of live OS state [src/features/command-palette/actions.ts:441] — action now uses live OS state when available. +- patched: Generated bindings contained trailing whitespace [src/generated/bindings.ts:54] — rustdoc/generation path now produces a whitespace-clean diff. +- patched: Spec artifact had stray closing tags [_bmad-output/implementation-artifacts/spec-8-4-auto-start-on-login.md:134] — malformed suffix removed. +- patched: Hand-edited `auto_start` config spelling was not accepted [src-tauri/src/models/config.rs:55] — `auto_start` is accepted as a deserialize alias while `autoStart` remains the emitted/generated key per the spec's serde-key/code-map notes and existing project convention. +- dismissed: Missing backend command module [src-tauri/src/commands/mod.rs:2] — false positive from reviewing tracked diff only; automation diff includes untracked `src-tauri/src/commands/autostart.rs`. +- dismissed: Raw autostart plugin commands bypass persisted config source of truth [src-tauri/capabilities/default.json:14] — frozen acceptance criteria explicitly require the three `autostart:allow-*` plugin ACL permissions in addition to the mediated custom commands. +- dismissed: Persisted TOML key is `autoStart`, not literal `auto_start` [src-tauri/src/models/config.rs:55] — frozen spec also names `autoStart` as the serde key and generated binding shape; alias support was added for hand-edited `auto_start` configs. + +## Design Notes + +**Why the plugin, not the `Platform` trait:** Story 8.4's frozen AC mandates `tauri-plugin-autostart` configured with `MacosLauncher::LaunchAgent` and the `autostart:allow-*` ACL entries — i.e. the plugin IS the contract. The plugin needs an `AppHandle` (`app.autolaunch()`), which the object-safe `Platform` trait (constructed by `platform::current()` with no app) cannot provide, and its `autostart_*` stubs are labeled for the Story 8.6 platform capstone. Using the plugin avoids a redundant second mechanism (the plugin already wraps the per-OS `.desktop`/LaunchAgent/registry logic the stubs described). + +**Why `config.toml [general] auto_start`, deleting `services/autostart.rs`:** The AC literally specifies `config.toml: [general] auto_start`. The scaffold's separate `autostart.toml`/`AutostartState` existed only to dodge a feared `GeneralConfig` ripple during the red phase, and its module doc explicitly says "reconcile the section name at green phase." Reconciling to the one config source removes a redundant file and keeps a single source of truth. Adding the field directly in `set_autostart` (not via `PartialGeneralConfig`) keeps the change off the shared partial-update path, so existing `update_config` partial literals are untouched. + +**Command shape (mirror `update_config`'s lock discipline):** +```rust +let mut cfg = config_state.lock().unwrap_or_else(recover_poisoned_config); +#[cfg(desktop)] { + use tauri_plugin_autostart::ManagerExt; + let mgr = app.autolaunch(); + if enabled != mgr.is_enabled().map_err(cfg_err)? { + if enabled { mgr.enable() } else { mgr.disable() }.map_err(cfg_err)?; + } +} +cfg.general.auto_start = enabled; +services::config::save(&config_dir_state.0, &cfg)?; // persist after a successful OS change +Ok(cfg.clone()) +``` + +## Verification + +**Commands:** + +- `cd src-tauri && cargo test` -- expected: all suites green, including the un-ignored `autostart_tests` and the extended `acl_tests`. +- `cd src-tauri && cargo clippy --all-targets -- -D warnings` -- expected: clean. +- `npm test -- src/features/settings src/features/command-palette` -- expected: new auto-start store/panel/palette tests pass. +- `npm run build` -- expected: tauri-specta regenerates `src/generated/bindings.ts` with `setAutostart`/`getAutostart`/`GeneralConfig.autoStart`; `tsc` + vite build succeed. + +**Manual checks (if no CLI):** + +- Platform launch-agent behavior (does the OS actually relaunch the app at login?) is QA-verified per `autostart_tests.rs`'s module note — not covered by automated tests. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 21f48eb..6285d6a 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -40,7 +40,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: 2026-04-03 -last_updated: 2026-06-17 # story 8-3 → done +last_updated: 2026-06-17 # story 8-4 → done # Note: epic-2-retrospective rewritten fresh on 2026-04-04 project: notey project_key: NOKEY @@ -136,7 +136,7 @@ development_status: 8-1-first-run-detection-onboarding-overlay: done 8-2-macos-accessibility-permission-guidance: done 8-3-hotkey-customization-during-onboarding: done - 8-4-auto-start-on-login: backlog + 8-4-auto-start-on-login: done 8-5-per-user-data-isolation: backlog 8-6-cross-platform-verification-wayland-fallback: backlog epic-8-retrospective: optional diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5dc78c2..77a697d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -213,6 +213,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "auto-launch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +dependencies = [ + "dirs 4.0.0", + "thiserror 1.0.69", + "winreg 0.10.1", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -737,6 +748,15 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "5.0.1" @@ -755,6 +775,17 @@ dependencies = [ "dirs-sys 0.5.0", ] +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + [[package]] name = "dirs-sys" version = "0.4.1" @@ -908,7 +939,7 @@ dependencies = [ "rustc_version", "toml 0.9.12+spec-1.1.0", "vswhom", - "winreg", + "winreg 0.55.0", ] [[package]] @@ -4016,6 +4047,7 @@ dependencies = [ "specta-typescript", "tauri", "tauri-build", + "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-global-shortcut", "tauri-plugin-opener", @@ -4105,6 +4137,20 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-autostart" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +dependencies = [ + "auto-launch", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.1" @@ -5133,7 +5179,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -5629,6 +5675,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "winreg" version = "0.55.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 496192c..7a1d9ca 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ tauri = { version = "2", default-features = false, features = ["wry", "compressi tauri-plugin-opener = "2" tauri-plugin-global-shortcut = "2" tauri-plugin-dialog = "2" +tauri-plugin-autostart = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.34", features = ["bundled", "modern_sqlite"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5fe5635..201a9d2 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -11,6 +11,11 @@ "global-shortcut:allow-register", "global-shortcut:allow-unregister", "global-shortcut:allow-is-registered", + "autostart:allow-enable", + "autostart:allow-disable", + "autostart:allow-is-enabled", + "allow-set-autostart", + "allow-get-autostart", "allow-create-note", "allow-get-note", "allow-update-note", diff --git a/src-tauri/permissions/autogenerated/get_autostart.toml b/src-tauri/permissions/autogenerated/get_autostart.toml new file mode 100644 index 0000000..ef7d75a --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_autostart.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-autostart" +description = "Enables the get_autostart command without any pre-configured scope." +commands.allow = ["get_autostart"] + +[[permission]] +identifier = "deny-get-autostart" +description = "Denies the get_autostart command without any pre-configured scope." +commands.deny = ["get_autostart"] diff --git a/src-tauri/permissions/autogenerated/set_autostart.toml b/src-tauri/permissions/autogenerated/set_autostart.toml new file mode 100644 index 0000000..ed8c7f5 --- /dev/null +++ b/src-tauri/permissions/autogenerated/set_autostart.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-set-autostart" +description = "Enables the set_autostart command without any pre-configured scope." +commands.allow = ["set_autostart"] + +[[permission]] +identifier = "deny-set-autostart" +description = "Denies the set_autostart command without any pre-configured scope." +commands.deny = ["set_autostart"] diff --git a/src-tauri/src/commands/autostart.rs b/src-tauri/src/commands/autostart.rs new file mode 100644 index 0000000..0696956 --- /dev/null +++ b/src-tauri/src/commands/autostart.rs @@ -0,0 +1,104 @@ +//! Thin Tauri command handlers for auto-start on login (Story 8.4 / FR41–FR43). +//! +//! The OS launch agent is owned by `tauri-plugin-autostart` (registered with +//! `MacosLauncher::LaunchAgent` in `lib.rs`) and reached via `app.autolaunch()`. +//! The user's preference is the single source of truth at `config.toml` +//! `[general] auto_start`; [`set_autostart`] registers/unregisters the OS agent +//! AND persists the preference atomically under the shared `Mutex`, +//! mirroring [`crate::commands::config::update_config`]'s lock discipline. + +use std::sync::Mutex; + +use tauri::State; + +use crate::errors::NoteyError; +use crate::models::config::AppConfig; +use crate::services; + +use super::config::ConfigDir; +use super::recover_poisoned_config; + +/// Enable/disable auto-start via the plugin, persist atomically, and return committed config. +#[tauri::command] +#[specta::specta] +pub fn set_autostart( + #[allow(unused_variables)] app: tauri::AppHandle, + config_state: State<'_, Mutex>, + config_dir_state: State<'_, ConfigDir>, + enabled: bool, +) -> Result { + // Hold the config mutex across the OS change + persist so a concurrent + // update_config cannot interleave and clobber the stored snapshot. + let mut config = config_state.lock().unwrap_or_else(recover_poisoned_config); + + #[cfg(desktop)] + let rollback_to = { + use tauri_plugin_autostart::ManagerExt; + + let manager = app.autolaunch(); + let currently_enabled = manager + .is_enabled() + .map_err(|e| NoteyError::Config(format!("Failed to query auto-start state: {e}")))?; + + if enabled != currently_enabled { + let result = if enabled { + manager.enable() + } else { + manager.disable() + }; + result.map_err(|e| { + NoteyError::Config(format!( + "Failed to {} auto-start: {e}", + if enabled { "enable" } else { "disable" } + )) + })?; + Some(currently_enabled) + } else { + None + } + }; + + // OS registration succeeded (or no change needed) — now persist the preference. + let mut updated = config.clone(); + updated.general.auto_start = enabled; + if let Err(save_err) = services::config::save(&config_dir_state.0, &updated) { + #[cfg(desktop)] + if let Some(previously_enabled) = rollback_to { + use tauri_plugin_autostart::ManagerExt; + + let manager = app.autolaunch(); + let rollback = if previously_enabled { + manager.enable() + } else { + manager.disable() + }; + if let Err(rollback_err) = rollback { + eprintln!("warning: failed to roll back auto-start after config save failure: {rollback_err}"); + } + } + return Err(save_err); + } + *config = updated; + + Ok(config.clone()) +} + +/// Whether auto-start on login is currently registered at the OS level. +/// Reports the live platform state via the autostart plugin (the source of truth +/// for the *active* registration), letting the UI reconcile its toggle with the +/// real OS state. Off desktop there is no launch agent, so it reports `false`. +#[tauri::command] +#[specta::specta] +pub fn get_autostart(#[allow(unused_variables)] app: tauri::AppHandle) -> Result { + #[cfg(desktop)] + { + use tauri_plugin_autostart::ManagerExt; + app.autolaunch() + .is_enabled() + .map_err(|e| NoteyError::Config(format!("Failed to query auto-start state: {e}"))) + } + #[cfg(not(desktop))] + { + Ok(false) + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6091572..389887b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod accessibility; +pub mod autostart; pub mod config; pub mod export; pub mod notes; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 686a0bf..39cdae5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -40,6 +40,8 @@ fn specta_builder() -> tauri_specta::Builder { commands::onboarding::increment_onboarding_session, commands::accessibility::check_accessibility_permission, commands::accessibility::open_accessibility_settings, + commands::autostart::set_autostart, + commands::autostart::get_autostart, commands::window::dismiss_window, commands::window::apply_layout_mode, commands::workspace::create_workspace, @@ -158,6 +160,12 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + // Auto-start on login (Story 8.4). LaunchAgent is the macOS mechanism; no + // extra launch args. The plugin exposes app.autolaunch() (ManagerExt). + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + Some(vec![]), + )) .invoke_handler(builder.invoke_handler()) .setup(move |app| { // Register typed tauri-specta events (e.g. `note-created`) into the @@ -208,6 +216,34 @@ pub fn run() { } } + // --- Auto-start on login: reconcile OS registration to the saved + // preference (Story 8.4 / FR43). The persisted `[general] auto_start` + // is the source of truth; aligning the launch agent on every startup + // ensures a reboot relaunches Notey when enabled (and that an + // externally-removed agent is restored). Best-effort and non-fatal — + // a plugin failure must never block startup. + #[cfg(desktop)] + { + use tauri_plugin_autostart::ManagerExt; + + let desired = config.general.auto_start; + let manager = app.autolaunch(); + match manager.is_enabled() { + Ok(active) if active != desired => { + let outcome = if desired { + manager.enable() + } else { + manager.disable() + }; + if let Err(e) = outcome { + eprintln!("warning: failed to reconcile auto-start to {desired}: {e}"); + } + } + Ok(_) => {} + Err(e) => eprintln!("warning: failed to query auto-start state: {e}"), + } + } + app.manage(Mutex::new(config)); app.manage(ConfigDir(config_dir)); diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index 950305d..8dd978a 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -37,15 +37,21 @@ pub enum Theme { } /// General application settings. -/// /// `theme` is one of `system` (the default — follow the OS `prefers-color-scheme` /// until the user picks a theme), `dark`, or `light`. A saved manual `dark`/`light` /// preference overrides the OS setting on restart. +/// `auto_start` (serialized `[general] autoStart`) is the persisted auto-start-on-login +/// preference (Story 8.4 / FR41–FR43). It defaults to `false` and tolerates a missing +/// key on older config files via serde. The OS launch agent is managed by +/// `tauri-plugin-autostart`; this field is the single source of truth the app +/// reconciles the OS registration to on every startup. #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct GeneralConfig { pub theme: Theme, pub layout_mode: String, + #[serde(default, alias = "auto_start")] + pub auto_start: bool, } /// Editor-specific settings. @@ -170,6 +176,8 @@ impl Default for GeneralConfig { // The default window mode (Story 7.5). `floating` is the always-on-top // 600×400 capture overlay — the app's primary form factor. layout_mode: "floating".to_string(), + // Auto-start on login is opt-in (Story 8.4). + auto_start: false, } } } @@ -220,4 +228,34 @@ mod tests { assert_eq!(config.shortcuts.command_palette, "Ctrl+P"); assert_eq!(config.shortcuts.close_tab, "Ctrl+W"); } + + #[test] + fn auto_start_defaults_to_false() { + // Story 8.4: a fresh install has auto-start opt-in (disabled). + assert!(!AppConfig::default().general.auto_start); + } + + #[test] + fn auto_start_roundtrips_and_tolerates_missing_key() { + // A config without the key deserializes to the false default (serde default). + let without: AppConfig = + toml::from_str("[general]\ntheme = \"dark\"\nlayoutMode = \"floating\"\n").unwrap(); + assert!(!without.general.auto_start); + + // The approved story text uses the Rust/TOML spelling in some places; accept it + // for hand-edited configs while keeping the existing camelCase emitted schema. + let snake_case: AppConfig = toml::from_str( + "[general]\ntheme = \"dark\"\nlayoutMode = \"floating\"\nauto_start = true\n", + ) + .unwrap(); + assert!(snake_case.general.auto_start); + + // An explicit `true` round-trips through serialize → deserialize. + let mut cfg = AppConfig::default(); + cfg.general.auto_start = true; + let serialized = toml::to_string(&cfg).unwrap(); + assert!(serialized.contains("autoStart = true")); + let parsed: AppConfig = toml::from_str(&serialized).unwrap(); + assert!(parsed.general.auto_start); + } } diff --git a/src-tauri/src/platform/linux.rs b/src-tauri/src/platform/linux.rs index 5df9417..41cb1e4 100644 --- a/src-tauri/src/platform/linux.rs +++ b/src-tauri/src/platform/linux.rs @@ -42,15 +42,17 @@ impl Platform for LinuxPlatform { } fn autostart_enable(&self) -> Result<(), NoteyError> { - todo!("Story 8.4: write XDG autostart .desktop entry") + // Story 8.6 (platform capstone): Story 8.4 ships auto-start via + // tauri-plugin-autostart (app.autolaunch()), not this trait method. + todo!("Story 8.6: route autostart through the Platform trait") } fn autostart_disable(&self) -> Result<(), NoteyError> { - todo!("Story 8.4: remove XDG autostart .desktop entry") + todo!("Story 8.6: route autostart through the Platform trait") } fn autostart_is_enabled(&self) -> Result { - todo!("Story 8.4: check for the XDG autostart .desktop entry") + todo!("Story 8.6: route autostart through the Platform trait") } fn accessibility_permission_granted(&self) -> Result { diff --git a/src-tauri/src/platform/macos.rs b/src-tauri/src/platform/macos.rs index 4b31fba..ab2729e 100644 --- a/src-tauri/src/platform/macos.rs +++ b/src-tauri/src/platform/macos.rs @@ -43,15 +43,17 @@ impl Platform for MacosPlatform { } fn autostart_enable(&self) -> Result<(), NoteyError> { - todo!("Story 8.4: install LaunchAgent plist") + // Story 8.6 (platform capstone): Story 8.4 ships auto-start via + // tauri-plugin-autostart (MacosLauncher::LaunchAgent), not this trait. + todo!("Story 8.6: route autostart through the Platform trait") } fn autostart_disable(&self) -> Result<(), NoteyError> { - todo!("Story 8.4: remove LaunchAgent plist") + todo!("Story 8.6: route autostart through the Platform trait") } fn autostart_is_enabled(&self) -> Result { - todo!("Story 8.4: check for the LaunchAgent plist") + todo!("Story 8.6: route autostart through the Platform trait") } fn accessibility_permission_granted(&self) -> Result { diff --git a/src-tauri/src/platform/windows.rs b/src-tauri/src/platform/windows.rs index 58adace..6eba4a9 100644 --- a/src-tauri/src/platform/windows.rs +++ b/src-tauri/src/platform/windows.rs @@ -41,15 +41,17 @@ impl Platform for WindowsPlatform { } fn autostart_enable(&self) -> Result<(), NoteyError> { - todo!("Story 8.4: add HKCU Run-key entry") + // Story 8.6 (platform capstone): Story 8.4 ships auto-start via + // tauri-plugin-autostart (app.autolaunch()), not this trait method. + todo!("Story 8.6: route autostart through the Platform trait") } fn autostart_disable(&self) -> Result<(), NoteyError> { - todo!("Story 8.4: remove HKCU Run-key entry") + todo!("Story 8.6: route autostart through the Platform trait") } fn autostart_is_enabled(&self) -> Result { - todo!("Story 8.4: check the HKCU Run-key entry") + todo!("Story 8.6: route autostart through the Platform trait") } fn accessibility_permission_granted(&self) -> Result { diff --git a/src-tauri/src/services/autostart.rs b/src-tauri/src/services/autostart.rs deleted file mode 100644 index 039aaa7..0000000 --- a/src-tauri/src/services/autostart.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Auto-start-on-login service: enable/disable the platform launch agent and -//! persist the user's preference. -//! -//! **RED-PHASE STUB (Epic 8 — Story 8.4).** Unimplemented scaffold. Acceptance -//! tests live in `tests/autostart_tests.rs`, marked `#[ignore = "red-phase: -//! Story 8.4"]`. -//! -//! ## Green-phase wiring (do this when implementing) -//! - Add `tauri-plugin-autostart` (verify the current 2.x version on crates.io — -//! do not pin from memory) initialized with `MacosLauncher::LaunchAgent`. -//! - Delegate `enable`/`disable`/`is_enabled` to the plugin via the [`Platform`] -//! abstraction (see [`crate::platform`]). -//! - Persist the preference as `[autostart] enabled = `. NOTE: AC 8.4 names -//! the key `[general] auto_start`; this scaffold keeps a dedicated section to -//! avoid a ~50-site TypeScript ripple from changing `GeneralConfig` during the -//! red phase. Reconcile the section name at green phase. -//! - Add the capability ACL entries `autostart:allow-enable`, -//! `autostart:allow-disable`, `autostart:allow-is-enabled`. - -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; -use specta::Type; - -use crate::errors::NoteyError; - -/// Persisted auto-start preference (`autostart.toml`). -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, Type)] -#[serde(rename_all = "camelCase")] -pub struct AutostartState { - #[serde(default)] - pub enabled: bool, -} - -/// Full path to `autostart.toml` within the given config directory. -pub fn state_file_path(config_dir: &Path) -> PathBuf { - config_dir.join("autostart.toml") -} - -/// Load the persisted preference, defaulting to disabled when missing. -pub fn load(config_dir: &Path) -> Result { - todo!("Story 8.4: read autostart.toml under {config_dir:?}, default to disabled") -} - -/// Enable auto-start: register the platform launch agent AND persist -/// `enabled = true`. Idempotent. -pub fn enable(config_dir: &Path) -> Result<(), NoteyError> { - todo!("Story 8.4: register platform autostart + persist enabled=true under {config_dir:?}") -} - -/// Disable auto-start: remove the platform launch agent AND persist -/// `enabled = false`. Idempotent. -pub fn disable(config_dir: &Path) -> Result<(), NoteyError> { - todo!("Story 8.4: remove platform autostart + persist enabled=false under {config_dir:?}") -} - -/// Whether auto-start is currently enabled at the platform level. -pub fn is_enabled() -> Result { - todo!("Story 8.4: query the platform autostart mechanism") -} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 74e0e86..69ec2ad 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,4 +1,3 @@ -pub mod autostart; pub mod config; pub mod export; pub mod notes; diff --git a/src-tauri/tests/acl_tests.rs b/src-tauri/tests/acl_tests.rs index 2febe77..245e3c2 100644 --- a/src-tauri/tests/acl_tests.rs +++ b/src-tauri/tests/acl_tests.rs @@ -19,6 +19,8 @@ const EXPECTED_COMMANDS: &[&str] = &[ "allow-increment-onboarding-session", "allow-check-accessibility-permission", "allow-open-accessibility-settings", + "allow-set-autostart", + "allow-get-autostart", "allow-dismiss-window", "allow-apply-layout-mode", "allow-create-workspace", diff --git a/src-tauri/tests/autostart_tests.rs b/src-tauri/tests/autostart_tests.rs index 8468d45..ef6fac7 100644 --- a/src-tauri/tests/autostart_tests.rs +++ b/src-tauri/tests/autostart_tests.rs @@ -1,69 +1,78 @@ -//! ATDD red-phase acceptance tests — Story 8.4 (Auto-Start on Login), -//! preference-persistence + service-contract slice. +//! Acceptance tests — Story 8.4 (Auto-Start on Login), preference-persistence +//! slice. //! -//! Every test is `#[ignore = "red-phase: Story 8.4"]` against the unimplemented -//! [`tauri_app_lib::services::autostart`] scaffold. Platform-level launch-agent -//! behavior (does the OS actually start the app at login?) is verified by -//! manual/platform QA — these tests pin the persistence and idempotence contract. +//! The user's auto-start preference is the single source of truth at `config.toml` +//! `[general] auto_start` (serde key `autoStart`). These tests pin the persistence +//! and idempotence contract through the config service, hermetically against a +//! `TempDir`. Platform-level launch-agent behavior (does the OS actually start the +//! app at login?) is owned by `tauri-plugin-autostart` and verified by +//! manual/platform QA — it is not exercised here. //! -//! cargo test --test autostart_tests -- --ignored +//! cargo test --test autostart_tests use tempfile::TempDir; -use tauri_app_lib::services::autostart::{self, AutostartState}; +use tauri_app_lib::models::config::AppConfig; +use tauri_app_lib::services::config; + +/// Persist a preference exactly as the `set_autostart` command does: set the field +/// on the loaded config and save through the config service. +fn persist(config_dir: &std::path::Path, enabled: bool) { + let mut cfg = config::load_or_create(config_dir).expect("load config"); + cfg.general.auto_start = enabled; + config::save(config_dir, &cfg).expect("save config"); +} + +fn load_enabled(config_dir: &std::path::Path) -> bool { + config::load_or_create(config_dir) + .expect("load config") + .general + .auto_start +} /// AC: a fresh install has auto-start disabled. #[test] -#[ignore = "red-phase: Story 8.4"] -fn load_defaults_to_disabled() { +fn fresh_install_defaults_to_disabled() { let tmp = TempDir::new().unwrap(); - let state = autostart::load(tmp.path()).expect("load must succeed on a fresh dir"); - assert_eq!(state, AutostartState { enabled: false }); + assert!(!load_enabled(tmp.path())); + assert!(!AppConfig::default().general.auto_start); } -/// AC: "the user enables auto-start … the setting is saved to config: -/// `auto_start = true`". (Scaffold persists `[autostart] enabled` — see module -/// docs; reconcile the section name at green phase.) +/// AC: enabling auto-start saves `[general] auto_start = true` to config.toml. #[test] -#[ignore = "red-phase: Story 8.4"] fn enable_persists_true() { let tmp = TempDir::new().unwrap(); - autostart::enable(tmp.path()).expect("enable must persist"); - assert!( - autostart::load(tmp.path()).unwrap().enabled, - "enabled preference must survive a reload" - ); - assert!(autostart::state_file_path(tmp.path()).exists()); + persist(tmp.path(), true); + assert!(load_enabled(tmp.path()), "preference must survive a reload"); + assert!(tmp.path().join("config.toml").exists()); } -/// AC: "the user disables auto-start … the config is updated: `auto_start = false`". +/// AC: disabling auto-start updates the config to `auto_start = false`. #[test] -#[ignore = "red-phase: Story 8.4"] fn disable_persists_false() { let tmp = TempDir::new().unwrap(); - autostart::enable(tmp.path()).unwrap(); - autostart::disable(tmp.path()).expect("disable must persist"); - assert!(!autostart::load(tmp.path()).unwrap().enabled); + persist(tmp.path(), true); + persist(tmp.path(), false); + assert!(!load_enabled(tmp.path())); } -/// Enabling twice is a no-op at the persistence layer (no duplicate launch agent). +/// Enabling twice is a no-op at the persistence layer (no divergent state). #[test] -#[ignore = "red-phase: Story 8.4"] fn enable_is_idempotent() { let tmp = TempDir::new().unwrap(); - autostart::enable(tmp.path()).unwrap(); - autostart::enable(tmp.path()).unwrap(); - assert!(autostart::load(tmp.path()).unwrap().enabled); + persist(tmp.path(), true); + persist(tmp.path(), true); + assert!(load_enabled(tmp.path())); } -/// AC: the platform mechanism is the source of truth for the *active* state. -/// `is_enabled` reflects what the OS reports (here: disabled by default in a -/// clean test environment). +/// A config file predating Story 8.4 (no `autoStart` key) loads as disabled. #[test] -#[ignore = "red-phase: Story 8.4"] -fn is_enabled_reports_platform_state() { - assert!( - !autostart::is_enabled().expect("is_enabled must query the platform"), - "a clean test environment must report auto-start as not registered" - ); +fn missing_key_loads_as_disabled() { + let tmp = TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("config.toml"), + "[general]\ntheme = \"dark\"\nlayoutMode = \"floating\"\n", + ) + .unwrap(); + assert!(!load_enabled(tmp.path())); } diff --git a/src/features/command-palette/actions.test.ts b/src/features/command-palette/actions.test.ts index fcd42e4..8fa0809 100644 --- a/src/features/command-palette/actions.test.ts +++ b/src/features/command-palette/actions.test.ts @@ -7,12 +7,14 @@ import * as autoSave from '../editor/hooks/useAutoSave'; import { useToastStore } from '../toast/store'; import { useWorkspaceStore } from '../workspace/store'; import { useSearchStore } from '../search/store'; +import { useSettingsStore } from '../settings/store'; import { createNewNote, trashActiveNote, toggleTheme, toggleFormat, toggleLayoutMode, + toggleAutostart, openSearch, stubAction, applyStartupConfig, @@ -1220,3 +1222,71 @@ describe('applyBootTheme', () => { expect(document.documentElement.getAttribute('data-theme')).toBe('light'); }); }); + +describe('toggleAutostart', () => { + beforeEach(() => { + useSettingsStore.getState().resetSettings(); + }); + + it('flips the live preference and persists via set_autostart', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_config') + return Promise.resolve(buildConfig({ general: { theme: 'dark', layoutMode: 'floating', autoStart: false } })); + if (cmd === 'get_autostart') return Promise.resolve(false); + if (cmd === 'set_autostart') + return Promise.resolve(buildConfig({ general: { theme: 'dark', layoutMode: 'floating', autoStart: true } })); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }); + + await toggleAutostart(); + + expect(mockInvoke).toHaveBeenCalledWith('set_autostart', { enabled: true }); + expect(useSettingsStore.getState().config?.general?.autoStart).toBe(true); + }); + + it('uses live OS state when it differs from the persisted preference', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_config') + return Promise.resolve(buildConfig({ general: { theme: 'dark', layoutMode: 'floating', autoStart: true } })); + if (cmd === 'get_autostart') return Promise.resolve(false); + if (cmd === 'set_autostart') + return Promise.resolve(buildConfig({ general: { theme: 'dark', layoutMode: 'floating', autoStart: true } })); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }); + + await toggleAutostart(); + + expect(mockInvoke).toHaveBeenCalledWith('set_autostart', { enabled: true }); + }); + + it('does not persist when getConfig fails', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_config') return Promise.reject({ type: 'Database' }); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }); + + await toggleAutostart(); + + expect(mockInvoke).not.toHaveBeenCalledWith('set_autostart', expect.anything()); + consoleSpy.mockRestore(); + }); + + it('coalesces concurrent invocations (singleflight)', async () => { + let getConfigCalls = 0; + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_config') { + getConfigCalls += 1; + return Promise.resolve(buildConfig({ general: { theme: 'dark', layoutMode: 'floating', autoStart: false } })); + } + if (cmd === 'get_autostart') return Promise.resolve(false); + if (cmd === 'set_autostart') + return Promise.resolve(buildConfig({ general: { theme: 'dark', layoutMode: 'floating', autoStart: true } })); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }); + + await Promise.all([toggleAutostart(), toggleAutostart()]); + + expect(getConfigCalls).toBe(1); + }); +}); diff --git a/src/features/command-palette/actions.ts b/src/features/command-palette/actions.ts index bb9c119..d107ed9 100644 --- a/src/features/command-palette/actions.ts +++ b/src/features/command-palette/actions.ts @@ -421,6 +421,42 @@ export async function toggleTheme(): Promise { }); } +/** + * Toggle auto-start on login (Story 8.4). Reads persisted config, reconciles to + * the live OS registration when available, then delegates to the settings store's + * `setAutostart` (plugin registration + persistence + toast). Guarded against + * concurrent invocations (e.g. key repeat) to avoid a lost-update race. + */ +export async function toggleAutostart(): Promise { + await singleflight('toggle-autostart', async () => { + try { + const configResult = await withTimeout(commands.getConfig(), { + label: 'get_config', + }); + if (configResult.status === 'error') { + console.error('getConfig failed:', configResult.error); + return; + } + + let current = configResult.data.general?.autoStart ?? false; + const liveResult = await withTimeout(commands.getAutostart(), { + label: 'get_autostart', + }); + if (liveResult.status === 'ok') { + current = liveResult.data; + } else { + console.error('getAutostart failed:', liveResult.error); + } + + const next = !current; + await useSettingsStore.getState().setAutostart(next); + } catch (error) { + console.error('toggleAutostart threw:', error); + return; + } + }); +} + /** Toggle editor format between markdown and plaintext. */ export function toggleFormat(): void { const { format, setFormat } = useEditorStore.getState(); diff --git a/src/features/command-palette/hooks/usePaletteCommands.test.ts b/src/features/command-palette/hooks/usePaletteCommands.test.ts index ee895a6..2e40105 100644 --- a/src/features/command-palette/hooks/usePaletteCommands.test.ts +++ b/src/features/command-palette/hooks/usePaletteCommands.test.ts @@ -3,9 +3,9 @@ import { usePaletteCommands } from './usePaletteCommands'; import { useSettingsStore } from '../../settings/store'; describe('usePaletteCommands', () => { - it('returns 12 commands total', () => { + it('returns 13 commands total', () => { const commands = usePaletteCommands(); - expect(commands).toHaveLength(12); + expect(commands).toHaveLength(13); }); it('includes all three groups', () => { @@ -35,14 +35,15 @@ describe('usePaletteCommands', () => { expect(nav.map((c) => c.label)).toEqual(['Open Note List', 'View Trash']); }); - it('has 6 settings commands', () => { + it('has 7 settings commands', () => { const commands = usePaletteCommands(); const settings = commands.filter((c) => c.group === 'Settings'); - expect(settings).toHaveLength(6); + expect(settings).toHaveLength(7); expect(settings.map((c) => c.label)).toEqual([ 'Toggle Theme', 'Toggle Layout Mode', 'Toggle Format', + 'Toggle Auto-Start on Login', 'Open Settings', 'Export to Markdown', 'Export to JSON', diff --git a/src/features/command-palette/hooks/usePaletteCommands.ts b/src/features/command-palette/hooks/usePaletteCommands.ts index b31c442..69cb023 100644 --- a/src/features/command-palette/hooks/usePaletteCommands.ts +++ b/src/features/command-palette/hooks/usePaletteCommands.ts @@ -5,6 +5,7 @@ import { toggleTheme, toggleFormat, toggleLayoutMode, + toggleAutostart, trashActiveNote, stubAction, } from "../actions"; @@ -103,6 +104,13 @@ export function usePaletteCommands(): PaletteCommand[] { shortcut: "", action: toggleFormat, }, + { + id: "toggle-autostart", + label: "Toggle Auto-Start on Login", + group: "Settings", + shortcut: "", + action: toggleAutostart, + }, { id: "open-settings", label: "Open Settings", diff --git a/src/features/settings/components/SettingsPanel.test.tsx b/src/features/settings/components/SettingsPanel.test.tsx index bc63795..45da0ff 100644 --- a/src/features/settings/components/SettingsPanel.test.tsx +++ b/src/features/settings/components/SettingsPanel.test.tsx @@ -359,4 +359,31 @@ describe('SettingsPanel', () => { fireEvent.keyDown(first, { key: 'Tab', shiftKey: true }); expect(document.activeElement).toBe(screen.getByTestId('settings-done')); }); + + it('toggles auto-start on and persists via set_autostart (Story 8.4)', async () => { + const cfgOn = buildConfig({ general: { theme: 'system', layoutMode: 'floating', autoStart: true } }); + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_config') + return Promise.resolve( + buildConfig({ general: { theme: 'system', layoutMode: 'floating', autoStart: false } }), + ); + if (cmd === 'get_autostart') return Promise.resolve(false); + if (cmd === 'set_autostart') return Promise.resolve(cfgOn); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }); + await useSettingsStore.getState().open(); + render(); + + const toggle = screen.getByTestId('autostart-toggle'); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + + fireEvent.click(toggle); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith('set_autostart', { enabled: true }); + }); + await waitFor(() => { + expect(screen.getByTestId('autostart-toggle')).toHaveAttribute('aria-checked', 'true'); + }); + }); }); diff --git a/src/features/settings/components/SettingsPanel.tsx b/src/features/settings/components/SettingsPanel.tsx index c522d68..8a72396 100644 --- a/src/features/settings/components/SettingsPanel.tsx +++ b/src/features/settings/components/SettingsPanel.tsx @@ -57,6 +57,7 @@ export function SettingsPanel() { const setLayoutMode = useSettingsStore((s) => s.setLayoutMode); const setFontSize = useSettingsStore((s) => s.setFontSize); const setFontFamily = useSettingsStore((s) => s.setFontFamily); + const setAutostart = useSettingsStore((s) => s.setAutostart); const overlayRef = useRef(null); const firstControlRef = useRef(null); useFocusTrap(overlayRef, true); @@ -101,6 +102,7 @@ export function SettingsPanel() { const fontSize = clampFontSize(config?.editor?.fontSize ?? 14); const fontFamily = config?.editor?.fontFamily === 'sans' ? 'sans' : 'mono'; const globalShortcut = config?.hotkey?.globalShortcut ?? 'Ctrl+Shift+N'; + const autoStart = config?.general?.autoStart ?? false; return (

+ +
+ + Start on login + + +
{/* Editor */} diff --git a/src/features/settings/store.test.ts b/src/features/settings/store.test.ts index a4b6e86..8ff62ca 100644 --- a/src/features/settings/store.test.ts +++ b/src/features/settings/store.test.ts @@ -6,6 +6,14 @@ import { useSettingsStore } from './store'; import { useSearchStore } from '../search/store'; import { useToastStore } from '../toast/store'; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + describe('useSettingsStore', () => { beforeEach(() => { useSettingsStore.getState().resetSettings(); @@ -254,4 +262,78 @@ describe('useSettingsStore', () => { expect(ok).toBe(true); expect(useSettingsStore.getState().bindings.search).toBe('Ctrl+F'); }); + + it('setAutostart enables, persists via set_autostart, and adopts the returned config', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_config') + return Promise.resolve(buildConfig({ general: { theme: 'system', layoutMode: 'floating', autoStart: false } })); + if (cmd === 'get_autostart') return Promise.resolve(false); + if (cmd === 'set_autostart') + return Promise.resolve(buildConfig({ general: { theme: 'system', layoutMode: 'floating', autoStart: true } })); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }); + await useSettingsStore.getState().open(); + + const ok = await useSettingsStore.getState().setAutostart(true); + + expect(ok).toBe(true); + expect(mockInvoke).toHaveBeenCalledWith('set_autostart', { enabled: true }); + expect(useSettingsStore.getState().config?.general?.autoStart).toBe(true); + expect(useToastStore.getState().toasts.some((t) => t.message.includes('enabled'))).toBe(true); + }); + + it('setAutostart keeps the prior state and toasts on failure', async () => { + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_config') + return Promise.resolve(buildConfig({ general: { theme: 'system', layoutMode: 'floating', autoStart: false } })); + if (cmd === 'get_autostart') return Promise.resolve(false); + if (cmd === 'set_autostart') return Promise.reject({ type: 'Config', message: 'boom' }); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }); + await useSettingsStore.getState().open(); + + const ok = await useSettingsStore.getState().setAutostart(true); + + expect(ok).toBe(false); + expect(useSettingsStore.getState().config?.general?.autoStart ?? false).toBe(false); + expect(useToastStore.getState().toasts.some((t) => t.message.includes('Couldn’t change auto-start'))).toBe(true); + consoleSpy.mockRestore(); + }); + + it('open() reconciles the toggle to the live OS auto-start state', async () => { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_config') + return Promise.resolve(buildConfig({ general: { theme: 'system', layoutMode: 'floating', autoStart: false } })); + if (cmd === 'get_autostart') return Promise.resolve(true); // OS reports enabled + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }); + + await useSettingsStore.getState().open(); + + expect(useSettingsStore.getState().config?.general?.autoStart).toBe(true); + }); + + it('ignores stale open() auto-start reconciliation after a completed toggle', async () => { + const liveState = deferred(); + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'get_config') + return Promise.resolve(buildConfig({ general: { theme: 'system', layoutMode: 'floating', autoStart: false } })); + if (cmd === 'get_autostart') return liveState.promise; + if (cmd === 'set_autostart') + return Promise.resolve(buildConfig({ general: { theme: 'system', layoutMode: 'floating', autoStart: true } })); + return Promise.reject(new Error(`unmocked: ${cmd}`)); + }); + + const openPromise = useSettingsStore.getState().open(); + await waitFor(() => { + expect(useSettingsStore.getState().isOpen).toBe(true); + }); + + await useSettingsStore.getState().setAutostart(true); + liveState.resolve(false); + await openPromise; + + expect(useSettingsStore.getState().config?.general?.autoStart).toBe(true); + }); }); diff --git a/src/features/settings/store.ts b/src/features/settings/store.ts index f845cf5..a3ab796 100644 --- a/src/features/settings/store.ts +++ b/src/features/settings/store.ts @@ -53,6 +53,15 @@ interface SettingsActions { setFontSize: (size: number) => void; /** Set editor font family (mono/sans): snapshot + persist+apply live. */ setFontFamily: (family: string) => void; + /** + * Enable or disable auto-start on login (Story 8.4). Delegates to the + * `set_autostart` command, which registers/unregisters the OS launch agent via + * `tauri-plugin-autostart` AND persists `[general] auto_start`. Not optimistic: + * awaits the backend, then adopts the returned config so the toggle reflects + * what was actually persisted; toasts on success, keeps the prior state and + * toasts on failure. Resolves `true` on success, `false` on error. + */ + setAutostart: (enabled: boolean) => Promise; /** * Set the global capture shortcut, conflict-checked. Unlike the live-apply * setters this is NOT optimistic: it awaits the backend, which registers the @@ -116,7 +125,22 @@ export const useSettingsStore = create((set, ge return false; } closeOtherOverlays('settings'); - set({ isOpen: true, config: result.data, bindings: bindingsFromConfig(result.data) }); + const loadedConfig = result.data; + set({ isOpen: true, config: loadedConfig, bindings: bindingsFromConfig(loadedConfig) }); + // Best-effort: reconcile the displayed auto-start toggle with the live OS + // state, which can drift from the persisted preference if changed outside the + // app. Failures are ignored — the toggle falls back to the persisted value. + try { + const os = await commands.getAutostart(); + if (os.status === 'ok') { + const cfg = get().config; + if (cfg === loadedConfig && cfg.general && (cfg.general.autoStart ?? false) !== os.data) { + set({ config: { ...cfg, general: { ...cfg.general, autoStart: os.data } } }); + } + } + } catch { + // ignore — best-effort reconciliation only + } return true; }, close: () => set({ isOpen: false }), @@ -169,6 +193,28 @@ export const useSettingsStore = create((set, ge } void applyFontFamily(family); }, + setAutostart: async (enabled) => { + let result; + try { + result = await commands.setAutostart(enabled); + } catch (error) { + console.error('setAutostart threw:', error); + useToastStore.getState().addToast('Couldn’t change auto-start on login.', 5000); + return false; + } + if (result.status === 'error') { + console.error('setAutostart failed:', result.error); + useToastStore.getState().addToast('Couldn’t change auto-start on login.', 5000); + return false; + } + // Adopt the backend's committed config so the toggle reflects the persisted + // (and OS-registered) state, never an optimistic guess. + set({ config: result.data }); + useToastStore + .getState() + .addToast(enabled ? 'Auto-start on login enabled' : 'Auto-start on login disabled'); + return true; + }, setGlobalShortcut: async (shortcut) => { let result; try { diff --git a/src/generated/bindings.ts b/src/generated/bindings.ts index bd59f86..d27e722 100644 --- a/src/generated/bindings.ts +++ b/src/generated/bindings.ts @@ -49,6 +49,15 @@ export const commands = { * No-op off macOS. */ openAccessibilitySettings: () => typedError(__TAURI_INVOKE("open_accessibility_settings")), + // Enable/disable auto-start via the plugin, persist atomically, and return committed config. + setAutostart: (enabled: boolean) => typedError(__TAURI_INVOKE("set_autostart", { enabled })), + /** + * Whether auto-start on login is currently registered at the OS level. + * Reports the live platform state via the autostart plugin (the source of truth + * for the *active* registration), letting the UI reconcile its toggle with the + * real OS state. Off desktop there is no launch agent, so it reports `false`. + */ + getAutostart: () => typedError(__TAURI_INVOKE("get_autostart")), // Hides the calling window (dismiss without destroy). dismissWindow: () => typedError(__TAURI_INVOKE("dismiss_window")), /** @@ -136,14 +145,19 @@ export type EditorConfig = { /** * General application settings. - * * `theme` is one of `system` (the default — follow the OS `prefers-color-scheme` * until the user picks a theme), `dark`, or `light`. A saved manual `dark`/`light` * preference overrides the OS setting on restart. + * `auto_start` (serialized `[general] autoStart`) is the persisted auto-start-on-login + * preference (Story 8.4 / FR41–FR43). It defaults to `false` and tolerates a missing + * key on older config files via serde. The OS launch agent is managed by + * `tauri-plugin-autostart`; this field is the single source of truth the app + * reconciles the OS registration to on every startup. */ export type GeneralConfig = { theme: Theme, layoutMode: string, + autoStart?: boolean, }; // Hotkey bindings. diff --git a/src/test-utils/factories.ts b/src/test-utils/factories.ts index 63fec26..97a4d95 100644 --- a/src/test-utils/factories.ts +++ b/src/test-utils/factories.ts @@ -31,7 +31,7 @@ export function buildWorkspaceInfo(overrides: Partial = {}): Work /** Build an AppConfig with sensible defaults. Override nested sections via the partial. */ export function buildConfig(overrides: Partial = {}): AppConfig { return { - general: { theme: 'system', layoutMode: 'floating' }, + general: { theme: 'system', layoutMode: 'floating', autoStart: false }, editor: { fontSize: 14, fontFamily: 'mono' }, hotkey: { globalShortcut: 'Ctrl+Shift+N' }, shortcuts: { From ca32305621ef57da718a06f167e61c2a75f344f5 Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 17 Jun 2026 03:15:05 -0700 Subject: [PATCH 09/15] story 8-5-per-user-data-isolation: implemented and reviewed via bmad-auto --- .../spec-8-5-per-user-data-isolation.md | 131 ++++++++++++++++++ .../sprint-status.yaml | 4 +- src-tauri/src/ipc/socket_server.rs | 50 +------ src-tauri/src/lib.rs | 10 +- src-tauri/src/platform/linux.rs | 6 +- src-tauri/src/platform/macos.rs | 7 +- src-tauri/src/platform/mod.rs | 86 +++++++++++- src-tauri/src/platform/windows.rs | 6 +- src-tauri/tests/ipc_tests.rs | 60 +++++++- src-tauri/tests/platform_tests.rs | 83 +++++++++-- 10 files changed, 362 insertions(+), 81 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-8-5-per-user-data-isolation.md diff --git a/_bmad-output/implementation-artifacts/spec-8-5-per-user-data-isolation.md b/_bmad-output/implementation-artifacts/spec-8-5-per-user-data-isolation.md new file mode 100644 index 0000000..14054d6 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-8-5-per-user-data-isolation.md @@ -0,0 +1,131 @@ +--- +title: "Story 8.5 — Per-User Data Isolation" +type: "feature" +created: "2026-06-17" +status: "done" +baseline_commit: "9572e56f7387003c4bae20e4b2d1143cf27e31a2" +context: + - "{project-root}/_bmad-output/implementation-artifacts/epic-8-context.md" + - "{project-root}/_bmad-output/project-context.md" +--- + + + +## Intent + +**Problem:** Per-user isolation is partly real but not yet routed through the `Platform` abstraction the epic mandates as the single source of truth (FR51/FR58). The SQLite database path is resolved by Tauri's opaque `app.path().app_data_dir()` (bundle id `com.pinkyd.tauri-app`) rather than a per-user platform-standard dir, and the `Platform` trait's `data_dir()` and `socket_path()` are red-phase `todo!()` stubs labeled "Story 8.5". The socket path/0600 logic already works but lives inline in `ipc/socket_server.rs`, so there is no single owner of path resolution. The Story 8.5 acceptance tests in `tests/platform_tests.rs` are `#[ignore]`. + +**Approach:** Implement `Platform::data_dir()` and `Platform::socket_path()` for all three OS targets so data and socket paths resolve from the current user's platform-standard directories (`dirs` crate: `data_dir()` + namespace; `runtime_dir()`/temp for the socket). Route the database location through `platform::current().data_dir()` and make `socket_server::socket_path()` delegate its default-case resolution to `platform::current().socket_path()` (keeping its existing `--socket-path` / `NOTEY_SOCKET_PATH` override seams). Add a `NOTEY_DATA_DIR` test/override seam to `data_dir()` mirroring the existing socket seam. Un-ignore the two Story 8.5 platform tests. + +## Boundaries & Constraints + +**Always:** +- Path resolution is namespaced per existing convention: `notey` on Linux/Windows, `com.notey.app` on macOS (mirrors `services::config::config_dir`). Data dir = `dirs::data_dir()` + namespace (XDG_DATA_HOME / %APPDATA% / `~/Library/Application Support`). +- `data_dir()` honors a `NOTEY_DATA_DIR` env override first (test/hermetic seam, mirrors `NOTEY_SOCKET_PATH`), then falls back to the platform-standard path; it returns `Err(NoteyError::Config(...))` when no directory can be determined. It is a pure resolver — it does NOT create the directory (the caller `db::init_db` already `create_dir_all`s). +- `Platform::socket_path()` returns the pure default user-scoped path (Unix: `dirs::runtime_dir()/notey.sock`, else user-scoped temp fallback; Windows: user-scoped namespaced pipe). The default-resolution body + the `user_scope_token()` helper move OUT of `socket_server.rs` and INTO `platform` so there is one implementation. +- `socket_server::socket_path()` keeps its override precedence — `--socket-path` arg, then `NOTEY_SOCKET_PATH` env — then delegates the default to `platform::current().socket_path()`. Behavior for both seams and the default must be byte-identical to today. +- The socket file is still created owner-only (0600 on Unix) by the existing `set_owner_only()` in `socket_server.rs` — do not change that mechanism. +- Only `data_dir()` and `socket_path()` are implemented this story. `config_dir()`, `log_dir()`, `register_hotkey()`, and the `autostart_*` trait methods remain Story 8.6 `todo!()` stubs. +- All dirs come from the already-present `dirs` crate (v5) — no new dependency. + +**Ask First:** +- Changing the live config-resolution path (`services::config::config_dir`) or implementing the trait `config_dir()` — that is Story 8.6's mechanism; config is already user-scoped. +- Adding a `--data-dir` CLI argument (full E2E-hermetic data isolation, DW-91/DW-95) — the `NOTEY_DATA_DIR` env seam is the agreed scope here. +- Migrating an existing `com.pinkyd.tauri-app` database to the new `notey` data dir (greenfield pre-v1; no migration intended). + +**Never:** +- Do NOT change the `notey-cli` crate's `client.rs` socket-path logic — its duplication of the resolution is intentional (standalone crate, no workspace) and already tested to mirror the server. Verify it still matches after relocation; do not unify it. +- Do NOT add an auth token or any isolation mechanism beyond the user-scoped path + 0600 file mode (v1 contract). +- Do NOT touch hotkey, autostart, accessibility, FTS, or any unrelated subsystem. +- Do NOT change the socket override seams' precedence or the 0600 logic. +- No new IPC commands, no frontend changes, no new E2E journey. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +| --- | --- | --- | --- | +| Linux data dir | `NOTEY_DATA_DIR` unset | `data_dir()` = `dirs::data_dir()/notey` (`$XDG_DATA_HOME` or `~/.local/share`) | dirs None → `NoteyError::Config` | +| macOS data dir | `NOTEY_DATA_DIR` unset | `data_dir()` = `~/Library/Application Support/com.notey.app` | dirs None → `NoteyError::Config` | +| Windows data dir | `NOTEY_DATA_DIR` unset | `data_dir()` = `%APPDATA%\notey` | dirs None → `NoteyError::Config` | +| Data dir override | `NOTEY_DATA_DIR=/tmp/x` | `data_dir()` = `/tmp/x` (verbatim) | N/A | +| Unix socket default | no override, `XDG_RUNTIME_DIR` set | `socket_path()` = `$XDG_RUNTIME_DIR/notey.sock` | N/A | +| Unix socket fallback | no `XDG_RUNTIME_DIR` | `socket_path()` = `tempdir/notey-.sock` (token from HOME/USER) | empty token → `notey.sock` | +| Windows socket | any | `socket_path()` = namespaced pipe `notey-` | empty token → `notey` | +| Socket override (server) | `NOTEY_SOCKET_PATH` / `--socket-path` set | `socket_server::socket_path()` returns the override (unchanged) | N/A | +| Server default == platform | no override | `socket_server::socket_path()` == `platform::current().socket_path()` | N/A | +| DB init | app startup | DB opened at `platform::current().data_dir()/notey.db`; dir created if missing | resolve error → startup fails fast (expect) | + + + +## Code Map + +- `src-tauri/src/platform/mod.rs` -- add `pub(crate)` helpers: `resolve_data_dir(namespace: &str) -> Result` (honors `NOTEY_DATA_DIR`, else `dirs::data_dir().join(namespace)`, else `Err(Config)`); `#[cfg(unix)] resolve_unix_socket() -> PathBuf` and `#[cfg(windows)] resolve_windows_socket() -> PathBuf` (relocated default resolution); `user_scope_token() -> Option` (moved verbatim from `socket_server.rs`). Update the module-doc green-phase note to reflect that 8.5 implements `data_dir`+`socket_path` and routes the DB + socket through the trait (config_dir/log_dir stay 8.6). +- `src-tauri/src/platform/linux.rs` -- `data_dir()` → `super::resolve_data_dir("notey")`; `socket_path()` → `super::resolve_unix_socket()`. Drop the two `todo!()`. +- `src-tauri/src/platform/macos.rs` -- `data_dir()` → `super::resolve_data_dir("com.notey.app")`; `socket_path()` → `super::resolve_unix_socket()`. Drop the two `todo!()`. +- `src-tauri/src/platform/windows.rs` -- `data_dir()` → `super::resolve_data_dir("notey")`; `socket_path()` → `super::resolve_windows_socket()`. Drop the two `todo!()`. +- `src-tauri/src/ipc/socket_server.rs` -- `socket_path()`: keep arg + env override checks, then `return crate::platform::current().socket_path();`. Remove the relocated inline unix/windows blocks and the local `user_scope_token()`. Clean up now-unused imports (`dirs`, possibly `Path`) to satisfy `clippy -D warnings`. +- `src-tauri/src/lib.rs` -- in `setup`, replace `app.path().app_data_dir()` (lines ~176-179) with `crate::platform::current().data_dir().expect("Failed to resolve data dir")`; drop the now-unused `app.path()` call there. Confirm `tauri::Manager`/`app.path()` is still needed elsewhere before removing any import. +- `src-tauri/tests/platform_tests.rs` -- remove `#[ignore]` from `data_dir_is_user_scoped_and_standard` and `socket_path_is_user_scoped` (the two Story 8.5 tests). Add `data_dir_honors_override_seam` (set/restore `NOTEY_DATA_DIR` under a mutex, assert verbatim passthrough) and, where cheap, a macOS-gated assertion that the data dir contains `com.notey.app`. Leave the 8.6-labeled tests `#[ignore]`. +- `src-tauri/tests/ipc_tests.rs` -- add `socket_server_default_matches_platform` asserting `socket_server::socket_path() == platform::current().socket_path()` with no overrides set (under the existing `SOCKET_PATH_ENV_LOCK`). Existing `int_002_*` socket tests must still pass unchanged. + +## Tasks & Acceptance + +**Execution:** + +- [x] `src-tauri/src/platform/mod.rs` -- add `resolve_data_dir`, `resolve_unix_socket`/`resolve_windows_socket`, and the moved `user_scope_token`; update the module-doc note. +- [x] `src-tauri/src/platform/{linux,macos,windows}.rs` -- implement `data_dir()` and `socket_path()` via the mod helpers; remove the two Story-8.5 `todo!()` per file. +- [x] `src-tauri/src/ipc/socket_server.rs` -- make `socket_path()` delegate the default to `platform::current().socket_path()`; remove relocated logic + dead imports. +- [x] `src-tauri/src/lib.rs` -- resolve the DB dir via `platform::current().data_dir()`; remove the dead `app_data_dir()` path. +- [x] `src-tauri/tests/platform_tests.rs` -- un-ignore the two Story 8.5 tests; add the override-seam test (+ macOS naming assertion). +- [x] `src-tauri/tests/ipc_tests.rs` -- add the server-default-matches-platform invariant test. +- [x] Grep for other readers of `app_data_dir`/the old data path and the relocated socket helpers; fix any stragglers so the single-source-of-truth holds (only `db::init_db` param + `lib.rs` startup; both rewired). + +### Review Findings + +- [x] [Review][Patch] Make env-sensitive path tests hermetic and XDG-compliant [src-tauri/tests/platform_tests.rs:65] — applied: `data_dir_is_user_scoped_and_standard` now asserts `platform::current().data_dir()` equals `dirs::data_dir().join(namespace)` instead of assuming Unix data dirs are under `$HOME`; `NOTEY_DATA_DIR` and `NOTEY_SOCKET_PATH` tests now restore prior env values with an `OsString` guard. + +#### Review Ledger (2026-06-17) + +- patch: Make env-sensitive path tests hermetic and XDG-compliant [src-tauri/tests/platform_tests.rs:65; src-tauri/tests/ipc_tests.rs:38] — fixed valid XDG_DATA_HOME-outside-HOME failures and non-Unicode prior-env restoration leaks in tests. +- dismiss: Change production `NOTEY_DATA_DIR` to `var_os` / ignore empty values [src-tauri/src/platform/mod.rs:118] — dismissed because the story explicitly mirrors the existing `NOTEY_SOCKET_PATH` string env seam and preserves override behavior; empty string remains a verbatim override. + +**Acceptance Criteria:** + +- Given the app resolves data paths, when the database path is determined, then it is `platform::current().data_dir()/notey.db` under the current user's platform-standard data directory (XDG_DATA_HOME / %APPDATA% / `~/Library/Application Support`), with no system-wide shared directory used. (FR51/FR58) +- Given the IPC socket path is resolved with no override set, when `socket_server::socket_path()` and `platform::current().socket_path()` are compared, then they are equal and (on Linux with `XDG_RUNTIME_DIR` set) live under that per-user runtime dir as `notey.sock`, and the created socket file is mode 0600. (FR51) +- Given the `NOTEY_DATA_DIR` / `NOTEY_SOCKET_PATH` override seams, when each is set, then `data_dir()` / `socket_server::socket_path()` return the override verbatim (the existing socket-arg/env precedence is preserved). +- Given two users on one machine, when both run Notey, then each resolves an independent database, config, and IPC socket from their own per-user directories and neither can reach the other's 0600 socket (automatable slice: paths derive from per-user `dirs`/runtime dirs; true multi-uid access stays manual QA per RISK-E6-007). +- Given the build, when the suites run, then `cargo test` (incl. the un-ignored `platform_tests` Story 8.5 cases and the existing `ipc_tests` socket cases), `cargo clippy --all-targets -- -D warnings`, `vitest`, `tsc`, and `npm run build` all pass. + +## Design Notes + +**Single source of truth.** The epic mandates that data/config/log/socket resolution funnel through the `Platform` trait. This story does the two methods labeled 8.5 (`data_dir`, `socket_path`) and rewires their two callers (`lib.rs` DB init, `socket_server::socket_path`). `config_dir()`/`log_dir()` and hotkey/autostart routing stay Story 8.6 stubs — the live config path (`services::config::config_dir`) is already user-scoped, so config isolation (AC3) holds today without touching 8.6. + +**Why relocate the socket logic instead of duplicating.** Putting the default resolution in `platform` and having `socket_server` delegate keeps one implementation inside the app crate. The override seams (`--socket-path`, `NOTEY_SOCKET_PATH`) are process-level concerns and stay in `socket_server`; the E2E harness depends on them (it routes `--socket-path` via `tauri:options.args` because a modern WebKitWebDriver resets the app env — see DW-95). The `notey-cli` crate keeps its own mirror copy (standalone, no workspace); it must continue to match the relocated default. + +**Shared resolver shape (platform/mod.rs):** +```rust +pub(crate) fn resolve_data_dir(namespace: &str) -> Result { + if let Ok(custom) = std::env::var("NOTEY_DATA_DIR") { + return Ok(PathBuf::from(custom)); + } + dirs::data_dir() + .map(|base| base.join(namespace)) + .ok_or_else(|| NoteyError::Config("Could not determine platform data directory".into())) +} +``` + +## Verification + +**Commands:** + +- `cd src-tauri && cargo test` -- expected: all suites green, including the un-ignored `platform_tests` Story 8.5 cases, the new override/invariant tests, and the existing `ipc_tests` socket cases. +- `cd src-tauri && cargo test --test platform_tests` -- expected: the two Story 8.5 tests (`data_dir_is_user_scoped_and_standard`, `socket_path_is_user_scoped`) now run and pass; the remaining Story 8.6 `todo!()` tests stay `#[ignore]`d (4 passed, 3 ignored). +- `cd src-tauri && cargo clippy --all-targets -- -D warnings` -- expected: clean (no dead-import warnings after the socket_server/lib.rs edits). +- `npm run build` -- expected: bindings regenerate unchanged (no IPC surface change); `tsc` + vite build succeed. + +**Manual checks (if no CLI):** + +- True multi-uid cross-user socket/data inaccessibility is platform QA (RISK-E6-007); the automated tests pin only the per-user path derivation + 0600 file mode. + + diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 6285d6a..4ac5a3f 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -40,7 +40,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: 2026-04-03 -last_updated: 2026-06-17 # story 8-4 → done +last_updated: 2026-06-17 # story 8-5 → done # Note: epic-2-retrospective rewritten fresh on 2026-04-04 project: notey project_key: NOKEY @@ -137,7 +137,7 @@ development_status: 8-2-macos-accessibility-permission-guidance: done 8-3-hotkey-customization-during-onboarding: done 8-4-auto-start-on-login: done - 8-5-per-user-data-isolation: backlog + 8-5-per-user-data-isolation: done 8-6-cross-platform-verification-wayland-fallback: backlog epic-8-retrospective: optional diff --git a/src-tauri/src/ipc/socket_server.rs b/src-tauri/src/ipc/socket_server.rs index 1f95e83..203dae3 100644 --- a/src-tauri/src/ipc/socket_server.rs +++ b/src-tauri/src/ipc/socket_server.rs @@ -144,9 +144,9 @@ fn socket_path_arg() -> Option { /// Precedence: an explicit `--socket-path` CLI argument wins (the channel the /// E2E harness routes through `tauri:options.args`, since it survives a launcher /// that resets the environment), then the `NOTEY_SOCKET_PATH` env override (the -/// testability seam). Otherwise, on Unix, prefers `$XDG_RUNTIME_DIR/notey.sock`, -/// falling back to a user-scoped temp-dir path; on Windows, returns a -/// user-scoped namespaced pipe name. +/// testability seam). Otherwise it delegates to the platform abstraction's +/// [`crate::platform::Platform::socket_path`], the single source of truth for the +/// default per-user path (Story 8.5). pub fn socket_path() -> PathBuf { if let Some(custom) = socket_path_arg() { return custom; @@ -156,49 +156,7 @@ pub fn socket_path() -> PathBuf { return PathBuf::from(custom); } - #[cfg(unix)] - { - if let Some(dir) = dirs::runtime_dir() { - return dir.join("notey.sock"); - } - let file_name = user_scope_token() - .map(|token| format!("notey-{token}.sock")) - .unwrap_or_else(|| "notey.sock".to_string()); - std::env::temp_dir().join(file_name) - } - - #[cfg(windows)] - { - PathBuf::from( - user_scope_token() - .map(|token| format!("notey-{token}")) - .unwrap_or_else(|| "notey".to_string()), - ) - } -} - -fn user_scope_token() -> Option { - dirs::home_dir() - .as_deref() - .and_then(Path::file_name) - .and_then(|name| name.to_str()) - .map(str::to_owned) - .or_else(|| std::env::var("USER").ok()) - .or_else(|| std::env::var("USERNAME").ok()) - .and_then(|candidate| { - let sanitized: String = candidate - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() { - c.to_ascii_lowercase() - } else { - '-' - } - }) - .collect(); - let trimmed = sanitized.trim_matches('-'); - (!trimmed.is_empty()).then(|| trimmed.to_string()) - }) + crate::platform::current().socket_path() } /// Build the transport `Name` for `path`: a filesystem socket on Unix, a diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 39cdae5..77d5777 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -173,10 +173,12 @@ pub fn run() { builder.mount_events(app); // --- Database --- - let data_dir = app - .path() - .app_data_dir() - .expect("Failed to get app data dir"); + // Resolve the per-user data directory through the Platform abstraction + // (Story 8.5) so data-path isolation has a single source of truth + // rather than Tauri's bundle-id-based default. + let data_dir = crate::platform::current() + .data_dir() + .expect("Failed to resolve data dir"); let conn = db::init_db(data_dir).expect("Failed to initialize database"); app.manage(Mutex::new(conn)); diff --git a/src-tauri/src/platform/linux.rs b/src-tauri/src/platform/linux.rs index 41cb1e4..a84176c 100644 --- a/src-tauri/src/platform/linux.rs +++ b/src-tauri/src/platform/linux.rs @@ -22,7 +22,8 @@ impl Platform for LinuxPlatform { } fn data_dir(&self) -> Result { - todo!("Story 8.5: $XDG_DATA_HOME/notey (per-user)") + // $XDG_DATA_HOME/notey (or ~/.local/share/notey), per-user (Story 8.5). + super::resolve_data_dir("notey") } fn config_dir(&self) -> Result { @@ -34,7 +35,8 @@ impl Platform for LinuxPlatform { } fn socket_path(&self) -> PathBuf { - todo!("Story 8.5: $XDG_RUNTIME_DIR/notey.sock (per-user, 0600)") + // $XDG_RUNTIME_DIR/notey.sock (per-user; bound 0600 by socket_server). 8.5. + super::resolve_unix_socket() } fn register_hotkey(&self, accelerator: &str) -> Result { diff --git a/src-tauri/src/platform/macos.rs b/src-tauri/src/platform/macos.rs index ab2729e..eeb926c 100644 --- a/src-tauri/src/platform/macos.rs +++ b/src-tauri/src/platform/macos.rs @@ -23,7 +23,8 @@ impl Platform for MacosPlatform { } fn data_dir(&self) -> Result { - todo!("Story 8.5: ~/Library/Application Support/com.notey.app (per-user)") + // ~/Library/Application Support/com.notey.app, per-user (Story 8.5). + super::resolve_data_dir("com.notey.app") } fn config_dir(&self) -> Result { @@ -35,7 +36,9 @@ impl Platform for MacosPlatform { } fn socket_path(&self) -> PathBuf { - todo!("Story 8.5: user-scoped temp-dir socket (per-user, 0600)") + // $XDG_RUNTIME_DIR/notey.sock or user-scoped temp fallback; bound 0600 + // by socket_server (Story 8.5). + super::resolve_unix_socket() } fn register_hotkey(&self, accelerator: &str) -> Result { diff --git a/src-tauri/src/platform/mod.rs b/src-tauri/src/platform/mod.rs index 52a2306..73de77a 100644 --- a/src-tauri/src/platform/mod.rs +++ b/src-tauri/src/platform/mod.rs @@ -13,11 +13,15 @@ //! on failure under Wayland, attempt the XDG GlobalShortcuts portal (verify the //! current `ashpd` version on crates.io — do not pin from memory); if neither //! works, return [`NoteyError::Config`] so the caller can notify the user (FR57). -//! - Route `crate::ipc::socket_server::socket_path` and the DB/config dir helpers -//! through this trait so per-user isolation (Story 8.5) has a single source of -//! truth. +//! +//! **Story 8.5 (done):** `data_dir` and `socket_path` are implemented for all +//! three targets via the shared resolvers below; the DB location (`lib.rs`) and +//! `crate::ipc::socket_server::socket_path` route through this trait so per-user +//! isolation has a single source of truth. `config_dir`, `log_dir`, +//! `register_hotkey`, and the `autostart_*` methods remain Story 8.6 stubs (the +//! live config path still resolves via `services::config::config_dir`). -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::errors::NoteyError; @@ -97,3 +101,77 @@ pub fn current() -> Box { Box::new(windows::WindowsPlatform::new()) } } + +// ── Shared path resolvers (Story 8.5 / FR51 / FR58) ────────────────────────── +// +// The per-OS `data_dir`/`socket_path` impls delegate here so resolution lives in +// exactly one place. `namespace` differs per OS (`notey` vs `com.notey.app`) to +// match `services::config::config_dir`'s existing convention. + +/// Resolve the per-user data directory, namespaced for this OS. +/// +/// Honors the `NOTEY_DATA_DIR` env override first (the hermetic-test seam, +/// mirroring `NOTEY_SOCKET_PATH`); otherwise `dirs::data_dir()` joined with +/// `namespace` (`$XDG_DATA_HOME` / `%APPDATA%` / `~/Library/Application Support`). +/// Pure resolver — the caller creates the directory. Returns +/// [`NoteyError::Config`] when no platform data directory can be determined. +pub(crate) fn resolve_data_dir(namespace: &str) -> Result { + if let Ok(custom) = std::env::var("NOTEY_DATA_DIR") { + return Ok(PathBuf::from(custom)); + } + dirs::data_dir() + .map(|base| base.join(namespace)) + .ok_or_else(|| { + NoteyError::Config("Could not determine platform data directory".to_string()) + }) +} + +/// Default per-user Unix socket path (no override seams — those live in +/// `socket_server::socket_path`). Prefers `$XDG_RUNTIME_DIR/notey.sock` (a dir +/// that is itself `0700`), falling back to a user-scoped temp-dir path. +#[cfg(unix)] +pub(crate) fn resolve_unix_socket() -> PathBuf { + if let Some(dir) = dirs::runtime_dir() { + return dir.join("notey.sock"); + } + let file_name = user_scope_token() + .map(|token| format!("notey-{token}.sock")) + .unwrap_or_else(|| "notey.sock".to_string()); + std::env::temp_dir().join(file_name) +} + +/// Default per-user Windows named-pipe name (no override seams). +#[cfg(windows)] +pub(crate) fn resolve_windows_socket() -> PathBuf { + PathBuf::from( + user_scope_token() + .map(|token| format!("notey-{token}")) + .unwrap_or_else(|| "notey".to_string()), + ) +} + +/// Derive a stable, filesystem-safe per-user token for fallback socket naming. +#[cfg(any(unix, windows))] +fn user_scope_token() -> Option { + dirs::home_dir() + .as_deref() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .map(str::to_owned) + .or_else(|| std::env::var("USER").ok()) + .or_else(|| std::env::var("USERNAME").ok()) + .and_then(|candidate| { + let sanitized: String = candidate + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + let trimmed = sanitized.trim_matches('-'); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} diff --git a/src-tauri/src/platform/windows.rs b/src-tauri/src/platform/windows.rs index 6eba4a9..a196ceb 100644 --- a/src-tauri/src/platform/windows.rs +++ b/src-tauri/src/platform/windows.rs @@ -21,7 +21,8 @@ impl Platform for WindowsPlatform { } fn data_dir(&self) -> Result { - todo!("Story 8.5: %APPDATA%\\notey (per-user)") + // %APPDATA%\notey, per-user (Story 8.5). + super::resolve_data_dir("notey") } fn config_dir(&self) -> Result { @@ -33,7 +34,8 @@ impl Platform for WindowsPlatform { } fn socket_path(&self) -> PathBuf { - todo!("Story 8.5: user-scoped named pipe") + // User-scoped namespaced pipe `notey-` (Story 8.5). + super::resolve_windows_socket() } fn register_hotkey(&self, accelerator: &str) -> Result { diff --git a/src-tauri/tests/ipc_tests.rs b/src-tauri/tests/ipc_tests.rs index 385c3f0..068b07a 100644 --- a/src-tauri/tests/ipc_tests.rs +++ b/src-tauri/tests/ipc_tests.rs @@ -35,6 +35,35 @@ struct TestServer { static SOCKET_SEQ: AtomicUsize = AtomicUsize::new(0); static SOCKET_PATH_ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); +/// Restores a process env var to its prior value, including non-Unicode values. +struct EnvVarGuard { + key: &'static str, + prior: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let prior = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, prior } + } + + fn unset(key: &'static str) -> Self { + let prior = std::env::var_os(key); + std::env::remove_var(key); + Self { key, prior } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.prior { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } +} + /// Serializes EVERY `IpcServer::start` in this suite. `IpcServer::start` reads the /// process-global `NOTEY_IPC_*` env vars on every start, so an env-setting test's /// "set env → start → clear env" window must not overlap any other server start — @@ -208,18 +237,20 @@ fn int_002_008_socket_path_is_user_scoped() { .unwrap_or_else(|poisoned| poisoned.into_inner()); // Override seam works (the testability ASR). - std::env::set_var("NOTEY_SOCKET_PATH", "/tmp/notey-override.sock"); - assert_eq!( - socket_server::socket_path(), - PathBuf::from("/tmp/notey-override.sock") - ); - std::env::remove_var("NOTEY_SOCKET_PATH"); + { + let _env = EnvVarGuard::set("NOTEY_SOCKET_PATH", "/tmp/notey-override.sock"); + assert_eq!( + socket_server::socket_path(), + PathBuf::from("/tmp/notey-override.sock") + ); + } // Default resolves under the per-user runtime dir (itself 0700) on Linux, // which — together with the 0600 file mode — is the automatable slice of // cross-user isolation. True multi-uid access is manual QA (RISK-E6-007). #[cfg(target_os = "linux")] if let Some(runtime) = dirs::runtime_dir() { + let _env = EnvVarGuard::unset("NOTEY_SOCKET_PATH"); let resolved = socket_server::socket_path(); assert!( resolved.starts_with(&runtime), @@ -229,6 +260,23 @@ fn int_002_008_socket_path_is_user_scoped() { } } +// ── 8.5: socket_server default delegates to the Platform abstraction ────────── + +#[test] +fn socket_server_default_matches_platform() { + let _guard = SOCKET_PATH_ENV_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _env = EnvVarGuard::unset("NOTEY_SOCKET_PATH"); + + // With no override seam set, socket_server must defer to the single source of + // truth in the platform abstraction (Story 8.5). + let from_server = socket_server::socket_path(); + let from_platform = tauri_app_lib::platform::current().socket_path(); + + assert_eq!(from_server, from_platform); +} + // ── 6.2-INT-003: protocol robustness — malformed / unknown, no panic ────────── #[test] diff --git a/src-tauri/tests/platform_tests.rs b/src-tauri/tests/platform_tests.rs index 506935c..6920424 100644 --- a/src-tauri/tests/platform_tests.rs +++ b/src-tauri/tests/platform_tests.rs @@ -13,6 +13,40 @@ use tauri_app_lib::platform; +/// Serializes the two tests that read/mutate the process-global `NOTEY_DATA_DIR` +/// env var, so the override test cannot leak into the standard-path test when the +/// harness runs them on parallel threads. +static DATA_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Restores a process env var to its prior value, including non-Unicode values. +struct EnvVarGuard { + key: &'static str, + prior: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let prior = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, prior } + } + + fn unset(key: &'static str) -> Self { + let prior = std::env::var_os(key); + std::env::remove_var(key); + Self { key, prior } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match &self.prior { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } +} + /// AC 8.6: `current()` resolves to the implementation for the target OS. #[test] #[ignore = "red-phase: Story 8.6"] @@ -29,31 +63,54 @@ fn current_resolves_to_target_platform() { /// AC 8.5/8.6/FR58: the data directory is a per-user, platform-standard path. #[test] -#[ignore = "red-phase: Story 8.5"] fn data_dir_is_user_scoped_and_standard() { + let _guard = DATA_DIR_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let _env = EnvVarGuard::unset("NOTEY_DATA_DIR"); + let dir = platform::current() .data_dir() .expect("data_dir must resolve"); - let s = dir.to_string_lossy(); + let namespace = if cfg!(target_os = "macos") { + "com.notey.app" + } else { + "notey" + }; + let expected = dirs::data_dir() + .expect("platform data dir must resolve") + .join(namespace); + assert_eq!( + dir, expected, + "data dir must be the platform data dir plus the Notey namespace" + ); + + // macOS namespaces under the bundle identifier, mirroring config_dir. + #[cfg(target_os = "macos")] assert!( - s.contains("notey"), - "data dir must be namespaced to notey: {s}" + dir.to_string_lossy().contains("com.notey.app"), + "macOS data dir must use the bundle id namespace: {}", + dir.display() ); +} - #[cfg(unix)] - { - let home = std::env::var("HOME").unwrap_or_default(); - assert!( - !home.is_empty() && s.starts_with(&home), - "data dir must live under the current user's HOME ({home}): {s}" - ); - } +/// AC 8.5: the `NOTEY_DATA_DIR` override seam (hermetic-test seam, mirroring +/// `NOTEY_SOCKET_PATH`) takes precedence over the platform-standard path. +#[test] +fn data_dir_honors_override_seam() { + let _guard = DATA_DIR_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let _env = EnvVarGuard::set("NOTEY_DATA_DIR", "/tmp/notey-data-override"); + + let resolved = platform::current() + .data_dir() + .expect("override data_dir must resolve"); + assert_eq!( + resolved, + std::path::PathBuf::from("/tmp/notey-data-override") + ); } /// AC 8.5/FR51: the IPC socket path is user-scoped (no system-wide shared path). #[cfg(unix)] #[test] -#[ignore = "red-phase: Story 8.5"] fn socket_path_is_user_scoped() { let path = platform::current().socket_path(); let s = path.to_string_lossy(); From 679e5946de0c7b9a750e95cf55f88e5e23a0cec3 Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 17 Jun 2026 03:42:53 -0700 Subject: [PATCH 10/15] story 8-6-cross-platform-verification-wayland-fallback: implemented and reviewed via bmad-auto --- .../implementation-artifacts/deferred-work.md | 32 ++++ ...-platform-verification-wayland-fallback.md | 138 ++++++++++++++++++ .../sprint-status.yaml | 4 +- src-tauri/src/lib.rs | 35 ++++- src-tauri/src/platform/linux.rs | 61 ++++++-- src-tauri/src/platform/macos.rs | 33 +++-- src-tauri/src/platform/mod.rs | 56 ++++--- src-tauri/src/platform/windows.rs | 32 ++-- src-tauri/src/services/config.rs | 15 +- src-tauri/tests/platform_tests.rs | 118 +++++++++++++-- 10 files changed, 442 insertions(+), 82 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-8-6-cross-platform-verification-wayland-fallback.md diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 45d714c..2a39f02 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -749,3 +749,35 @@ reason: The CLI live-sync suite originally pinned a unique per-run `NOTEY_SOCKET status: done 2026-06-16 resolution: Added a permissive `--socket-path` parser to `socket_server::socket_path()` (precedence: CLI arg > `NOTEY_SOCKET_PATH` > default) with unit tests, and routed a per-run `--socket-path` through `tauri:options.args` in `e2e/run.mjs` (via `createSession(application, args)`); the live-sync suite now binds an isolated endpoint regardless of env stripping, so the `realInstancePresent` skip guard and multi-candidate socket discovery were removed. decision: 2026-06-16 App-level --socket-path arg — Add a --socket-path CLI arg to the desktop app that overrides the socket path, and route a per-run unique path through tauri:options.args in e2e/run.mjs so each E2E run binds an isolated endpoint independent of env propagation. + +### DW-96: Native Wayland global-shortcut portal via `ashpd` (fast-follow) + +origin: bmad-auto-dev split of spec-8-6-cross-platform-verification-wayland-fallback.md, 2026-06-17 +location: src-tauri/src/platform/linux.rs (register_hotkey WaylandPortal arm); test linux_hotkey_falls_back_to_wayland_portal (#[ignore]) +severity: medium +reason: PRD (prd.md:144,323,423) and architecture.md (49,870) explicitly scope the native xdg-desktop-portal GlobalShortcuts integration as a post-v1 fast-follow gated on Tauri global-hotkey PR #162; v1's Wayland fallback is XWayland (epic-8 context "XWayland is the baseline fallback for v1"). The trait + HotkeyBackend::WaylandPortal enum already anticipate it; implementing the real ashpd D-Bus registration + activation-signal wiring needs a Wayland harness (un-CI-testable) and a new async dependency. Deferred from the 8.6 capstone to keep that story CI-verifiable. +status: open + +### DW-97: Route auto-start through the `Platform` trait (autostart_enable/disable/is_enabled) + +origin: bmad-auto-dev split of spec-8-6-cross-platform-verification-wayland-fallback.md, 2026-06-17 +location: src-tauri/src/platform/{linux,macos,windows}.rs (autostart_* methods, currently todo!()) +severity: low +reason: The trait's autostart_* methods take only &self with no Tauri AppHandle, but the real mechanism (tauri-plugin-autostart via app.autolaunch()) requires the handle and already fully satisfies FR41-43 from Story 8.4 (commands/autostart.rs + lib.rs reconcile). Routing through the trait would mean reimplementing the plugin's plist/.desktop/registry logic by hand — a refactor with no functional gain and real cross-platform risk. Needs a trait-signature redesign (pass the handle, or move autostart off the path-trait) before it is worth doing. The todo!() stubs are relabeled to point here. +status: open + +### DW-98: CI release pipeline producing artifacts for all 5 targets (FR — AC4 of 8.6) + +origin: bmad-auto-dev split of spec-8-6-cross-platform-verification-wayland-fallback.md, 2026-06-17 +location: .github/workflows/ (new release.yml; ci.yml currently runs tests + a Linux debug build only) +severity: medium +reason: AC4 of story 8.6 wants build artifacts for Windows x64, macOS x64, macOS ARM64, Linux x64, Linux ARM64 (via tauri-apps/tauri-action on tag push). This is a standalone ops/infra deliverable that cannot be exercised by this session's cargo test / clippy / vitest gate (it only runs on a release tag), so adding an unverifiable workflow here would give no confidence. Belongs in its own release-engineering PR. +status: open + +### DW-99: User-facing notification when the global shortcut is unavailable on the compositor (FR57) + +origin: bmad-auto-dev split of spec-8-6-cross-platform-verification-wayland-fallback.md, 2026-06-17 +location: src/ (frontend toast/dialog) + src-tauri/src/lib.rs (global-shortcut setup) +severity: low +reason: FR57 / AC2 asks that the user be notified when no hotkey backend works on their compositor. Story 8.6 implements the backend detection (Platform::register_hotkey) and a clear startup warning log for the unavailable case, but the user-facing UI surface (toast/dialog + a typed event) is separable frontend work. Deferred to keep 8.6 backend-only and CI-verifiable; the backend signal it needs is in place. +status: open diff --git a/_bmad-output/implementation-artifacts/spec-8-6-cross-platform-verification-wayland-fallback.md b/_bmad-output/implementation-artifacts/spec-8-6-cross-platform-verification-wayland-fallback.md new file mode 100644 index 0000000..44c20a7 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-8-6-cross-platform-verification-wayland-fallback.md @@ -0,0 +1,138 @@ +--- +title: "Story 8.6 — Cross-Platform Verification & Wayland Fallback (Platform trait completion)" +type: "feature" +created: "2026-06-17" +status: "done" +baseline_commit: "1b1716644ceecd58095b0990fb15e09ec3fd6664" +context: + - "{project-root}/_bmad-output/implementation-artifacts/epic-8-context.md" + - "{project-root}/_bmad-output/project-context.md" +--- + + + +## Intent + +**Problem:** The `Platform` trait is meant to be the single source of truth for all OS-divergent behavior (epic-8 capstone, FR56/FR57/FR58), but three contracts are still red-phase `todo!()` for every OS: `config_dir()`, `log_dir()`, and `register_hotkey()`. Config paths today resolve through a separate `services::config::config_dir()` (a second implementation), there is no platform log-dir resolver at all, and nothing routes the global-hotkey backend through the trait — so the Wayland gap (FR57) has no single place to detect and degrade gracefully. Three platform acceptance tests are `#[ignore]`. + +**Approach:** Implement `config_dir()`, `log_dir()`, and `register_hotkey()` for all three OS targets via the trait. Make `services::config::config_dir()` delegate to `platform::current().config_dir()` so config-path resolution has one implementation. Implement `register_hotkey()` as a Wayland-aware backend selector (X11/XWayland/macOS/Windows → `Standard`; pure-Wayland-without-XWayland → `Err`, since native portal support is the post-v1 fast-follow per the PRD), and have `lib.rs` consult it so a compositor with no working backend is detected and logged (graceful degradation; window stays summonable via tray). Un-ignore the two now-satisfiable platform tests. + +## Boundaries & Constraints + +**Always:** +- Path namespaces match the existing convention (`services::config::config_dir`): `notey` on Linux/Windows, `com.notey.app` on macOS. +- `config_dir()` = `dirs::config_dir()/` (`$XDG_CONFIG_HOME` / `%APPDATA%` / `~/Library/Application Support`). `log_dir()` per-OS standard: Linux `dirs::state_dir()/notey/logs` (`$XDG_STATE_HOME`), macOS `~/Library/Logs/com.notey.app`, Windows `dirs::data_local_dir()/notey/logs` (`%LOCALAPPDATA%`). Both are pure resolvers — they do NOT create the directory — and return `Err(NoteyError::Config(..))` when the base dir cannot be determined. +- `services::config::config_dir()` keeps its signature and returns the same path it returns today; it now obtains that path from `platform::current().config_dir()`. The byte-for-byte result must be unchanged. +- `register_hotkey(accelerator)` returns `Ok(HotkeyBackend::Standard)` on macOS, Windows, and any Linux session where the X11 path is usable (X11 native, or Wayland with XWayland — i.e. `DISPLAY` is set). It returns `Err(NoteyError::Config(..))` only on a pure-Wayland session with no XWayland (`XDG_SESSION_TYPE=wayland`/`WAYLAND_DISPLAY` set AND `DISPLAY` unset). It never panics and never requires a Tauri `AppHandle`. +- `lib.rs` consults `platform::current().register_hotkey(&shortcut_str)` in the desktop global-shortcut setup: on `Ok(_)` it performs the existing plugin registration exactly as today; on `Err(e)` it logs a clear FR57 degradation warning (global shortcut unavailable on this compositor; summon via tray) and skips the initial `register()` call. The plugin builder/handler install stays unconditional so re-registration via `update_config` still works. +- All path/dir resolution uses the already-present `dirs` crate (v5) — no new dependency. + +**Ask First:** +- Adding a real `ashpd` D-Bus GlobalShortcuts portal registration (the native Wayland path) — that is the post-v1 fast-follow tracked in DW-96, gated on Tauri global-hotkey PR #162. +- Implementing the `autostart_*` trait methods or any user-facing (toast/dialog) notification UI — tracked in DW-97 / DW-99. +- Introducing a logging plugin or any consumer of `log_dir()` — this story only adds the resolver to satisfy the trait contract. + +**Never:** +- Do NOT change `services::config::config_dir`'s public signature, the `notey`/`com.notey.app` namespaces, or any persisted path. No data/config migration. +- Do NOT implement the `autostart_*` methods (leave them as `todo!()` relabeled to DW-97) or the `accessibility_*`/`data_dir`/`socket_path` methods (already done in Stories 8.2/8.5). +- Do NOT change the existing fail-soft behavior of hotkey registration for the `Standard` backend, or touch the conflict-detection / `update_config` re-registration path. +- Do NOT add new IPC commands, frontend changes, new E2E journeys, or new env-override seams. +- Do NOT modify the CI workflow / release pipeline (AC4 release artifacts → DW-98). + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +| --- | --- | --- | --- | +| Linux config dir | unset env | `config_dir()` = `dirs::config_dir()/notey` | dirs None → `NoteyError::Config` | +| macOS config dir | unset env | `config_dir()` = `~/Library/Application Support/com.notey.app` | dirs None → `NoteyError::Config` | +| Windows config dir | unset env | `config_dir()` = `%APPDATA%\notey` | dirs None → `NoteyError::Config` | +| Linux log dir | unset env | `log_dir()` = `dirs::state_dir()/notey/logs` | dirs None → `NoteyError::Config` | +| macOS log dir | unset env | `log_dir()` = `~/Library/Logs/com.notey.app` | home None → `NoteyError::Config` | +| Windows log dir | unset env | `log_dir()` = `%LOCALAPPDATA%\notey\logs` | dirs None → `NoteyError::Config` | +| Hotkey on macOS/Windows | any | `register_hotkey()` = `Ok(Standard)` | N/A | +| Hotkey on X11 / XWayland | `DISPLAY` set | `register_hotkey()` = `Ok(Standard)` | N/A | +| Hotkey headless (cargo test) | no `DISPLAY`, no wayland vars | `register_hotkey()` = `Ok(Standard)` | N/A | +| Hotkey pure Wayland | wayland session, `DISPLAY` unset | `register_hotkey()` = `Err(Config)` | caller logs FR57 warning, skips register | +| config single-source | any | `platform::current().config_dir()` == `services::config::config_dir()` | both propagate the same `Err` | + + + +## Code Map + +- `src-tauri/src/platform/mod.rs` -- add `pub(crate) fn resolve_config_dir(namespace: &str) -> Result` (mirrors `resolve_data_dir`, no env seam). Update the module-doc green-phase note: 8.6 implements `config_dir`/`log_dir`/`register_hotkey`; `autostart_*` deferred to DW-97; native Wayland portal deferred to DW-96. +- `src-tauri/src/platform/linux.rs` -- `config_dir()` → `super::resolve_config_dir("notey")`; `log_dir()` → `dirs::state_dir()/notey/logs` (Err if None); `register_hotkey()` → Wayland-aware backend selection (see Design Notes). Drop the three `todo!()`; relabel the `autostart_*` `todo!()` to DW-97. +- `src-tauri/src/platform/macos.rs` -- `config_dir()` → `super::resolve_config_dir("com.notey.app")`; `log_dir()` → `dirs::home_dir()/Library/Logs/com.notey.app` (Err if None); `register_hotkey()` → `Ok(Standard)`. Drop the three `todo!()`; relabel `autostart_*` to DW-97. +- `src-tauri/src/platform/windows.rs` -- `config_dir()` → `super::resolve_config_dir("notey")`; `log_dir()` → `dirs::data_local_dir()/notey/logs` (Err if None); `register_hotkey()` → `Ok(Standard)`. Drop the three `todo!()`; relabel `autostart_*` to DW-97. +- `src-tauri/src/services/config.rs` -- `config_dir()` body becomes `crate::platform::current().config_dir()`; keep the doc comment and signature. Remove the now-dead `dirs`/`cfg` namespace logic and any import left unused. +- `src-tauri/src/lib.rs` -- in the `#[cfg(desktop)]` global-shortcut block, call `platform::current().register_hotkey(&shortcut_str)`; on `Ok(_)` keep the existing register flow, on `Err(e)` log the FR57 warning and skip the initial `register()`. Plugin builder install stays unconditional. +- `src-tauri/tests/platform_tests.rs` -- un-ignore `current_resolves_to_target_platform` and `linux_hotkey_uses_standard_backend_on_x11`; keep `linux_hotkey_falls_back_to_wayland_portal` ignored. Add `config_dir_is_user_scoped_and_standard`, `log_dir_is_user_scoped_and_standard`, a non-Linux `hotkey_uses_standard_backend`, and `config_dir_matches_services_config` (single-source invariant). + +## Tasks & Acceptance + +**Execution:** + +- [x] `src-tauri/src/platform/mod.rs` -- add `resolve_config_dir`; update module-doc note. +- [x] `src-tauri/src/platform/{linux,macos,windows}.rs` -- implement `config_dir()`, `log_dir()`, `register_hotkey()`; remove their `todo!()`; relabel `autostart_*` stubs to DW-97. +- [x] `src-tauri/src/services/config.rs` -- delegate `config_dir()` to `platform::current().config_dir()`; drop dead code/imports. +- [x] `src-tauri/src/lib.rs` -- consult `register_hotkey()` in the desktop shortcut setup; log FR57 warning + skip `register()` on `Err`. +- [x] `src-tauri/tests/platform_tests.rs` -- un-ignore the two satisfiable tests; add the config/log/hotkey/single-source tests (hermetic, `#[cfg(target_os)]`-gated where the expected path differs). +- [x] Grep for other readers of the trait stubs / `services::config::config_dir` to confirm the single-source-of-truth holds and nothing still depends on the removed code. + +**Acceptance Criteria:** + +- Given the `Platform` trait, when the app runs on each target OS, then `config_dir()`, `log_dir()`, `register_hotkey()`, `data_dir()`, and `socket_path()` all resolve without any `todo!()`/panic, and `#[cfg(target_os)]` implementations exist in `platform/{linux,macos,windows}.rs`. (AC1) +- Given config-path resolution, when `services::config::config_dir()` and `platform::current().config_dir()` are compared, then they return the identical path (single source of truth), under the per-user platform-standard config dir with no system-wide shared directory. (FR58) +- Given a Linux session where the X11 path is usable (X11 native or XWayland present, including headless `cargo test`), when `register_hotkey()` is called, then it returns `Ok(HotkeyBackend::Standard)`; given a pure-Wayland session with no XWayland, then it returns `Err` and `lib.rs` logs that the global shortcut is unavailable on the compositor while leaving the window summonable via the tray. (FR56/FR57) +- Given the existing `Standard` backend path, when the app registers the global shortcut at startup, then behavior is unchanged from before this story (fail-soft on conflict; re-registration via `update_config` still works). +- Given the build, when the suites run, then `cargo test` (incl. the un-ignored `platform_tests` cases and the new config/log/hotkey/single-source tests), `cargo clippy --all-targets -- -D warnings`, `vitest`, `tsc`, and `npm run build` all pass. + +## Design Notes + +**Single source of truth.** `services::config::config_dir()` and the trait `config_dir()` currently duplicate `dirs::config_dir() + namespace`. Collapsing the live function onto the trait (rather than vice-versa) keeps the trait authoritative — the epic's stated design — without changing any caller's signature. The 8.5 spec explicitly flagged this as "Story 8.6's mechanism." + +**Linux hotkey backend selection (no AppHandle needed).** The trait method only *selects/validates* the backend; the actual Tauri plugin registration stays in `lib.rs` where the `AppHandle` lives. Selection rule: +```rust +// linux.rs register_hotkey +let wayland = std::env::var_os("WAYLAND_DISPLAY").is_some() + || std::env::var("XDG_SESSION_TYPE").map(|t| t == "wayland").unwrap_or(false); +let xwayland = std::env::var_os("DISPLAY").is_some(); +if wayland && !xwayland { + return Err(NoteyError::Config( + "global shortcut unavailable: Wayland compositor without XWayland \ + (native portal support is a fast-follow — DW-96)".into(), + )); +} +Ok(HotkeyBackend::Standard) +``` +This makes the headless CI `cargo test` (no env vars set) return `Standard`, satisfying `linux_hotkey_uses_standard_backend_on_x11`, while honoring the v1 contract that XWayland is the baseline fallback. The `linux_hotkey_falls_back_to_wayland_portal` test stays `#[ignore]` (it expects `Ok(WaylandPortal)`, which only the DW-96 portal work will deliver). + +**Why autostart stays a stub.** The `autostart_*` trait methods take `&self` with no `AppHandle`, but `tauri-plugin-autostart` (the mechanism that already satisfies FR41–43 in Story 8.4) needs the handle. Routing through the trait would mean hand-rolling plist/.desktop/registry logic — a no-gain, high-risk refactor. Tracked in DW-97; the `todo!()` bodies are relabeled, not removed (AC1 requires the trait to *define* them, and nothing calls them). + +## Verification + +**Commands:** + +- `cd src-tauri && cargo test` -- expected: all suites green, including the un-ignored `platform_tests` cases (`current_resolves_to_target_platform`, `linux_hotkey_uses_standard_backend_on_x11`) and the new config/log/hotkey/single-source tests; `linux_hotkey_falls_back_to_wayland_portal` stays ignored. +- `cd src-tauri && cargo clippy --all-targets -- -D warnings` -- expected: clean (no dead-import/dead-code warnings after the `services::config` and `lib.rs` edits). +- `npm run build` -- expected: bindings regenerate unchanged (no IPC surface change); `tsc` + vite build succeed. + +**Manual checks (if no CLI):** + +- Real pure-Wayland-without-XWayland degradation and native portal behavior are platform QA (RISK-001 / DW-96); the automated tests pin only the per-OS path derivation and the X11/headless `Standard` backend selection. + +### Review Findings + +- [x] [Review][Patch] Platform docs still contradicted the frozen Wayland contract [src-tauri/src/platform/mod.rs:1] — patched: removed stale red-phase and native-portal-now guidance, and updated the trait docs to describe Standard vs pure-Wayland-unavailable behavior with DW-96 deferred. +- [x] [Review][Patch] Linux hotkey test was not hermetic for X11/XWayland [src-tauri/tests/platform_tests.rs:203] — patched: serialized hotkey env mutation and made the X11 test set `DISPLAY`, clear `WAYLAND_DISPLAY`, and set `XDG_SESSION_TYPE=x11`. +- [x] [Review][Patch] Empty `DISPLAY` counted as XWayland support [src-tauri/src/platform/linux.rs:66] — patched: display env vars are now treated as present only when non-empty. +- [x] [Review][Patch] Pure-Wayland unavailable path lacked an active regression test [src-tauri/tests/platform_tests.rs:210] — patched: added an active pure-Wayland-with-empty-`DISPLAY` test that expects a graceful `Config` error. + +#### Review Ledger (2026-06-17) + +- patch: Platform docs still contradicted the frozen Wayland contract [src-tauri/src/platform/mod.rs:1] — stale red-phase/portal-now docs contradicted the approved DW-96 deferral; patched. +- dismiss: Startup-only backend gate leaves rebind path inconsistent [src-tauri/src/commands/config.rs:109] — approved spec explicitly preserves `update_config` re-registration behavior and forbids touching that path in this story; registration failure remains fail-soft before persistence. +- dismiss: User notification is only stderr logging [src-tauri/src/lib.rs:309] — approved story scope requires a clear FR57 warning log and defers user-facing UI to DW-99. +- patch: Empty `DISPLAY` incorrectly counted as XWayland support [src-tauri/src/platform/linux.rs:66] — empty env vars are a real edge case; patched. +- dismiss: Autostart trait methods still panic in production paths [src-tauri/src/platform/linux.rs:82] — frozen scope explicitly leaves `autostart_*` as relabeled DW-97 stubs and current app autostart is handled by `tauri-plugin-autostart`. +- patch: Linux hotkey test was not hermetic for the X11/XWayland scenario [src-tauri/tests/platform_tests.rs:203] — ambient Wayland/X11 env could flip the result; patched. +- patch: Pure-Wayland unavailable behavior lacked an active regression test [src-tauri/tests/platform_tests.rs:210] — degradation behavior is now covered by an active Linux test. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 4ac5a3f..86ec795 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -40,7 +40,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: 2026-04-03 -last_updated: 2026-06-17 # story 8-5 → done +last_updated: 2026-06-17 # story 8-6 → done # Note: epic-2-retrospective rewritten fresh on 2026-04-04 project: notey project_key: NOKEY @@ -138,7 +138,7 @@ development_status: 8-3-hotkey-customization-during-onboarding: done 8-4-auto-start-on-login: done 8-5-per-user-data-isolation: done - 8-6-cross-platform-verification-wayland-fallback: backlog + 8-6-cross-platform-verification-wayland-fallback: done epic-8-retrospective: optional # ═══════════════════════════════════════════════ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 77d5777..2f53e47 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -281,14 +281,33 @@ pub fn run() { .build(), )?; - // Non-fatal: a saved shortcut that conflicts with another app - // must not brick startup. Log and continue — the window stays - // summonable via the tray and the user can rebind in Settings. - if let Err(e) = app.global_shortcut().register(shortcut) { - eprintln!( - "Warning: failed to register global shortcut '{}': {}", - shortcut_str, e - ); + // Story 8.6 / FR57: consult the Platform abstraction for the + // hotkey backend available on this session before registering. + // On a compositor with no usable backend (pure Wayland without + // XWayland — native portal support is a fast-follow, DW-96) it + // returns Err; notify the user and skip the initial registration. + // The plugin/handler above stays installed so a later rebind via + // update_config can still register, and the window remains + // summonable from the tray. + match crate::platform::current().register_hotkey(&shortcut_str) { + Ok(_backend) => { + // Non-fatal: a saved shortcut that conflicts with another + // app must not brick startup. Log and continue — the + // window stays summonable via the tray and the user can + // rebind in Settings. + if let Err(e) = app.global_shortcut().register(shortcut) { + eprintln!( + "Warning: failed to register global shortcut '{}': {}", + shortcut_str, e + ); + } + } + Err(e) => { + eprintln!( + "Notice: global shortcut unavailable on this compositor ({e}); \ + summon Notey from the tray icon instead." + ); + } } } diff --git a/src-tauri/src/platform/linux.rs b/src-tauri/src/platform/linux.rs index a84176c..4e1ff6e 100644 --- a/src-tauri/src/platform/linux.rs +++ b/src-tauri/src/platform/linux.rs @@ -1,12 +1,14 @@ -//! Linux [`Platform`] implementation (X11 + Wayland). RED-PHASE STUB (Story 8.6). +//! Linux [`Platform`] implementation (X11 + Wayland). Paths + hotkey backend +//! selection are implemented (Stories 8.5/8.6); `autostart_*` is deferred (DW-97). use std::path::PathBuf; use crate::errors::NoteyError; use crate::platform::{HotkeyBackend, Platform}; -/// Linux platform behavior. Hotkey registration prefers the standard plugin and -/// falls back to the XDG GlobalShortcuts portal under Wayland (FR57). +/// Linux platform behavior. Hotkey backend selection uses the standard plugin +/// when X11/XWayland is available and reports pure Wayland as unavailable until +/// native portal support lands in DW-96. #[derive(Debug, Default)] pub struct LinuxPlatform; @@ -27,11 +29,17 @@ impl Platform for LinuxPlatform { } fn config_dir(&self) -> Result { - todo!("Story 8.6: $XDG_CONFIG_HOME/notey") + // $XDG_CONFIG_HOME/notey (or ~/.config/notey), per-user (Story 8.6). + super::resolve_config_dir("notey") } fn log_dir(&self) -> Result { - todo!("Story 8.6: $XDG_STATE_HOME/notey/logs") + // $XDG_STATE_HOME/notey/logs (or ~/.local/state/notey/logs), per-user (8.6). + dirs::state_dir() + .map(|base| base.join("notey").join("logs")) + .ok_or_else(|| { + NoteyError::Config("Could not determine platform state directory".to_string()) + }) } fn socket_path(&self) -> PathBuf { @@ -39,22 +47,51 @@ impl Platform for LinuxPlatform { super::resolve_unix_socket() } - fn register_hotkey(&self, accelerator: &str) -> Result { - todo!("Story 8.6: standard plugin, Wayland portal fallback for {accelerator}") + fn register_hotkey(&self, _accelerator: &str) -> Result { + // Story 8.6 / FR57: select the hotkey backend for this Linux session. The + // actual Tauri plugin registration stays in `lib.rs` (it needs the + // AppHandle); this only validates which backend can serve the shortcut. + // + // v1 contract: XWayland is the baseline fallback. The standard + // (X11-based) backend works on X11 natively and on Wayland whenever + // XWayland is present (`DISPLAY` set). A pure-Wayland session with no + // XWayland cannot use the X11 path; native portal support is a fast-follow + // (DW-96), so report the shortcut as unavailable and let the caller notify + // the user (FR57) rather than returning `HotkeyBackend::WaylandPortal`. + let wayland_display = std::env::var_os("WAYLAND_DISPLAY") + .map(|value| !value.is_empty()) + .unwrap_or(false); + let wayland = wayland_display + || std::env::var("XDG_SESSION_TYPE") + .map(|t| t == "wayland") + .unwrap_or(false); + let xwayland = std::env::var_os("DISPLAY") + .map(|value| !value.is_empty()) + .unwrap_or(false); + if wayland && !xwayland { + return Err(NoteyError::Config( + "global shortcut unavailable: Wayland compositor without XWayland \ + (native portal support is a fast-follow — DW-96)" + .to_string(), + )); + } + Ok(HotkeyBackend::Standard) } fn autostart_enable(&self) -> Result<(), NoteyError> { - // Story 8.6 (platform capstone): Story 8.4 ships auto-start via - // tauri-plugin-autostart (app.autolaunch()), not this trait method. - todo!("Story 8.6: route autostart through the Platform trait") + // Deferred (DW-97): auto-start is owned by tauri-plugin-autostart via the + // Tauri AppHandle (Story 8.4: commands::autostart + lib.rs). The `&self` + // trait signature cannot reach the handle, so routing it through the trait + // is a no-gain refactor tracked in DW-97. Not called today. + todo!("DW-97: route autostart through the Platform trait") } fn autostart_disable(&self) -> Result<(), NoteyError> { - todo!("Story 8.6: route autostart through the Platform trait") + todo!("DW-97: route autostart through the Platform trait") } fn autostart_is_enabled(&self) -> Result { - todo!("Story 8.6: route autostart through the Platform trait") + todo!("DW-97: route autostart through the Platform trait") } fn accessibility_permission_granted(&self) -> Result { diff --git a/src-tauri/src/platform/macos.rs b/src-tauri/src/platform/macos.rs index eeb926c..fb65bd7 100644 --- a/src-tauri/src/platform/macos.rs +++ b/src-tauri/src/platform/macos.rs @@ -1,5 +1,6 @@ //! macOS [`Platform`] implementation. Accessibility-permission methods (Story 8.2) -//! are implemented; paths/hotkey/auto-start remain RED-PHASE stubs (Stories 8.4–8.6). +//! and paths/hotkey-backend selection (Stories 8.5/8.6) are implemented; +//! `autostart_*` is deferred (DW-97 — owned by tauri-plugin-autostart). use std::{io, path::PathBuf}; @@ -28,11 +29,18 @@ impl Platform for MacosPlatform { } fn config_dir(&self) -> Result { - todo!("Story 8.6: ~/Library/Application Support/com.notey.app") + // ~/Library/Application Support/com.notey.app, per-user (Story 8.6). + super::resolve_config_dir("com.notey.app") } fn log_dir(&self) -> Result { - todo!("Story 8.6: ~/Library/Logs/com.notey.app") + // ~/Library/Logs/com.notey.app, per-user (Story 8.6). `dirs` has no + // dedicated logs dir on macOS, so derive it from the home directory. + dirs::home_dir() + .map(|home| home.join("Library").join("Logs").join("com.notey.app")) + .ok_or_else(|| { + NoteyError::Config("Could not determine home directory for logs".to_string()) + }) } fn socket_path(&self) -> PathBuf { @@ -41,22 +49,27 @@ impl Platform for MacosPlatform { super::resolve_unix_socket() } - fn register_hotkey(&self, accelerator: &str) -> Result { - todo!("Story 8.6: standard plugin registration for {accelerator}") + fn register_hotkey(&self, _accelerator: &str) -> Result { + // Story 8.6: macOS has a single global-shortcut backend (the standard + // plugin). The actual registration happens in `lib.rs`; this only reports + // the backend. (The Accessibility-permission gate is handled separately + // via `accessibility_permission_granted`, Story 8.2.) + Ok(HotkeyBackend::Standard) } fn autostart_enable(&self) -> Result<(), NoteyError> { - // Story 8.6 (platform capstone): Story 8.4 ships auto-start via - // tauri-plugin-autostart (MacosLauncher::LaunchAgent), not this trait. - todo!("Story 8.6: route autostart through the Platform trait") + // Deferred (DW-97): auto-start is owned by tauri-plugin-autostart + // (MacosLauncher::LaunchAgent) via the Tauri AppHandle (Story 8.4). The + // `&self` trait signature cannot reach the handle. Not called today. + todo!("DW-97: route autostart through the Platform trait") } fn autostart_disable(&self) -> Result<(), NoteyError> { - todo!("Story 8.6: route autostart through the Platform trait") + todo!("DW-97: route autostart through the Platform trait") } fn autostart_is_enabled(&self) -> Result { - todo!("Story 8.6: route autostart through the Platform trait") + todo!("DW-97: route autostart through the Platform trait") } fn accessibility_permission_granted(&self) -> Result { diff --git a/src-tauri/src/platform/mod.rs b/src-tauri/src/platform/mod.rs index 73de77a..f1a9392 100644 --- a/src-tauri/src/platform/mod.rs +++ b/src-tauri/src/platform/mod.rs @@ -1,25 +1,23 @@ //! Cross-platform abstraction over OS-specific behavior: standard paths, global -//! hotkey registration (with a Wayland fallback on Linux), auto-start, and the -//! macOS accessibility-permission flow. -//! -//! **RED-PHASE STUB (Epic 8 — Stories 8.2, 8.5, 8.6).** The [`Platform`] trait -//! defines the contract; the per-OS implementations in [`linux`], [`macos`], and -//! [`windows`] are unimplemented scaffolds (`todo!`). Acceptance tests live in -//! `tests/platform_tests.rs`, marked `#[ignore = "red-phase: Story 8.x"]`. -//! -//! ## Green-phase wiring (do this when implementing) -//! - Implement each `#[cfg(target_os = ...)]` struct against the real OS APIs. -//! - Linux `register_hotkey`: try the standard Tauri global-shortcut plugin first; -//! on failure under Wayland, attempt the XDG GlobalShortcuts portal (verify the -//! current `ashpd` version on crates.io — do not pin from memory); if neither -//! works, return [`NoteyError::Config`] so the caller can notify the user (FR57). +//! hotkey backend selection, auto-start, and the macOS accessibility-permission +//! flow. //! //! **Story 8.5 (done):** `data_dir` and `socket_path` are implemented for all //! three targets via the shared resolvers below; the DB location (`lib.rs`) and //! `crate::ipc::socket_server::socket_path` route through this trait so per-user -//! isolation has a single source of truth. `config_dir`, `log_dir`, -//! `register_hotkey`, and the `autostart_*` methods remain Story 8.6 stubs (the -//! live config path still resolves via `services::config::config_dir`). +//! isolation has a single source of truth. +//! +//! **Story 8.6 (done):** `config_dir`, `log_dir`, and `register_hotkey` are +//! implemented for all three targets. `services::config::config_dir` now +//! delegates to this trait so config-path resolution has a single source of +//! truth, and `lib.rs` consults `register_hotkey` to detect a compositor with no +//! usable hotkey backend (pure Wayland without XWayland) and degrade gracefully +//! (FR56/FR57/FR58). Still deferred: the native Wayland `ashpd` GlobalShortcuts +//! portal (fast-follow, DW-96; `register_hotkey` returns `Err` on such sessions +//! rather than `HotkeyBackend::WaylandPortal`), and routing the `autostart_*` +//! methods through the trait (DW-97 — those remain `todo!` because auto-start is +//! owned by `tauri-plugin-autostart` via the Tauri `AppHandle`, which the +//! `&self`-only trait signature cannot reach; Story 8.4 already satisfies it). use std::path::{Path, PathBuf}; @@ -34,8 +32,8 @@ pub mod windows; /// Which mechanism satisfied a global-hotkey registration request. /// -/// Distinguishing the path taken lets the UI explain a degraded experience on -/// compositors where only the portal works (Story 8.6 / FR57). +/// Distinguishing the path taken lets callers explain degraded hotkey support +/// when a compositor cannot use the standard backend (Story 8.6 / FR57). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HotkeyBackend { /// The standard Tauri global-shortcut plugin (X11, macOS, Windows). @@ -63,8 +61,9 @@ pub trait Platform: Send + Sync { fn socket_path(&self) -> PathBuf; /// Register the global capture hotkey, returning which backend served it. - /// Linux attempts [`HotkeyBackend::WaylandPortal`] when the standard plugin - /// fails under Wayland (FR57). + /// Linux returns [`HotkeyBackend::Standard`] when X11/XWayland is available + /// and [`NoteyError::Config`] on pure Wayland without XWayland. Native + /// [`HotkeyBackend::WaylandPortal`] support is deferred to DW-96. fn register_hotkey(&self, accelerator: &str) -> Result; /// Enable auto-start on login. Story 8.4 / FR41–FR43. @@ -126,6 +125,21 @@ pub(crate) fn resolve_data_dir(namespace: &str) -> Result { }) } +/// Resolve the per-user config directory, namespaced for this OS (Story 8.6). +/// +/// `dirs::config_dir()` joined with `namespace` (`$XDG_CONFIG_HOME` / `%APPDATA%` +/// / `~/Library/Application Support`). Pure resolver — the caller creates the +/// directory. This is the single implementation behind both the [`Platform`] +/// trait's `config_dir` and `services::config::config_dir`. Returns +/// [`NoteyError::Config`] when no platform config directory can be determined. +pub(crate) fn resolve_config_dir(namespace: &str) -> Result { + dirs::config_dir() + .map(|base| base.join(namespace)) + .ok_or_else(|| { + NoteyError::Config("Could not determine platform config directory".to_string()) + }) +} + /// Default per-user Unix socket path (no override seams — those live in /// `socket_server::socket_path`). Prefers `$XDG_RUNTIME_DIR/notey.sock` (a dir /// that is itself `0700`), falling back to a user-scoped temp-dir path. diff --git a/src-tauri/src/platform/windows.rs b/src-tauri/src/platform/windows.rs index a196ceb..2c1f11b 100644 --- a/src-tauri/src/platform/windows.rs +++ b/src-tauri/src/platform/windows.rs @@ -1,4 +1,6 @@ -//! Windows [`Platform`] implementation. RED-PHASE STUB (Story 8.6). +//! Windows [`Platform`] implementation. Paths + hotkey-backend selection are +//! implemented (Stories 8.5/8.6); `autostart_*` is deferred (DW-97 — owned by +//! tauri-plugin-autostart). use std::path::PathBuf; @@ -26,11 +28,17 @@ impl Platform for WindowsPlatform { } fn config_dir(&self) -> Result { - todo!("Story 8.6: %APPDATA%\\notey") + // %APPDATA%\notey, per-user (Story 8.6). + super::resolve_config_dir("notey") } fn log_dir(&self) -> Result { - todo!("Story 8.6: %LOCALAPPDATA%\\notey\\logs") + // %LOCALAPPDATA%\notey\logs, per-user (Story 8.6). + dirs::data_local_dir() + .map(|base| base.join("notey").join("logs")) + .ok_or_else(|| { + NoteyError::Config("Could not determine local data directory for logs".to_string()) + }) } fn socket_path(&self) -> PathBuf { @@ -38,22 +46,26 @@ impl Platform for WindowsPlatform { super::resolve_windows_socket() } - fn register_hotkey(&self, accelerator: &str) -> Result { - todo!("Story 8.6: standard plugin registration for {accelerator}") + fn register_hotkey(&self, _accelerator: &str) -> Result { + // Story 8.6: Windows has a single global-shortcut backend (the standard + // plugin). The actual registration happens in `lib.rs`; this only reports + // the backend. + Ok(HotkeyBackend::Standard) } fn autostart_enable(&self) -> Result<(), NoteyError> { - // Story 8.6 (platform capstone): Story 8.4 ships auto-start via - // tauri-plugin-autostart (app.autolaunch()), not this trait method. - todo!("Story 8.6: route autostart through the Platform trait") + // Deferred (DW-97): auto-start is owned by tauri-plugin-autostart via the + // Tauri AppHandle (Story 8.4). The `&self` trait signature cannot reach + // the handle. Not called today. + todo!("DW-97: route autostart through the Platform trait") } fn autostart_disable(&self) -> Result<(), NoteyError> { - todo!("Story 8.6: route autostart through the Platform trait") + todo!("DW-97: route autostart through the Platform trait") } fn autostart_is_enabled(&self) -> Result { - todo!("Story 8.6: route autostart through the Platform trait") + todo!("DW-97: route autostart through the Platform trait") } fn accessibility_permission_granted(&self) -> Result { diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index a0574c3..a00c96c 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -7,17 +7,12 @@ use crate::models::config::{AppConfig, Theme}; /// Returns the platform-standard config directory for Notey. /// Linux: `$XDG_CONFIG_HOME/notey/`, macOS: `~/Library/Application Support/com.notey.app/`, /// Windows: `%APPDATA%\notey\` +/// +/// Delegates to [`crate::platform::current`]'s `config_dir` so config-path +/// resolution has a single source of truth in the `Platform` abstraction +/// (Story 8.6 / FR58). The returned path is identical to the prior inline logic. pub fn config_dir() -> Result { - let base = dirs::config_dir().ok_or_else(|| { - NoteyError::Config("Could not determine platform config directory".to_string()) - })?; - - #[cfg(target_os = "macos")] - let dir = base.join("com.notey.app"); - #[cfg(not(target_os = "macos"))] - let dir = base.join("notey"); - - Ok(dir) + crate::platform::current().config_dir() } /// Returns the full path to config.toml within the config directory. diff --git a/src-tauri/tests/platform_tests.rs b/src-tauri/tests/platform_tests.rs index 6920424..4c3fec8 100644 --- a/src-tauri/tests/platform_tests.rs +++ b/src-tauri/tests/platform_tests.rs @@ -3,10 +3,9 @@ //! Permission Guidance), exercised through the [`tauri_app_lib::platform`] //! abstraction. //! -//! Tests are `#[ignore = "red-phase: Story 8.x"]` against the unimplemented -//! per-OS scaffolds. Assertions are `#[cfg(target_os = ...)]`-gated so each only -//! compiles/runs on the platform it describes (the CI matrix covers all five -//! targets). True cross-user isolation and real Wayland behavior are verified by +//! Assertions are `#[cfg(target_os = ...)]`-gated so each only compiles/runs on +//! the platform it describes (the CI matrix covers all five targets). True +//! cross-user isolation and real Wayland portal behavior are verified by //! platform QA; these pin the automatable contract. //! //! cargo test --test platform_tests -- --ignored @@ -18,6 +17,10 @@ use tauri_app_lib::platform; /// harness runs them on parallel threads. static DATA_DIR_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +/// Serializes tests that read/mutate process-global display env vars. +#[cfg(target_os = "linux")] +static HOTKEY_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Restores a process env var to its prior value, including non-Unicode values. struct EnvVarGuard { key: &'static str, @@ -49,7 +52,6 @@ impl Drop for EnvVarGuard { /// AC 8.6: `current()` resolves to the implementation for the target OS. #[test] -#[ignore = "red-phase: Story 8.6"] fn current_resolves_to_target_platform() { let expected = if cfg!(target_os = "linux") { "linux" @@ -108,6 +110,79 @@ fn data_dir_honors_override_seam() { ); } +/// AC 8.6/FR58: the config directory is a per-user, platform-standard path using +/// the same namespace convention as `services::config`. +#[test] +fn config_dir_is_user_scoped_and_standard() { + let dir = platform::current() + .config_dir() + .expect("config_dir must resolve"); + let namespace = if cfg!(target_os = "macos") { + "com.notey.app" + } else { + "notey" + }; + let expected = dirs::config_dir() + .expect("platform config dir must resolve") + .join(namespace); + assert_eq!( + dir, expected, + "config dir must be the platform config dir plus the Notey namespace" + ); +} + +/// AC 8.6/FR58: config-path resolution has a single source of truth — the live +/// `services::config::config_dir` returns the same path as the trait method. +#[test] +fn config_dir_matches_services_config() { + let via_service = + tauri_app_lib::services::config::config_dir().expect("services config_dir must resolve"); + let via_trait = platform::current() + .config_dir() + .expect("platform config_dir must resolve"); + assert_eq!( + via_service, via_trait, + "services::config::config_dir must delegate to the Platform trait" + ); +} + +/// AC 8.6/FR58: the log directory is a per-user, platform-standard path. +#[test] +fn log_dir_is_user_scoped_and_standard() { + let dir = platform::current().log_dir().expect("log_dir must resolve"); + #[cfg(target_os = "linux")] + let expected = dirs::state_dir() + .expect("platform state dir must resolve") + .join("notey") + .join("logs"); + #[cfg(target_os = "macos")] + let expected = dirs::home_dir() + .expect("home dir must resolve") + .join("Library") + .join("Logs") + .join("com.notey.app"); + #[cfg(target_os = "windows")] + let expected = dirs::data_local_dir() + .expect("local data dir must resolve") + .join("notey") + .join("logs"); + assert_eq!( + dir, expected, + "log dir must be the platform-standard log path" + ); +} + +/// AC 8.6/FR56: macOS and Windows expose a single standard global-shortcut +/// backend (no Wayland branch). +#[cfg(not(target_os = "linux"))] +#[test] +fn hotkey_uses_standard_backend() { + let backend = platform::current() + .register_hotkey("Ctrl+Shift+N") + .expect("hotkey backend must resolve"); + assert_eq!(backend, platform::HotkeyBackend::Standard); +} + /// AC 8.5/FR51: the IPC socket path is user-scoped (no system-wide shared path). #[cfg(unix)] #[test] @@ -131,22 +206,47 @@ fn socket_path_is_user_scoped() { /// standard plugin backend. #[cfg(target_os = "linux")] #[test] -#[ignore = "red-phase: Story 8.6"] fn linux_hotkey_uses_standard_backend_on_x11() { + let _guard = HOTKEY_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let _display = EnvVarGuard::set("DISPLAY", ":99"); + let _wayland_display = EnvVarGuard::unset("WAYLAND_DISPLAY"); + let _session_type = EnvVarGuard::set("XDG_SESSION_TYPE", "x11"); + let backend = platform::current() .register_hotkey("Ctrl+Shift+N") .expect("hotkey registration must succeed on X11"); assert_eq!(backend, platform::HotkeyBackend::Standard); } +/// AC 8.6/FR57: pure Wayland without XWayland degrades gracefully instead of +/// pretending the standard X11 backend is available. +#[cfg(target_os = "linux")] +#[test] +fn linux_hotkey_errors_on_pure_wayland_without_xwayland() { + let _guard = HOTKEY_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let _display = EnvVarGuard::set("DISPLAY", ""); + let _wayland_display = EnvVarGuard::set("WAYLAND_DISPLAY", "wayland-0"); + let _session_type = EnvVarGuard::set("XDG_SESSION_TYPE", "wayland"); + + let error = platform::current() + .register_hotkey("Ctrl+Shift+N") + .expect_err("pure Wayland without XWayland must report no standard backend"); + assert!( + error + .to_string() + .contains("Wayland compositor without XWayland"), + "unexpected pure-Wayland error: {error}" + ); +} + /// AC 8.6/FR57: when the standard plugin fails under Wayland, registration falls /// back to the XDG GlobalShortcuts portal. /// -/// NOTE: requires a Wayland session/harness to exercise the fallback path; until -/// then this documents the expected backend selection. +/// NOTE: native portal support is deferred to DW-96; this test remains ignored +/// until the ashpd-backed implementation and a Wayland harness exist. #[cfg(target_os = "linux")] #[test] -#[ignore = "red-phase: Story 8.6 (needs Wayland harness)"] +#[ignore = "DW-96: native Wayland portal support needs a Wayland harness"] fn linux_hotkey_falls_back_to_wayland_portal() { let backend = platform::current() .register_hotkey("Ctrl+Shift+N") From 46ea161dbcb1c7ff3227f266af09d09157b0da7f Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 17 Jun 2026 10:53:39 -0700 Subject: [PATCH 11/15] chore(sweep): record deferred-work decisions --- _bmad-output/implementation-artifacts/deferred-work.md | 1 + 1 file changed, 1 insertion(+) diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 2a39f02..ebff29e 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -765,6 +765,7 @@ location: src-tauri/src/platform/{linux,macos,windows}.rs (autostart_* methods, severity: low reason: The trait's autostart_* methods take only &self with no Tauri AppHandle, but the real mechanism (tauri-plugin-autostart via app.autolaunch()) requires the handle and already fully satisfies FR41-43 from Story 8.4 (commands/autostart.rs + lib.rs reconcile). Routing through the trait would mean reimplementing the plugin's plist/.desktop/registry logic by hand — a refactor with no functional gain and real cross-platform risk. Needs a trait-signature redesign (pass the handle, or move autostart off the path-trait) before it is worth doing. The todo!() stubs are relabeled to point here. status: open +decision: 2026-06-17 Redesign trait + route — Redesign the Platform trait's autostart_* signatures to accept the Tauri AppHandle (or move autostart off the path-trait into a dedicated abstraction), then implement autostart_enable/disable/is_enabled on each platform by delegating to tauri-plugin-autostart's autolaunch(), and switch commands/autostart.rs to call through the trait. ### DW-98: CI release pipeline producing artifacts for all 5 targets (FR — AC4 of 8.6) From c78ce68f2753cfa376b6ab8c4c82e141f05d0230 Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 17 Jun 2026 11:08:03 -0700 Subject: [PATCH 12/15] sweep dw-release-pipeline-multi-target: DW-98 via bmad-auto --- .github/workflows/release.yml | 97 ++++++++++ .../implementation-artifacts/deferred-work.md | 3 +- .../spec-dw-release-pipeline-multi-target.md | 176 ++++++++++++++++++ 3 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 _bmad-output/implementation-artifacts/spec-dw-release-pipeline-multi-target.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b1e9a88 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,97 @@ +name: Release + +# Builds and uploads Notey bundles for all five targets required by AC4 of +# story 8.6 — Windows x64, macOS x64, macOS ARM64, Linux x64, Linux ARM64 — +# to a draft GitHub Release from a release tag. +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + releaseTag: + description: 'Existing release tag to build, e.g. v1.2.3' + required: true + type: string + +env: + CARGO_TERM_COLOR: always + +jobs: + release: + permissions: + contents: write # required to create the GitHub Release and upload assets + strategy: + fail-fast: false # one target failing must not cancel the others + matrix: + include: + - platform: macos-latest # macOS ARM64 (Apple Silicon) + args: '--target aarch64-apple-darwin' + - platform: macos-15-intel # macOS x64 (Intel) + args: '--target x86_64-apple-darwin' + - platform: ubuntu-22.04 # Linux x64 + args: '' + - platform: ubuntu-22.04-arm # Linux ARM64 (native GitHub-hosted ARM runner) + args: '' + - platform: windows-latest # Windows x64 + args: '' + + name: ${{ matrix.platform }} ${{ matrix.args }} + runs-on: ${{ matrix.platform }} + + steps: + - name: Validate manual release tag + if: github.event_name == 'workflow_dispatch' + shell: bash + env: + RELEASE_TAG: ${{ inputs.releaseTag }} + run: | + if [[ ! "$RELEASE_TAG" =~ ^v.+ ]]; then + echo "releaseTag must match the release tag pattern v*" + exit 1 + fi + + - uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.releaseTag || github.ref }} + + - name: Install system dependencies (Linux) + if: startsWith(matrix.platform, 'ubuntu-') + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libxdo-dev + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + + - name: Install Rust toolchain + # Pinned nightly required by specta =2.0.0-rc.24 (see rust-toolchain.toml). + # On macOS, install both apple-darwin targets so each matrix job can + # cross-build its single architecture via the --target arg. + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly-2026-04-03 + targets: ${{ startsWith(matrix.platform, 'macos-') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + + - name: Cache Cargo + uses: swatinem/rust-cache@v2 + with: + workspaces: './src-tauri -> target' + + - name: Install npm dependencies + run: npm ci + + - name: Build and release Notey + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tagName: ${{ github.event_name == 'workflow_dispatch' && inputs.releaseTag || github.ref_name }} + releaseName: Notey ${{ github.event_name == 'workflow_dispatch' && inputs.releaseTag || github.ref_name }} + releaseBody: 'See the assets below to download and install this version.' + releaseDraft: true # artifacts are unsigned (v1) — a human publishes after review + prerelease: false + args: ${{ matrix.args }} diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index ebff29e..e66c91d 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -773,7 +773,8 @@ origin: bmad-auto-dev split of spec-8-6-cross-platform-verification-wayland-fall location: .github/workflows/ (new release.yml; ci.yml currently runs tests + a Linux debug build only) severity: medium reason: AC4 of story 8.6 wants build artifacts for Windows x64, macOS x64, macOS ARM64, Linux x64, Linux ARM64 (via tauri-apps/tauri-action on tag push). This is a standalone ops/infra deliverable that cannot be exercised by this session's cargo test / clippy / vitest gate (it only runs on a release tag), so adding an unverifiable workflow here would give no confidence. Belongs in its own release-engineering PR. -status: open +status: done 2026-06-17 +resolution: Added .github/workflows/release.yml — tag-push (`v*`) + workflow_dispatch trigger with explicit releaseTag input, tauri-action@v0 with a 5-entry fail-fast:false matrix (macos-latest ARM64, macos-15-intel x64, ubuntu-22.04, native ubuntu-22.04-arm, windows-latest), pinned nightly-2026-04-03 toolchain, draft GitHub Release. Validated with YAML parse; actionlint was unavailable in the review environment. ### DW-99: User-facing notification when the global shortcut is unavailable on the compositor (FR57) diff --git a/_bmad-output/implementation-artifacts/spec-dw-release-pipeline-multi-target.md b/_bmad-output/implementation-artifacts/spec-dw-release-pipeline-multi-target.md new file mode 100644 index 0000000..050f06a --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-dw-release-pipeline-multi-target.md @@ -0,0 +1,176 @@ +--- +title: "Release pipeline producing artifacts for all five targets (DW-98)" +type: "chore" +created: "2026-06-17" +status: "done" +baseline_commit: "46ea161dbcb1c7ff3227f266af09d09157b0da7f" +context: ["{project-root}/_bmad-output/project-context.md"] +--- + + + +## Intent + +**Problem:** AC4 of story 8.6 requires distributable build artifacts for five +targets — Windows x64, macOS x64, macOS ARM64, Linux x64, Linux ARM64 — but the +repo has no release workflow. `ci.yml` only runs tests plus a Linux debug build, +so tagging a release produces nothing. + +**Approach:** Add a net-new `.github/workflows/release.yml` that fires on a +release tag push and uses `tauri-apps/tauri-action` with a five-entry runner +matrix to build and upload bundles for all five targets to a GitHub Release. + +## Boundaries & Constraints + +**Always:** Match this repo's existing CI conventions (npm + `npm ci`, Node 22, +the pinned `nightly-2026-04-03` toolchain, the same Linux apt dependency set as +`ci.yml`). Use the Tauri-recommended macOS split (one matrix entry per arch via +`--target`) and native `ubuntu-22.04-arm` runner for Linux ARM64. Pin +third-party actions to the same major versions already used in `ci.yml`. +Release must be created as a **draft** (artifacts are unsigned per project +context — a human publishes after review). + +**Ask First:** Switching Linux ARM64 from a native ARM runner to +cross-compilation/emulation. Adding code-signing or notarization secrets. +Changing the tag trigger pattern. + +**Never:** Modify `ci.yml` or any existing workflow. Touch application code, +`tauri.conf.json`, `package.json`, or Rust sources. Add network/telemetry to the +app. Introduce auto-update infrastructure. Rename the product (`productName` is +out of scope for this deliverable). + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +| -------- | ------------- | -------------------------- | -------------- | +| Tag push | `git push` of a tag matching `v*` | Workflow triggers; five matrix jobs run; bundles uploaded to a draft GitHub Release for that tag | `fail-fast: false` so one target's failure does not cancel the others | +| Manual run | `workflow_dispatch` from the Actions UI | Same five-job matrix executes | N/A | +| Non-tag push | push to a branch | Workflow does NOT trigger | N/A | + + + +## Code Map + +- `.github/workflows/release.yml` -- NEW. The entire deliverable. +- `.github/workflows/ci.yml` -- REFERENCE ONLY. Source of repo conventions + (toolchain pin, apt deps, Node version, action major versions). Do not edit. +- `src-tauri/tauri.conf.json` -- REFERENCE ONLY. `bundle.active: true`, + `bundle.targets: "all"` — confirms tauri-action will produce platform bundles. +- `rust-toolchain.toml` -- REFERENCE ONLY. Pins `nightly-2026-04-03` (required by + specta); the workflow must install this exact toolchain, not `stable`. + +## Tasks & Acceptance + +**Execution:** + +- [x] `.github/workflows/release.yml` -- Create the release workflow: + - Trigger: `on: push: tags: ['v*']` plus `workflow_dispatch`. + - `permissions: contents: write` (needed to create the Release). + - Matrix (`fail-fast: false`) of five entries: + - `macos-latest` + `args: --target aarch64-apple-darwin` (macOS ARM64) + - `macos-15-intel` + `args: --target x86_64-apple-darwin` (macOS x64) + - `ubuntu-22.04` + `args: ''` (Linux x64) + - `ubuntu-22.04-arm` + `args: ''` (Linux ARM64) + - `windows-latest` + `args: ''` (Windows x64) + - Steps: `actions/checkout@v6`; install Linux apt deps (same list as `ci.yml`: + `libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libxdo-dev`) + on both ubuntu runners; `actions/setup-node@v6` (Node 22, `cache: npm`); + `dtolnay/rust-toolchain@master` with `toolchain: nightly-2026-04-03` and macOS + `targets: aarch64-apple-darwin,x86_64-apple-darwin`; `swatinem/rust-cache@v2` + scoped to `./src-tauri -> target`; `npm ci`; then `tauri-apps/tauri-action@v0` + with `GITHUB_TOKEN`, tag push/manual-dispatch-safe `tagName`, + matching `releaseName`, `releaseDraft: true`, `prerelease: false`, + `args: ${{ matrix.args }}`. + +**Acceptance Criteria:** + +- Given a tag matching `v*` is pushed, when the workflow runs, then exactly five + matrix jobs execute covering Windows x64, macOS x64, macOS ARM64, Linux x64, + and Linux ARM64. +- Given the macOS jobs, when they build, then each targets a single architecture + via its `--target` arg and the toolchain has both apple-darwin targets added. +- Given any single target's build fails, when the matrix runs, then the remaining + targets still build (`fail-fast: false`). +- Given the workflow file, when linted/parsed as YAML and GitHub Actions syntax, + then it is valid with no syntax or schema errors. + +### Review Findings + +- [x] [Review][Patch] macOS x64 job used ARM64 `macos-latest` runner + [.github/workflows/release.yml:25] +- [x] [Review][Patch] manual dispatch could create branch-named releases + [.github/workflows/release.yml:10] +- [x] [Review][Patch] deferred-work resolution overstated `actionlint` + validation and named the stale macOS matrix + [_bmad-output/implementation-artifacts/deferred-work.md:777] + +#### Review Ledger (2026-06-17) + +- patch: macOS x64 job used ARM64 `macos-latest` runner + [.github/workflows/release.yml:25] — changed the x64 matrix row to + `macos-15-intel` and broadened macOS Rust target installation to all + `macos-*` matrix labels. +- patch: manual dispatch could create branch-named releases + [.github/workflows/release.yml:10] — added a required `releaseTag` input, + checks out that tag on manual runs, validates the `v*` pattern, and uses that + tag for the release name/tag. +- patch: deferred-work resolution overstated `actionlint` validation and named + the stale macOS matrix [_bmad-output/implementation-artifacts/deferred-work.md:777] + — updated the closeout text to match the corrected matrix and actual + verification result. +- dismiss: Linux ARM64 depends on a runner label that may not exist + [.github/workflows/release.yml:29] — current GitHub-hosted runner docs list + `ubuntu-22.04-arm`, and the current Tauri pipeline guide uses it for Linux + ARM64. +- dismiss: release action runs concurrently against the same release + [.github/workflows/release.yml:69] — the current Tauri pipeline guide uses + the same matrix pattern with `tauri-apps/tauri-action` creating/updating one + draft release across matrix jobs. +- dismiss: prerelease-looking tags are always marked stable + [.github/workflows/release.yml:78] — the approved execution checklist + explicitly requires `prerelease: false`, and releases remain draft for human + review before publication. + +## Design Notes + +Tag-push releases use the pushed tag itself, and manual dispatch uses the +required `releaseTag` input for checkout plus release naming. This attaches the +Release to an explicit version tag rather than the docs' branch-push + +`__VERSION__` pattern, because the intent specifies a release-tag workflow. + +Toolchain deviates from the Tauri docs (which use `@stable`): this repo is pinned +to `nightly-2026-04-03` in `rust-toolchain.toml` (specta RC requirement), so the +release build must install that exact nightly or it will not compile. + +Linux ARM64 uses the GitHub-hosted native `ubuntu-22.04-arm` runner (the current +Tauri-recommended approach, free on public repos) — far simpler and more reliable +than cross-compilation or QEMU emulation. + +Manual dispatch requires an explicit existing `releaseTag` input and checks out +that tag before building, so a run from `main` or a feature branch cannot create +a branch-named draft release. + +## Spec Change Log + +- 2026-06-17 review: amended the non-frozen matrix checklist from + `macos-latest` to `macos-15-intel` for the macOS x64 job. Current GitHub + runner docs map `macos-latest` to ARM64, so retaining `macos-latest` for the + x64 row risked producing only ARM-hosted macOS artifacts while claiming Intel + coverage. + +## Verification + +This workflow only runs on a real release tag push and cannot be exercised by the +local cargo/vitest gate. Verification is config-correctness review. + +**Commands:** + +- `python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/release.yml'))"` -- expected: exits 0 (valid YAML). +- `actionlint .github/workflows/release.yml` -- expected: no errors (if `actionlint` is available; skip if not installed). + +**Manual checks:** + +- Confirm five matrix entries map 1:1 to the five required targets. +- Confirm action versions match `ci.yml` majors (`checkout@v6`, `setup-node@v6`, + `dtolnay/rust-toolchain@master`) and `tauri-action@v0`. +- Confirm `releaseDraft: true` and `permissions: contents: write` are present. From e00de1a3044f219c77fe5b5fcd6613ba2a9ac39e Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 17 Jun 2026 11:33:57 -0700 Subject: [PATCH 13/15] sweep dw-hotkey-unavailable-user-notification: DW-99 via bmad-auto --- .../implementation-artifacts/deferred-work.md | 3 +- ...dw-hotkey-unavailable-user-notification.md | 126 ++++++++++++++++++ src-tauri/capabilities/default.json | 3 +- .../autogenerated/get_hotkey_status.toml | 11 ++ src-tauri/src/commands/config.rs | 21 ++- src-tauri/src/commands/hotkey.rs | 27 ++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/ipc/protocol.rs | 15 ++- src-tauri/src/lib.rs | 20 +++ src-tauri/src/models/hotkey.rs | 77 +++++++++++ src-tauri/src/models/mod.rs | 1 + src-tauri/src/services/notes.rs | 13 +- src-tauri/src/services/search_service.rs | 10 +- src-tauri/src/services/window_layout.rs | 10 +- src-tauri/tests/acl_tests.rs | 1 + src/App.tsx | 6 + src/features/hotkey/unavailableNotice.test.ts | 93 +++++++++++++ src/features/hotkey/unavailableNotice.ts | 56 ++++++++ .../toast/components/Toaster.test.tsx | 44 ++++++ src/features/toast/components/Toaster.tsx | 51 ++++--- src/generated/bindings.ts | 23 ++++ 21 files changed, 568 insertions(+), 44 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-dw-hotkey-unavailable-user-notification.md create mode 100644 src-tauri/permissions/autogenerated/get_hotkey_status.toml create mode 100644 src-tauri/src/commands/hotkey.rs create mode 100644 src-tauri/src/models/hotkey.rs create mode 100644 src/features/hotkey/unavailableNotice.test.ts create mode 100644 src/features/hotkey/unavailableNotice.ts create mode 100644 src/features/toast/components/Toaster.test.tsx diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index e66c91d..d6e3b5c 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -782,4 +782,5 @@ origin: bmad-auto-dev split of spec-8-6-cross-platform-verification-wayland-fall location: src/ (frontend toast/dialog) + src-tauri/src/lib.rs (global-shortcut setup) severity: low reason: FR57 / AC2 asks that the user be notified when no hotkey backend works on their compositor. Story 8.6 implements the backend detection (Platform::register_hotkey) and a clear startup warning log for the unavailable case, but the user-facing UI surface (toast/dialog + a typed event) is separable frontend work. Deferred to keep 8.6 backend-only and CI-verifiable; the backend signal it needs is in place. -status: open +status: done 2026-06-17 +resolution: 2026-06-17 (spec-dw-hotkey-unavailable-user-notification.md). The `register_hotkey` Err arm in lib.rs now records the outcome in managed `Mutex` state (new models/hotkey.rs); a thin sync command `get_hotkey_status` (commands/hotkey.rs, +ACL/perm) exposes it. Frontend `startHotkeyUnavailableNotice` pulls it at startup and raises a persistent, click-dismissable toast via the existing toast store when unavailable. Chose pull over the bundle-suggested setup-time event because the window is startup-hidden and the webview listener isn't attached when `setup` runs, so an event would be dropped (logged as a PREFERENCE escalation). Verified: cargo test, clippy -D warnings, vitest (627), tsc, eslint all green. diff --git a/_bmad-output/implementation-artifacts/spec-dw-hotkey-unavailable-user-notification.md b/_bmad-output/implementation-artifacts/spec-dw-hotkey-unavailable-user-notification.md new file mode 100644 index 0000000..298b871 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-dw-hotkey-unavailable-user-notification.md @@ -0,0 +1,126 @@ +--- +title: "Hotkey-unavailable user notification (FR57 / DW-99)" +type: "feature" +created: "2026-06-17" +status: "done" +context: ["{project-root}/_bmad-output/project-context.md"] +baseline_commit: "c8fcb550dc81527ad8416a01aed037f4a11d560d" +--- + + + +## Intent + +**Problem:** When no global-shortcut backend is available on the user's compositor (pure Wayland with no XWayland, per Story 8.6 / FR57 / AC2), the app silently falls back to tray-only summoning. The only signal is an `eprintln!` in `src-tauri/src/lib.rs` (the `Err` arm of `register_hotkey`) that the user never sees, so they are left with an app that does not respond to its hotkey and no explanation. + +**Approach:** Record the hotkey availability detected at startup in Tauri-managed state, expose it through a thin read-only command, and have the frontend query it on launch and raise a toast (via the existing `src/features/toast` surface) when the hotkey backend is unavailable. The toast is persistent and click-dismissable so it survives until the user actually opens the (startup-hidden) window and acknowledges it. + +## Boundaries & Constraints + +**Always:** + +- Reuse the existing toast system (`useToastStore` / `Toaster`) — no new notification infrastructure. +- Hotkey detection stays exactly where Story 8.6 put it (the `register_hotkey` match in `lib.rs`); this work only records its outcome and surfaces it. Keep the existing `eprintln!` notice. +- All IPC via tauri-specta generated bindings (`commands.getHotkeyStatus`) — never raw `invoke`. Backend IPC struct uses `#[serde(rename_all = "camelCase")]`. +- Notification is best-effort and non-blocking: any failure querying status or showing the toast must be logged and must not affect startup or window summoning. +- The toast must remain visible long enough to be seen given the window is hidden at startup — use a persistent toast, not a short auto-dismiss. + +**Never:** + +- Do not emit the status as a `setup`-time Tauri event consumed by a frontend listener: the window is hidden and the webview listener is not yet attached when `setup` runs, so such an event is dropped (see Design Notes). Pull-on-startup is the reliable mechanism. +- Do not auto-show or focus the main window on hotkey-unavailability — surfacing the window proactively is a separate product decision, out of scope here. +- Do not change the hotkey detection logic, the registration fallback, or tray behavior. +- Do not surface the raw technical error string in the toast (log it to the console instead). + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +| ------------------------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | +| Backend available | `register_hotkey` returns `Ok` (or non-desktop build) | Managed `HotkeyStatus { available: true, reason: null }` | N/A | +| Backend unavailable | `register_hotkey` returns `Err(e)` | Managed `HotkeyStatus { available: false, reason: e.to_string() }`; existing `eprintln!` still logged | N/A | +| Frontend startup, available | `getHotkeyStatus()` → `{ available: true }` | No toast shown | N/A | +| Frontend startup, unavailable | `getHotkeyStatus()` → `{ available: false, reason }` | Persistent toast: "Global hotkey unavailable on this system — open Notey from the tray icon."; `reason` logged via `console.warn` | N/A | +| Duplicate startup invoke (React StrictMode) | `startHotkeyUnavailableNotice()` called twice | Status queried once, at most one toast | Idempotent via module guard | +| `getHotkeyStatus()` rejects | command throws | No toast; error logged | `console.error`, swallowed | +| Toast clicked | user clicks any toast card | Toast dismissed immediately | Idempotent (no-op if gone) | + + + +## Code Map + +- `src-tauri/src/models/hotkey.rs` -- NEW: `HotkeyStatus { available: bool, reason: Option }` (serde camelCase + specta `Type`) with `available()` / `unavailable(reason)` constructors. +- `src-tauri/src/models/mod.rs` -- add `pub mod hotkey;`. +- `src-tauri/src/commands/hotkey.rs` -- NEW: thin sync command `get_hotkey_status(state) -> HotkeyStatus` reading managed state; recovers a poisoned lock by cloning the inner value. +- `src-tauri/src/commands/mod.rs` -- add `pub mod hotkey;`. +- `src-tauri/src/lib.rs` -- register `commands::hotkey::get_hotkey_status` in `collect_commands!`; manage `Mutex` (default available) before the `#[cfg(desktop)]` global-shortcut block; in the `register_hotkey` match, set it to `available()` on `Ok` and `unavailable(e.to_string())` on `Err` (alongside the existing `eprintln!`). +- `src-tauri/permissions/autogenerated/get_hotkey_status.toml` -- NEW if `cargo build` does not generate it (per project-context Tauri-v2 known issue). +- `src-tauri/capabilities/default.json` -- add `"allow-get-hotkey-status"`. +- `src-tauri/tests/acl_tests.rs` -- add `"allow-get-hotkey-status"` to `EXPECTED_COMMANDS`. +- `src/generated/bindings.ts` -- regenerated by the build/`export_bindings` test (NOT hand-edited); will expose `commands.getHotkeyStatus` + `HotkeyStatus` type. +- `src/features/hotkey/unavailableNotice.ts` -- NEW: `startHotkeyUnavailableNotice()` — queries `getHotkeyStatus()`, raises the persistent toast when unavailable; module-guarded for idempotency. +- `src/features/hotkey/unavailableNotice.test.ts` -- NEW: unit tests for the matrix. +- `src/features/toast/components/Toaster.tsx` -- add click-to-dismiss on each toast card so the persistent notice can be cleared. +- `src/features/toast/components/Toaster.test.tsx` -- NEW: cover click-to-dismiss. +- `src/App.tsx` -- call `void startHotkeyUnavailableNotice()` in the startup effect (best-effort, errors logged, non-blocking). + +## Tasks & Acceptance + +**Execution:** + +- [x] `src-tauri/src/models/hotkey.rs` -- define `HotkeyStatus` (camelCase serde, specta `Type`, rustdoc) with `available()`/`unavailable(String)` constructors -- typed status carried across the IPC boundary. +- [x] `src-tauri/src/models/mod.rs` -- add `pub mod hotkey;` -- expose the new model. +- [x] `src-tauri/src/commands/hotkey.rs` -- thin sync `#[tauri::command] #[specta::specta] get_hotkey_status(State>) -> HotkeyStatus`; clone inner value, recovering a poisoned lock -- read-only IPC accessor. +- [x] `src-tauri/src/commands/mod.rs` -- add `pub mod hotkey;` -- register the command module. +- [x] `src-tauri/src/lib.rs` -- add `commands::hotkey::get_hotkey_status` to `collect_commands!`; `app.manage(Mutex::new(HotkeyStatus::available()))` before the desktop shortcut block; in the `register_hotkey` match update the managed status (`available()` on `Ok`, `unavailable(e.to_string())` on `Err`) while keeping the existing `eprintln!` -- records detection outcome for the frontend to pull. +- [x] `src-tauri/capabilities/default.json` + `src-tauri/permissions/autogenerated/get_hotkey_status.toml` -- add `"allow-get-hotkey-status"` and the perm TOML (create manually if the build skips it) -- ACL grant for the new command. +- [x] `src-tauri/tests/acl_tests.rs` -- add `"allow-get-hotkey-status"` to `EXPECTED_COMMANDS` -- keep the ACL coverage test green. +- [x] `src/features/hotkey/unavailableNotice.ts` -- `startHotkeyUnavailableNotice()`: query `commands.getHotkeyStatus()`; if `!available`, `useToastStore.getState().addToast(message, 0)` (persistent) and `console.warn` the reason; idempotent via module guard; swallow+log errors -- frontend surface wiring. +- [x] `src/features/toast/components/Toaster.tsx` -- add `onClick={() => dismissToast(toast.id)}` (cursor pointer, `title="Dismiss"`) to each toast card -- lets the user clear the persistent notice. +- [x] `src/App.tsx` -- invoke `void startHotkeyUnavailableNotice()` in the startup effect, non-blocking -- triggers the pull on launch. +- [x] `src/features/hotkey/unavailableNotice.test.ts` + `src/features/toast/components/Toaster.test.tsx` -- unit-test the I/O & Edge-Case Matrix (unavailable→persistent toast + warn, available→none, idempotency, command rejection, click-dismiss) -- and a Rust `#[cfg(test)]` in `models/hotkey.rs` asserting camelCase serialization (`available`, `reason`). + +### Review Findings + +- [x] [Review][Patch] `Ok` arm should record `HotkeyStatus::available()` [src-tauri/src/lib.rs:300] -- fixed in review: the successful backend-probe arm now explicitly writes `HotkeyStatus::available()` to managed state, matching the frozen Code Map requirement instead of relying only on the default. +- [x] [Review][Patch] Toast click-dismiss control needs keyboard accessibility [src/features/toast/components/Toaster.tsx:37] -- fixed in review: toast cards are now semantic buttons with an accessible dismiss label, preserving click dismissal while allowing keyboard and assistive-technology dismissal. + +#### Review Ledger (2026-06-17) + +- patch: `Ok` arm should record `HotkeyStatus::available()` [src-tauri/src/lib.rs:300] -- fixed; Acceptance Auditor correctly found the frozen spec required an explicit available write in the `Ok` arm. +- patch: Toast click-dismiss control needs keyboard accessibility [src/features/toast/components/Toaster.tsx:37] -- fixed; Edge Case Hunter correctly found the persistent dismiss affordance was mouse-only. +- dismiss: Non-desktop builds default to hotkey available [src-tauri/src/lib.rs:257] -- specified behavior; the frozen I/O matrix says non-desktop builds produce `available: true`. +- dismiss: Registration failure after backend probe should mark status unavailable [src-tauri/src/lib.rs:305] -- out of scope; this status tracks backend availability per FR57, while shortcut conflicts remain existing logged/rebind behavior. +- dismiss: One-shot guard suppresses retry after `getHotkeyStatus()` rejection [src/features/hotkey/unavailableNotice.ts:33] -- specified behavior; the matrix requires rejection to log, swallow, and show no toast. +- dismiss: All toasts are now click-dismissable [src/features/toast/components/Toaster.tsx:40] -- specified behavior; the Code Map calls for click-to-dismiss on each toast card. +- dismiss: Missing visible close-button text [src/features/toast/components/Toaster.tsx:41] -- no product requirement for a separate close button; fixed accessibility through semantic button labeling. +- dismiss: Tray guidance may be wrong if tray is unavailable [src/features/hotkey/unavailableNotice.ts:10] -- out of scope; the approved message explicitly directs users to the tray icon. +- dismiss: Technical reason is only logged, not displayed [src/features/hotkey/unavailableNotice.ts:38] -- specified behavior; frozen constraints forbid surfacing the raw technical error in the toast. +- dismiss: Generated `getHotkeyStatus` binding is not wrapped with `typedError` [src/generated/bindings.ts:114] -- expected codegen for a command returning plain `HotkeyStatus` rather than `Result`. +- dismiss: `HotkeyStatus` derives `Deserialize` unnecessarily [src-tauri/src/models/hotkey.rs:18] -- harmless consistency with other IPC models, not a behavioral risk. +- dismiss: Click-dismiss test relies on event bubbling [src/features/toast/components/Toaster.test.tsx:24] -- superseded by added semantic-button coverage. + +**Acceptance Criteria:** + +- Given a session where `register_hotkey` returns `Err`, when the app starts and the frontend queries `getHotkeyStatus`, then a persistent toast tells the user the hotkey is unavailable and to use the tray, and the technical reason is logged to the console. +- Given a session where the hotkey registers successfully, when the app starts, then no hotkey toast is shown. +- Given the startup effect runs twice (React StrictMode), when `startHotkeyUnavailableNotice` is invoked again, then at most one toast is shown. +- Given the persistent toast is visible, when the user clicks it, then it is dismissed. +- Given `getHotkeyStatus` is unavailable or rejects, when the notice runs, then startup and window summoning are unaffected and the error is logged. + +## Design Notes + +**Why pull, not a `setup`-time event.** The intent (DW-99) suggested emitting a typed Tauri event from the `Err` arm and adding a frontend listener. That is unreliable here: the `main` window is configured `visible: false` (`tauri.conf.json`), the webview loads asynchronously, and the `register_hotkey` match runs early in the synchronous `setup` hook — well before the React `useEffect` listener attaches. Tauri does not buffer events for not-yet-registered listeners, so the event would be dropped. Recording the outcome in managed state and letting the frontend pull it on startup is race-free and matches the codebase's existing startup-state pattern (e.g. `get_onboarding_state`). This is a deliberate deviation from the suggested mechanism, logged as a PREFERENCE. + +**Persistent toast.** Because the window is hidden at startup, a normal 3s auto-dismiss toast would expire before the user ever opens the window. The notice is added persistently (`addToast(msg, 0)` — non-positive duration skips the auto-dismiss timer, per the existing store contract) and click-to-dismiss is added to the `Toaster` so the user can clear it after reading. The export-progress toast already relies on the persistent path, so this reuses an established store capability. + +**Managing the status.** `app.manage(Mutex::new(HotkeyStatus::available()))` is done unconditionally so the command always has state (including non-desktop builds where the `#[cfg(desktop)]` shortcut block is compiled out). The desktop block updates the managed value through the lock, mirroring the existing `Mutex` pattern. + +## Verification + +**Commands:** + +- `cd src-tauri && cargo test` -- expected: all pass, including `acl_tests` (new `allow-get-hotkey-status`) and the `HotkeyStatus` serde test. +- `cd src-tauri && cargo test export_bindings` -- expected: regenerates `src/generated/bindings.ts` with `commands.getHotkeyStatus` and the `HotkeyStatus` type. +- `cd src-tauri && cargo clippy --all-targets -- -D warnings` -- expected: clean. +- `npm run test` -- expected: new `unavailableNotice` and `Toaster` tests pass; existing toast/store tests unaffected. +- `npx tsc --noEmit && npx eslint src/features/hotkey src/features/toast src/App.tsx` -- expected: no type or lint errors. diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 201a9d2..1590477 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -43,6 +43,7 @@ "allow-rebuild-fts-index", "allow-search-notes", "allow-export-markdown", - "allow-export-json" + "allow-export-json", + "allow-get-hotkey-status" ] } diff --git a/src-tauri/permissions/autogenerated/get_hotkey_status.toml b/src-tauri/permissions/autogenerated/get_hotkey_status.toml new file mode 100644 index 0000000..8b2ec51 --- /dev/null +++ b/src-tauri/permissions/autogenerated/get_hotkey_status.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-get-hotkey-status" +description = "Enables the get_hotkey_status command without any pre-configured scope." +commands.allow = ["get_hotkey_status"] + +[[permission]] +identifier = "deny-get-hotkey-status" +description = "Denies the get_hotkey_status command without any pre-configured scope." +commands.deny = ["get_hotkey_status"] diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 8844386..3c6e491 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -46,9 +46,9 @@ fn restore_shortcut_registrations( .map_err(|e| NoteyError::Config(format!("Failed to reset shortcut registrations: {e}")))?; if let Some(shortcut) = desired_active { - app.global_shortcut() - .register(shortcut) - .map_err(|e| NoteyError::Config(format!("Failed to restore shortcut registration: {e}")))?; + app.global_shortcut().register(shortcut).map_err(|e| { + NoteyError::Config(format!("Failed to restore shortcut registration: {e}")) + })?; } Ok(()) @@ -131,9 +131,7 @@ pub fn update_config( // unchanged persisted config. #[cfg(desktop)] if registered_new { - if let Err(recovery_error) = - restore_shortcut_registrations(&app, old_active_shortcut) - { + if let Err(recovery_error) = restore_shortcut_registrations(&app, old_active_shortcut) { return Err(NoteyError::Config(format!( "{} (original save error: {})", match recovery_error { @@ -155,16 +153,15 @@ pub fn update_config( if let Some(new_shortcut) = parsed_new_shortcut { if let Some(old_shortcut) = old_active_shortcut { if old_shortcut != new_shortcut { - if let Err(unregister_error) = - app.global_shortcut().unregister(old_shortcut) - { - restore_shortcut_registrations(&app, Some(new_shortcut)) - .map_err(|recovery_error| { + if let Err(unregister_error) = app.global_shortcut().unregister(old_shortcut) { + restore_shortcut_registrations(&app, Some(new_shortcut)).map_err( + |recovery_error| { NoteyError::Config(format!( "Failed to retire previous shortcut: {} (recovery: {})", unregister_error, recovery_error )) - })?; + }, + )?; } } } diff --git a/src-tauri/src/commands/hotkey.rs b/src-tauri/src/commands/hotkey.rs new file mode 100644 index 0000000..21635de --- /dev/null +++ b/src-tauri/src/commands/hotkey.rs @@ -0,0 +1,27 @@ +//! Thin Tauri command exposing the global-shortcut backend status detected at +//! startup (Story 8.6 / FR57, DW-99). +//! +//! `lib.rs` records the outcome of the startup `register_hotkey` probe in +//! managed `Mutex` state; the frontend pulls it on launch via this +//! command and warns the user when the hotkey is unavailable. Synchronous per the +//! project's command convention (a mutex read, no async work). + +use std::sync::Mutex; + +use tauri::State; + +use crate::models::hotkey::HotkeyStatus; + +/// Return the global-shortcut backend status for the current session. +/// +/// Reads the value recorded during startup hotkey detection. A poisoned lock is +/// recovered by cloning the inner value — the status is plain data with no +/// transactional state to repair. +#[tauri::command] +#[specta::specta] +pub fn get_hotkey_status(state: State<'_, Mutex>) -> HotkeyStatus { + state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 389887b..9073869 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod accessibility; pub mod autostart; pub mod config; pub mod export; +pub mod hotkey; pub mod notes; pub mod onboarding; pub mod search; diff --git a/src-tauri/src/ipc/protocol.rs b/src-tauri/src/ipc/protocol.rs index ee0c761..8859b8c 100644 --- a/src-tauri/src/ipc/protocol.rs +++ b/src-tauri/src/ipc/protocol.rs @@ -402,7 +402,10 @@ mod tests { .to_string(); let filtered = handle_request( &conn, - &request("list_notes", serde_json::json!({ "workspaceName": ws_name })), + &request( + "list_notes", + serde_json::json!({ "workspaceName": ws_name }), + ), ); assert!(filtered.success); let rows = filtered.data.unwrap(); @@ -414,7 +417,10 @@ mod tests { // A non-existent workspace name → empty. let none = handle_request( &conn, - &request("list_notes", serde_json::json!({ "workspaceName": "no-such-ws" })), + &request( + "list_notes", + serde_json::json!({ "workspaceName": "no-such-ws" }), + ), ); assert!(none.success); assert!(none.data.unwrap().as_array().unwrap().is_empty()); @@ -499,7 +505,10 @@ mod tests { let resp = IpcResponse::ok(serde_json::json!({ "title": "x" })); assert_eq!(created_note_id(&raw, &resp), None); // Unparseable request bytes never panic. - assert_eq!(created_note_id(b"{not json", &IpcResponse::ok(Value::Null)), None); + assert_eq!( + created_note_id(b"{not json", &IpcResponse::ok(Value::Null)), + None + ); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2f53e47..ff2f442 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -53,6 +53,7 @@ fn specta_builder() -> tauri_specta::Builder { commands::search::search_notes, commands::export::export_markdown, commands::export::export_json, + commands::hotkey::get_hotkey_status, ]) } @@ -249,6 +250,12 @@ pub fn run() { app.manage(Mutex::new(config)); app.manage(ConfigDir(config_dir)); + // Hotkey backend status (Story 8.6 / FR57, DW-99). Managed + // unconditionally so `get_hotkey_status` always resolves, including on + // builds where the desktop shortcut block below is compiled out. + // Defaults to available; the detection arm updates it on a backend miss. + app.manage(Mutex::new(models::hotkey::HotkeyStatus::available())); + // --- Global shortcut --- #[cfg(desktop)] { @@ -291,6 +298,11 @@ pub fn run() { // summonable from the tray. match crate::platform::current().register_hotkey(&shortcut_str) { Ok(_backend) => { + *app.state::>() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = + models::hotkey::HotkeyStatus::available(); + // Non-fatal: a saved shortcut that conflicts with another // app must not brick startup. Log and continue — the // window stays summonable via the tray and the user can @@ -307,6 +319,14 @@ pub fn run() { "Notice: global shortcut unavailable on this compositor ({e}); \ summon Notey from the tray icon instead." ); + // Record the miss so the frontend can pull it on startup and + // warn the user the hotkey will not work (Story 8.6 / FR57, + // DW-99). The managed default is `available()`, so only the + // unavailable case needs to overwrite it. + *app.state::>() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = + models::hotkey::HotkeyStatus::unavailable(e.to_string()); } } } diff --git a/src-tauri/src/models/hotkey.rs b/src-tauri/src/models/hotkey.rs new file mode 100644 index 0000000..451dffa --- /dev/null +++ b/src-tauri/src/models/hotkey.rs @@ -0,0 +1,77 @@ +//! Runtime status of the global-shortcut backend, surfaced to the frontend. +//! +//! Story 8.6 / FR57 detects, at startup, whether the session has a usable +//! global-shortcut backend (a pure Wayland session without XWayland and without +//! native portal support has none — see `lib.rs`). This model records that +//! outcome in Tauri-managed state so the frontend can pull it on launch and warn +//! the user that the hotkey will not work (DW-99). +//! +//! Pulled rather than pushed because the detection runs in the synchronous +//! `setup` hook, long before the (startup-hidden) webview attaches an event +//! listener — a `setup`-time event would be dropped. + +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// Availability of the global-shortcut backend for the current session. +/// +/// Wire shape (camelCase): `{ "available": false, "reason": "" }`. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct HotkeyStatus { + /// `true` when a usable global-shortcut backend was found at startup. + pub available: bool, + /// Human-readable reason the backend is unavailable, when `available` is + /// `false`. `None` when the hotkey is available. + pub reason: Option, +} + +impl HotkeyStatus { + /// A status indicating the global-shortcut backend is available. + pub fn available() -> Self { + Self { + available: true, + reason: None, + } + } + + /// A status indicating the backend is unavailable, carrying the technical + /// `reason` for logging (not shown verbatim to the user). + pub fn unavailable(reason: impl Into) -> Self { + Self { + available: false, + reason: Some(reason.into()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn available_has_no_reason() { + let status = HotkeyStatus::available(); + assert!(status.available); + assert!(status.reason.is_none()); + } + + #[test] + fn unavailable_carries_reason() { + let status = HotkeyStatus::unavailable("no portal"); + assert!(!status.available); + assert_eq!(status.reason.as_deref(), Some("no portal")); + } + + #[test] + fn serializes_camel_case_shape() { + let value = serde_json::to_value(HotkeyStatus::unavailable("boom")).expect("serialize"); + // camelCase field names, no snake_case leakage. + assert_eq!(value["available"].as_bool(), Some(false)); + assert_eq!(value["reason"].as_str(), Some("boom")); + // `available` true omits the reason as JSON null. + let ok = serde_json::to_value(HotkeyStatus::available()).expect("serialize"); + assert_eq!(ok["available"].as_bool(), Some(true)); + assert!(ok["reason"].is_null()); + } +} diff --git a/src-tauri/src/models/mod.rs b/src-tauri/src/models/mod.rs index c4e4c77..e05fdbc 100644 --- a/src-tauri/src/models/mod.rs +++ b/src-tauri/src/models/mod.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod hotkey; pub mod workspace; use serde::{Deserialize, Serialize}; diff --git a/src-tauri/src/services/notes.rs b/src-tauri/src/services/notes.rs index f81d152..11b411a 100644 --- a/src-tauri/src/services/notes.rs +++ b/src-tauri/src/services/notes.rs @@ -1210,7 +1210,12 @@ mod tests { /// Create a titled note in `workspace_id` and stamp a fixed `updated_at` so /// ordering is deterministic. - fn seed_note(conn: &Connection, title: &str, workspace_id: Option, updated_at: &str) -> i64 { + fn seed_note( + conn: &Connection, + title: &str, + workspace_id: Option, + updated_at: &str, + ) -> i64 { let note = create_note(conn, "markdown", workspace_id).expect("create note"); conn.execute( "UPDATE notes SET title = ?1, updated_at = ?2 WHERE id = ?3", @@ -1259,7 +1264,11 @@ mod tests { seed_note(&conn, "from-two", Some(ws2), "2026-06-12T00:00:00+00:00"); let items = list_notes_with_workspace(&conn, Some("dup")).expect("list"); - assert_eq!(items.len(), 2, "name filter must match both 'dup' workspaces"); + assert_eq!( + items.len(), + 2, + "name filter must match both 'dup' workspaces" + ); } #[test] diff --git a/src-tauri/src/services/search_service.rs b/src-tauri/src/services/search_service.rs index 09b9d19..f399f1a 100644 --- a/src-tauri/src/services/search_service.rs +++ b/src-tauri/src/services/search_service.rs @@ -429,8 +429,8 @@ mod tests { ); let existing = search_notes(&conn, "by_name_term_xyz", None).expect("search"); - let by_name = search_notes_by_workspace_name(&conn, "by_name_term_xyz", None) - .expect("search"); + let by_name = + search_notes_by_workspace_name(&conn, "by_name_term_xyz", None).expect("search"); assert_eq!( serde_json::to_value(&by_name).expect("serialize by-name search"), serde_json::to_value(&existing).expect("serialize existing search"), @@ -477,7 +477,11 @@ mod tests { let results = search_notes_by_workspace_name(&conn, "dup_term_qrs", Some("dup")).expect("search"); - assert_eq!(results.len(), 2, "duplicate names match every sharing workspace"); + assert_eq!( + results.len(), + 2, + "duplicate names match every sharing workspace" + ); } #[test] diff --git a/src-tauri/src/services/window_layout.rs b/src-tauri/src/services/window_layout.rs index 3ebb076..5b9228c 100644 --- a/src-tauri/src/services/window_layout.rs +++ b/src-tauri/src/services/window_layout.rs @@ -74,7 +74,11 @@ pub fn compute_layout( work_area: Option, scale_factor: f64, ) -> Result { - let scale = if scale_factor > 0.0 { scale_factor } else { 1.0 }; + let scale = if scale_factor > 0.0 { + scale_factor + } else { + 1.0 + }; match mode { "floating" => Ok(LayoutPlan { @@ -125,7 +129,9 @@ pub fn compute_layout( skip_taskbar: false, maximize: true, }), - other => Err(NoteyError::Validation(format!("Unknown layout mode: {other}"))), + other => Err(NoteyError::Validation(format!( + "Unknown layout mode: {other}" + ))), } } diff --git a/src-tauri/tests/acl_tests.rs b/src-tauri/tests/acl_tests.rs index 245e3c2..29f81dc 100644 --- a/src-tauri/tests/acl_tests.rs +++ b/src-tauri/tests/acl_tests.rs @@ -34,6 +34,7 @@ const EXPECTED_COMMANDS: &[&str] = &[ "allow-search-notes", "allow-export-markdown", "allow-export-json", + "allow-get-hotkey-status", ]; // P0-INT-006: Tauri ACL rejects unauthorized commands diff --git a/src/App.tsx b/src/App.tsx index 6030c15..3aeaca7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { useWorkspaceStore } from './features/workspace/store'; import { restoreSession, startSessionAutoSave } from './features/session/persistence'; import { startNoteCreatedSync } from './features/note-list/realtimeSync'; import { initOnboarding } from './features/onboarding/bootstrap'; +import { startHotkeyUnavailableNotice } from './features/hotkey/unavailableNotice'; /** Application root — renders the main CaptureWindow and the toast overlay. */ function App() { @@ -19,6 +20,11 @@ function App() { // the workspace/session chain below — failures must not block startup. void initOnboarding(); + // If no global-shortcut backend is available on this session, warn the user + // (via a persistent toast) that the hotkey will not work (FR57, DW-99). + // Best-effort and independent of the chain below. + void startHotkeyUnavailableNotice(); + // Attempt workspace init first, then restore the saved session. Auto-save // still starts if either step fails so session persistence keeps working. void (async () => { diff --git a/src/features/hotkey/unavailableNotice.test.ts b/src/features/hotkey/unavailableNotice.test.ts new file mode 100644 index 0000000..51dcbbf --- /dev/null +++ b/src/features/hotkey/unavailableNotice.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { commands, type HotkeyStatus } from '../../generated/bindings'; +import { useToastStore } from '../toast/store'; +import { + startHotkeyUnavailableNotice, + resetHotkeyUnavailableNotice, + HOTKEY_UNAVAILABLE_MESSAGE, +} from './unavailableNotice'; + +describe('startHotkeyUnavailableNotice', () => { + let getStatusSpy: ReturnType; + let warnSpy: ReturnType; + let errorSpy: ReturnType; + + beforeEach(() => { + resetHotkeyUnavailableNotice(); + useToastStore.getState().reset(); + getStatusSpy = vi.spyOn(commands, 'getHotkeyStatus'); + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + useToastStore.getState().reset(); + }); + + function mockStatus(status: HotkeyStatus): void { + getStatusSpy.mockResolvedValue(status); + } + + it('raises a persistent toast and logs the reason when unavailable', async () => { + mockStatus({ available: false, reason: 'no portal backend' }); + + await startHotkeyUnavailableNotice(); + + const { toasts } = useToastStore.getState(); + expect(toasts).toHaveLength(1); + expect(toasts[0].message).toBe(HOTKEY_UNAVAILABLE_MESSAGE); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('no portal backend'), + ); + }); + + it('persists the toast (no auto-dismiss) past the default duration', async () => { + vi.useFakeTimers(); + mockStatus({ available: false, reason: 'no portal backend' }); + + await startHotkeyUnavailableNotice(); + // Advance well beyond the default 3s auto-dismiss window. + vi.advanceTimersByTime(60_000); + + expect(useToastStore.getState().toasts).toHaveLength(1); + vi.useRealTimers(); + }); + + it('shows no toast when the hotkey is available', async () => { + mockStatus({ available: true, reason: null }); + + await startHotkeyUnavailableNotice(); + + expect(useToastStore.getState().toasts).toHaveLength(0); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('shows the toast but skips the warn when reason is null', async () => { + mockStatus({ available: false, reason: null }); + + await startHotkeyUnavailableNotice(); + + expect(useToastStore.getState().toasts).toHaveLength(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('is idempotent across duplicate startup invocations', async () => { + mockStatus({ available: false, reason: 'no portal backend' }); + + await startHotkeyUnavailableNotice(); + await startHotkeyUnavailableNotice(); + + expect(getStatusSpy).toHaveBeenCalledTimes(1); + expect(useToastStore.getState().toasts).toHaveLength(1); + }); + + it('swallows and logs a status-query rejection without showing a toast', async () => { + getStatusSpy.mockRejectedValue(new Error('ipc down')); + + await expect(startHotkeyUnavailableNotice()).resolves.toBeUndefined(); + + expect(useToastStore.getState().toasts).toHaveLength(0); + expect(errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/features/hotkey/unavailableNotice.ts b/src/features/hotkey/unavailableNotice.ts new file mode 100644 index 0000000..664aa95 --- /dev/null +++ b/src/features/hotkey/unavailableNotice.ts @@ -0,0 +1,56 @@ +import { commands } from '../../generated/bindings'; +import { useToastStore } from '../toast/store'; + +/** + * User-facing message shown when no global-shortcut backend is available on the + * session (Story 8.6 / FR57, DW-99). The technical reason is logged to the + * console rather than surfaced here. + */ +export const HOTKEY_UNAVAILABLE_MESSAGE = + 'Global hotkey unavailable on this system — open Notey from the tray icon.'; + +/** + * Guards against re-running the one-shot startup check. React StrictMode mounts + * the root effect twice in development, and the check should query status and + * raise the notice at most once per session. + */ +let started = false; + +/** + * Pull the global-shortcut backend status on startup and, when no backend is + * available, raise a persistent toast telling the user the hotkey will not work + * and that they can summon Notey from the tray icon. + * + * Pull (not a backend event) because the status is decided in the Rust `setup` + * hook before the startup-hidden webview attaches any listener — a `setup`-time + * event would be dropped. The toast is persistent (`durationMs <= 0`) so it + * survives until the user actually opens the window and dismisses it by clicking. + * + * Best-effort and idempotent: a failed status query or toast must never block + * startup, and duplicate invocations raise the notice at most once. + */ +export async function startHotkeyUnavailableNotice(): Promise { + if (started) return; + started = true; + + try { + const status = await commands.getHotkeyStatus(); + if (!status.available) { + if (status.reason) { + console.warn(`Global hotkey unavailable: ${status.reason}`); + } + // Persistent toast (no auto-dismiss); the user clears it by clicking. + useToastStore.getState().addToast(HOTKEY_UNAVAILABLE_MESSAGE, 0); + } + } catch (e) { + console.error('hotkey status check failed:', e); + } +} + +/** + * Reset the one-shot guard. Test-only — production code calls + * {@link startHotkeyUnavailableNotice} exactly once at startup. + */ +export function resetHotkeyUnavailableNotice(): void { + started = false; +} diff --git a/src/features/toast/components/Toaster.test.tsx b/src/features/toast/components/Toaster.test.tsx new file mode 100644 index 0000000..95130eb --- /dev/null +++ b/src/features/toast/components/Toaster.test.tsx @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { useToastStore } from "../store"; +import { Toaster } from "./Toaster"; + +describe("Toaster", () => { + beforeEach(() => { + useToastStore.getState().reset(); + }); + + afterEach(() => { + useToastStore.getState().reset(); + }); + + it("renders active toasts", () => { + useToastStore.getState().addToast("hello", 0); + render(); + expect(screen.getByText("hello")).toBeInTheDocument(); + }); + + it("dismisses a (persistent) toast when clicked", () => { + // A persistent toast (durationMs <= 0) never auto-dismisses; clicking is the + // only way to clear it. + useToastStore.getState().addToast("persistent notice", 0); + render(); + + const toast = screen.getByText("persistent notice"); + fireEvent.click(toast); + + expect(screen.queryByText("persistent notice")).not.toBeInTheDocument(); + expect(useToastStore.getState().toasts).toHaveLength(0); + }); + + it("renders toast cards as accessible dismiss buttons", () => { + useToastStore.getState().addToast("persistent notice", 0); + render(); + + expect( + screen.getByRole("button", { + name: "Dismiss notification: persistent notice", + }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/features/toast/components/Toaster.tsx b/src/features/toast/components/Toaster.tsx index a739c80..5a61cd9 100644 --- a/src/features/toast/components/Toaster.tsx +++ b/src/features/toast/components/Toaster.tsx @@ -1,4 +1,4 @@ -import { useToastStore } from '../store'; +import { useToastStore } from "../store"; /** * Renders active toasts stacked at the bottom-right of the window. @@ -8,10 +8,13 @@ import { useToastStore } from '../store'; * visible. Individual toasts re-enable pointer events so only the visible * cards, not the full overlay, can ever intercept clicks. Uses * `aria-live="polite"` so screen readers announce new messages without - * interrupting. Auto-dismissal is owned by the store. + * interrupting. Auto-dismissal is owned by the store; clicking a toast dismisses + * it immediately, which is the only way to clear a persistent toast (one created + * with a non-positive duration, e.g. the hotkey-unavailable notice). */ export function Toaster() { const toasts = useToastStore((s) => s.toasts); + const dismissToast = useToastStore((s) => s.dismissToast); return (
{toasts.map((toast) => ( -
dismissToast(toast.id)} + aria-label={`Dismiss notification: ${toast.message}`} + title="Dismiss" style={{ - pointerEvents: 'auto', - backgroundColor: 'var(--bg-elevated)', - color: 'var(--text-primary)', - border: '1px solid var(--border-default)', - borderRadius: '6px', - padding: 'var(--space-2) var(--space-3)', - fontSize: '13px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)', - maxWidth: '320px', + appearance: "none", + pointerEvents: "auto", + cursor: "pointer", + backgroundColor: "var(--bg-elevated)", + color: "var(--text-primary)", + border: "1px solid var(--border-default)", + borderRadius: "6px", + padding: "var(--space-2) var(--space-3)", + fontSize: "13px", + font: "inherit", + textAlign: "left", + boxShadow: "0 4px 12px rgba(0, 0, 0, 0.3)", + maxWidth: "320px", }} > {toast.message} -
+ ))}
); diff --git a/src/generated/bindings.ts b/src/generated/bindings.ts index d27e722..f79d634 100644 --- a/src/generated/bindings.ts +++ b/src/generated/bindings.ts @@ -104,6 +104,14 @@ export const commands = { * export service. */ exportJson: (filePath: string) => typedError(__TAURI_INVOKE("export_json", { filePath })), + /** + * Return the global-shortcut backend status for the current session. + * + * Reads the value recorded during startup hotkey detection. A poisoned lock is + * recovered by cloning the inner value — the status is plain data with no + * transactional state to repair. + */ + getHotkeyStatus: () => __TAURI_INVOKE("get_hotkey_status"), }; /** Events */ @@ -178,6 +186,21 @@ export type HotkeyConfig = { */ export type HotkeyPressed = null; +/** + * Availability of the global-shortcut backend for the current session. + * + * Wire shape (camelCase): `{ "available": false, "reason": "" }`. + */ +export type HotkeyStatus = { + // `true` when a usable global-shortcut backend was found at startup. + available: boolean, + /** + * Human-readable reason the backend is unavailable, when `available` is + * `false`. `None` when the hotkey is available. + */ + reason: string | null, +}; + export type Note = { id: number, title: string, From 735c4a1722b15cf9233a292c02223242f6c90aa6 Mon Sep 17 00:00:00 2001 From: pbean Date: Wed, 17 Jun 2026 11:51:57 -0700 Subject: [PATCH 14/15] sweep dw-decision-dw-97: DW-97 via bmad-auto --- .../implementation-artifacts/deferred-work.md | 3 +- .../spec-dw-decision-dw-97.md | 119 ++++++++++++++++++ src-tauri/src/commands/autostart.rs | 44 +++---- src-tauri/src/lib.rs | 14 ++- src-tauri/src/platform/linux.rs | 20 ++- src-tauri/src/platform/macos.rs | 23 ++-- src-tauri/src/platform/mod.rs | 70 +++++++++-- src-tauri/src/platform/windows.rs | 22 ++-- 8 files changed, 234 insertions(+), 81 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/spec-dw-decision-dw-97.md diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index d6e3b5c..6e079a1 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -764,7 +764,8 @@ origin: bmad-auto-dev split of spec-8-6-cross-platform-verification-wayland-fall location: src-tauri/src/platform/{linux,macos,windows}.rs (autostart_* methods, currently todo!()) severity: low reason: The trait's autostart_* methods take only &self with no Tauri AppHandle, but the real mechanism (tauri-plugin-autostart via app.autolaunch()) requires the handle and already fully satisfies FR41-43 from Story 8.4 (commands/autostart.rs + lib.rs reconcile). Routing through the trait would mean reimplementing the plugin's plist/.desktop/registry logic by hand — a refactor with no functional gain and real cross-platform risk. Needs a trait-signature redesign (pass the handle, or move autostart off the path-trait) before it is worth doing. The todo!() stubs are relabeled to point here. -status: open +status: done 2026-06-17 +resolution: Redesigned the `Platform` trait's `autostart_enable/disable/is_enabled` to take `&tauri::AppHandle` (kept object-safe). Added shared `pub(crate)` `autostart_*` helpers in `platform/mod.rs` that delegate to `app.autolaunch()` (one call site, no per-OS reimplementation); each of linux/macos/windows now delegates to them instead of `todo!()`. Switched `commands/autostart.rs` (`set_autostart` incl. rollback, `get_autostart`) and the `lib.rs` startup reconcile to route through `crate::platform::current()`. No behavior/IPC/bindings change; FR41–43 preserved. Verified: cargo build, clippy -D warnings, autostart_tests + platform_tests green, no `.autolaunch()` call sites outside `platform/mod.rs`, bindings.ts unchanged. decision: 2026-06-17 Redesign trait + route — Redesign the Platform trait's autostart_* signatures to accept the Tauri AppHandle (or move autostart off the path-trait into a dedicated abstraction), then implement autostart_enable/disable/is_enabled on each platform by delegating to tauri-plugin-autostart's autolaunch(), and switch commands/autostart.rs to call through the trait. ### DW-98: CI release pipeline producing artifacts for all 5 targets (FR — AC4 of 8.6) diff --git a/_bmad-output/implementation-artifacts/spec-dw-decision-dw-97.md b/_bmad-output/implementation-artifacts/spec-dw-decision-dw-97.md new file mode 100644 index 0000000..ed4bb03 --- /dev/null +++ b/_bmad-output/implementation-artifacts/spec-dw-decision-dw-97.md @@ -0,0 +1,119 @@ +--- +title: "Route auto-start through the Platform trait (DW-97)" +type: "refactor" +created: "2026-06-17" +status: "done" +context: ["{project-root}/_bmad-output/project-context.md"] +baseline_commit: "135168ccd75f702e45cd68b0b37779d712854282" +--- + + + +## Intent + +**Problem:** The `Platform` trait declares `autostart_enable/disable/is_enabled` but every implementation is `todo!()` (DW-97), so auto-start is NOT actually routed through the trait. The real mechanism — `tauri-plugin-autostart` via `app.autolaunch()` — is reached directly from `commands/autostart.rs` and the `lib.rs` startup reconcile, because the `&self`-only trait signature cannot reach the Tauri `AppHandle` the plugin needs. The trait therefore advertises a contract it does not honor. + +**Approach:** The human chose to redesign the trait and route through it. Add `app: &tauri::AppHandle` to the three `autostart_*` trait methods, implement them on each platform by delegating to a single shared `app.autolaunch()` resolver (mirroring the existing `resolve_data_dir`/`resolve_config_dir` pattern so the plugin logic is centralized, never reimplemented per-OS), then switch `commands/autostart.rs` and the `lib.rs` reconcile block to call through `crate::platform::current()` instead of touching `autolaunch()` directly. No behavioral change to the user-facing auto-start feature (FR41–FR43 stay satisfied). + +## Boundaries & Constraints + +**Always:** +- Keep the `Platform` trait object-safe — it is returned as `Box` from `current()`. A `app: &tauri::AppHandle` parameter is object-safe; preserve that. +- Centralize the `autolaunch()` delegation in ONE place (shared `pub(crate)` helpers in `platform/mod.rs`) so all three per-OS impls are thin delegates — do NOT triplicate plugin calls. +- Preserve `set_autostart`'s existing lock discipline exactly: hold the `Mutex` across the OS change + persist, and keep the rollback-on-save-failure path. Only the calls that today hit `app.autolaunch()` change to trait calls. +- Preserve the `#[cfg(desktop)]` / `#[cfg(not(desktop))]` structure in `commands/autostart.rs` (the `not(desktop)` arms still return `Ok(false)` / persist-only) — route only the existing desktop arms through the trait. +- Keep error messages and `NoteyError` variants identical to today's wording (`Failed to enable auto-start: {e}`, `Failed to disable auto-start: {e}`, `Failed to query auto-start state: {e}`). +- The persisted `[general] auto_start` preference remains the single source of truth; OS registration is reconciled to it. Unchanged. +- Update rustdoc that currently says `autostart_*` is "deferred (DW-97)" / `todo!` across `platform/mod.rs`, `linux.rs`, `macos.rs`, `windows.rs` to describe the implemented delegation. + +**Ask First:** +- Any change to the `set_autostart`/`get_autostart` command signatures or the generated TS bindings (`src/generated/bindings.ts`) — these must NOT change; the redesign is internal to the Rust trait layer. + +**Never:** +- Do NOT hand-reimplement plist / `.desktop` / registry launch-agent logic — delegate entirely to `tauri-plugin-autostart`. +- Do NOT change the user-facing behavior, the config schema, the command names, or the IPC surface. +- Do NOT introduce a new error variant or a new dependency. +- Do NOT remove the `tauri-plugin-autostart` plugin registration in `lib.rs`. + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +| -------- | ------------- | -------------------------- | -------------- | +| Enable via trait | `autostart_enable(&app)`, plugin succeeds | OS launch agent registered; `Ok(())` | N/A | +| Disable via trait | `autostart_disable(&app)`, plugin succeeds | OS launch agent removed; `Ok(())` | N/A | +| Query via trait | `autostart_is_enabled(&app)` | `Ok(true/false)` matching live OS registration | N/A | +| Plugin error on enable/disable | `autolaunch()` op returns `Err(e)` | propagate as `NoteyError::Config("Failed to {enable/disable} auto-start: {e}")` | mapped error | +| Plugin error on query | `is_enabled()` returns `Err(e)` | `NoteyError::Config("Failed to query auto-start state: {e}")` | mapped error | +| `set_autostart` save fails after OS change | config `save` returns `Err` | roll OS registration back to prior state via trait, then return the save error | rollback + propagate | + + + +## Code Map + +- `src-tauri/src/platform/mod.rs` -- `Platform` trait def (3 `autostart_*` signatures gain `&tauri::AppHandle`); add shared `pub(crate)` delegation helpers next to `resolve_data_dir`; update module-header rustdoc. +- `src-tauri/src/platform/linux.rs` -- replace 3 `todo!()` bodies with delegates to the shared helpers; update header. +- `src-tauri/src/platform/macos.rs` -- same. +- `src-tauri/src/platform/windows.rs` -- same. +- `src-tauri/src/commands/autostart.rs` -- `set_autostart`/`get_autostart` desktop arms call `crate::platform::current()` instead of `app.autolaunch()` directly. +- `src-tauri/src/lib.rs` -- startup reconcile block (~L228–250) calls the trait via `app.handle()` instead of `app.autolaunch()` directly. +- `src-tauri/tests/autostart_tests.rs` -- persistence-contract tests; unchanged (plugin delegation is QA-verified, not unit-testable without a Tauri runtime). Confirm still green. + +## Tasks & Acceptance + +**Execution:** + +- [x] `src-tauri/src/platform/mod.rs` -- Change the three trait methods to `fn autostart_enable(&self, app: &tauri::AppHandle) -> Result<(), NoteyError>`, `autostart_disable(&self, app: &tauri::AppHandle) -> Result<(), NoteyError>`, `autostart_is_enabled(&self, app: &tauri::AppHandle) -> Result`. Add `pub(crate)` free fns (e.g. `autostart_enable`, `autostart_disable`, `autostart_is_enabled`) that `use tauri_plugin_autostart::ManagerExt;` and call `app.autolaunch().enable()/.disable()/.is_enabled()`, mapping `Err` to `NoteyError::Config` with the exact wording above. Rewrite the module-header rustdoc so DW-97/`todo!` language is replaced by "implemented; routes through the trait." -- single delegation point + honest trait contract. +- [x] `src-tauri/src/platform/{linux,macos,windows}.rs` -- Replace each `todo!("DW-97: …")` body with a thin delegate (`super::autostart_enable(app)` etc.) and the new signature; rewrite each file-header comment to drop "deferred (DW-97)". -- per-OS impls honor the trait without duplicating plugin logic. +- [x] `src-tauri/src/commands/autostart.rs` -- In `set_autostart`, inside the existing `#[cfg(desktop)]` block, obtain `let platform = crate::platform::current();` and replace the `manager.is_enabled()` / `manager.enable()` / `manager.disable()` calls (including the rollback path) with `platform.autostart_is_enabled(&app)?` / `platform.autostart_enable(&app)` / `platform.autostart_disable(&app)`; drop the now-unused `use tauri_plugin_autostart::ManagerExt;`. In `get_autostart`, replace the `#[cfg(desktop)]` arm body with `crate::platform::current().autostart_is_enabled(&app)`. Keep all `#[cfg(not(desktop))]` arms, the lock discipline, and the error mapping unchanged. -- route commands through the trait. +- [x] `src-tauri/src/lib.rs` -- In the `#[cfg(desktop)]` auto-start reconcile block, replace `app.autolaunch()` usage with `let platform = crate::platform::current();` and `platform.autostart_is_enabled(app.handle())` / `.autostart_enable(app.handle())` / `.autostart_disable(app.handle())`; drop the local `use tauri_plugin_autostart::ManagerExt;`. Preserve the best-effort, non-fatal `eprintln!` warnings and the `desired`-vs-`active` comparison logic. -- the trait becomes the single source of truth for auto-start side effects. + +**Acceptance Criteria:** + +- Given the redesigned trait, when `cargo build` and `cargo clippy` run, then they succeed with no warnings and no remaining `todo!("DW-97…")` in the platform module. +- Given `crate::platform::current()`, when any caller needs to enable/disable/query auto-start, then it goes through `autostart_{enable,disable,is_enabled}(&app)` and NOT a direct `app.autolaunch()` call (grep for `.autolaunch()` call sites — excluding doc comments — returns nothing in `src/commands` and `src/lib.rs`; the only `.autolaunch()` calls live in `platform/mod.rs`). +- Given the `set_autostart` command, when a config save fails after the OS registration changed, then the OS registration is rolled back to its prior state through the trait before the error is returned (existing rollback contract preserved). +- Given the generated bindings, when the trait redesign is complete, then `src/generated/bindings.ts` and the `set_autostart`/`get_autostart` command signatures are byte-for-byte unchanged. +- Given `cargo test --test autostart_tests` and `cargo test --test platform_tests`, when run, then all pass. + +## Design Notes + +The shared-helper pattern already exists in `platform/mod.rs` (`resolve_data_dir`, `resolve_config_dir`, `resolve_unix_socket`). Auto-start delegation is platform-agnostic — the plugin handles plist/.desktop/registry internally — so a single shared helper per operation keeps all three OS impls one-liners and eliminates the cross-platform-divergence risk the ledger flagged: + +```rust +// platform/mod.rs +pub(crate) fn autostart_enable(app: &tauri::AppHandle) -> Result<(), NoteyError> { + use tauri_plugin_autostart::ManagerExt; + app.autolaunch() + .enable() + .map_err(|e| NoteyError::Config(format!("Failed to enable auto-start: {e}"))) +} +``` + +```rust +// platform/linux.rs (macos.rs, windows.rs identical) +fn autostart_enable(&self, app: &tauri::AppHandle) -> Result<(), NoteyError> { + super::autostart_enable(app) +} +``` + +In `lib.rs` the reconcile runs in `setup` where `app` is `&mut tauri::App`; pass `app.handle()` (an `&AppHandle`) to the trait methods. + +The autolaunch delegation cannot be unit-tested without a live Tauri runtime, so test coverage stays at the persistence contract in `autostart_tests.rs` (the established boundary — that file documents that launch-agent behavior is QA-verified). + +## Verification + +**Commands:** + +- `cd src-tauri && cargo build` -- expected: compiles clean. +- `cd src-tauri && cargo clippy --all-targets -- -D warnings` -- expected: no warnings; no `todo!` in platform module. +- `cd src-tauri && cargo test --test autostart_tests --test platform_tests` -- expected: all pass. +- `cd src-tauri && grep -rn '\.autolaunch()' src/commands src/lib.rs | grep -vE ':[0-9]+:\s*//'` -- expected: no matches (no call sites; only doc comments may mention it). +- `git diff --stat src/generated/bindings.ts` -- expected: no change. + +## Review Findings + +#### Review Ledger (2026-06-17) + +- dismiss: Unconditional autostart helper may break non-desktop builds [src-tauri/src/platform/mod.rs] — false positive: `tauri-plugin-autostart::ManagerExt` is available in the installed crate, this desktop Tauri app already registers the autostart plugin unconditionally in `lib.rs`, and the required desktop cfg command/reconcile arms still guard runtime use. +- dismiss: Public trait signature change can break undisclosed implementors/callers [src-tauri/src/platform/mod.rs] — expected spec-driven API redesign: the frozen intent explicitly requires adding `app: &tauri::AppHandle`; repository search found only the three target platform implementors and updated call sites, and clippy/tests compile cleanly. +- dismiss: Platform abstraction now depends directly on Tauri runtime state [src-tauri/src/platform/mod.rs] — intentional accepted design tradeoff: the frozen spec chose the `AppHandle` parameter specifically because `tauri-plugin-autostart` owns the launch-agent mechanism; Acceptance Auditor found no violation. diff --git a/src-tauri/src/commands/autostart.rs b/src-tauri/src/commands/autostart.rs index 0696956..04f8f60 100644 --- a/src-tauri/src/commands/autostart.rs +++ b/src-tauri/src/commands/autostart.rs @@ -1,8 +1,10 @@ //! Thin Tauri command handlers for auto-start on login (Story 8.4 / FR41–FR43). //! //! The OS launch agent is owned by `tauri-plugin-autostart` (registered with -//! `MacosLauncher::LaunchAgent` in `lib.rs`) and reached via `app.autolaunch()`. -//! The user's preference is the single source of truth at `config.toml` +//! `MacosLauncher::LaunchAgent` in `lib.rs`) and reached through the +//! [`crate::platform::Platform`] trait's `autostart_*` methods (DW-97), which +//! delegate to `app.autolaunch()`. The user's preference is the single source of +//! truth at `config.toml` //! `[general] auto_start`; [`set_autostart`] registers/unregisters the OS agent //! AND persists the preference atomically under the shared `Mutex`, //! mirroring [`crate::commands::config::update_config`]'s lock discipline. @@ -33,25 +35,18 @@ pub fn set_autostart( #[cfg(desktop)] let rollback_to = { - use tauri_plugin_autostart::ManagerExt; - - let manager = app.autolaunch(); - let currently_enabled = manager - .is_enabled() - .map_err(|e| NoteyError::Config(format!("Failed to query auto-start state: {e}")))?; + // Route the OS registration through the Platform trait (DW-97) rather than + // hitting `app.autolaunch()` directly, so the trait is the single source + // of truth for auto-start side effects. + let platform = crate::platform::current(); + let currently_enabled = platform.autostart_is_enabled(&app)?; if enabled != currently_enabled { - let result = if enabled { - manager.enable() + if enabled { + platform.autostart_enable(&app)?; } else { - manager.disable() - }; - result.map_err(|e| { - NoteyError::Config(format!( - "Failed to {} auto-start: {e}", - if enabled { "enable" } else { "disable" } - )) - })?; + platform.autostart_disable(&app)?; + } Some(currently_enabled) } else { None @@ -64,13 +59,11 @@ pub fn set_autostart( if let Err(save_err) = services::config::save(&config_dir_state.0, &updated) { #[cfg(desktop)] if let Some(previously_enabled) = rollback_to { - use tauri_plugin_autostart::ManagerExt; - - let manager = app.autolaunch(); + let platform = crate::platform::current(); let rollback = if previously_enabled { - manager.enable() + platform.autostart_enable(&app) } else { - manager.disable() + platform.autostart_disable(&app) }; if let Err(rollback_err) = rollback { eprintln!("warning: failed to roll back auto-start after config save failure: {rollback_err}"); @@ -92,10 +85,7 @@ pub fn set_autostart( pub fn get_autostart(#[allow(unused_variables)] app: tauri::AppHandle) -> Result { #[cfg(desktop)] { - use tauri_plugin_autostart::ManagerExt; - app.autolaunch() - .is_enabled() - .map_err(|e| NoteyError::Config(format!("Failed to query auto-start state: {e}"))) + crate::platform::current().autostart_is_enabled(&app) } #[cfg(not(desktop))] { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ff2f442..e93b18f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -227,16 +227,18 @@ pub fn run() { // a plugin failure must never block startup. #[cfg(desktop)] { - use tauri_plugin_autostart::ManagerExt; - + // Route through the Platform trait (DW-97) so auto-start side + // effects have a single source of truth; the trait delegates to + // `tauri-plugin-autostart`. let desired = config.general.auto_start; - let manager = app.autolaunch(); - match manager.is_enabled() { + let platform = crate::platform::current(); + let handle = app.handle(); + match platform.autostart_is_enabled(handle) { Ok(active) if active != desired => { let outcome = if desired { - manager.enable() + platform.autostart_enable(handle) } else { - manager.disable() + platform.autostart_disable(handle) }; if let Err(e) = outcome { eprintln!("warning: failed to reconcile auto-start to {desired}: {e}"); diff --git a/src-tauri/src/platform/linux.rs b/src-tauri/src/platform/linux.rs index 4e1ff6e..2db2edd 100644 --- a/src-tauri/src/platform/linux.rs +++ b/src-tauri/src/platform/linux.rs @@ -1,5 +1,6 @@ //! Linux [`Platform`] implementation (X11 + Wayland). Paths + hotkey backend -//! selection are implemented (Stories 8.5/8.6); `autostart_*` is deferred (DW-97). +//! selection (Stories 8.5/8.6) and `autostart_*` (DW-97, delegated to +//! `tauri-plugin-autostart` via the shared helpers in [`super`]) are implemented. use std::path::PathBuf; @@ -78,20 +79,17 @@ impl Platform for LinuxPlatform { Ok(HotkeyBackend::Standard) } - fn autostart_enable(&self) -> Result<(), NoteyError> { - // Deferred (DW-97): auto-start is owned by tauri-plugin-autostart via the - // Tauri AppHandle (Story 8.4: commands::autostart + lib.rs). The `&self` - // trait signature cannot reach the handle, so routing it through the trait - // is a no-gain refactor tracked in DW-97. Not called today. - todo!("DW-97: route autostart through the Platform trait") + fn autostart_enable(&self, app: &tauri::AppHandle) -> Result<(), NoteyError> { + // DW-97: delegate to the shared tauri-plugin-autostart helper (Story 8.4). + super::autostart_enable(app) } - fn autostart_disable(&self) -> Result<(), NoteyError> { - todo!("DW-97: route autostart through the Platform trait") + fn autostart_disable(&self, app: &tauri::AppHandle) -> Result<(), NoteyError> { + super::autostart_disable(app) } - fn autostart_is_enabled(&self) -> Result { - todo!("DW-97: route autostart through the Platform trait") + fn autostart_is_enabled(&self, app: &tauri::AppHandle) -> Result { + super::autostart_is_enabled(app) } fn accessibility_permission_granted(&self) -> Result { diff --git a/src-tauri/src/platform/macos.rs b/src-tauri/src/platform/macos.rs index fb65bd7..17c72d3 100644 --- a/src-tauri/src/platform/macos.rs +++ b/src-tauri/src/platform/macos.rs @@ -1,6 +1,6 @@ -//! macOS [`Platform`] implementation. Accessibility-permission methods (Story 8.2) -//! and paths/hotkey-backend selection (Stories 8.5/8.6) are implemented; -//! `autostart_*` is deferred (DW-97 — owned by tauri-plugin-autostart). +//! macOS [`Platform`] implementation. Accessibility-permission methods (Story 8.2), +//! paths/hotkey-backend selection (Stories 8.5/8.6), and `autostart_*` (DW-97 — +//! delegated to `tauri-plugin-autostart`'s `LaunchAgent`) are implemented. use std::{io, path::PathBuf}; @@ -57,19 +57,18 @@ impl Platform for MacosPlatform { Ok(HotkeyBackend::Standard) } - fn autostart_enable(&self) -> Result<(), NoteyError> { - // Deferred (DW-97): auto-start is owned by tauri-plugin-autostart - // (MacosLauncher::LaunchAgent) via the Tauri AppHandle (Story 8.4). The - // `&self` trait signature cannot reach the handle. Not called today. - todo!("DW-97: route autostart through the Platform trait") + fn autostart_enable(&self, app: &tauri::AppHandle) -> Result<(), NoteyError> { + // DW-97: delegate to the shared tauri-plugin-autostart helper. The plugin + // is registered with MacosLauncher::LaunchAgent in lib.rs (Story 8.4). + super::autostart_enable(app) } - fn autostart_disable(&self) -> Result<(), NoteyError> { - todo!("DW-97: route autostart through the Platform trait") + fn autostart_disable(&self, app: &tauri::AppHandle) -> Result<(), NoteyError> { + super::autostart_disable(app) } - fn autostart_is_enabled(&self) -> Result { - todo!("DW-97: route autostart through the Platform trait") + fn autostart_is_enabled(&self, app: &tauri::AppHandle) -> Result { + super::autostart_is_enabled(app) } fn accessibility_permission_granted(&self) -> Result { diff --git a/src-tauri/src/platform/mod.rs b/src-tauri/src/platform/mod.rs index f1a9392..01c756f 100644 --- a/src-tauri/src/platform/mod.rs +++ b/src-tauri/src/platform/mod.rs @@ -12,12 +12,18 @@ //! delegates to this trait so config-path resolution has a single source of //! truth, and `lib.rs` consults `register_hotkey` to detect a compositor with no //! usable hotkey backend (pure Wayland without XWayland) and degrade gracefully -//! (FR56/FR57/FR58). Still deferred: the native Wayland `ashpd` GlobalShortcuts -//! portal (fast-follow, DW-96; `register_hotkey` returns `Err` on such sessions -//! rather than `HotkeyBackend::WaylandPortal`), and routing the `autostart_*` -//! methods through the trait (DW-97 — those remain `todo!` because auto-start is -//! owned by `tauri-plugin-autostart` via the Tauri `AppHandle`, which the -//! `&self`-only trait signature cannot reach; Story 8.4 already satisfies it). +//! (FR56/FR57/FR58). +//! +//! **DW-97 (done):** the `autostart_*` methods are implemented for all three +//! targets. Their signatures take the Tauri `AppHandle` so they can reach +//! `tauri-plugin-autostart` (`app.autolaunch()`), which owns the per-OS +//! launch-agent mechanism. Each per-OS impl delegates to the shared +//! `autostart_*` helpers below (one place to call the plugin, no per-OS +//! reimplementation), and `commands::autostart` + the `lib.rs` startup reconcile +//! now route through this trait so auto-start side effects have a single source +//! of truth. Still deferred: the native Wayland `ashpd` GlobalShortcuts portal +//! (fast-follow, DW-96; `register_hotkey` returns `Err` on such sessions rather +//! than `HotkeyBackend::WaylandPortal`). use std::path::{Path, PathBuf}; @@ -66,14 +72,22 @@ pub trait Platform: Send + Sync { /// [`HotkeyBackend::WaylandPortal`] support is deferred to DW-96. fn register_hotkey(&self, accelerator: &str) -> Result; - /// Enable auto-start on login. Story 8.4 / FR41–FR43. - fn autostart_enable(&self) -> Result<(), NoteyError>; + /// Enable auto-start on login (Story 8.4 / FR41–FR43, DW-97). + /// + /// Delegates to `tauri-plugin-autostart` via the Tauri [`AppHandle`], which + /// owns the per-OS launch-agent mechanism (plist / `.desktop` / registry). + /// + /// [`AppHandle`]: tauri::AppHandle + fn autostart_enable(&self, app: &tauri::AppHandle) -> Result<(), NoteyError>; - /// Disable auto-start on login. - fn autostart_disable(&self) -> Result<(), NoteyError>; + /// Disable auto-start on login (DW-97). See [`autostart_enable`]. + /// + /// [`autostart_enable`]: Platform::autostart_enable + fn autostart_disable(&self, app: &tauri::AppHandle) -> Result<(), NoteyError>; - /// Whether auto-start on login is currently configured. - fn autostart_is_enabled(&self) -> Result; + /// Whether auto-start on login is currently registered at the OS level + /// (DW-97). Reports the live plugin state. + fn autostart_is_enabled(&self, app: &tauri::AppHandle) -> Result; /// Whether the OS has granted the accessibility permission required for the /// global hotkey. Non-macOS platforms return `Ok(true)` (no such gate). @@ -101,6 +115,38 @@ pub fn current() -> Box { } } +// ── Shared auto-start delegation (Story 8.4 / FR41–FR43 / DW-97) ───────────── +// +// Auto-start is platform-agnostic at this layer: `tauri-plugin-autostart` owns +// the per-OS launch-agent mechanism (plist / `.desktop` / registry), so the +// three per-OS `Platform::autostart_*` impls all delegate here. Centralizing the +// `app.autolaunch()` calls in one place keeps the impls thin and removes the +// cross-platform-divergence risk of reimplementing the plugin per OS. + +/// Register the OS launch agent so Notey starts on login. +pub(crate) fn autostart_enable(app: &tauri::AppHandle) -> Result<(), NoteyError> { + use tauri_plugin_autostart::ManagerExt; + app.autolaunch() + .enable() + .map_err(|e| NoteyError::Config(format!("Failed to enable auto-start: {e}"))) +} + +/// Remove the OS launch agent so Notey no longer starts on login. +pub(crate) fn autostart_disable(app: &tauri::AppHandle) -> Result<(), NoteyError> { + use tauri_plugin_autostart::ManagerExt; + app.autolaunch() + .disable() + .map_err(|e| NoteyError::Config(format!("Failed to disable auto-start: {e}"))) +} + +/// Report whether the OS launch agent is currently registered. +pub(crate) fn autostart_is_enabled(app: &tauri::AppHandle) -> Result { + use tauri_plugin_autostart::ManagerExt; + app.autolaunch() + .is_enabled() + .map_err(|e| NoteyError::Config(format!("Failed to query auto-start state: {e}"))) +} + // ── Shared path resolvers (Story 8.5 / FR51 / FR58) ────────────────────────── // // The per-OS `data_dir`/`socket_path` impls delegate here so resolution lives in diff --git a/src-tauri/src/platform/windows.rs b/src-tauri/src/platform/windows.rs index 2c1f11b..9ce37b9 100644 --- a/src-tauri/src/platform/windows.rs +++ b/src-tauri/src/platform/windows.rs @@ -1,6 +1,6 @@ -//! Windows [`Platform`] implementation. Paths + hotkey-backend selection are -//! implemented (Stories 8.5/8.6); `autostart_*` is deferred (DW-97 — owned by -//! tauri-plugin-autostart). +//! Windows [`Platform`] implementation. Paths + hotkey-backend selection +//! (Stories 8.5/8.6) and `autostart_*` (DW-97 — delegated to +//! `tauri-plugin-autostart`'s Run-key entry) are implemented. use std::path::PathBuf; @@ -53,19 +53,17 @@ impl Platform for WindowsPlatform { Ok(HotkeyBackend::Standard) } - fn autostart_enable(&self) -> Result<(), NoteyError> { - // Deferred (DW-97): auto-start is owned by tauri-plugin-autostart via the - // Tauri AppHandle (Story 8.4). The `&self` trait signature cannot reach - // the handle. Not called today. - todo!("DW-97: route autostart through the Platform trait") + fn autostart_enable(&self, app: &tauri::AppHandle) -> Result<(), NoteyError> { + // DW-97: delegate to the shared tauri-plugin-autostart helper (Story 8.4). + super::autostart_enable(app) } - fn autostart_disable(&self) -> Result<(), NoteyError> { - todo!("DW-97: route autostart through the Platform trait") + fn autostart_disable(&self, app: &tauri::AppHandle) -> Result<(), NoteyError> { + super::autostart_disable(app) } - fn autostart_is_enabled(&self) -> Result { - todo!("DW-97: route autostart through the Platform trait") + fn autostart_is_enabled(&self, app: &tauri::AppHandle) -> Result { + super::autostart_is_enabled(app) } fn accessibility_permission_granted(&self) -> Result { From 069603e87da74d6c86cc125c7a8a1a07e4b726cd Mon Sep 17 00:00:00 2001 From: pbean Date: Thu, 18 Jun 2026 08:48:43 -0700 Subject: [PATCH 15/15] docs: add comprehensive OSS documentation, screenshots, and rename binary to notey Curate professional open-source documentation and clean up placeholder project identity ahead of release. Documentation: - Rewrite README with features, screenshot gallery, install, dev setup - Add CHANGELOG (0.1.0), ROADMAP, CONTRIBUTING, SECURITY, CODE_OF_CONDUCT, LICENSE (MIT), and .github issue/PR templates - Add docs/ guides: installation, user-guide, keyboard-shortcuts, configuration, cli, architecture Screenshots: - New e2e/screenshots.mjs captures every view (light + dark) into docs/images via tauri-driver; add takeScreenshot primitive and `npm run screenshots` - Grant core:window:allow-show / set-focus so the capture window can be shown for screenshotting (symmetric with existing allow-dismiss-window) Metadata + binary rename (tauri-app -> notey): - Cargo crate + lib (notey_lib), main.rs, tauri.conf.json (productName "Notey", identifier com.pinkyd.notey), package.json, e2e APP_PATH, and integration tests - Fill in author (Paul Bean ), license, repository Verified: cargo test, npm test (628), and npm run build all pass. Co-Authored-By: Claude Opus 4.8 --- .github/ISSUE_TEMPLATE/bug_report.yml | 61 +++ .github/ISSUE_TEMPLATE/config.yml | 8 + .github/ISSUE_TEMPLATE/feature_request.yml | 31 ++ .github/PULL_REQUEST_TEMPLATE.md | 28 ++ CHANGELOG.md | 69 ++++ CODE_OF_CONDUCT.md | 46 +++ CONTRIBUTING.md | 111 ++++++ LICENSE | 21 + README.md | 126 +++++- ROADMAP.md | 53 +++ SECURITY.md | 45 +++ docs/architecture.md | 77 ++++ docs/cli.md | 70 ++++ docs/configuration.md | 58 +++ docs/images/command-palette-dark.png | Bin 0 -> 42896 bytes docs/images/command-palette.png | Bin 0 -> 41367 bytes docs/images/editor-dark.png | Bin 0 -> 35716 bytes docs/images/editor-light.png | Bin 0 -> 34750 bytes docs/images/note-list.png | Bin 0 -> 36448 bytes docs/images/onboarding.png | Bin 0 -> 34928 bytes docs/images/search.png | Bin 0 -> 27099 bytes docs/images/settings-general.png | Bin 0 -> 38259 bytes docs/images/trash.png | Bin 0 -> 34864 bytes docs/images/workspace-selector.png | Bin 0 -> 48320 bytes docs/installation.md | 73 ++++ docs/keyboard-shortcuts.md | 44 +++ docs/user-guide.md | 110 ++++++ e2e/driver.mjs | 9 + e2e/run.mjs | 2 +- e2e/screenshots.mjs | 432 +++++++++++++++++++++ package.json | 12 +- src-tauri/Cargo.lock | 56 +-- src-tauri/Cargo.toml | 10 +- src-tauri/capabilities/default.json | 2 + src-tauri/src/main.rs | 2 +- src-tauri/tauri.conf.json | 4 +- src-tauri/tests/autostart_tests.rs | 4 +- src-tauri/tests/db_tests.rs | 8 +- src-tauri/tests/helpers/factories.rs | 4 +- src-tauri/tests/ipc_tests.rs | 10 +- src-tauri/tests/onboarding_tests.rs | 2 +- src-tauri/tests/platform_tests.rs | 6 +- src-tauri/tests/search_tests.rs | 6 +- src-tauri/tests/workspace_tests.rs | 20 +- 44 files changed, 1548 insertions(+), 72 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 CHANGELOG.md create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 ROADMAP.md create mode 100644 SECURITY.md create mode 100644 docs/architecture.md create mode 100644 docs/cli.md create mode 100644 docs/configuration.md create mode 100644 docs/images/command-palette-dark.png create mode 100644 docs/images/command-palette.png create mode 100644 docs/images/editor-dark.png create mode 100644 docs/images/editor-light.png create mode 100644 docs/images/note-list.png create mode 100644 docs/images/onboarding.png create mode 100644 docs/images/search.png create mode 100644 docs/images/settings-general.png create mode 100644 docs/images/trash.png create mode 100644 docs/images/workspace-selector.png create mode 100644 docs/installation.md create mode 100644 docs/keyboard-shortcuts.md create mode 100644 docs/user-guide.md create mode 100644 e2e/screenshots.mjs diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..0a09b17 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,61 @@ +name: Bug report +description: Report something that isn't working as expected in Notey. +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report! Please fill out the + sections below so we can reproduce and fix the issue. + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear and concise description of the bug. + placeholder: When I do X, Y happens instead of Z. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: How can we reproduce the behavior? + placeholder: | + 1. Open Notey with the global hotkey + 2. Press Ctrl+P and run '...' + 3. See the error + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + options: + - Linux + - macOS + - Windows + validations: + required: true + - type: input + id: version + attributes: + label: Notey version + description: See the About/Settings, or the release you installed. + placeholder: 0.1.0 + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs or screenshots + description: Paste any relevant logs or attach screenshots. (Logs live in the platform state/log directory.) + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..319bd23 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Security vulnerability + url: https://github.com/pbean/notey/security/advisories/new + about: Please report security issues privately — do not open a public issue. See SECURITY.md. + - name: Question or discussion + url: https://github.com/pbean/notey/discussions + about: Ask questions and discuss ideas with the community. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..aced985 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,31 @@ +name: Feature request +description: Suggest an idea or improvement for Notey. +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Thanks for the suggestion! Notey aims to stay a fast, focused capture tool — + please describe the problem you're trying to solve, not just the solution. + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: What are you trying to do that Notey makes hard or impossible today? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed solution + description: Describe what you'd like to happen. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any alternative approaches or workarounds you've thought about. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..36b75db --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ + + +## Summary + + + +## Changes + + + +- + +## Testing + + + +- [ ] `npm test` passes +- [ ] `cargo test` passes (in `src-tauri/`) +- [ ] `npm run build` is clean +- [ ] `node e2e/run.mjs` passes (if behavior is user-facing) +- [ ] Verified manually in the running app + +## Checklist + +- [ ] My changes follow the conventions of the surrounding code +- [ ] I updated `CHANGELOG.md` under **Unreleased** +- [ ] I updated relevant documentation (README/`docs/`) +- [ ] I regenerated `src/generated/bindings.ts` if I changed a Tauri command/event diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..67c52da --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,69 @@ +# Changelog + +All notable changes to Notey are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +_Nothing yet._ + +## [0.1.0] - 2026-06-17 + +The first release of Notey — a fast, keyboard-driven, workspace-aware note-capture +desktop app built on Tauri 2, React 19, and Rust with a local SQLite store. + +### Added + +#### Capture & editing +- Always-on-top capture window (720×480) that launches hidden and is summoned from + the system tray or a global hotkey. +- Global capture hotkey (default `Ctrl+Shift+N`, `Cmd+Shift+N` on macOS) that + shows/focuses the window and toggles it away again. +- CodeMirror 6 editor with Markdown and plain-text formats and a per-note format + toggle. +- Debounced auto-save with a live save-status indicator (idle / saving / saved / + failed) and flush-on-dismiss. +- Multi-tab editing: open several notes at once, reorder tabs, and navigate with + `Ctrl+Tab` / `Ctrl+Shift+Tab` / `Ctrl+1…9`. +- Session persistence — open tabs, cursor, and scroll position survive restarts. + +#### Organization & discovery +- Workspaces tied to filesystem paths, with auto-detection and a status-bar + workspace selector. +- Note list panel filtered to the active workspace or "All Workspaces". +- Full-text search (SQLite FTS5 with BM25 ranking) with match snippets, scoped to a + workspace or global. +- Command palette (`Ctrl+P`) exposing every action with fuzzy search. + +#### Lifecycle & data +- Soft-delete to trash with restore and permanent-delete, plus a configurable + retention window (default 30 days) that auto-purges on startup. +- Export all notes to Markdown files or a single JSON document. + +#### CLI integration +- A standalone `notey` CLI (`add`, `list`, `search`) that talks to the running app + over a local IPC socket, with live desktop sync when notes are added. + +#### Personalization & accessibility +- Light / dark / follow-system themes applied at boot (no flash). +- Configurable font size and family, and floating / half-screen / full-screen + layout modes. +- Rebindable in-app shortcuts with conflict detection; settings persisted to + `config.toml`. +- Keyboard-first navigation throughout, focus traps in overlays, and screen-reader + announcements. + +#### Platform integration +- System tray with open/quit, opt-in auto-start on login, and first-run onboarding + (including macOS accessibility-permission guidance). +- Cross-platform builds for macOS (arm64/x64), Linux (x64/arm64), and Windows (x64). + +### Known issues +- On pure Wayland sessions without XWayland, the OS-level global capture hotkey may + be unavailable; the window remains summonable from the tray and a notice is shown. + A native Wayland global-shortcut portal is planned (see [ROADMAP.md](ROADMAP.md)). + +[Unreleased]: https://github.com/pbean/notey/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/pbean/notey/releases/tag/v0.1.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9406f16 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Code of Conduct + +## Our pledge + +We want participation in the Notey project to be a welcoming, respectful, and +harassment-free experience for everyone, regardless of background or identity. + +## Our standards + +Examples of behavior that helps build a positive community: + +- Being kind, patient, and respectful to others. +- Welcoming differing viewpoints and giving constructive feedback gracefully. +- Focusing on what is best for the community and the project. +- Showing empathy toward other community members. + +Examples of unacceptable behavior: + +- Personal attacks, insults, or derogatory comments. +- Public or private harassment of any kind. +- Publishing others' private information without explicit permission. +- Other conduct that would reasonably be considered inappropriate in a + professional setting. + +## Our standards in practice + +This project adopts the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1, as the basis for expected behavior. The full text is available at +. + +## Scope + +This Code of Conduct applies within all project spaces — the repository, issues, +pull requests, and discussions — and in public spaces when an individual is +representing the project. + +## Enforcement + +Instances of unacceptable behavior may be reported to the project maintainer at +**pinkyd@luckytick.net**. All reports will be reviewed and investigated promptly +and fairly, and the maintainer will respect the privacy and security of the +reporter of any incident. + +Maintainers are responsible for clarifying standards and may take appropriate and +fair corrective action in response to behavior they deem inappropriate, up to and +including removing contributions or banning contributors. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2c2c9a8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,111 @@ +# Contributing to Notey + +Thanks for your interest in improving Notey! This guide covers how to set up a +development environment, build and test the app, and submit changes. + +By participating you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Prerequisites + +- **Node.js** 20+ (CI uses 22) and **npm**. +- **Rust** — the toolchain is pinned in [`rust-toolchain.toml`](rust-toolchain.toml) + (`nightly-2026-04-03`); install [rustup](https://rustup.rs) and it will be selected + automatically. +- **Tauri system dependencies** for your OS — see the official + [Tauri prerequisites](https://tauri.app/start/prerequisites/). + +### Linux system packages + +The CI image installs these (Debian/Ubuntu names); install the equivalents for your +distribution: + +```sh +sudo apt-get install -y \ + libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \ + patchelf libxdo-dev webkit2gtk-driver +``` + +`webkit2gtk-driver` and `tauri-driver` (`cargo install --locked tauri-driver`) are +only needed to run the end-to-end suite and the screenshot capture. + +## Running the app + +```sh +npm install # install front-end dependencies +npm run tauri dev # run with hot-reload (Vite dev server + Rust watch) +``` + +The window starts hidden; summon it with the global hotkey (`Ctrl+Shift+N`) or from +the system tray. + +## Building + +```sh +npm run build # type-check + build the front end (dist/) +npx tauri build --debug --no-bundle # debug binary at src-tauri/target/debug/notey +npx tauri build # release bundles for your platform +``` + +## Testing + +```sh +npm test # front-end unit tests (Vitest) +cargo test # Rust unit + integration tests (run in src-tauri/) +node e2e/run.mjs # end-to-end tests via tauri-driver (needs a debug build) +``` + +On a headless Linux box, wrap GUI-driven commands with `xvfb-run` and the WebKitGTK +software-rendering flags (the E2E runner sets these automatically). + +## Regenerating screenshots + +Documentation screenshots in `docs/images/` are produced from the live app: + +```sh +npx tauri build --debug --no-bundle # ensure the debug binary is current +npm run screenshots # drives the app and writes docs/images/*.png +# headless: xvfb-run -a npm run screenshots +``` + +## Type-safe IPC bindings + +Rust commands and events are exposed to TypeScript via +[tauri-specta](https://github.com/oscartbeaumont/tauri-specta). The generated file +`src/generated/bindings.ts` is committed; it is regenerated when the app builds in +debug. After changing a `#[tauri::command]` / `#[specta::specta]` signature, rebuild +so the bindings stay in sync, and commit the result. + +## Project layout + +``` +src/ React 19 + TypeScript front end, organized by feature + features// each feature owns its store, components, and API calls + components/ui/ shared UI primitives + generated/ tauri-specta TypeScript bindings (generated) +src-tauri/ Rust back end + src/commands/ Tauri IPC command handlers + src/services/ business logic + src/db/ SQLite schema + migrations (FTS5) + src/ipc/ local socket server for the CLI + src/platform/ OS abstraction (macOS / Linux / Windows) +notey-cli/ standalone `notey` CLI (talks to the app over the socket) +e2e/ tauri-driver E2E suite + screenshot capture +docs/ user-facing documentation +``` + +See [docs/architecture.md](docs/architecture.md) for a deeper overview. + +## Pull requests + +1. Fork and create a feature branch off `main`. +2. Keep changes focused; match the conventions of the surrounding code. +3. Add or update tests for behavioral changes. +4. Make sure `npm test`, `cargo test`, `npm run build`, and (where relevant) + `node e2e/run.mjs` pass. +5. Update `CHANGELOG.md` under **Unreleased** and any affected docs. +6. Open a PR describing the change and linking any related issue. + +## Reporting bugs and requesting features + +Use the [issue templates](https://github.com/pbean/notey/issues/new/choose). For +security issues, follow [SECURITY.md](SECURITY.md) instead of opening a public issue. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..75d8b91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Paul Bean + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 102e366..153541f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,125 @@ -# Tauri + React + Typescript +
-This template should help get you started developing with Tauri, React and Typescript in Vite. +# Notey -## Recommended IDE Setup +**A fast, keyboard-driven, workspace-aware note-capture app for your desktop.** -- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) +Summon it with a global hotkey, capture a thought, hit `Esc` — it's gone until you need it again. + +[![CI](https://github.com/pbean/notey/actions/workflows/ci.yml/badge.svg)](https://github.com/pbean/notey/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +![Tauri](https://img.shields.io/badge/Tauri-2-24C8DB?logo=tauri&logoColor=white) +![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black) +![Rust](https://img.shields.io/badge/Rust-stable-000000?logo=rust&logoColor=white) + +![Notey editor](docs/images/editor-light.png) + +
+ +## What is Notey? + +Notey is a lightweight desktop notepad built for *quick capture*. It lives in your +system tray as a small always-on-top window that you summon with a global hotkey, +type into instantly, and dismiss with `Esc`. Notes are organized by **workspace** +(typically a project directory), searchable with full-text search, and persisted +locally in SQLite — no account, no cloud, no telemetry. + +It's built with [Tauri 2](https://tauri.app), a React 19 + TypeScript front end, and a +Rust back end, so it starts fast, stays small, and runs natively on macOS, Linux, and +Windows. + +## Features + +- **Instant capture** — a global hotkey (default `Ctrl+Shift+N`) shows and focuses the + window from anywhere; `Esc` hides it again. +- **Workspaces** — group notes by project directory; filter the list and search to the + active workspace or view everything at once. +- **Full-text search** — SQLite FTS5 with BM25 ranking and match snippets. +- **Tabs** — keep several notes open at once, reorder them, and jump between them with + the keyboard. +- **Markdown or plain text** — a CodeMirror 6 editor with per-note format toggle and + auto-save (no save button, ever). +- **Command palette** — `Ctrl+P` for every action, fuzzy-searchable. +- **Trash & restore** — soft-delete with recovery and a configurable retention window. +- **Export** — dump your notes to Markdown files or a single JSON document. +- **CLI** — a `notey` command-line companion that talks to the running app over a local + socket (add, list, search) with live desktop sync. +- **Themeable** — light / dark / follow-system, adjustable font, and floating / + half-screen / full-screen layouts. +- **Accessible & keyboard-first** — full keyboard navigation, focus traps, and + screen-reader announcements. + +## Screenshots + +| Command palette | Search | Note list | +| --- | --- | --- | +| ![Command palette](docs/images/command-palette.png) | ![Search](docs/images/search.png) | ![Note list](docs/images/note-list.png) | + +| Settings | Trash | Dark theme | +| --- | --- | --- | +| ![Settings](docs/images/settings-general.png) | ![Trash](docs/images/trash.png) | ![Dark theme](docs/images/editor-dark.png) | + +## Install + +Download the latest bundle for your platform from the +[Releases page](https://github.com/pbean/notey/releases), or +[build from source](docs/installation.md). See the +[installation guide](docs/installation.md) for platform-specific notes. + +## Quick start + +1. Launch Notey — it starts hidden in the system tray. +2. Press the global hotkey (`Ctrl+Shift+N`, or `Cmd+Shift+N` on macOS) to summon it. +3. Start typing. Your note auto-saves as you go. +4. Press `Ctrl+P` to open the command palette and discover everything else. +5. Press `Esc` to dismiss the window; summon it again any time. + +New here? Read the [user guide](docs/user-guide.md) and the +[keyboard shortcuts](docs/keyboard-shortcuts.md). + +## Development + +Notey uses **npm** for the front end and **Cargo** for the Rust back end. + +```sh +# Prerequisites: Node.js 20+, Rust (stable), and the Tauri system dependencies +# for your OS — see CONTRIBUTING.md. + +npm install # install front-end dependencies +npm run tauri dev # run the app with hot-reload (Vite + Rust watch) +``` + +Other useful scripts: + +```sh +npm run build # type-check + build the front end +npm test # front-end unit tests (Vitest) +node e2e/run.mjs # end-to-end tests (tauri-driver) +npm run screenshots # regenerate docs/images/*.png from the live app +``` + +For the full contributor workflow, system dependencies, and project layout, see +[CONTRIBUTING.md](CONTRIBUTING.md). + +## Documentation + +| Guide | What's inside | +| --- | --- | +| [Installation](docs/installation.md) | Install per platform & build from source | +| [User guide](docs/user-guide.md) | Feature-by-feature walkthrough | +| [Keyboard shortcuts](docs/keyboard-shortcuts.md) | Full shortcut reference | +| [Configuration](docs/configuration.md) | `config.toml` reference | +| [CLI](docs/cli.md) | The `notey` command-line companion | +| [Architecture](docs/architecture.md) | How Notey is built | +| [Roadmap](ROADMAP.md) | What's shipped and what's next | +| [Changelog](CHANGELOG.md) | Release history | + +## Contributing + +Contributions are welcome! Please read [CONTRIBUTING.md](CONTRIBUTING.md) and our +[Code of Conduct](CODE_OF_CONDUCT.md) before opening a pull request. Security issues +should follow [SECURITY.md](SECURITY.md). + +## License + +Notey is open source under the [MIT License](LICENSE). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..d6af9e2 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,53 @@ +# Roadmap + +This roadmap describes the direction of Notey. It is a statement of intent, not a +commitment to dates or ordering — priorities shift as the project and its users +evolve. For released changes see the [Changelog](CHANGELOG.md). + +**Legend:** ✅ Shipped · 🚧 In progress · 🔭 Planned + +## ✅ Shipped (v0.1.0) + +The 0.1.0 release delivered the core product across eight areas of work: + +- **Instant capture** — hidden-by-default capture window, global hotkey, tray, and + fast dismiss. +- **Editing** — CodeMirror 6 editor, Markdown/plain-text toggle, debounced auto-save, + multi-tab editing, and session persistence. +- **Workspaces** — path-based workspaces with detection and a status-bar selector. +- **Search & discovery** — FTS5 full-text search and a fuzzy command palette. +- **Note lifecycle** — trash, restore, retention-based auto-purge, and export to + Markdown/JSON. +- **CLI** — `notey add / list / search` over a local socket with live desktop sync. +- **Personalization & accessibility** — themes, fonts, layout modes, rebindable + shortcuts, and keyboard-first/screen-reader support. +- **Platform integration** — tray, auto-start, onboarding, and cross-platform release + builds. + +## 🚧 In progress + +- **CLI hardening** — broadening the `notey` command surface and finishing the + end-to-end live-sync coverage between the CLI and the desktop app. +- **Cross-platform polish** — routing auto-start through the platform abstraction and + shoring up Windows-specific IPC verification. + +## 🔭 Planned + +- **Native Wayland global shortcut** — a `xdg-desktop-portal` GlobalShortcuts + integration so the capture hotkey works on pure Wayland without relying on + XWayland (today's baseline fallback). +- **Richer search** — query operators and result previews building on the FTS5 + foundation. +- **Editor enhancements** — Markdown niceties (link handling, lists, and live + preview affordances) within the lightweight capture model. +- **Sync & backup** — optional mechanisms for moving notes between machines while + staying local-first by default. + +## Out of scope (for now) + +Notey is deliberately small and local-first. Accounts, mandatory cloud storage, and +heavyweight knowledge-base features are not planned — the goal is a fast capture tool, +not a second brain platform. + +Have an idea? Open a +[feature request](https://github.com/pbean/notey/issues/new/choose). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a2a179b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,45 @@ +# Security Policy + +## Supported versions + +Notey is in active early development. Security fixes are applied to the latest +released version and `main`. + +| Version | Supported | +| ------- | --------- | +| 0.1.x | ✅ | +| < 0.1 | ❌ | + +## Reporting a vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, report privately through either of: + +- GitHub's [private vulnerability reporting](https://github.com/pbean/notey/security/advisories/new) + ("Report a vulnerability" under the Security tab), or +- Email **pinkyd@luckytick.net** with the details. + +Please include: + +- A description of the issue and its impact. +- Steps to reproduce (a proof of concept if possible). +- Affected version(s) and platform(s). + +You can expect an acknowledgement within a few days. We'll keep you updated on +progress and coordinate a disclosure timeline with you once a fix is ready. + +## Scope notes + +Notey is a **local-first desktop application**. It stores notes in a local SQLite +database and exposes a **local IPC socket** (Unix domain socket / named pipe) so the +`notey` CLI can talk to the running app. It does not run a network server and does not +transmit your notes anywhere. + +Reports of particular interest include: + +- Issues with the local IPC socket (e.g. unauthorized local access, input handling). +- Path-traversal or injection issues in workspace/path handling, search, or export. +- Anything that could lead to data loss or corruption of the notes database. + +Thank you for helping keep Notey and its users safe. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..b095c83 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,77 @@ +# Architecture + +A high-level overview of how Notey is built. For exhaustive design records see the +planning artifacts under `_bmad-output/planning-artifacts/` (PRD, architecture +decision document, and epic breakdown). + +## Stack + +| Layer | Technology | +| --- | --- | +| Shell | [Tauri 2](https://tauri.app) (Wry webview) | +| Front end | React 19 + TypeScript, Vite, Tailwind CSS, Zustand | +| Editor | CodeMirror 6 | +| Back end | Rust | +| Storage | SQLite (via rusqlite) with FTS5 full-text search | +| IPC types | [tauri-specta](https://github.com/oscartbeaumont/tauri-specta) (generated TS bindings) | + +## Shape + +``` +┌────────────────────────────────────────────┐ +│ Tauri window (Wry webview) │ +│ ┌────────────────────────────────────────┐ │ +│ │ React front end (src/) │ │ +│ │ feature modules: editor, tabs, search, │ │ +│ │ note-list, command-palette, settings, │ │ +│ │ trash, workspace, onboarding, theme … │ │ +│ └───────────────┬────────────────────────┘ │ +│ │ type-safe IPC (specta) │ +│ ┌───────────────▼────────────────────────┐ │ +│ │ Rust back end (src-tauri/) │ │ +│ │ commands → services → db (SQLite/FTS5) │ │ +│ │ platform abstraction (mac/linux/win) │ │ +│ │ local socket server ◄── notey CLI │ │ +│ └────────────────────────────────────────┘ │ +└────────────────────────────────────────────┘ +``` + +## Front end (`src/`) + +Organized by **feature** rather than by file type — each feature owns its Zustand +store, components, and API calls. Shared UI primitives live in `components/ui/`. +The Rust↔TS contract is generated into `src/generated/bindings.ts`, so command and +event signatures are type-checked end to end. + +## Back end (`src-tauri/`) + +A layered design: + +- **`commands/`** — thin `#[tauri::command]` handlers exposed to the front end. +- **`services/`** — business logic (notes, workspaces, search, config, export, + onboarding, window layout). +- **`db/`** — SQLite schema, migrations, and the FTS5 search index. +- **`platform/`** — a trait-based abstraction isolating macOS/Linux/Windows + specifics (data/config/socket paths, hotkey backend, accessibility). +- **`ipc/`** — a local socket server and protocol that lets the `notey` CLI drive + the running app and emit live-sync events. + +## Data & search + +Notes live in a local SQLite database with WAL mode. Full-text search uses SQLite's +**FTS5** extension with BM25 ranking and an external-content table kept in sync via +triggers. Everything is local — there is no network server and no telemetry. + +## IPC and the CLI + +Beyond the webview↔Rust bridge, the back end runs a small **local socket server**. +The standalone `notey` CLI connects to it to add, list, and search notes; new notes +broadcast an event the desktop app listens for, so the UI updates live. See the +[CLI guide](cli.md). + +## Platform integration + +Notey registers a global capture hotkey, a system tray, and (optionally) auto-start +on login. The global shortcut backend is abstracted per platform; on pure Wayland +without XWayland the OS hotkey may be unavailable, in which case the window stays +reachable from the tray (a native Wayland portal is on the [roadmap](../ROADMAP.md)). diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..3dee8d3 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,70 @@ +# Command-line interface + +Notey ships a companion `notey` command-line tool that talks to the **running** +desktop app over a local IPC socket. Notes you add from the terminal appear in the +app immediately (live sync). + +> The CLI is under active development; the command surface below reflects the +> current implementation. + +## How it works + +The desktop app exposes a per-user local socket (a Unix domain socket, or a named +pipe on Windows). The `notey` CLI connects to it, sends a request, and prints the +response. Because both talk to the same database through the app, the CLI and the +desktop UI always agree — and the app's note list refreshes the moment the CLI adds +a note. + +The desktop app must be **running** for the CLI to work. If it isn't, the CLI exits +with a non-zero status indicating the app is not running. + +## Commands + +### `notey add` + +Create a note. + +```sh +notey add "Pick up milk" # quick note +notey add "## TODO\n- ship docs" --format markdown +echo "piped content" | notey add --stdin # read the body from stdin +``` + +- `--stdin` — read the note content from standard input instead of an argument. +- `--format ` — note format (defaults to `markdown`). + +### `notey list` + +List notes, most recently updated first. + +```sh +notey list +notey list --workspace my-project # filter to a workspace +``` + +### `notey search` + +Full-text search across your notes. + +```sh +notey search "release checklist" +notey search "fts5" --workspace notey +``` + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Success | +| `1` | The app returned an error | +| `2` | The app is not running, or the request timed out | + +## Building the CLI + +The CLI is a standalone crate: + +```sh +cd notey-cli +cargo build # debug binary at notey-cli/target/debug/notey +cargo build --release # optimized binary +``` diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..55573a2 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,58 @@ +# Configuration + +Notey stores its settings as TOML in a per-user config file. Most options are +editable from **Settings** (`Ctrl+,`); advanced users can edit the file directly. + +## Where the config lives + +| Platform | Path | +| --- | --- | +| Linux | `$XDG_CONFIG_HOME/notey/config.toml` (usually `~/.config/notey/config.toml`) | +| macOS | `~/Library/Application Support/notey/config.toml` | +| Windows | `%APPDATA%\notey\config.toml` | + +Your notes database is stored separately in the platform **data** directory +(e.g. `~/.local/share/notey/notey.db` on Linux). + +## Options + +The file is divided into sections. All keys have sensible defaults, so a missing +key or section falls back to the shipped value. + +```toml +[general] +theme = "system" # "system" | "dark" | "light" (default: system) +layoutMode = "floating" # "floating" | "halfScreen" | "fullScreen" (default: floating) +autoStart = false # launch Notey on login (default: false) + +[editor] +fontSize = 14 # editor font size in px (default: 14) +fontFamily = "mono" # "mono" | "sans" (default: mono) + +[hotkey] +globalShortcut = "Ctrl+Shift+N" # global capture hotkey (macOS default: Cmd+Shift+N) + +[shortcuts] +commandPalette = "Ctrl+P" +search = "Ctrl+F" +newNote = "Ctrl+N" +toggleNoteList = "Ctrl+B" +toggleTheme = "Ctrl+Shift+T" +closeTab = "Ctrl+W" + +[trash] +retentionDays = 30 # days a trashed note is kept before auto-purge (default: 30) +``` + +### Notes on values + +- **theme** — `system` follows your OS appearance until you pick `dark` or `light`; + an explicit choice persists across restarts. +- **globalShortcut** / **[shortcuts]** — canonical form is + `[Ctrl|Cmd]+[Shift]+[Alt]+KEY` where `KEY` is a letter or number. The in-app + shortcuts treat `Ctrl` and `Cmd` interchangeably and display `⌘` on macOS. +- **retentionDays** — trashed notes older than this are purged automatically on + startup. + +Settings changed in the UI are written back to this file. If you edit the file by +hand, restart Notey to be sure changes are picked up cleanly. diff --git a/docs/images/command-palette-dark.png b/docs/images/command-palette-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..c891c3a2ed4051764a35c21d4c7114cfdcc95803 GIT binary patch literal 42896 zcmd43c{r78_%^Izmx_o&$k0SGRWfEsh02tq%tK@*^E_qR5-Md3Ns*8;My3)HGG(4J zWFEpo=I>mq{T|;R-|_zS``+XG?0xLk)>_ZHpZmG*>pHLVJg+-QSy7g9H{)(HGBV1` zau-#|$hNJKk!`gg--_SVH%V&Zj~xbAWiOI#690K#oDxn(#zuDe;(1l)XOlfU-BoY& zN^aQA5FX11Jh^Q;+%R9J{T;Yx>Nb!VEwsyc{R1xlbJ=*lQF)&9#Ko^6vIa7iew#iqu^!>MHSA9c3M?42cymRms$( zjOcY947qo2Yx7Q){rhtqr#g4CI6r^&sv?idoG{vW#`rr0CFQGpgP7RZJ-c^1&h&b0 zthr3YsYmn_xy)~@uS%{?o^co2=3g~f7j^1()62`Ao}Q;nrD9fYDrmd(Y7j#O-V_qv!PwNuOC%bR(43tF)Gi` z&rhq=vu8N-=ObAb%-|8fA1y7~h4w*JgUf&ZJeZjqOG$`H_;+r8KK#n7KLa}+Ds#F| z<+dF@a%30#fddCBDk@G#*VfltFHUw`PnLIC{uMbtKfmg7zmj*`4;@VChvEyRDJdh~ ztj!e_mkvK-BTyY=Wo1oO)wp%bq?3l3*?oD=s4J9?hURfZM5Rr4XRi6uOrMg_ZvW4L zte$`3-KNz`+?P|Yr4jpJ&x(%!H&+(l`6@NMnavah{$?BmDj)b z?`!0mwG4(yS~Xq{lUVJ#f8~{#g~boE+#ZREsVVJi*G>(EU{c(6tv5C{+6`lkRb#-q zm7#}lxv`<4mu(X#xwt}^MN~93%{#-5I{G#@mXZVOC05gyZ6ub5XoJ6&*#5fmO2yRG zImxF8k06+i=Y?0A>Kl3;=T8JxJG*(w&E@)yA~9p*kuP5^YK%W`9y2pHx5JOEZTb=g zIXQ!n)w9@GrYai;2Z!kB=xr){ZMt?-Qd*jso!<2H_4TDmNbuU|IQ-6Ywt}APsaZ#s(Un(o8>=(e4v|-0ojrSY=U(RaLWi_V4hPHmm%8oh z)77)PdwRsg#P9%xth(FFz1x=OM`C5)TkZV(7W2A77@KI#5VIZnnq^djJvcZz+TPG` zckR!tLabnJet!6QnNt5aF{c>a8eDySkahFVKro-->FBU9B^Q^1<*N^52HW4?rsux( zb7rPK%fiNHcDOF~%$YMhp=+65>osdCn=;#6w6(Q`EZQ47I}1%3Uj)%|^y1zmBqW%t zu-Gg+bE;o_3-;7i5?|OKR^|5l)6VkGpZ%ouZ`^3X4liV-`8h!^0ye*jink znw_1E?fLVQ@9NS_eu>q=Gk0n|mU`%^smpA-TheakTleWGD@$3q#mC1l^?7+|YxiJC z8FrgLQm}fU;pxeLy9XAs*vpo3;Zf*f8716lw17ER8Yzx-%m&!(V?%2`$dW%_Z;q&KG z$HQIR+??#~pFDjUE!mQgPshxB&bs%UM1t;(8#h!{k4Dyb;K$wl-o1OCYqPTR;)qL| z6Qvu$`cZRpbA^SXJ9h^D`0?Yx0}2DDKkgDW8-i38w{EpRJ$chln!Sg2|89Kx>Xj>q zrioV*_SW!Iuzw+3KebeQrv8?D*sou|I251#URYRITzs3J&dtMfR@^9FEftXst0zW3 zDwv*ccx0sJ*IavhdrG`37boYaFO>v;U--t>@bGX}*U>}*fq)0^;_O@-b9N#zkj2*C zer;{OzRpRf!1h;nft`b>wv3F-?b}HS34NN@#Vz%w&rP<;JYbkz?6SUk^f5x=Bf0&D z4#h=8(6O+T+^LI+-D^I^vBGn!k2ftfwP15&HDmMa!H~))PoDIxPdm}`Y6-h7TIlKN zUA*`uVS|uzQ`h6{$&)qi_37#9j~~;lh+USG8(pS-SZ&yxqO_T?xty@JwpLqP>+S80 ztnl^g*V46Le8Zc`qu=-K+gB6GSL(U$>gbr4mS&Dw3s;gD8y>!MDv#sD3Ddfmv(kyb z=YG{3Jb3W?_wT1pow|1I+OA!@yjHvVYV9`tcG2oM_x}^1Z);~)Q&Xd@r4{e7*fG_W zAKs;7Zl0!*v53cm;q%yT=9o5Pr`+ae5MZq1u{=UXMlsT$TP0Y24W` zdj#Kt{CuZ7ckWPab5W1*AT-Jz3|RhMPPH`Mb5&?%c{xM-qvLRGwB$M=+@eYOJ2t0@G#@lVf;r|gQ4 zAluoq3zHq$Gez@zDRWCoO7KDv&v~dioa&J1#U0o204yvfv8t33BrLDVw@fW$H=1bW zS#$*Rl|;qHUgaa#^<4giWahtzUc+z0VWPEBGJWvF0!LZapuVKI8y2v6{J%vxL5aE&Lg}z#5=H@ye`Qtu`t6`>O z6=RjQD?Ks0i6OM!tN`2+3NW$ZjWD8O7S+Bt>-6;6bH0K? zLP*)P(zR|U5eX1WWs*efh98*TR!~sLxBE`vgz@*=)r;rj{z3;OHrM7yHg3=K79)ZX ztTjF~abWhVzka0-x|mtK*vVvFbP5qhQnGX-KcaplQ~TrM`cj`zSk0?fuk59`4!t+) zCyX@@>vm_BZglqcD$d-F66|$o)21lL{Z>BtGnTH5P`0gUI80*YtNmbFdirFft|z;n z{4?Q46j?w=fDTAn8JhW4*h1G+6p`bm3R2w`CliAbdI_ujfvltCA)a|h#l?FF9k$F% zm^DU<9ZE{|&!4jei>JMLQ)~V4kf{CWx2Gq$YeHEidvk2CxKvVq7+eaSXv=J_p_-q2 zht0h2h)hmSPLysP9X)+)@>*eCZf>q+_lNf-CB0o;t0k+`w7dP~fWw_a*%`m4sijSf zkJsim9NzLs?qUE3qiCj9fx-LY*RQ{t2yi{UvUBH7jnIMVX+`qX^z@w9C7dTuUX_`UwwD03hr>eoXEtZ%)j)BS&syKL|Q0ndNF+ z?|UbI98f9ep8sNplh|_D!YjrQ<(#GmFB-$uvZ8Cgf6r5V<#9t5 zK@mLaPL7NrG7&~kuNKC z<4r7Q%~UNcEDQ~Y2)rMiW_0qc;w%qLg@%S=U$6iPAwO4KCu}Xdx7=VD97Ee2fhTMK zF=c^{NmWhF(e2j!`6=I&_wQ$sdT!-gY1`Y|<7!%3T6%gv@BOm}xq7KlmNmLkB{u4c z31{>3ABS3~SCmQ;M#$WS{|MoY~l5e-K4107$*72k7*zp}b&90+w);*3> zls5=5F*2G-+`pNq{Nclg{q*$BJnLfvzl5i4BkN*?#6(3=WO(&|_6NEwa9tSx9w)Zy zDu9|``s0kNvokh`$Ldr*wx_^TBC_deT_ntDCh3YF`fT+!vpn*nk@!_Ao`rsgQ<4>c z`RTIziZ)gz1eKndy=XDZE^KgNVMT=f^5x5oG_?axr}~;#E-+BHvTj|!)|jB1A9;|@ zEynX%@=T5W&gz)4cq$BA#b?X%oj?^^+pMw8=Z_yhzH;SCx+Et`x-)rxo-uBPQ#*gn zI+xE%`Up0d<_>i6byo;v!{)|J=qX=;5pdZXFWr|CuuXZ&}jX_ zi{%@cZJKKzX@f5E^70CKxJUx&AT4{jAZWfwN=lKBKFu~rXYt3~#4nd+r7u?eEMo}j zyIAWIQ;Afm{B%Bs*ZsK^-3U{%VJsPK6H8NFkx@~vRZ>};fBFE9urF;RYZua_AzPn$ zb&ET1RQ+WIP0wO^M?yfrj!TiT)0a5}6E8^&2PCuH<}|u$NHu6C*Pnoea5%<7CvO)EknK zf#wz#%p$fg9eN@c^sRFopE}$Uvaz$9a5SDb;2QrDM2n@szU@mz#j(?;9~*U8(Q9&Y za{e0{np}P}z&kcJw&JGD9_w_;?Gx5M)20$z6SJ~ZT7D&upduJwj4C&gz88JQIF#}1 zmCUwHq#?rh+HF+lyt}E$?sB|)l@&qvE{l8O*RKVy(26YJ@#T?Ur zNYE`3maA=6|?PTV;xvxiVS3Y>}84;TC(zJGkX{O+ItY-yX+iP}q_Kl5= zm#ilKIThBvRfFpJrAbM?d7lQx-v|EkD=#nS>D@2>Xd!CO=Kw1!R?OyxQ|aoV;&(HP znkE(&M=u=a<^8^vsKr~_Smr7!mp>9VffP?z46W$7$`Tg&__2XRyRT<R&et}|^D)_zfQk(;i|U0yW$_>`8=%mpL$yg){#7)EP??x$zZm~%Cz1_n;+f8IX7 zO)*x`68s5hg?!W7rlzKDZXYimK8S#2dRtEJtGB$U3)AksdwGGHl@`yRKffNGKZ44Y zLn;2KU%F1Q=!p~6Pik*P6i>YUh)B-YTh|IH1s4Q!ZFEQ5wV0= z{IMXK$R|j|y;YRjaoyF`Ra^T|+}RfQai*Y)S@vTpD~cJV{^w64+s}tCZ*I(N4$nFp zX>%_=2n!S1w5SSUqTj#&W%Jl5-~G;fYhBc>UmqQ*i~Rm`*7@Ykw_3%nxyv8^ot&I( zZB;{hY<^syBN6}hhDG+Vs}bim{tVjhKb=tTwKiLIGyT^2ggFAi9j|AKmvow}A3b^` zFl!Acw5YCZmg@=#1Q5XWl zgjEd+PnY>@Th|&Bl$7+UjXH(-caoNtmX!^>#jVDBK}tfG55h#IemsHk6^HJbeA1-vHGn z6^^p9Rf|(y#HvF7v){XS@7}!ON>r9h8OqGeOu3$N-L+UkTznl*FP>jtT24;R!=qR_ z@vf6oAJQ0r>c>Njr_%lX{G#d;$EK!u!mC$kS9YTA=ds=iT4y?A(E zm5r{)LJI)*itDYQi*u9N^)W0>wk`qsI_|TVnk&~=mo&L`432U5z|S za~VM_JpB5lOBEam?gks|s3PdW1J({y1fwEF)rwKw^YtY@e&cwT%Hb11$qKQ78n3hYMAayz}&Cvou3G>ZeT|KeqM}B} z$KNP(f*d1)FNAIU^}O@B0DV{9V~jc|5(TZgOT(FddQ(<3D!M z9^bxw`=8}yl-S7USfp48(Mz2%nEn#nF4o5`3JQwdyH(O!u$_P<2^yhq($ZFm#7@Hn zs!JCy`UM0;L`D+hz}VQ>Rv)%gr)+?gQ&UqT`E;kvM>+h8P)&hpKqWxAXOFqHwIt9k z%Jxe&vpfMl)4j#9)TdD#JbALeSVvb!X9FzI1WI7UTe_uVukq-Ba(J{01x}r6{PZa? zF;Tzmtu`LaVc*Pm@9u&W#y8!r3SF3++r4X7Yc&{GX1&@brp9S{G>+$b8BF|2GZ)FRLi4%dU^) zj#87iZ-N`#V#Y$e@v=DoLE*v8SIuLhVq$@!glz7U`*&Z`emFx?cy>^i^B3_BGl_&w zOro*~ks16M;OQg97G8bv@FUY;wX_x!0TLu^XtrYxJW2WtSy$A6Kuy*;mM|mrXn9an zU$n{DA%URiO=(`(hO+H#48oD=E z1F(JPaRR~=uJTB3^g@VCb7yB~S2F2a%Dipw-hFuG6=7y^(J@!UlAmGaz>2?gVt6=h zacKi+`=+MPHr?KN2T9jnsYMiAtg&NmL;SL%+^fVJ**7W@Ee`^WAz*BB(!#_9bit_G zZPL5-ema2g%g^7leXB%*Qt)rL*K;!xgfkRivN^-6$=U1b>RykKM)9HNE^}LNuN6PT z7lZf8oI|tD8X0-{i3vvPuT)OZ1POLx1xd+`PG zNI*aUyX6VeXbHYDNr`dm+%*~xCXDJ>8>0V+gqoY0p1(|5RSuS@?%zbyyJ?nV%yhB~x-49eq z!lAc*2;CB`^h=c!H5+t)yjBymc#Ro|tLB~}=>|SVe+k!T5H*W@RkPT1(9O!>?~c^d z%gM`YigpKQop5LKd`!}OKJD*i?wUF0b1PNe2}I$POi%bUV~A^UuO5GcB*AsvJK;rc zZcAh1HQva1h)AFGOsuV0UlV8dx8#l?Z{jD&76XmcgUiy_!+c_-bdwst8lHY6_l3jH z-qCTd+yKS}0P#x2Rlv2{K!EYo1WDg43*o0IpO9Xy6A1-%kEOh>)JRcolH1wGsT>f~Hjc!UaW#!Z^($}n(+dNAx3alT&A|kC{6T-~MC)E8LLIm^NPEVGr+;q*C zB^O+a6;mcg8cCm-mS5qOPJCE3SpCa3!f3T1lrCgtZG*$JK#P|qX!OuF`4!T&8@zBRDSy8qehybD36eSzoUA>>%Z)EU3N}u8A-^Ie7)HY!` zF^R9jCxEo;&C1P5>$Ge~PJK__?gWWIPh9BPdBJBCq&u*vv|&!D&reiMsZUH$Jm6>b z-*sdze4w;*S`;D7FT0Cw|B%V|cdc$bXMeV1?i1xY_iEQwIT5*3` zHaNTE1fluUSLsCM%r^5}4OME=w|B=hSeJ`fxk?EM2?1{d#LZ7Jh9u($tXWdhZM}MO z;%NBY8+HEZ)2^sTKrmUHogH)JzNgByFkuTs0P*3X7iq|+1;p34UEcjQD?H7?xVN~L<%TnX=%wM zXz`)N67%9M_L(f~+f)d6*`SNkRz%t_`FJhn~a_H}}hyt58w4NjzpC4xy}s z(m|;dHCn1_&YKYc@?!UvT=O<9ZEe{H4D9=Ni;IgxSN;013wq=7{VE8ND6EeAS#)Go zxI12}6(HIt$edh9m*(bPG%iDU5nr0s{90eHBriWSzYJ`if0Up}aYRs?Oh8a@+`cf; z`fm)hWL$w>XzJvw8#sht$HXuyDJi|b#T{M^&6m^Am2xMd;65NJY?a!2u()a2WfWW&JQKbAX+w*z(7eEVi*WtAsU z5*&P0OY0e`Okdw^+TYkXI8w5*vLfsDKvmtd$Iiy4e}4JUp+oqDnA3Fk!bF?fvMnBW zR1^aShU%wSr>~(w`PQwd$VlVjTU{u+lU}^QG`&(u1r&lHrKtEF6IZ)*@RY$9%pKx&174Jg0J z$jC;M^vulquV0_D2CJmXU%7%BVPJk4@&P@c&N;7*qJ^7-zkVH{r5)_=XQvBB!iByB zb;eX%J)d%sKwv&|2YKBOzjZqkU#ghe z+Y7KCg_dU`5R;dmn_E#?dH>>z`}cQG42CfYeQ#`h|NQy$moNROA!qSXlS5E9u8+?k z)u1(5G(lflTF!EDy-7~4JiJu(9-1Hsj{8_xz7Nh?xh`E`qppn; z69VT1)KEV{f<$9Anwpx>2dq{iz>ba7MklGH9;Mk;ljSba_vzE8`uh65zD>}cJA8en zEBP^j-@xdB1NV?61SbuidaF}xw!0f7p{QtKcJ_66Ok&PnCF(@3sjB0XlVA!Re|Qh$e3&Q;1BWG5`q!4NX&1lZvjcwb_An3hbzvf)N(fc)p+5Xo7&#oSlVxEWsk9 zP?Sl!#zrmo3h&h|&?45Bv&A4uji5~-eCsnvBxEXcqR@jjv>czjBR@YsB{>;>2pi_A zk@;V?E>cq7Qfwbw7v3!EUyzoDaRT>hw!(~ua`^CJ4Gj%25qRj1j*AMCj>KnZXYZc| zJ2 ze{oy$@FQne*QTc9^HV7L&N5I`g|YITIDz5}7KH8(HZb$RDAMOXA2O!By1Wc$O%S#1 z!gvcpRdVw20xgzCBdlqx&YneqSos)%>Z+EU%FbWsZLQSepCC>lA>G}|j z3PBJ&videqq33d{{j7atlN7}&jgw|t}$2G*Rca9ceuHFXT-YfDQDmTg;`5XQ{T zo){PgEr(36jB-p~IQgBGrcLPB4Z*+-_Bp!lCiSFJBr~g&u-FGBq_tNl6LTL2`5LO!3jXc6LYnIQQR2CB9GEbA6!=pKWPt z!*Xx?@q-zXA;MoyoPC}e?IXD-2?+^taSAgBptfKE1W;qfP*OjRkMHg1xLo=X#FQN~ z2VHP+Q4zPs*TLDn%)(N9E@v4PTwO(bn%ml(Am$MQE+cM#^4&R>OIbX4v5{Qo)~yfl z)F8!#$XwUdbR&#jbKQ4oW+WfB$8Ac4pj~@M!qiJhK*9w7tFF}qu8G}q5@RHvLi;=Ya(wf|!~{|a zTpAycCv65R7joNEHNmV1GEhJf#l~N~8^;8rTInLuyK!>SJr)b%~yvPj+39)~=!Sm#Fexh1t zTeHtjdsaKT3-khvlv4}&EZ^j37H{baXb3&$W)O=>M<=Vsn4I6_4P^e^Ye>~#|=-Gl}U-#1us18uaWH(W-n9=!Yat%S(jzJ5I%$s7b2=l&kW%PvPyDMmcM%WQng$WyZ5!-ofqXJH`v zcSoOR>)va}jvXTju_4SNj~5quU+1MLQS9)oa}rrwUx#y3-bO8{QR|~)MoEdz;YSU& zn_>|vsqh~p2M6!*c->-_mX?N{(PFj$FPXIi5HuNCK>xXQgp8bNk#pzPYNOAziYE)v zFfhoObAYdO+k6whyt-OdQPKE@$@h~~VEA4+zVuIeoLpSHf!cQMI>EzpV7EVk+x|hO zztxPJu&8L0)$L9e)aT@;CMNAgF4>e**SMZ$->Dmzb;hzTC@28FJ98jL_R^&u0YY~s!6Dnsq#hY#H*N`-MOQ-YuP<;~3ANGC$$0!#cRJ>A08G%=w_B{fHc(7c3@^HL>s zVfE0%#3RJv=Ua~=Pgq-8I^4ZW9bAFrWxsz+0jfr0H7^L7Jh?)pKklB81pt{qgYbxc zgX^fS?od0whO#m>3(GX@bR+ZIwr+JkrvtGi|LiOfGxjwd(-?mPG6bYg*VGI2fi%1Q zTTdQDsp{xhaP2lQ1KtmNmZ^b4@UJFUnfkY?TnIQBeV<{N8T$G2E<#-4S_DRxf8jn> zZpPz$hFo`ZqJDn9xGUc}#X&8ZCeAAG_o9l2hj@k|Hfc6Ps66@X!orkp8nL*t8uTer zJ%4@+PvY$2VqR2VvgU68!^e*YY-p>fG$mXsIvvd|BH{vPV)$_dTU%RLHRY9*`rhep zi~AH#!C5FADaH1Cem?1ltF>wu?f(6Sr(8nij=q?egX^QY9&Xn+85to}gU2{HG>f`x zYRt!)UY%4+AJu2$%NT$Emd^>5KP6@HnA@oZuP}H?3TI(RkV$%^^WXp-oh@^UGAAs( z+>7$^^814>f}r{EA*x$&l;gGlBabK9Rv$g+PlBv!Db0)s+DC8Ybtih{@C;fI)+39jOCz6ql6Sz^^g69*>^TL&P- zE$&6khTSqfJ^fOHuc9@*v$Hc+#n8wI(%JsqzW46o0?TWCp4&h+7+u%2&9?PWQ4i)399c9}C=5lJ}bV*9g~IJey=tj>?7 z{KIni(Z9piKzmV9P|-SnyecSA=XwfMd*bO8e`)z420b= zKRb&e9Sa-9vqrND93)9Ck3q20)6!nJ*>%=>JYL+bmSYj%NW_&e3LD?&!l)T{z)vKW zg@(r%_UqT2Nc$%sBgdap$D`ju!x(a>ufzjRwY&EAS}-=!>$=+{F`WSHB?3=jn-|vf z^C>3f@>EK3qQ@S|eJ(E_#6_d=K*PiBGtl9a#!`Hjt-EA3<0MYD4>8Q z;KR4Lb+Qbrp(a>ph(BMFm4KHQmcb`ie)Tx@S$4i{F>7vaHm;4*F9CjhLA%jFCCf(L z5J*_Uejh8}q9wf-b2iNh#-N$HqqP;_ymDmzV1Uh}ZPKq-@V8?r70W$;bvp0Y25wQk zeoEMR{9}VpP0!x`SKq#UHG2Gi z4dK;=X5II6G9fd~Bthf%^&NYq3P-Aqjg6xt#MD5TlS4yG#vUXfKH|gg-@hkJg8xAF zkd*9uVNq=&z;e#{9VQqq2t}_{wKQ`xGm}BRt#&Sn ziFj_DlN8aKLE#(kc_pnyr^tC01>kOfY2HY1tcl96+a^F_%OsgvSqUacWkXa2IEdfa zAS_Y6f`%7i6ny`FI_+PEvEf5SVnUNyZZ6#S916?H-F>M0VvP&}byQSTprAbuZZtUu z4J^Zo)SuB^u)>UddPKWmQSDUy`nD|BX+7%Q{wk?YTDysChEUrX+%Fkfv3|Koqx4t?_DLy0!p@NA{$l~SKYoO^}W+EwCnD` zCDI#LEA9P%Cz7wj*!)(nvyGm_KuLKu_=}mc&4f!BJBcnFRoj7v2{JMU1NRNTRCsWXihyC1I#d`p))O z|C;k;xu@euzJ06c+crIZ!n`->3!F|99Rm}MCIah9Yujj7Og&0n5HCjla zSz(P}9UDfwZ{I186#@!QO08znY)>{T2YNC#Bb@AFN6p|~5|Yn|iZ@v)frXXTy5xOs z?!p8wLf_xU7x1#yau-SgS0f|o*@vL<3NVI%DB|X}APdWEF|+%albcKVQ=h~_2jsG6 zjHMi!hacbH-yiiPs3$~LfVfsSnM;=daon7p$^Ab=s7IE9R{%j$S`H|xc&YcQ(;iAn zkBN+Ya&~g#8S8PVE$b1I&^ zMq;u3HuN-mwa35(37IBDO;yMRu`+iv<7&i5(+g zN|R8ZnU;2j?~{_EV!WUwBl{3w4U>@7InXrf>d~d8rATe1rJFF{0$`#Q=!Gg5=xa23 z5dE4*WJ3*&jP^CI{X+?Bk;} zXvQ)%k&q$Yns--za)RT$kf5MWvFp>thGu@tQ-M?pQ=pIl$U*-%myH!jVh-6c@^iUHXFL;{4x3j+_F^zFnFL%Wob)YYWmP3(RdjD?!DCuWm_t+r(-o zv5rMk*q-&|8#(ChX`jj#AoP8Q>}Z_eLDy5!*VmU>1F(qBywcU_kJ#~=JyOI^cx%$H zEW3NEs;W|nJ%9rglq@JKh{*QA_;Fb}SCa+3fubDTrFN-japXvGVI)IxI#>@R$LnLAnCf1|k|#c2oe(qz+L8d&%310zo|m1U%-3wT{Q9$rjny6P?= z$;&(4kaz*R#oEF`mFp>1+Vb*pa*s5rZ&8VS8UDagy7_@srh>*spker>$=QdQtcg8f zFe}1EhYpN?*k5L3@NjU*rnN}G-l>vmkKq@WGLpj4yZjEfQR-fhUBO?JMt&!T4@Hf8 zVIN;1-?eAY$B}rqCs$sna)sQ#e}&jEhz2IOcpG{~%XFcjxr*2vp_8Qzt^k*bo+`;h z+6^f#f*?u(+|g)*4Kc>GWw*Zp%B!5Ys({$P_v_fy45|uvau++AHYXed!L$N1KfUs+ zzu#u6GZ&BA*~KLUwoYmKW-Bl>*l+^k+}zyqmoH!A+>c#j216gD6AAQ5PWbkXnt()x zXPGC?CnXY&HOr5j=2*f%LR>azcK`r4gCoc^dw-0%S;ZoMC-{-!VEJt;$t=ZCjyu zCnD{rN=dOXKSK%H;P8J$3-2qHpo#$~bMSS-Qfx4f`r6zj z-Gx}0#zN4a|O+p~*drNZFU@j((wVmQrR)A?-Ga@RUVIk~@d*LTuy$@1P8e6EUU z;3L*_@2A{JBjW!Ow+?50Z9D01?_O=Et5{`*Yz&vZ;IqFLf)(`EcJSxr)$>US{jCys z3@@u06vCjeU?2tTZ~M_rYubxpE~22G|ZP**YdJ<&~T@^`;EaXON_uB@IwS0Cy*j&cQ* zf3V819U@l4SliY`vz2UzNK-a__X6Vz6BE-ec643BfJu8$f{}>{yM%}lu(M&0_1`WJpft-BWoOq%_gsDZG|AvA#azH6lEX&LAZ6 z@%26EGnlLQ+L%XYC`gfyj#I8sLqH%X)Q2(&h3Z^Y*PSXxthayWy>OSiHTg797_lD$i~s-u@J!W=*QT4bwe`d-8A8Vqzn96$ zO-_%{?c*@6+ALx4{tvot+CSI~5`%=p)cpe|*}No~|Cbkl`OqP^4k9A;*0;pdK(Ezn znIsqBJ8&u}8es<)L&1nfTdw{0VTNz$>FF6bMiVsTNWKp2h(^;Oj(E0wpTxIg^>uYU zaL$EQO&H}qkYaNN1taxtQ@)2+#}P*I^_ zznH2D@EI-nYhVDnlQj8dC8ZgKGbQLq$;vuyZ9RjT`5?6Vv9Qo{q2;F6a*eJ`7|Yb; zB=;k^ty{N7@M^E16FX+-3IZN-e{*@^Cc^4VO;xTIAt-=NSNSTwd_f~^v^*m73jC6g zIgYWhJs093Mn#4a0|NtG<4ubhvE>B;{tBq6De$jef67@w%pZ@evF@UI%<8rF9-|P1UuvlM1m8qCH|WMHgloyK`J07=U+J2Gc5W%$syJ9c51bXY@%(Vz#Pzh+49zx6H*Q2~U53V``tc?rOC;|@B(xAokQE;aNonT5ch#X9#YStL3eY?+6yc*8gWHj=7vBB+s_SM z_K|w$x?onwG4&3{3<18ugOZ--*k8VR^NotB4Of-DE?2FxE!Q{f1)LtpnncIlj( ztzzB8$p>tHLqkJF4ZpAyvpoCJy{_xr?~hdi_q4roTM+S>g!8h;AByo4V&53CxJ{k{ z5@(vCJ9J2aTm-#^3)g}6Nk)SP4`c%PpN(}|GgY~c96oF>+Jn*$zp2XNrd;uR`1LEi z2&v6u@;7~oRkgJ6Z12Fe#WyBnaQpTyc0|DUp5w4o0|gU2n-!E~m&O)v%JrcG(6B!L z3UNsX@S&m<9kt=#8y_B?ZQjOv`VaA34z${%MGB1waowV8nhpcocJ6&9>cE9=?3On- zu(oaUR~yEck7>JZoNd^hfJR>;1szLWY<6)v=C!TVE=$Y<>{%Z&=$ytZZQD@<(iI$e!5!&d`dE~;Scd^02H2{(; ze~y!eOBp)2WAGjL-r_z?(lRi5J|~Ad8W~M%p!N1xN}kPyqDu@b8o9*E4P08ci1_*s zV#^vo16mY~b;55eWT>_EM5U(1wvKjEqcG~fPN zS?NWJA5xqcOR@-0ZZX~CDzGR%D69P(r zzEw_bg#2;0$@f)vzCUw8XOFELI{#qYFIGu)M`QfVmxjI{KL|t~qQWZU$YgJ^8@8yt zd@V{Io_LM-1{K6+a_c^c`EDh#8I0Okr|UvvDI79=37stAXrp8mz6-_25O?T*RS7w`1oZ+v)2JU0u{Ri<=FH~0B%(JC$Y_3{ou7?t`lhgO1<6D;Rn7;hf0#`Ki0$~;H~pm->mBO2)tyo;IU2e zs`?u?1-8#yRW7MYDZdlqzNQ>=f9pP`N}99BBRH?AKA<`F{wU*tQw+XOISr`pZ@vEy zHPc}Q^-D4Sd1%xxX)AY$cUkJZbm<<0P+vXkQm&YtqvIVqJFk)2nP)Da`tP0-7e6NL zWoE{eP>)Ug{Lt^sBE36sz}?|0cdc-4(J}lU?xdVcg*$zfq=5vb9G7xkrJkCoU*W9 zoriXqM&du{Whm>4vXI23=$i&<{4E3!J?d%Qon86E9rwh{@ zj2Z)DW2T0NXl`@DqPuegl(vRO434dUsRGy2n4`|kcLU@G^Cw|9EDxw2bOWtwjV~@? zyXR(SCuNu4pQ%J-hOkJ&)b2V$O^%62 zSx3ck?hoiYqE$ughF3Ob>Ehu=cv{fih4VgoEV}Q9g;{G;;DdW8D3}E;7?!CP3AEC% z(yb)FUBYCe-HV7QaRHDyRwgFSps%E*Kf|f@a%baxr~bYVHoo;E!I=i48?kZcOAp{l zuP2{6h8T?=HH10BL2KA<32*(;$qTR5QAXT(-7}FhXV2!MxfW&r&6|;__FTNYi%Uzo zaOZ%u(=B#wX=wP3zG4{1TwPrB?fLMm*XY5yQZfm`aCZ!(3sMdvBO{_M5J^{-Q83Om zigmRmf)t`I4Jo(G?o<@4r9iml3Im2q?yqmX%d$&kv%V>qY&znOFJ}4;PKs zTT)d+mLs`R)TWgFxBZ1og1MlT^6&Xv-eE5e_-IY*&K)%*E<3V6Qa;T8b+RPT5dNON z^;P(q7=Zvfuw+J-^rq(-IfoyXI*rnkZ=YHHd*YYL$qe$LjMOn%Y$b&r>2HtmAKS*%^(Vhxui+)oQLAkuKa|<}Oj{pj2n3fMD^Xtt}~= zR2)9lz|mNFH+YJrnba5om*I{DvHsVo?cIvY z;f}x}J6xX=x~ikCU7V43DTR<&KLru}0)aF)&V3(X#^1hmi|KXC*5-OJlnx5OS?v~i zkSmLK=xxxGz6x8Mvi|TRxrU)~g@?_Hi3xB#pL+D)YzMw|BE9M0e}}BG4gPn?nj!Ol zhpavL?~t{JHvb*6cKScrL#pyWBIc{`e}}C7AG(Ov$AFD3+jbD^FZ_s!t-^fWVeyyp=x7ve)lI-_@@Mqqe(jkZkmlFcjL$XVkc#nTF%~KlqI5?J2 zRcu0AffIsxuPl6iK*ONlus3ICXTfR@NnTc0cR_zDtH~gOE+Oj^(&jb~63^pKv%ho1 z9EaBoO-(5f3(m06P+m?>z4y1&)YRZphlK-d1`YXkkZ#+S`CwbWh64m}><~h?8?jn0 z79$QNfecLS&6_t74B=w?=O2H3513FKC4>SXciG9Z{aydBnn+~&jNW@VUjm4=FGmm{ zNl-B2tNbW@)3Bw9vJWAQ4FnQTD59m!#%T`d;e~5#74RQZFeEvMOW>A>C(n$xq{)Ub z9^1cLa*>6&tk=>278RZeU%Pxci2C#3ET+mn2G*i@5~jVpazs#25C{((A~FUWEAfCV z!e^j+$1a~m@^#b|Xj^9UyC@@bB~u#_3b;~1UjDdz6g3^)@%N6PYSDI2FnEh<2@V>J z2?{_{*Qv8-DRJfozn-^`&v|gEAbZ{2-N8>=T4p$BgIP!FxPHAEN#@V$s-5sN5b-*7>92Ei&r9^Z5@=jD=zHp74 zt^r?gr#7lF?Iz#0ZNBiNcNe<}adBN)iT4c%khy#J9bAPV(Vsoj!f|293&ehA1nCIY z6PV;ONkemUCLHEv@E*w{CMxRB%7VExPJz3E@;7krq4DWytU>q$j?JDxHZw$m2)&PqP*l7Vfd~pC zYZ2-NoH&SEZAxVAv}T(DhG7+Vy-2%pq8qz6u1Q&WBY^R!+#wgC3@?Cg1{88FfuGTKFHNvF8Uk6hu>BuIN-Xa$` z*$hff9X;w--cOu0V05^R;nH{}XsH3M3yTsf_*G(J*)l6F^E4v|B(0&E%r7x1gVCf)TnVFgrTht+pfM0aWEM`1yCx%=ODy{P+y^9^Qdy2VOTPBrGfpXzt5mc2-sdkLDtx5=1C8a(3b*#mj8e z#J&W?BAD%f+Z5&HpW*NVtS)u#Ry2Jdd;foR_a@L>wtu^K_f#5*=BSi3N=g(-X(AzM zq9Q6(q(R8oT}qmWQW7FVB_vaWp+R#(N*N+jQAzYOWZa+2{rsOjto`i$uC>>G-~H}; z{nx$zcLSH}cb?z#_#VgcJf|HP)jx_`+HGxOjaXY+T;XJ{VDbs^ho-v`}q6c@5v0waHu&a(kHcOwZ#*v zVpVXD`Z*Yj;JLyGameosqDDS?`*w|-B%5X0$JSTCVB}8Bnf3L~pWif8=X;gf87FT& zopl9~EEa|q(ct-mKaM`|8?m{bJw=IOLs#-0YIk4Y&2)=}R2rwH?e5+YG>jh&6+GR! z;fQfPC+C44;)J3~XhGQaQ`y_roRHOn-haS=0d!iKG)_LIdpn;P6zYiKv=yo$NhxIJ zb?-AImvV+md`BKUz4n%UHJtX@ha5iS?K!K%yE3ZBKz(Uxi2OSqUpvW=!kLgzE&ToU z!Bi6-d%+*xb$@tDuuHYHNyrqb%B!9=DHL(fwsI-hP6}QdDZtE&h^s@e!dCk!OUCUhR~7eOM?? zWL+Dr__suC3LK=Bl_Q_%zJ1-CCoB@nd-GJV{B@lJa5QGuI{iM^SF>s02?-i@NyjVGNJdqB&4%z*h83me-R-e|-;X^esIe z{<)^6%7Lkl8|%IY6;)MN=MCnJ08;VhP8>Ocw~DY;re>Dv>fUy$3(s;J$PctnJbK`- zFC$+>|B@Hnnm?DnY}4_wpXPQKV_(v0lY6M*^6AUwg|&w}xwxY9f|sSd7f2f31~&j2 zDj_alOz;ohi=Zy(S3QmC$IAIwmcc(=VMFT z%{QE?sw&>AS5dl)<5!i@hK)Z5q3J6GPKKVcu6%rwlk^uG*SYC?O^Hwz*}iO3$~L`1 zZ1GCr@dE^>3H}%(qgILF_T#nwRxGX2h6}?lUDD3il$rgbEcp8LnKPXLpp-W1_sea2 zXP3ef4jDdt8&2Czn-;iSB{-Ag1E5poo1eOta0b39hDrEz`G6Fz)gQ z{0?h3;8L}nD#r)Ygk`jXEP#z0H%8ixVY}qsoDtMMQz1Xvwlpp&=~O_^<0CX~ja4sr z`_}1t&-MVcIs#AxP5Z_9SsZ7a`nH920**{F1sU>b-MsmWE;YzD_u^u4wv3D3oGR~9 zoYFs?AN$JEvX-)8*LEM?=GDEv^wyrE5*8aa%#B{^_i$Z;QhrW;rGiql95;C`U>jT(h97Vo1U&H_3rmQ%Kx?Y*_k^>pLv%3{JGr7HNZ!V?1v6Lgm#a3 z6BQZRUyG>Fpx|a}zU_0vG#T5Cwzk>l=kMecg*X-!EN02V8q?}82z6O}^f1zUGNH&V z`}Q{lM++X33wx|j5mMO{G^~&GOy>Dd?wvir^?a^@)X#^2*^$YdCbYUybPtw$8mU@44Z!=Xbh&T7KjfbC-sOCoy)2epTb6 z*|n>lPqXUds?D1teSC7VE& zt5V~4sa$;{2Yj_jSXHvJ`f%x~Sfz2|ERzsp2v%PQx_5nCYL zX_QtkTQ(E>FomB_nc$W~{bZc0a%FM9jPOb2i`18EIFS&1_Wd z&lMHnF)_s)B!J$UlAHb#yWo=|BIKf!LUW=AYq2RRhSM^hUJlC&F3b3T(ffw$J{ z=v?;b?0_{RZ50=mxs8Jh0Brn&0qYew(YwV#EK!^ng{BvK#Pn%C+ zY4E%Zot*4Y=Y$aqXw%(q(H|3{(ihALnjLiS(Hc!uU8*CE34isJ`*W$# z9C8gi04KaUDkJ+EP(wJQZXpMxx< zr`@xsfp64LM(x$N$QrsO8s^!vXVZaD)gFx7fBe|7rFwdOQTX$teg0{I35n06WXh61#HdGbo?!o&t3}Kbx)K=0da~iFRZ|#DX84`< zKPc+m^JmX^l&@3?y7J189|>iO49#hfhsAFaiM|0%pqUcGI(PaTR9J1z3=#CZZryx8 zp8MzjG_U!evp-JK)!F~@0{p+t1pS}GN&PQ9Nzn|E_>id^=#7~5fQW&SCKzEk&4s|+ zwjFH*^?ck#%lR%PNN`A;dDe~`ySAy0gLmi662Q1l{4^DnPb!588@)s(0|%aNYjd4) zMi_;xkDrB81F~yHl2O$efB#m9E*K62MMcGaUKg)iDGHn4qj&G4#Oota`TF{br~d&` zRbI))^Yf9LFu_~mC4qa@D*;H7idi&P6DCfawpRFx-JBGAMXaA}lJuCgNeQM^N65&m zU$zW#g9&?jPibjs+P1M0VpwauQd(}&O$jrKsQZ;=eBmcKIq^I`yPGI9NwWu=+@RuN zJ*Ya>$rM9DY%E)k+isu743(f7T9x02tDh1e_eR*m-RZ@)*Tma8Vb%$@kK{6B~U)lL+YS@j(uG%?M z8bjb8fBZpb1_;8#=c@t=>2mqStcoRvpdv;{u?D*!-Hp(6U*}2<+Ph=N4+sX2mg;e% zr-zXX7E%x?jVja8_UpMYV|4cHS;{{-&~B7fkXZJUCtLjOX1g{|{nbPh%Q-c9(6a{* zKC-JTVmQa{r+ue`OAH(NPVkJ4(l;~H96w%r?b;w=HWYrBs=A?Aj&ng?e$n^=OU^5~ zxwX?b)4x=H`s9AfzmSrn<1dd_3a*AGn-4Q8C@267b5ing--0=z`0@VfSc5c1_w9Qe!gAc$vHU0B=_`HYryVnE zXNxn+7IEesJ$uEoS<)xarDQ!z&-t#22@ijX9bD@%ghSRDas#HUyYYiLYhgEW2IG(> zZjS;!HbZ&(bpK}`8B63i;_Jh*$xWR2rKXj3=G>Vxk3Q}q4C9GKLX{R$E+;7*I z<-EG%c0N|&B+<+Tfj_oLU#f$XYizgUf}G>dUdQ!PpJxM%j}H&9QiZgb=q& zm#PXAKl3K0v|k3RviyuqOx%yH?+9(H`T8~H!i60iO@gw9Z66~7b)P)-K>irok7l7$ z*AUMFOUtONU0{9mCDtcsLFkC1qdg8wX??i8;D#!1$GmMF9f-+RtQNa2 z(GplJ{hzr4`5KM z&S|!#jjV_W|WT2GKL6LR2*n8(AS8l z;X- zf<*QluxBDbWXwN&yz|4B8>)omM&kpat7Tb@-9mgmJ{j; zWC5cn8a>Y@yq=iIFF&i~PKkS<5rPFE?3a8#5)jih0v*R6 zS`&BW$`Ucc*|+aj&d491bD`Xq9FaAX>n@BRH_pn&X7YeujKO+OKG*@S%C`_6zyD4;}RKf^wrpz__=>W(O6H{ev8UK!T_MlLt}*6SiY! zDcd{I+WQ3ciQKhB-w~^qNNF7wB=6WhCdEI0l+SlIT%b6}qqE%+h=@TH=8qA@o0L}Q zmdTt;GiJOa;8677H39%J_3uA@Dj=Sca-FfINnrSSe*~dX60CR%k=ezxG*?LU>(}eq z03>iT3`R3U?1ebZL`oX)2rZ5qygn1Ww zbC{a_B_Y}~o|f!-T3Q=!$5HM-BR*!HBz*(Lh0TsUK5ffOIV?ro@ zrz$#Ra}(pcj3SoTIq~l#Fa33#5UA&Hsp;t&iN4vE#43@Qn%j~8Lq6%nekEvOqC6>? zSn`1ZyldSi_l)%*2TrA+f|qTK@{Ue8|L6nH8G~@7r|}*6ld;$J$Wov`IwRVzh~nJf zGnWR7_3axxZF4U@F_CZJ>_R`OdyKO56Bl>fz1t-}Oar@ISlHk9%MUwVdwHUhGgPZO1RKo;-Yb$QZAa9oL_* z+%VYl8tT^R#{UL=&w|fC;)d=1q8SnKPB6vj*6ds=;PH+p!T@gD$Uf;agUIRTP&lX+ zM%D-+6L?XpLXT22i!;B{b_)&LRkLuR7TkolFqQFs6Q28~B3T0(3DW5Uo0;Jl3O1ma zsYhDcPh?S}!u0bRBlGpYqKS`|i6o-K!urd|V8A2)Xz-9BYmAKMpK-+XbLY-JiD>`{ zw(Y>dgEfI!P)Oh#c~4PoS?H>f-nWUhVkk#o-k&~YpR!_Z)3s|oJ~hGUtVg7DKjJXF z`U#`t$QJs1#?;lhbLV1D^7DI1l`CsfwMbZl$x_zyyoQSd!V3S;-_WCwb6Uu}jO-@( zARCp|noL@+JDDSc>)JSL>{>D#@S4$~*BNsC&QuZ@CY&>{t8cE`See8Rg$?*TR<7LB@MWcUP`9KvnHt+%iWEh>3>Wwo9BkA`(UM8j zQXZe#hj7o}(xDfUYbyrM5jAig3&t1jWSX?ex~dcAa&5v!wkv1+=#%3~N3PJCh51A9 zRl*);H9TP-%XN%`7A`?LC+Taa!Qx?|Ihi{@9BQ`LknZQzu`^dwtf(;OD)qA_@1j@p zcvFxth(b!s9dPD7d?n?QH2X3!S=uF!vXj|1D(cgwz2xqm8#mt4I&*3~pBQDGaqE^z z+ZZq-cw{}X&mxcQK-eDMC+B0lrgr(^BT+Z#w4DqzV(&w8O4?135aT#rC}tiv)}&*& zz*mPXt@e^_1E#Ykxw-1pjRP3{e@x(2OUYI$JP>BzAhUR)jR9hR?mwVs-6ZX%x6%7g zAh19hV%laV!z-(^wR8SWJx0<6r45!YCE~|f2>`2qKtKxXUl@%79Z(Zf0l%15OzMwQ zInZ~KzWR=sHr;mVGXw43c@jCIs@*fC5tdPvQ7zF}?Z1}F%QbcKSy2#O5)1hR%qqCUq{`j`YOhU8dRWunx;yky1q02Z}0)pkY|g*Z~&2FuFP zn$mE;+?Lt$C5E;Yf26nKsf@`KNBi$r92-T`>(*OXbWqDGDlFthO}wOlhWz~<7zLd- z9`1OTzjh;!cB<4#OY8UaKO#XN8Ru_avK@5vfxxDq@GV?;kWIxNe(=U}^X9ymqr!BC;#pYqEDdBgmpV)jgv(4uRvRddrp`yi>ko5xkRKjZ%};+K%C~NX=M| z|6;T^4Vrl9CvJ`uCA?g1xnZzX1RQWto8Z!7vM)f? z$&XDbRcZ&ndjJ0SosyFCZOE zC-h9$-a}ZnhD!ZG_QLd6TYRf0>^T!+0@_`Mdn+Mke!M zFF1s;YP~ET1sPzmC^A=uak)u~if2k^?jA+Twxr@&|BwV-fsMqquPYWdc!=aSzz->0 zt@vFVlZ#o{pD>7ZrY1ODK!R$k)$x{=)3khwojVG9wJRD4 z;FT?O{J5TvJjcqMU6tC2!DZ|lshr_BV))2q9*(DbzVr0yk&LLl@3OOx_xz6ZC7SZv z0j`7kkDuH<(A}wt&55V z%lO5BxxaiFHhA#jjT*Zjpi%Cnn}<3FFYr+TkHD}XP;4FWjLsemkN zl+~BphAd)Y7U>YIgPqZwVaJG`*-f0hKF5#S{#t!BUWU{?xZ=Q_EwDJeT+}zxu%mo- zZF^_8!pyAmZ|6C@?2fV4#{%Mx*-1$%y{`fJGEq5p&=JKg4$A8>fnAhGPmIXE5aq48n;icRA@ z>;SznJU1dDv~vJ!ukNS8;`SvMTOWQq8yP8pbj~O`0*Zvk*8&Ag$(0@+9(B28^<{^* z&mK3?V$<}*m3{MClln44m?x}vcP)|=m zQPIG4?gl&}<@ddq=@#6;{Oq;-Fm3A6+qKNtFu>wYE&o(kIaSp+yl?g!)PlbL5*Eps z)hkx)m?_PjTt3Tl%k*coiwy1(lVaiS+lP#BtlKJKsWW&h*;h9|ZGlQ;2sCi9vmq-g;>~Rc z-_xbjWM19-xNGeQVYmwT7JBNG=v(1*wT=c7#3e(&KP6Ld^k@rgoTAue zkojee8c82RD0Kgf$(9LgM2jQFF}mXLZ4EQ60)%JU5J|SeN6&Z2`#fIhobC5Hj|)S@ zdE-2HV`eH{QSK)?WZlm{PC8Xe-REN@S+}C+tdVrcMi??!WTJ`A4O)}-Xs+Mesk$~( zl$eSrRaG(vn>Qt*@Z=Gi*XsN{qAA~gYgmE`5 z^%KW*yi!n$wPH34Tmb*IFz0}i4lbp4$fG@Fdk>mi^)}&Ra}QLZ>czs1QT4&zCr;4& zD!aKM7gA(kC4}oJ`VEdsZosL--|#(APyCpXYtEWm_LsAsa0m(efKQ?NMgF*6`Mu4& zhwp|*kxUd%Zd1MWFL9`XS~S+-GD<%a9`G6DYi|AZghQ^`aUu+HcJ@jmBh15T_8tKt zA*Mh!zF_B*b;=`u5B{mKo%a(iK77 zL7`(Tz*RTpjBto0xmsmCD#-Qf#${G;oUjWo$6`#Tg$bl%`}>a`jOSaJn}<gzQACpO}a$%E~7qdqLQ5HH3mk5$uOA03 z3dmW#eEAZxo-$8PDK&rN_p5tko~gFwl-gA{aK-b0++2jgIIFiKy#`Hc*KGf&nwZ9s z=Y1`4zG2c?V{EL{^?_YT6ie~2HE7K)-QC&Y=HbzabpuxNx$Wo-E|{vpmC;fdhka$p zbjv;pUwkiV3*tH|`TE!Q_IIE>uymo^1VWR87qp6aCQn`M5cZ0xIiLMbyrN|RAiYa>Nu{5rl0I0?U^5;*9UsRn!Wakz7hAg(z9hb& zXhIWS-IwF0D=D3vGwUe)XD)3N90;jYFr`E=%q$tPyWWoEk2eggh=JdVuytDv;hbmhv^k3M`v9c?osV`UH=PRR+(NyiJ%OXoVbKCk$?pLC!WhA0YU}39kp~LGH#9ZPz#Kk$ zs9BoR$(JpXG-@A%Es*=jBJoWipmBP$#1a)17Ai3bS|Q9@RpPC&d$RS2Z{oMec|~AHBOtJqv+Ss z6T>OuJ|9`xwA5y=`+jFtWiGsq(hugaEkgW#scsKg==MV~&)uN1%Rs!(=l6eZ#`rJX z)3}R;KlOO#zuV^j_xh>-m$a+@xj^{;nJ9?8dn8%Pw%390si~V1)(Gn*Av06+S8X76 z>D0}K#7CBfs9W0F>SeBGKWSuk8RX|Agmf@v_Jw98avp0$fT45xlMnlO*G>4%=C|ry z69YdM^@?zN^6VLo3dpOUdVS^f&ue!S3%$9&|ARAJHq-d^O)dKX)TdYLqj;z=Wt?kw+OcCQ{upl6j9)TR z$zp-My$I(H{SN0adCwf{jW0G1owbV#d`ZX{?Kx!FuxWc55p4$9saWS;pkE=~lH?6R ztw5OoM~t?$0)X%gW|{6lLk;*Zn5*W4zx{`@$2@0s^f;6run6rJm24)D4e-M!Tq;Wc zqkA903j#e01j{);>%-^IZw~e7+8?1i3_5YQSXYHkHNVG3EL%}5lxy&;c08BNmLyvF zH2dxwp@$9=trrx)EX1~r867R##*Dd0<)I|$*xVIzRV4=xX5vCy%)HBlfp#}L|AH=q zE}=LLLtr=*9tnxnt5!YyCa4;2Soap1QB<#*Wtz_JyZamxJXe-0%$|KdDVVRTuT>HBp1q7a4Jpdx z!4ru9SKxwkN9%FhFTHeQ<9~SpepdG=ehNf^TEdp~t)?bWy(d(aAijp?l+kLUdmx~= zy1B7mAQe2|*!p?R4`NF#Ua){$O*Nfi&lhU`Lyy~D%9@tEP; zO?PiUcfVZ7vQ5EkGS-vZ#RBmB+L;pu?&*uF?(8p62mq6Do|uU~!CGeW!pOej>ML#@ z<{$~mHchZt7$ctl{Q2aWGtWv$4jPn#8AI@;kfLvC>COnf{nYOmrMLo4dpLH$U)(z$ zXO`K4Y05bUhMi}U&{`F1k_FsR@Q>H&dia!xck`j zd2F|ykek3!dCxZP{FOYg4t@&$GPA$d*(@CKjMHC;kFTw$7+vbu*e*m6WXN#CAfpb5 zKbtm1I8Eoe+S{q%w@Zu%+`1nzTygT`Q)lhGB(87Cmp2(W-{%qoCOh|E{t^a>jF%Pc zW>BkeSdrVk2FQAP*FXQ!U2WajlK}xL)CO+u)3yne7~OvDua+Q_HEWVy_ZK`_ccR-D zn3|+dC;f^;-?ALZsc)Y?c;}#bBQ>eL_yd-R-2}giipVz9It*0G$e1HriD&>xCpmBq zlRfB_1LJGLA{Qh@dSU`4A@S0s=BVEu)mBzjX{&O_iQKB!1qA|d;lc4-j|~8=5&nPy z=OPk_@#BBE#AgBEyQ+J{XF1Cx@A` zLE$fUE$us;8rH9=V?QK)(4?vg2zX?*KnzF3#pydQn^5!3>Kp=J?j}vw<{$iITmZa$ z{D5hLyMeOsQbirF9(XTF-mYjRePw~nn0HQzkM2$OXr2GG^r$HEugLK5X*O@CGGYdV zBX=s-T>RAjdz~F3P6jYrJ5{`e=_4dC5IB4wCjgDIDI#u&M$d}DlBed5y5Rp;^1oiV zOv_)t--V$f*Rz}_Y)h@fIlzjcW0kNpqTX}FYjlHrC)P}k1D@xDz z@9s=H(%iy^{ykTM8yPb6aHP`z5MYt?>o>xAk&36O7kuld`=&BUmfFX8!%l$z{?3oY zcN6>uF&r>swKQcb84z#@;)-E(cjQ3gSJ=7kjGYUmlltq`W$Nwbd-t5X)?`(#Hqaq;R_^JFYPMCZ`N&D8%q}5TRzLo&{;qHZqM@OZqGB!Kfv~2t{hO=r z_sv>+>-KFbDGr^(86-wiM*-Rle1wk~Uv;%>{_;n4%gnIQp)bB7QonycccXP;{?DUL zB~n_rTt+FG(3k8exN2OHU%!lVo6>`hA+2mJp-^RY=*M!J0kX{OTU@>4LAIv!4#179 zqwj+reE!)}R&;p{b+v+7L$CXR+&_?Y1tG_T-0epn(yLPz1zlLFLCu$3_)B0eq_z>a zO&-9MHP@gPO)SPF{qVlrA#h)M#$6mgC@{%Dw6_8GN{yyRRajgYA z<;9Eb^n6LhE_kp+jrZ2(%HHJDwVyI-5qZ$T+RAcro3K+*IJngbV=U>0&0rxNajsMG zbDavL@s#*CHef>Zzq0{=o2s?WvAe)4oV}cD*y}f-CrqfmUo%dq z5lIJp3cOpPBJoXdE<;dcY;ktq`5(5?Jw&wU40^#2N@`)27^AU)q3Z@7U1%Yryqm88 znQC#21TQ|xEdwV{mcM^5K^!wjATJ3bBiE~*#1|j_0#^33c?;T%Nc`)aUfR3^*P`;~ zrvs3qK%Fdl>;<`2{W!w@J8d>>o7#@|`=BHc0OV43Q(Aze?z!s)2)9qXX5*^upY$-9hR1@>)zPHnO_ceZV3AP|$oRfTw;-~YZ)L{zUMMpgmD||PWM%)x%J$?*U{H|w&}j?i&mTib z8tJXf!`CKf!?_WhIs>5T=?D+9-Z#$CI1YkH~SoN=YZsF*3w34HaC|Q z3?yg${ioZN&eqe*X2Wn8y5Ep-DyqD1`_FS^j%eYlipdh)uG97k_=$M}kDlemjbr+^ zkoQ#x+XMlt60{!Gf}5b3b>{9At;1YuB2Y|0*hmjyR`K|7ZpOB%qlXV4MiZtpBE14c zNiUoKk<2YUnAt{VtyZsAMfO9ig02Mv@ZB<<8L2g4++)H(-*WU+=a{~RkbdyT@CzuqrUAN?QNlZ_g7ZpR>i@WwOWP=D82$w z$BO5tn(v1N{n?l(&jXbaViKA|nj)t++M&HI&h6vxC^fx$b-K(%7E4J9kkE>kD$Ob> zNdpqt_jqgLfJ}+(%794~ zR8tUJ%z63LMws&JHaB2?Cr|;!P{vg2P5q{8QP?Z5!y5- zv<%-R;}$f6I0h*68BTShm6cy%JiX`HlVygo{M<@m@?=4u5Hn79-UE(QceG*1gw6t$ za~*^ym&XzkiCjqZyGQxGzq*`Q2yu9(7;@w5aw%=x<;%b5by4ycyN&_QDc5mr#rjP5 z;0G35cI52h0ukmTJq!EE2V3VU0)Fq-ar&pI_n-SYNjGoWL^dHR0=gfK4#8nufU=$@ zJiPA0yaQsgd|-kpgsByVufV9TuCrycnA=>qFtV@X)hk!N(0!uJb-7&Hfdrzbf5&lO z=Bnk(G3wG)t(&rXlmuDm6)312pcTXaLD*Dt-Z(Hm=QE;5E4vFEg5+y^BUO05Zk=By zsPjD;r6Ow!=Gqb5$lK-({ie6v$jIRQ%z0mTh#DH;A$!U~*H(=5;9ThZ7PEgp95)N4 zcxybI@)%QNK^|jUfx24)?w3nw35H>A&`#>#>tL!~*@zii2P8(MrJa|c`4s8^=_fr8 zYH2-wAa9!-+Vv=*5AWX;ljUc8qK9=KHkjVZ+T6UH7HmMTEklnn4PZNqUdb}`j~a(J1omYCt{)wPJLShZQ4(h?G+3M{f4)%Dnq>*toT zLZ!6q_w2zAu8{B)b5_QMl|m$dvT4(|LZh@mLLgR4MU@2?R7{VYS;3fN>I;}*)+Vo( z3bWPH@}XrSTMKWDoO27ma*M_ZLO(ARK92XxJC;_E2%q0YEp*$I7Trl%WuOWh7i}~z z3P6afik?Au8F3*z&-9;#W{fO#COdwfXBg5$`jMIM?%xJ}T)x3@>VBs`PtAXDA$*99 z#j&#z<0JPkzG7IMaIt3D3`42ovU>Sj^p0JS9D9-1XcHT7Wu;-=5wV6jzs>v0^KrtY zulF1dMZ=LDT_iD}%w5wXA*VI%(VWN4E@e%#pdV+__HmUkW5=Lq$PIjj+-8G+bZzIv zx%Q7zuU!Mn<;JH>QmZ`Nv*E$pGOIe82`qNpVAoicw-K-(j*~=5Q`3W5(6W_@g9frI zfBtk~2%UNZfD&dC3x;x+?4bkugbh7Qyi%A&;D$4@j^+mq*^Y}_pQX_8fu(hN$!4_h zO;T=qS&oYq9qQP)b7umnvf~HPhpvoytronUI?ev$?O{;VUAmNfCoZMJ1~Y{O%ZAOL zKYuPG`S#T%LuRU=><3M%he+SU!&wB3(D5VAoADl~okz;t1*WXpS=Q=L5bx(%V#1?2 z-8FJxew)@}Jo(mKu@|-s*FBfYU&TCzsBZoIJC;EXTW+_j`1A=&5ss{1p+uBYSq{Y% zUwQq7rd7DpL0ZqgTDV&We-$+mt#CrGhNIOfoDTk=bp2@JtNhEKhY0z3PU55Hs37DCF_>rC3O~xjvTeQYn0Ab%nr;+TA5c%>C4r0JHD;|Ns7CqIInl2@Q?J<9X0zWn@e| zy~H^WX;}!s+$vLI6!?ex9}Pwjla&Ex%6axoSg3jOk;`Y{&IS(B+`;!Tx&PHT zF0=5cvZ}{&#}@u1-PuaNc~#tms5Fs{bxQ7XQj&F;nEt)ue$dvVh88Lggp zn&S(keUo`*cu&-4++$4Ka&@^77tA=r(1Vk})BntOWL(PaB>jZjH&~YD_>rl|esb6# zsc& z9_#>7FbVS(8pV|hBKGXuN#wfA=U_)6|Ile%<@@)(=gz5FWQn;w@s$U6MtRv9etZGwt*7E&Q1m%EWS$9-`FNW9?+z$c}p8Q5p&Qply<^tE4Ho;6mc zZ65>ow%lYVvU=zbp=}&&@Wivm8ZTZhH|))g{MiZ zpe;Z?0fh)Nf_i|y94sNR!M<|P%I&L=WRPc`XJ=#Ek_LK8OScuQwX-cv<8COXg~zF= zr0A{HG658`kdbYQDIRE1fAt>3O}2~xatnr=3|>a$hK}}D7@x`5bO^pD8i9qM?$*kW zEL?TRhm&WHcOrwrf^7|n3rW96(C`n5K+xd9_{ zJ&!1Ot!`h8jkQGwMI{Yj@VU6GLj_AhlHeGDP+~aAW)1U~FTu+Fe`c*7F~_wzU-fVZ z`Jqe$qjbDV*mGfAJGYg1%jv)u!3jy#265tcw7kg_t|f(mBanO%9}`BDu{yo_^$Veu zl+!^!Yvm)J;(+ag5nUlI?C{~Kvu%z}RDH%(u#^BlIovpoH~306(xd*Hr3RvVWpB^GxWhaZaP7rLwv+wiexda zQBPmrVaE=E&B$?YoZddaotX;CrW(zWnwm2|GtB$87-1qg_Tq|(h~6fKhIKdeCtxgM z%c?#v0Eq}!573S+wCi2h{XNWjR4WAf3#|7bXih6z^nWBjkuapZjrMlVry(<$Bun)X?PBpKydzjt47ZHNtVIrPvWlY{!)s| zxi{IzL>KD8x_WB&#l9 z3or2-Fl5O1IY+{(7z0&_-AfyI@$Nf?Dv>U1uv3x(27f5_6f5KW6&DlJUb=K0C4}~0lcNKyW#eDJA!jz-jWy)HG_^25(8hSqBUN{w-<@x=zEeh7==X*=;oZ{A-%B2zZ zhmXI1e!d$v-`wv@qcv=#6waysJiXdf>02-JEnBKTe3))qr0n*D<$PTJYFn5$BU*6n zU?TKjHVEx~N1czy;e^ABxcnqR$ShzWKq@L;a^uV5Bl^)~ z?K+hds7SwL^O@*nS+{>}NqHTvGaYN-Xtj0R2C;WtkC0-$4nPlPD6d0@xEsZ}BFh^j z0gzoZL(ghDkE`msqfnoV2J1ZIEtPjW_sBEU%B-@g`2M~0+p4@s(^vg#x1+l=9=CKG zGi1Xwc5OHOXi4Yh9k&>@;f&fL;>Wm~8-CNHqwU+b^Cq$PyLIc)Q)j`v72g`?1Y7^6 z;|E~hDop?BLxwFmW|FZe3Og9GGr zuKE(5woGZv=@96A%;b3)tm?@-O*!~1%?3N|-3tgxY3ds}A>sL+#Em^^o50bG%M!jx0uE2CjFbFd6isyzV*ImmK3Id%@q-#U!lZi!Fe*mi$T z|4c@O@CqpHOV{H9+Pp*jsYT@(up|6W1J3})g`zJQ(cT*$_HMxAp{L#g`pBqzINVQ8 zBRe7>Cm*$3xTNj3KbAhyF=6|?eT!G&tiz|f&jz=Q)iFu3J}IXI#fE-W^N)*zsa0Yr z#>fXjljd;Vh)$!TFnVn4;?l}s5%d+bpqnu5^zc|AqlnegD;2IxZi&KIhiyscRF(ZL zrX`p8MG!+mvHKVLJ9%Q(oQT;iO8b;Xa}wQM}&nZ#Uv;5NuriXA{( zreL`%EN_=(PF22vCQ4sjQ&U-r?b@!G#G_tbkJj3yCj&Wnw`X{P(b1{pRN%QWGF;~v z5s}3}FP#M5@RmoHkwfuCf;DgK{TWrgD~)AspJmsuH}qq&2YfiphCrU8hu79BM&%4X z`E77!^FeMEnm6z6Zuc2s?w`6>FH>7~<`@E78)^4zQf!PDNZr@N5q?h7Bg0&W&CAsc z(>Fgo#jOlE%mUuK7k=&94&2L};q)lehb5JUKjq+*n3NjK@lS_C&j~ny$0ICGNF0gw zE*FL%2n|^5{2if-r%50%64>U!Eop~>zI^>^MfyTZXVPib1;+&vKgL^vMDJP~k+7w% z*OzPs6Cu?ZVo_H1?xRQJ$T5p~UGVA^QrVHXOcE1HaN$;q=0K%U_WOv5EgL_8%hv$} z2Ab2;*Zi=;5J4Ia7{bDN?ikKNkNWfD=|~bti|o}4^D=?zu%7@oixDB59(j+-_fFh< zt2>Px`^Np=Ju#UDJ$kkK$w37Zp1+mz9qyYuSYl#h(|3J*N@gG1VY8E+75&Y!@ku7O zCQ?iZLCCgi#zSNr-Sf`G(AWNO#Ir^DfEpXLqI@wHq(HMv36o0 zEx~qk^ot~DTfq8`Wp1k6i2==p3s`Qc@b7 z5jcgc3AOiWi^xpXG`hTk4-DIyc)xxQ{G>Z}EW5md+J%uQdy1*t2pfRGf`tnk2=QR3 zf$`hw`5qWUJp4IRg&QljrJPu^Mbwi!3phN4i(jRsuX3rev^1X{Zmd9#5hjPjy$=+5 zwp0(R;^;Z|}C`BE|rR#*G`dDuayjM&*m)M7;@XtwMMY{|9CzZt(*lF^Ywv_m(NRMgN|!Xz ztkqG%V5^+mkQ24r4L9@qb1h8^aupXHrnkn=Ll)fXuw@JZu}n CqFsdm literal 0 HcmV?d00001 diff --git a/docs/images/command-palette.png b/docs/images/command-palette.png new file mode 100644 index 0000000000000000000000000000000000000000..9b2e26a73f2f4e092371c6a7c06f9512069661c1 GIT binary patch literal 41367 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 literal 0 HcmV?d00001 diff --git a/docs/images/editor-dark.png b/docs/images/editor-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..364f87d10acadb8c15cc1008dff15fa4faafed35 GIT binary patch literal 35716 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 literal 0 HcmV?d00001 diff --git a/docs/images/editor-light.png b/docs/images/editor-light.png new file mode 100644 index 0000000000000000000000000000000000000000..4550ee0eb6b2b9c448338134144f7cdd8248ae56 GIT binary patch literal 34750 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 literal 0 HcmV?d00001 diff --git a/docs/images/note-list.png b/docs/images/note-list.png new file mode 100644 index 0000000000000000000000000000000000000000..f76ebb79c208cbc9bc82d709fb7b03a56e6f07a6 GIT binary patch literal 36448 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 V4_K50>TxwXl?xh5>E~|T{~u6Fpt=A6 literal 0 HcmV?d00001 diff --git a/docs/images/onboarding.png b/docs/images/onboarding.png new file mode 100644 index 0000000000000000000000000000000000000000..41d73de9de8c22257ca4ac92b8ef19dc93de1d3b GIT binary patch literal 34928 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 literal 0 HcmV?d00001 diff --git a/docs/images/settings-general.png b/docs/images/settings-general.png new file mode 100644 index 0000000000000000000000000000000000000000..de1911e5fe3b9a2538e6b882b716c254ee29b5ef GIT binary patch literal 38259 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 Zy01rTr@4}0>_bCy;`k}$SI5k5{|^IDt+N0C literal 0 HcmV?d00001 diff --git a/docs/images/workspace-selector.png b/docs/images/workspace-selector.png new file mode 100644 index 0000000000000000000000000000000000000000..3fe4462cc6d354d82f68bd62ea04af6320f42218 GIT binary patch literal 48320 zcmd?RbySt_8a9ZDB8Y_`si>3!ib#h@2@(A+lhav|0Sx0|Ji%zy0iq*HsL=}h4Dc|L??-4B*au7g-vuj2;DT<;G6Nd z_9|vm`;?Z?aV?X^nC42kj6|t8ow!$TOlr8BVoVg@smcbQr}%k9lIgnA8M8oPv#;7_ z%X!Ivm)9I!d*=muZ8%Hq4%mxK7eA_73bTo@nHmY3KhOB(%NHtqJpNpHy|6U%lKD4mw->Z{@Cr63@y}D8<)e|>xLZmP&xQPD`L zSU)w_a{g&E{N~MZJG;eL>43RH{%?M@hE9(jC28j>U%!4`OiWBt(qL(#y?A5p zx~^`-m4hMC(a`|`0omESw9bT&eLNkjXMcRylJC=}Piksv60+9TUkeI08luHlwfcRI zGAHX5`|GzQD8<^^*=cENe*OCOY6q?0!~4~5*n?LuadG8oX79Fr@$;UhhQ^sQXKqt{ zjERZ)^y!wmdPrbk;QRM~@n>@KOG-*_?OnbYvao&sKC@`{qo|-@ep;GURWPfBtgI}_ zYA=V3tnAgp+s_VA_7u7N9T{0(nH{j7?lJGk*e^_ejDkYxxL6wNp{UMm3+6LtDyynI zpFWk4mhLTf6XoXSK6Ofey!jL9-qN}{|I+>&#}6Mq{6wX}>Dck(TvRWXJ`N8KI`>Wx zK00cecvD73M%?RQT%$4JV&?{b2CZsosj43H)Ze>z@A2cuC1_b$SsgZ3toH1Awzaul z@#DvlOOgCGo>H-!OPv-}p1FB>zpgD!c1qu}F*8d{Ntv#Cea_C#j)H`?xkVvv7*c{)H}!`OIqtSLT(T`S9N zduwB3!)bXcoZseSUfx>0==PcVjrHl`{QP`hU*GyjQMWSh!zWIhz~cRFO{vJ+VbE6{ z!g)P}1ADo(u~Gj5m6erMYl@ztoZQ0v{L!OFON-)`>Pm37xwyDkjQZUos3j$F-VZW} zB&DQ?+D~<5WMn*j`gHMcYj3{2MNfglAiv*>7gg2O7fJ74pyJdm7+u-d+}gUnvO+8L z=!Uv_Yh~r==K3-|1wU+MW22RNdxya=-r*>-0^eWYHYBLxG)HtxcEUW-?E*P^1L=mZ}|N#8)pz-3N%=iC4N z{Y4{%`P{j4r%wk(M!K!e4HY^qAEdhQ_^s{s)~4Ims@YNnZe8}go7%38tI#L&ct&wTg>Rm=mP9L@CwzlANZDV8gl7J^J=F|~U zm$iGhZ$BQ$NlZK!*w)<4?J#$bwjxD4_v)FCadCb$yay>fe)sq(#CLXfT6AVjE{rwl z6}w9Mh=_=EW|^mFWhuzYj*pQN%6}B`jLO~qiT3m-PoB8Cx-v2{N(a!BZRp|FsHmz+ zxY@L(t*Za{@ne3tR-8-*=gAeX$QeXtl0Gz^MN!Uv(4F^ivLnZ8z`G1z`1$kaDCWZ> zBQ~60E&EGur5hgJ=FlzBkd=L2e#d}tH+JmULFLJ=k$M|-bFk%AG5?HT`JHeP7wWHbGSlHRkgXNM^{d6qA6a{#Ka_+|8;P1gRjHxMZvOu%d1zfdP{I}rg(aK zVi&&@EoWh2iAvdl3t&7l@aGR38{0htgKUdlg)^omCSPu)@BMqglk|jj-?t}+PfHCB z4e|5yU!)>sU|?AAcUxbYbmq+^wD~KMKNK5Ml9Q7Y6FZCD+!lW8ExX-RSN}UU7F~=_ zU(uMK?93*o7ebYo!KvL|9f`zh;*?}%X3ERRd|$b2J$MbLSVg7j_wV1Rn}7cNK_|n? z;)-Ks$Gd!M!uW$WQoOw@(F<8F#ee)5GtLlfKt@KE_5i=~CMf81d{n|3PHY>m=ewC0 zLkjd>)Nhq|h0EOBww9JG?Ce9i-k(0PuIky2zkl~mz*wQ<#Gt7V%Ux<}Y9Vs>w6u$7 zpRHR)>*}%om3ZwhAt52+vL-P>Cyq#*H}K`lHmqlau+yucpu2bO=oUII4%faX zHvgTX=QcMevt^or!o2mT+!r;sYtw0N&-FqPohk`i&bWx6iS% z`fT;JU&(q;d`eiDj&3!XKDngiDrsp+X(=EC%cbS%-pHT#uw7$qmgVaw?ETjR=!G@) z^aAwTJjrO76vEn?LJ6njO7H$Pe%gb+}+?v z2Nqe0rnx@W!$Vv{<1McJuY9V!@|cjEb;8=3&FUM z17u`p{jcEXKYkqk_GFinfC}O6jh+@OE-v22M|iv4R8_513E|LrzUBS=x$@1MldfBy z2k9IKD-WY5q6Y~-nt2=$wT*Hi$7D0rl}k@gKS-}RF)@)Bbn%#)vSa_53jgir`Q$gQ z+Spt=yT?31kH*^3+yv&zO1KV1%*02x6Nl;uR}tnf0@!yQ{TCJ*T&j9 z>C2Z~(Zj}OW=Ah`=doS9c(^nb?;aGZFA%P)qT*p!>V5e1*Yxz<#9Yc_$8IQlcSoz9 zWjlAy%wni3QSwsIIh9if&GRBQA7;}qF!cWV_0@URy6Vi~!-1?SAIr+iXYG%#RFlT( zi#riV7nVL_TRmA-g}qT8{&QodztkZ)XuW1h0fa(VSGTdTF@e4qcM!J*Nc7|sfi7%S z=WVEI0AH!At0&dII(yw)tpM!~XtAt#FeE8l>DDb11B0Qyz8RnhOGQb#tSI9b`Sb^u z&*^g=`>UdBiS@eM?a}kF*md(=Sfyclu2U22uh((z)| zY`;LdkB?8VHO2Aco{NqNy&A`6r@L~GPFDAQ_n4fRkn*F(p0b_&Q$BsEiiWV=94SN+ z-LZXNFo;HYPXOAfsHoW4+OE`jy?XU4M$-32NML=0@M*?$EXL!a?}NAE=?EWVN}rxTL15u8tPU|EJg8eJ5%;X=zPuZJNc;t}f^11SM?h$&Spiu`vlp zaq&_hHl_QuF2{=k2>?c~2GFc1E>2QMhi5(zKLl5dEx&aP2;BbENDZ;vL zFgs3zV4Skw4tf7x3nh%Le`d1e*#QFs17Pg;Jm#-nzn1ji#n+DZ=R*C=F>B?hVni>B zR|v1Ft!-&-Rlj-D{J{g1EH-v_KW}d;QCFdNMu1CNS@&}t7flTe;((gKneN>itbNbx z;qFc^^vDtvFMvVxK~F($qFp%H2+rg9^mH*G0f@-YpFiW`<6D3K?r3iZ_IoiW;4tvR zZ)s@>7(@R0bsAAuXGzI&T+qXbHmj`-2ktwR217u*%d@jSFJ3e^Hkue4?;|I-xPKp1 z>w~R7tL3h}q&Y6@S#fa&j*e^CEta$lmrYf%3Q9_cKc9629l^)i*=3?Vq6d~0C&Zoi zDm@iom5&RMb$f?;Aj$6Ib&&4eua7`Pz^a3~#jjttphg?jynQl06BQtd-y0kkMgwk5 z*69SU!ExNVbEmDXEj0}deh0@VE-6V)^}0Cw4%hX`Y@uBW@Yg>WbD|I3m3S|%q*>?_1>vq z-$D_P?|P!zm+@gW!fl<=+rJhR6!eo!AODSeiTjryL~-j@dsNg(J-uGADD*I+d-qVU z57O}5Ut1W%`k_N#TxJA=>+D>|7Qlwa763iKzf@LwYwsy9D;q=&=iuNdDk}2WOZKa& z$!TRK8hiHn^Qwvf27qi~A))i^?5oCokH&tT1!DIiXDD)6SJTvVL>0ha@Vmng`tq#? zq)1DrdJ5M^8&9J}SPlHZeXhZu_V&>4w=J{9=>%+zuU)GQy<~(2qo$&A32+Cr)Mb5X zw)>Rwh;|zt}wz}He@iA4s zy>5VWK;+;mgd4+ce8`~=h3_CW_vqT<-_X#|XV0FY>*D<{8P=qxr{7I)2in}Qqg$u* z_V!NF%1$mMtm7at`@bV+?``W=sde%ScJ$QL6dIc-Fss}AH#K$j*u0fnw{C5(o#8=0 zN=iC!Z*M;}n@jjDA|kPwZoz-j3!xhR->606y8r#oQ`&;D|4$^O|0SA~PtC@c%=meL zi@UlJqhqv$Ylu46C;xvba|IS}tyPzo?g_`tXzCDhS<8r@ z|8=gctSoBR5hxeE)L;hSa>;RV6jW5+`w5LU=Cq2I)_OQET9HC*Bf4|({KM_jd3wWw zr%#@I{G*z1%yT_~R6ZpnfI?rOdQoSwygEOE4u>+P0`{q@8pdt%>p8_adQsOG`@D+2Wlm z$p`)X{B#@z#)Z+S9!_=1Y@7p|WoH*Qtf8W#3x9o19lY{R;g#uvdaq~C&JxtphXX&z zw6r?KJoYZy`d?xAeci+LSeKcpX^b0Scl-S;T+kJ@h&OMt0HWvStQnux8d7HL#~)JC zg~i38+`e!@d)LjyR>)ycOH@ws?{JQ78l&NT;Jm)X&OdAOest8BnK{0>Srs~&7@aj-;5NIwJh9Dlr;BtYrp?6gyRqj=7|%bd3gdr&=7BI4FkKn zw2#d~AKIR)=IrR`K=)6;RkN_Xg>1)U%M!_17sfv@IQVqYh4x^4a`Kc54LNzhWwYO5 z=ryVtUWxPzb?XJMUL9Gx3uLf!Luq}BN&X!gHO1k>7a`_F)K$BwbCRuzI&7hiwqf&fYilbcXKW-C{kQMlLBOD@usZI0Q$u5Ayd@E2CPlX}uh0N`U4G<_lZSYC zcv${Gn@dYgr4oFY^!9BVegtq(TwEMDWRH_gSEd=I{{UW}Q%gG3XZlPTXocxmpX2s# z0O6gxc9oWv0s;%HDll_!9Ne@*#}yV9UdT>LLa)19Y@qPuTTX5+TDTQo815v5U3>dw z;8zQa>A=&{Kw+sy#toP@MgAyQ;>zFzd`7`mx;x*?fw`H{niO)PFL^ph{-fZIX+k8mN0(!fmg+>X?3-+o2K*vJNp-lH>@*c z{e1or4XUcD*fOn2x6&WY{^{!Kf~wis)^<%+cB!v%I&*oo-8{8O&RBQvqFrIyDGMnn zDTv#7d5*=d+uNaokTnJ=x4Q%1y?d_z7GJ)eckRfLBhW~MDDS0d?Af!YyPoJ!vrO5A znqR+OjgVr4JRayejpO|}G4WYnU+H3xpcNnO0`cr$qBU+vLaO91f8JwaW?r^^_$?cV zcn@(l@2KCh{M7OqG*_r!J3kTsFigKl`WHyU>AP@wvQECWjZJk`mG@rLQ>RV^*B$lV zTM=A1v$*K8xn>8c#l@u%H__d_1b~XyqNk>N**!Pc;p$a)?2b>LOl)lzplPDmC4T;l zpJP&tC@^Vax_kF-M7z_{L});OqO~HE%dk%{R?Ny*NNStzn4B_-$7lO-f2!LhE%$jFEBS%JR@w={ps%{{Ss zik-bXj)3(v&cm_EMs1jzpU1xb;ZKK?p69auaBBPt2tb1F3E&<{C*h!|@ht4n9I#lF z7hm7&%F4>}@_wjlPo8{-@c_77Ghd*WWBDB)Tv1Wc*(u1(?DhS2AD;+74!)%y_2uUy zS?}JxbNBFI5OIDBBBG>(uOV#prwmmH)pS0Iva`8)aBS=ZEo~TpcSFMk=M~G-r(*#K zWv*Sj-L0mr4W+FAD-XB?%WO8!U^`X=>nc?Bb=1rb9+9taHM#^yxEJY(nE3c?{Ng8- zc#?a)`A-A>ZemYEiyJ(lmZIwd()sJxFMx`3OiX)q-rue8FMK#joyK<1YkqF18ug!< ziOF?q132~C_3Pl2oSGTNrJiJyHx&|-k_v1_>U=3VLBybv-Me=Wa?r=*WWseaxNQWE zJnN0)+NMK8vxOYmHHN~-=Wi2VQC8Mf;1JaF@grHiW~G7)58!d`L)0$jW-y zma0zxwqL*gYHgi9>BXs6L`p*9h>e8$2*wG{49(7UZ7lxm^*4u>e-KU{dHMFnd_8zb zj(W*GE@t_6&24S?n-qFMm8wAa7L)`uBnYKdRkG0092{1mZYF7@UA-Z%x!So*AN~2W z?COisibaL^CN#hI@82&^wA-xA=tt13^?Na3g?{+aEJHw;afxKUXJiDEU>Y0@694(* zzEZKZfbB4funuKHZczWAqFgqtc?&7iJ{1O!AqB;25Ow@cU8HE_h|ZqZt8Hx>5oT9J ze;c==R%vT%W3Rvxf$#_i5@ZBISZ%u3D_Dn|oSdSf#b~8GmoH=IR8>}1{{H>&+v6Rh zM``)bjDAM(MXiX9+{+Z?bENU$tD)gxYqx$e#YLRD<*9ChB8D{s4<4=y&xd-k)Umw~ z&DHDSc)fvvmezT7&R}5~T&STo*xMF81WW95X#Q8+b!WFu$?~|OA`xNX!b|6TyuB0P zrH!kh%3sSh;O5~m%$S{-A+XHo=u^wexw*OEz&5tFXOvCN%wjv+Iy&h2EFTM9dSBa< zZ_k+<3fhdupdleqhJAS9JL|b~PLPMOpmMat$9b4ga%5IWeh@??l0XAj`^#yi+e?xru zIS+fq0M%h|74|I57fNbsLJ3IfG&MD4JbijqOHEd`sQ_Ml!Mu=2d^|pG%JW{{^{G~&}c0+o%fMWJ(caUpQ~TjqGPJiEX6 z5`pel`_V*DJ4XNhohMz(R~;_)h!*!!c*nhes31DJUvH$ozCOU)yYH?z%&63~v{MYp zU%ztMp6zIB+d$v%@`XFaz-M`qkMC`mhK|nWXL)gPkIc-=vSEVu$uTj10IP(Agc|-H zd7h9^)n~)@Ke7M`KYoCm=9qZ_^w8m!X&2*O^2f{HzTJoYy0$!BU$OPjrq}6^)ufH+ zvxi%bA+KLw=HhZBtZYSq!`{u+)zwk9DAm6H0g>L0j`4{J=d}fsKlw-3R_khO7nhbm z@|iTVQ8{8;8Rg~W>B4@aDcc_M{`>&7Cj5f_&OAA9%T>LhkrB${$2qjJ;4!`kdjtgp z`egHW4u>riUosk=LzDL!rXkvN!WG#4OV}84ppzo&wkb{mfpyzY(+4W6st&?got~Z^ z8cB7Mq};)5gjy!Ij5Gw6nYHTh7M8v9z!Ncv@~nTeyR<-ont(b6^EdpCxT?o}C%m zlgD>duSau(KK}j(JN`m%2%_Y7+Y*xH<>B#v@#4O*aYAzPK@yT*EiE^dm9GX}6&KG9 zu+-N-wfsOpP%!orL_~=@wzk}|w7d_x@V*fuE}B|e)weI8_5iK=Fog@)eO}B5*Irv& zOL&kF5~7YRS}}iA@D9-OhYx_bs>;f9Xoh$N88B7`=j-onvc<(kaCJWa>E7bvLIf|Lql<}J6$5&was zOmZvm*Bk<|uT{@rF?6)GA+Oy!uO=ho6B|1Kd)1OQJ1dK+zP{h`4X2)e7SxcXj12C* z9!ko})^H)>c)CUk6QS2zvEyq-QNZnZZ5_(Z%WE^851|i;9%w*Yo3`pLl%IF+sQm{} zQ?bLk+wDbW>96|@qRci1SjFBGSm8aX*RXKYKrlmI%x-?N9sc~8(*2)^|ycG><-U~yTX zB2uBYs9@YsWfi+~dw{JQ9TC{54&Q;hY^xp}R*~E@0y!Dpf;&%!VeLBvUvR!4oEAB+ z{wgcmU14?hY#gLoZlebeINr_Uh_gH^?T?C|!lAwM4A^co+oQDK(!&4y?WkA$E^7-* zUnR7sfr+6aFDxva=j1fHKn02duSq%xs*c>P=PXBH&q`RVfo?$qON@?w^85y`8}(Z` zCaY(-U$GQSXU^Q1N)Vbpg;m2w1(LaR>1a1S8mT?E$&VK&?(^g?hphH|%HF;C^k109 z4v-e@PT8M9)PEoc5HgCVe|%h4NHj?^lZ}zledP6z+J=Th6|;yE*cuuh_bm^98yNT` z3o`W4DWZK9vp6LxT;kd_M)eVAkHDhCCby!EylkchhudKbLexSmMnzLN(EqaS?Dk+JzU*hI2K+&;h`B8Y9&tKGa-Hkuk zr>yf%4i1OC;sosXGMp9I%?aNx7^*z~mGnIq!Yt$77+ zWtvjdYc}_W78VM}T^)gQ-00(3cZ(rY7tv?{+ z`N2WcD#K2%q0cwptV9XPO*|dz_{vs5n2qgum@AMZY3bgBw4;4}4WU0yp;kl9Iqj(r z9k}ZR0#AnyAHG%O%+D_jhcH|XU-HhecRi`oy((ao2u(bA@Bn--J$;^BbmNAS(nZU@ zE3#iyMUw5|3aK{3DDh?!zow@48+9=E@6ycoiVCk zgpWQI$+JD)MnP3SgP3DAzQpHg z<4SX3-^1U!v1`9TxGT~-@LmA14ljBIQ-ugA1&WSCh6r^^v8c1td*3MG?gBik8&MtKzF4F zKaHLSxJ-+QZf{(26E=-q-!~HXaOJ;%MceA_&Gq0_-LpbMw0nkxocNoO)3Lb6 zq7KiT(No`MxMs|GnFtoi72a>?0@jWC-=}s!V@lPaDE`wAnIBGqjQMWyZA5No@Xeh!I9Pr{k68E%JJnLw7 zQnNA2bj`uRVK9o}rVMk*jbkMwXGTZbwO_J$4d#ZQElLB+rjiDrl7C-liGqumioCQjzRlmgFkx_1avXN}z^5*6z|@#?v^_X~Pq z0Z~x~1J4OGe~A(1dx!~2NRzp9nRjsGO<>!AMiCqo9-b{k4zc*b$#)MeIC!~_e)sHR zyWZ#JYb+*7Pm<@c`qLK#YtQAsXL_1uUJULVqBwYwkHLy0fk@H(4ewEF+A-U$z|~T^Jh~91%Gq_{@oG`LbbKE`MuW=abwRX45z6&~1xx3bEqINVrVf@*LiPK`_b6JWRyIcFfAILmt{W@mNMe;@*Sz zsi~?3jS_=GyC5Q-A(gL(837g#;868=Gcv8#wAeYkVZjyc5t63RV`}&#B(& z2M;HI5i~BLSdBjwo=$zK{^Xpi`;O}L*%u|stCA%ZGS1Q+yjCF)xr0zpXPq6Sj2}G6 zN>4w%W){a=Iy{)Y_mI=ZR;xv(&N-8RyI5}SpsyjN>0wVuR zlW*oBJZamNM#cZ&WO5aXL}wZMqVe zREa|30o-f^5e}&yj1+eA%+sEqn-h36(+5g~Bv+28%Zy7Qu-;rs@fOMc{i}adiW~Av zaIlZrq-bV-ejjR-A;w82xE1W>y6F?eKsI-jf}*_NG9xq7sP^4igiY5v9+;Z`8F-Q| z93H^fAk|6J^s!=^CcK`g*I=volltz>mX?;_Cp0V!DKs=RJ|_g4s%aRGb7&(g;)Lif zS_DqZ6zMV*C8hnss8Tb-q($+jl!LRhPWrrd>HzP^Yd`%2Nw(GL$n`sWNR%7{ zdg=CogCKWuwKx-$M&gZ2B2(llWfupFSnZu3{gi&I!2?|)$jb#zB`^emS6aj@ z58Ul_x>2Fjjm5rERt#+lMK37w5a>o6u_Fl`9UU)kHgGIlMu@@_6ZdIo-uLz0rlh2# zN+A&|7r%b}FUn20keWvwn!{fL3tg2KXH5c>)Sx0-Ol5NoA*mqJdhtO-O4fjQv9j^5hYGKqWt*R}cz?wFF2 z(m}6uh!hLwwo@9^!-4RCXL}q7Iufo3;6w&ke{r1Rb0hPqQ;oCFdS$Twdr7Hoz|Gcu zLV5pag~ey`*Q-5I>dj*W{wYJ#d`4AEi)K?xS=s$_(H>&rqpU(^X$N*64!3DlA3I`U zVWDbO($eyJ-9yLRoc*_XLg_SQ?FiwOxuogqT?f2*4}~)@=BK8<6bu9k%Ny?6b&XEg zG2>~7#lpMD7kqs}6%MN+kLcwX1M|1-BD_M-9s^G|GL)t4%)Mz}(eQQh=8kFcNxs## zW!(eajA~yr4J<4aSDCIPCwA-rqagI7Q>UPa?kp5OOWr=q;8ipSy8m#iy9aV!W61HC zDLZB?Omx=q1UgH^qCR()b_X)t0S*Uxiw%M1pwY?pY&;Rg2;&ICAPvq|u!UfJU zGAgU7p#t%X=}aed%955gP&9UQJa^xkDjt@0kjk_aj64UJx-g)nrsnBbCcWr?L+c~D zcyy!^FyEHuj#;wJBbnK!g;#d1bysb;sif>%>!k4wDeW(C+t!69FD^bHct1QrqB8x- zLC31;n)1h{QsZM|R&Mt{CnZTluIBB}bO3A{;-A}7KU`Uscz!OG?wZrzQ!k09PnK0w z#EuZ}c$l<6r|`$VVOFM}J_Nb9=UtRL3<7bwkBV)B=Qm7CkDL~=eWy9FDVMldjW?+`(Z2v2* zf~MO{FG)6aP)|Mfb`7n*NiUT3ES!mtws^ft-TTY_4F98-|dE)`X? zeSVHWa^1S!^b7Yk`b&vp@=hs^=AW^rYW?-=j)g_9Zg?0+sHmIB+6O!MJ5@$Gi26n+ z=X97IKYHZI)zAgtIcr7TE{G$t4h-M=Eup&K*lcESUD>f-Bv8p-b<^o^%d*w_lDTZx zmG~xPtUMT9Bgo=W-KAten78n1jM@*+R+}5XS zuH7WpM5JUqxvZzBsIWdOL0Xc_Ho9?a0g-Uqt{*;E<>k$hJQ@A{oQ>+dUQw74`+;4% zcBv)+u9S4!S&&aTkqaOt9~MUIRmyKYxNkMpe#BAQWeffLnWtxrUMWJphf);sOfcMm z{)8F?l0CB89=5(O_W8$A@$4s0b~w@`#K)s#1crwC6fL-JS0yBTv*DwSArpws$)R$| zJ#zBT(d=(-)N9(I)3*xGjn>!HoFU#!VBp^I@b@hxrRdn$bkmm4uuGt(iyEsIfYl4y zPi;UR8s&vh@UifXI7^=S{rg=RCMwR(&hqkP)ILGVL8QX=u#2|Qr-5uq_wFJiHa9f| zf?<2f+koZ;h%Q<90PYHhh)5B_X$tY8$YQsAz9}Tga%ERkly00wSB{dJ+K+o&P&&fG z!^!Ccnyr%IfS*5q9xln4tpIhi4R|=vc|xg4gz!EW6)eIf9-fDmmas}?uV1f*JX%vj zx^JHbq}I=D!bWwpIyyQ#9t7^o)i|$K@tO{XE-Wf+zJY-OBpabp6OwB8+qs(CMp4ST zeBreb5@UD`G<)v?6|`jN=joWJq2mVo^bndyDoHAUzV=SqjiHy=8Bp{QM#e~r=Rf82 zHil&qq)p4deIsh|M&!q{u+T+K?Rd~nB!uj&toUt54>6b*ZByjQ!RJEg4=y2kCUnuJ zrX~kh&hb~tQ%<~gbB@fX-5)Q-?ATqn7Z~Cr%pv%_9^WggtMj2zW7MN#-Ril&|8I!U z$o2v~v9e+|1pCnu?ShT@UuvA_uZhI&-?(?7Ik}i-Nl-rjZTKu4K|<^do`jMT^ryg} zAQdQDl9GGE73%BimKGPgbF5A~*2@~|>pu};h5SaCaq8_AMa+qrc?4z$a`EtRaZD!7 z2`NFAQRzbBtkx3+IXQgP9I^0iU?m~ak7Fhe5{=RW4GR5xrAblLE>(~Apw}a#=AKe^ zxAh(mM8@6yW$xU8H5Gp)*&bL}^?h$^E2Z61@6V}KL7PZuYtlMXPoF(IK|=!}1Wc`I zYyr8@;NW2RT2K{Y^o(|rj5d%)-;cs8GE z0hSl|3ntSL5_h&^%1`WvF;C+o01s+@7+4D&0j@9D2=+6tMTbjr&2o}%5X$wk z?w_cCw+rX-;e%3P6P&v6u`{ZDK>8B{10KnT1;Q03wjgOkrQ~2{=1n4?ByR>CVq&kA zl}8!-R`+Aq@x$%Lh0^ide;XFoMc@{sx4wBH*XHo(5on*Pnp%X3c<7V-*X(&V7{<7a z44fTJ6)A2P(%(!>Or>7r(x=}5x5oDCJ<`mxHIbLE#d${!3+s3puiV2M{Y#Qom%D@I z-G`VME@&6Fj{tqK0=?lb)3@;5mgeSU<>Z9y#)F~wS@q=G6yp5+=`Zyf4N;Pk`dH{H zx|&}<-I=Wjksh!R5-C*CL0$Ht{j_^FN5h#MG+x1yM1!%i{{{6N>3_w+=cC%iIXN1b z5roUZqK+?{kNx(W4Cff$J;bdm>(@UJG6u*32bZ+VgTzC|=}pxa$Do7{PuYiF*Yz(R zzKNFzX4! z>wnkjeJ6MIYDstZCMGSgv$1EO5n^M5bX>6;*oU)+e}(l7ixvS{b8~aL%cjksarP5! z(Ett!(MfRL327nY#u!N9OlQv`L`(i(2osVDFfq*~L((|$Gu*G#EiI8Iw81%)lzavo z4WHuU^AmQSfGayQ$nW99v&+41@Tpvog2jrUuWDyMp!+N^w~5F-oUaw=KiJ-I0Q9*+ z!MzyJMnA{|frCKqZ{DoL<&(Hn^?gc4fugt92}d2lwt~$M2d1LD91|&+|HI)zpd_~N z*G(0bf$?zx`zdYEh8Hhhz$l}wz);vjc(e0C8SvB~DZ#fzG$rWOD-b_E+BziRjk({! zT*oH(94`0r_3Oput}Vf+5P*$ zh_*Kx3z_lT-&9{N6cn&2getW(Zq(F?sT+(Z`W~@?;^pP#g|V?kq>A7_Ckz}sdNc@P zAjTn}z}gURLd3~MokKxDo*#V*ArCY^bS|L4d_<1veO4}#uCL7Abqc}nT$iLdHg<&I z&6iQ#JO*?W*%@V@+`WHI$>wKHP7XGyklWSt5@&fAfsy_TrV`O!{<+o7qHm( z?)~`T0|GJA z!VZ3pzeM#ShNnA)mh<#!Si~cflZZu}Ubv}-Ndi4RH=L-hfuFXK(b4*k9<5+lOc$YM zAe1|4d&%ixQOb!hIser%C?fW;{WQn8qKHcubMX*z-OzY^_w>F*7$SW9{P@SrRvL2N zW;eJVr&j&=h@$HEA2oWb0LrH|hTTj6W;Wjs4&8VpOg=B%vD0_G2 zQVYkVzL}j3t-{VLhwPm99~M2dGeI&bA;EZ>;n*JA{r``w@;|_wV042xovCmfwL4jeo>QK}AW4JM`J>*D*TxB|d%s z)9ht>LVi`T;rDOD^mYl0DcmG?>UoOC(^n)Up8mWiVq7Y02SE@3B)-wulH)1qzYDn1 z%EHEmNl`MY3t+}SOG@rEeU!J{EylnFYY!SMRG`t(QQ&;IYA}CLe=r?`v?3f7G?s$+ z`F*>|^Coux%eG=}e2)a-)nhj?vt3^M7j*vT(Dwhb+42LrSFRBGg}{fgwI)NyK}-#i zw%Zq|dQW-~5%mW~MEFl#0BxBZD0!K_ zDdN-q9{%8-#?R_13k&Q-V$LIYCj&7)QCztEWydAjyetX_qcF=bBr4qk%dDU zgw+Ssii{x4SM)6E6DQ(wFK>(dk1W8)j}MJDF?02+p@9mGG+}hR-G2XmAFw6GFg`*T zd!L_*GJfn;vWO&RykmqAJ^_XO_gzBfXJ&45g=$PgcA=y8%K})9sM|*rW!Cp+&*OMk z4T}=PbYAlgjEX*JNjslPy3RbytKTmmu@I;A=rS?A>r^n zL`0(a;3)Fln>YY0f6x*L)3?xSf2yEI?a`>v!N3iA$msNwUBq_^eKg-k)a@ba?Sg`A zkvaBE%ng#YqR|ebZ{#_Tc8pH8I3Y`gJR|aT7zT)qJ$?3%wD$!%qLaH}%0icU85-K& z`7R|@Bgx&}9q-il9U&cP{jjbrZHW{uXLmLb_bhlleH!(-5IOi@lI5$@(3%KfQbFW9+N0=ZV#8}u(pe6GC`&rr8K7aWFtkZ@e zo-avBNDl7F7H@-GjTsM2K)|d-FG|%fwWLKzb9=UeAzm)@YRzz8U*E)p(48`Gq~csR z9|iwFRpfF0(XDBfEH_i(J0RT*QzNMx5X#iJ!HV!2^3t+|s4GLcl2m&--xZ6mD;s28I zr^TZQGI9Qp9?_gQf#CH~8;s*%-sed{ z65<(n5&~2dTtLX1H)v!aVKHC6xcvRizEvf}u)O;AE!BYoz8Fp+d8d!n@%-D3*?KcuCph%oQtS(`XY*kvZa&N#k@$LbdDhSV$$ z1MV`8Ga?|tpD+Q4eMjY4i?4rVw)oL($&VkN3h_r-A>{3VB5~h!b%^rGL>&?*^dipJ z6j;sY5yk|B#JxN`hJ_?dTVZ1XupQ^rtttErj7cRWK2EduHKgWm$ zYUA7n77tAfto$@H48OB6wK&UIs!FT9jslQ)X5a=k3`6Ds%(j6ovB32H`)$OuaA3^7 zyYFeUUj{7&lm!RJLjr^lRs$fVtB;I~EJjsDu7nY0kMzGp8#I;502w!&Uda7;`huTd zEvmrJ#&G~9)a)nvZ{ac@o{b$AhDz={tzcs_kN1Ykh{J<9a2(sG`}h5q(OW%L46P1= z3nSwnlocUL98iAbr10}T$C!d-;z6XFa241;6_=cxwV8C-=L=D}TfuQK^&6<%$b>vL zMgT-aBAfydW`k{A=(ZiUs^cuVePMEm-9s-l?sgsEcVSwt^n^Pp||4`8R_c>w6+I3U0!_}HuS zZ~?M2JMoSU9a#^$sqttBoK`>=G~bsm58EqqCh)hVC;tLuLR@PuC2@fbv^loTXcQ0LIM36p%qu71k++3qzul)tDK z!U(%(X14tLb)>=y(fT`txszyjS>4Zhd79GF#1)X4?ITQrKlrYgxuC7U zk!GdAlT&cF@wf-X)g%}_zF*+tDzS1#>IWNka1|*6v#nN)o>A#fT zmoJ#x)X1?s#bymA(nT{qZt$yxtJej4qufe}on1aP5r9HU+8SpOG~gTu2WC-0D=~eH zNEzawJj;x>0xqC9#>=<_@vB!cQiJpeXtbe)MKL7Bstve97-~oNfzva3UPDU@k2*lt z-okyyLoNt|5sr&&t3|caq0{3oA_4;Ma$=D~gpr8@1!96W0Qwp$1AH(w?0`5_sZ|#o zRtz-aF&0Pxe7_ALEGIu7LRR#9W#m!nU$ATETwr93!Loe6eTJDCF{SVkMK(7|i;z0O z4i0-6qNtQnb}`YQpA*XW&Xx%_uC#%MRx^Zniks(W-uo9`DsR% zO#n;5_JbvFG{=4Vw2DgbdkQ7tnEwED1f;Sq&4Y@@hR1cy;!Tt$nP@A}Un&LNkFf6G zINK1tn|o_(i^TZuax?7cXjoFA;PSGFyXN>;z7x}g|eY#chVjunrjU>6u z4hWLTAYV6lKte`__44IR4>@QzfB1^<%nOc5P2`Rd?<7o5_FHQ1`GDZ3a9$1g^jt9D zV_ZT)CEdcv;9!F>H*E#vr|Y-0kmQ4T4*b;85_e2hgd-^V&8jZVg0z%Wj0+wrGcm{r zW-Q_R$iczR&SvzDuX{HFh)aflO+!ZpL=?+@11mV4W6@AoRsHa^T1R-=SeV5?uwYjhH|~rXoHab^ClB*r zyoeX=ylE9ps_~=WvLW69tQXyp5z(B_2?_g3Qz5q6v{ngY&U4N5g`?v5LEF9Uh*>HK zB0%!gl*Pf(QJE>4?gZ#zL793_v*J4SsVCg{X*zJ=Gl z4Gorn>X-mV^7ny7T}OweDm@(bk6*sXhvvcXRQ;Sw9`fv2ITGd&A@H0G7}hW|K;0XP zq30Z1p+_~tVZ(5EAdR|h6S|p_6En6~6&)9*l+}$;8Xu8dK z2ns?SP{#M{-hIf9xAQa`8$ugC`fmdQ$d%G4ZVJIZ-#`1PwD#M##~Z31hbALfCpQ`n znLyiF{iyg_r(+o(Vw(|i{#J?2UR4^t)2BV0H3Tl$j^0?f)P;y@DCD!JTPLYqj>zx1 z5unlK>ufZd28ipYo&M%L7uOLwV(g!(PY_L!>HT)$#YmqER0uU?WkRF|ddrtD?4J6f z@O_Y%PZe=*`vk4rN-_Mh*>U?nAdVME-wW8Wu~8h8*rg&nQ&E8e3U>@XyFr8PF=`IY z4DYZNh@1}|T*IBiCN8}Is~x|$Wi~m39II+8t=)xh*{CPkygvW#hfw{Sg~@KERl#Wh zldf{aG>(}+bFNX^fjQ^CTf?B)&pWp4w%Kp0{~xPfx1;aX-w)Wiv9|+%Jy8THcifg9 z6WUQ2AKf&D=zlPS615+6si&F&R>fI}yeL`_zpovxdy7Wr5Aah2QR&P{E2t(zTaEajZTF4=-bFg-Pd~TC9s_~#cajJ%G@XI|N0+OyGpi}%dq8BD(SPd4^g;Dm#;71yeWD(`7S31gN=?@J*)fc&oKlO6?833 zIzrL*-e~68=Z0GhPRn$Z<(VtnZlf2@xj){w@A+lwa~DZ|Q0G&5P?sk+dbe6%?m1%` z<8wLf!>AuSla!=|>VnkW@?@nKvKQM9c(+NW-i|$=KQTQ!Bs8_Ll&ih(s4%Hx?Z#$8Jr8AFi~4`0oT$2K;9zWLWgORw+pc1h&?9WlB2t3&pZL5i;_ z^QXY4bGO{jyt8;wJmcHy{EUR6M(g2=_w2Y$AJmd$YuXx4o6HPqy{By~lHYoOMXzWBU)~J^Pk3S>oYOPh+zZ|Iy_0y1&dQFmr(AJ0)y|(W z`#6-gxJP%MbAx|dJ2HXOxqIF%8C?G5@+lvkP;T!6_rC`Vem`#v4o4W1;N$rDv5)U^ zFozQmWj#~{JdEd4#f~oGp#fHidO}Sm7g;&k8c>N3E#8>!Tg5h)dO>v4>0ZQx z=NN9`nJ0D_6oEK~X9xmQ4-Ac$Id}dtGeiCb+8XqB!XUJRv-;MCZ~S(FpdLIKTicAB z&1%kK+ljW>FWEh>bdUa775NepGW*n%1OO9;r_k^=h2Po_QM!Vf&OwLX>GC&maYO6; zNL8bip%3v~23WLt{_UMEW(TJ8d>OXqHT=X1vJQ-Q zI&Gy0&S;+FVBeJWDuF;T(s7P5_eekC;a)M<<3@XX@oWt&-OyUoBz%O(-$rXD9a-j1 z1FU!)-+WPupP&4V8x6R1r6nb3aNz!B*SKsj#C7DHsZaD%P_$w=yifzLFNIJ*;RI(o zBa>%4_G;m?(0uMW(T^i;L#^=QX+@khUVT7>D-Y2XrUR30Y{o)Lm+a0wnXbjw@&DY|aAjcQnR#cCko0&P}aiz?%Cw}qL#p3EFM^@Ma z+J%lcU!55se;(rYesFtJcLUCiF-%;7{;PKorW)(sO19v$H%O*chW_`B#YHlZ=Sw+* zfR(jNtuMuqVAGVjyj>}ocaE)LcT1#KK%}V=JbPL_rg$BK(f`KYn}_whzWv@`EUaao z%akc8LXxRchC@B~2F;`i&Clzz*1hk& z_q~tf`QtgBKc4*@`#z4}y4RxN^Zi`cbzbNBKHu;6Ngx0G*|U=j_hq#IkS-1vkR`~l z8$9F#xJiZ$o7^?#I#xc0E1SO!86NMYtw}l6JA2X#Jc8)a`ACjecK&YZvCO!0&(Frj zTSkeotv6398znlZ7wi-y_c+gs*w=Vk4AnzXJxXkXFLnP*#7xBR(L2edAN9Y%?0 zoUnTv3~Kyk+Yr{_eIz09Hn^8x_oL`G|XSTWyo4pljYHf8g zVz{aU+dF#Bdc43+B5%BNuYOgm==RE5{u5)+U2(fJ`XMb{^Q4}Ry*2xa4TF9 zt@3=(`|XV9%E{?woRPCHRhzTomC>ON7x$04HanjArn&Aq0j=Cx;SvP_X~qe=*)Gjr zU5vLp*gJIm5-x6IXA_y=pNU>F)<_~!fQXFhccS?hD_-aRO)Sy~Evpht)khl9i5%wk z_I5JO(X;hrxnE8MyZO!90ubz9TS!XqI;RFfKE!Y^E&|Rp$~mH)tV*BvKYH9?Cu7BEFmM zJEC9cbOq#G;DrL{#Q z@v6>^$m87$6x+9N-;xT`>y1XV!>{5a@$VmmOK-Rz_-tzSCE*q$&7i@ zaQ*AXo%Q0qXWWzHPnAkN9=cv}dD!n1G%5DMP!R1gUAv!Qqs*{rwjhE*UpeBD{I@ zLKp9$OD?n{tm42zcX78Yb+ov!qZI>F%u=7z#@rl-@|W;5YxX|i1Ax@xQoG7W0hZ7?l`sG9#rFO)Lg&}vNGFJe z!LDfB{`~R7Z$~Q`1vmi@IV$4M}NwmzfN3Sjd5V4TS}=ki*$fDaLHgNAWzi3j>RjZ0)Bie zoT2E|pCA%E*8bv9b_zuIrvpd9ByAhFtWlD43|X-A z6+YvmrZ-BaISYzi;Q&wlW?C>HCq%tJ3?-;Iutd0Cd-or8G4nxZ(SdrrX-h!;;I-=1 zeww1N>tnNOcWWjZV>aqsr5CH;6rGuoZ0s@dSgRD#gB;Ao}K(eG`0dNfGcMP6ZLQ5e|GH}ZuM{Reo)O&mc0 zhtkL~$Yuv}sQj%TKJGigRWQ;4Z9yRC^(FZ?ieRG+ONJ*8J#R9l|9zQlbY4J>1y@Fs zMQe;dkmD#!&{i~id4}s-kWD_^BW~xgd-uh~2b{b{%{1=X)hJ*8(-fF0@b$Kov^Q_Q z!3kFIIBO8Dzk>shavsk5rVCF)|DG1?8d9Lbq2ZOE_S5Ew+@g$aU8_r*9_%jp^JQ^G^L{tYL38FL>Pw6t zAGzkC7zH%xuSiXu2`ix(GC#Xo&| z4WN`CKBxK~(Iv0n$mty%uF}n>+^*K|Zf5aL=zndPyFhQ--Fx>ss;+sf9W&fBa>n`s zLKcCGUp|g1m9ir!=-tMsn@J2_UH~0WZ5ZGo~EbQVne(j-&;1F<*uPu zWNKQackE`T+zsd+2Eh`0}sf5(aMM_($$=Pes!i6Wr=a|wIWEnFZ9Ye*; zEiGHg<1uUoUiJkS8++d;5d0hW_Ji z?>prmWBchV|8ced-{Bi)m2MvK-?#w(8$I^_dDQ=ZY`_1rj{j#J1^34P2cgoUvu1*R zbRHI;RW192w8Ju)0ASp?P2?Pj!~DiCIH2E0j0USEHgb+dc*zOGR-a$t^VEMgFYB(>|527<3 zc!hKr-pr}KXSmDU{Kl`p;DKSdExqp6`8da+9X@}EmS_y<+@IVvAtFo`?;NJr~>3pEirdptYLKiva)X`CHq9d=j$|<`vmh&_E z9DV|Z#NQVcUCtRKk}l~tqZ#9YU!>yC+(haUok4+t3S$O}^%Uv)jC8G9AU;6I0&pb3 zW7Ns1!fxil5MiDM+WM?E-CG7S?l!-P&Ts5Ju1&r6BW+!D3bgmR^84+s6n#%ZpMVv^ z1(KnOs@L`8pnbt6aguecrjE9-^shpZw*XbYX z*JAG7qbI*cM@MXGIbZgNo}617Zs(F3A^sDV*pr%V0&x5?rN8K)Pg&~nN7>76y0HXMf$1x2@|dTGG<*iq50oJDb1u_-=S7E^eJl zf=DDi$yl;^hUyWovAj89^;563*D<*A9T06Hdmdcs`|q@*zc)5oeY{uwsJqjy>AZ~MH*A1B_$sYS3IH=}@Q!FwdBfR< z01vtdDO)-Zet|8+1LCZ5r?0(n^4PJJOO`w)hlb#AvR?Z6`~eeGl$Dg)^GrObwJZ}I zzD!?#`va>k)E&>SO&q~aQ_vpCg8AHdx!xRxa6OuaM*)cG7Vy; zZqQrxskKGbj#HifL#JH;4WK}{udS_KvO{*>JRqlVXatmjh^WH1Jp4i3EI0{OU#BXm zNb4RjZlPT8&&Gy^tet`*3bncfX3Er4#4^5B=q><1Ud$8JVFL$J>N6oG8paU{bb1wl zDK>WjR==Nsv2OC)9B0*%v9c8#uN|8Nx9n5>H{9MhDcu2u92MWpbO)hm+@Bol2$iCA zs9oWM>jE(c>!uC!*J6SVpftK3Gn%4U+04L2cfmJ8PV)&HZm`7Sd8$ZKplQga<13Nz z@}=(St84t_D+cp}v1F7y*CdBroQ1!*!5!l|h%Bcj2t72uSRr~7B_a6UrN9RuP$#@h zLqL$gIOjwqZhg}8)QauKO%c&4@=;E7#mCIEy10Q;9Vos@PZv)AtaJyUow?Q+{G`Y7 zY{vH2__f>tr8hAUM4o7A)Cz!rreUtCJNgQ(fcY}HC@D^vOi$@<`uwEV7vr0gOETyi5~Z1RTbuPnuLB#0sV|O<;#H z3aRYq_YF1~7v~VYpJPY|!ncP=XyVbC`~10-qGB89@%oyombRkg0~6R_Sy}dYgK*;3 ztXlOU$>lDf0QO_RrRq5;k{Ue3>VGFP%|86BTV~6HdmnVzhuW8w|AW8=)t?|MyXC<< zOxalv!riO4PL$unrY2tZAa=ycKKfa7Kr;I7XiRu;_ASmrkL zD%o@nhP?OyR)$jnY>@vkcE{>XexP>I=Utn#R>sP50t!Qqv=eR6AgEbYm~d(8yPM>Kh3W%D98FCg^@$AW)O!+EyIR@a|XR-QiI2x+#giIvsWJ)<2q zH%b{qIqR?BGiN4hqViP#f2oA)O#^yl+R={UiDlM;J}&%56j;YBBW1kRnY?iT9`CuxBCyX^X}Uw38SRCTJ` zxXCX-!7}9hUq_F=imk&=xN*AyXpHlddp7ym2}#iDV`VWS$S5BQt5WL69v;7}5xb;@ z_9Z(|+c8a|=yJ?6fQsV{i(f^h+Jd?X{cJbYj70J3^v z`nu`fz0!%qg9fSW`xXcdgB^Xcy|cDuzx`AAb-!kT9)h5S%s!P?M619Ya<7s6{^p&) z$mC7--+d%WzM*k3>!}(WTe&F9?b~x$8x5altNJu3We}|-HQET#GiP>Z_KyR`7eW9R z6J^1Vd@)=BBsjC(9lO@DzswC8fhL58ZEEpBiZm? zH#zU8kYQA=bJ$Q`$X~z+M1DZ4^!&1wD_=l)9$`Fq!qoR(V>yNNIEMwfsyUB5Ymo8Q z&e|FbajjlA+(*-2w-+i8r~P(8vKnZU*3a`VTt?|-XGrISZV#6+iSaJRODsFm@A+mz z(Uu!)Mr-(hUxw}-GZfCm9Gd)uXmPOz4Mt(guLX%~UALjq9=7EwrWabbfHmJmY|shR z*UL_F*`wh@pYtAX6%V!R=I||qM_3bQhDteY+DekI&}F*UHAkKsmUVZ=++)&1GZpgk z4l-DWlv#h8ooU8B=lqK#E<=`w`1W0QJ+DE?zb|Z?>1zPP#7{CDj4p4F^p}BS6a;ry zKwF&AL09|Nlo-a_3jVqDG$f}^Me}()O>y(Ros;g*w|<`ieu^*w`u}%E!EL5{Bby$3 zb7FQYbynQcCjqgA8kV9fL7*GMujS}>|ysIbSminlATJS9KVJHab$!&xKGOQyNy zVYwRG+M^a!pwc87=VQ z(__@TDQwVBg@udRBYXyy{FStEOm2RDA}J)OGZ&5UVuovtsu|Epe~7p^^6+mol2!O1 zpyq~~yMyzV18NH$uTi4}We+{H!bNKRo8fqAO}+*aFEQ9cK<*=niFg8^{uTZ3;Q=lu zG9X86g38o~U5=kg*K)mrW2G=|19+QdD4-2Po_@LlGVWDq5cJ*9shBzpGgfjj`F%S_ z(nUF+fGt+Aw1?>6$!Yf_R-3v|{hcIkqdw0yA2E_T6#1HEdpd0P{|6LDHb^Cq8pmiw_?@7f_s( zG&}SX7RtxRQM_~ELE@z?*iH+J%Nn?to@(ej7=-3ggC*Zj)>Rx6gp~j|ykbxkc??Ps zq8Q>O(;PS(R^!@I_oQVuw}y;eL$*Uj9vEDSS#9CsV;?*a51}q(p|Ey~$f;JVbl!t@ zBh&GH$A;(s)cGoi6S<&hKDd4iUw`Ak{t9;}iZ-sjc_Ak@`IVd zhmai?K8W-7&fHh*Hcca&od49r!+h*|ij<%^+mHT=#*Utz0ug7Ngv6{q7o#ZaoR5)o zSA@+_Qrdj-%E-m3slV$5X>Xu-;E|Jro4Skch=XJPkW1BbZMna-@bkIo`ky6?vQ#E%qXO8NXGyVrJsFMT9|zz zb*-n6`rkM|M`coo4lPa(+t^REvB{rn`7+a0*5{i+NAo6CD4aO8 z$1~U7$e_;ZY6|XVd~9~;4RMuiU7;s@p`D*d+y@=*WO|(8*T4Q4{w%&H-RN_AJt`u^ zyi&u2v9z(Lqm85MxwRo5oeaB2i(|U(u;q`AHxl|^Ei@d?sVZ%M%-xcj;rq&9E}vWX zmZxAj8F1{D5!B%mqwUl#xSe}j|M~O8kM_auuX=E$BCO*W?t%azI1bQ(;YdUOxk0jt zOpWP8txRxh@)*W}IoYk`(d9~-Pj?(u{RtoFJ-)L|RFssIq@{P#praMNN?Tj7<%Ph6 zn%pM4ebonYTH3RCFJS2MmDE`KqTDu=Vm^`sC6qV%Us@4(h~NR)V@+T@6*Hxr(e2-4 zHL|(O?iofV^dq{C+%7s_czMwtb22J+v9DzH)4{4EhPQ7tG<@Sfcjew8m|}UJIH8Gs zt(R9jOReraaKHf9%AkGEyeAP>MjpOML~`JlBEUj4$KgcTnSAGkUQ!ZAY@W>r-RL~B zdl$q}5a`eb{NSM}V@S~%1+_!oGF-e7*X>#Mnj=Y92(tiG)>OTuwiqm+3%lph-39wC z#`YsZb>r4^3|~D`JyonylN*UT(#vaZk`%}#MYeRh0Nyp-93>_;&Claw%=Tj&6<;9`)SF|im4?zYK z$QEM#$w+xaQr;sI8t+=Nil%m4)5E56T@G12&%ZV|;zF#p|Hi-wG9LIEa?!0s1iF1Q zyc;Q1KqWTYIAGu3+&m&)z(AM-`lVdD|CyN-EcCo99(H~{Ja~;?tl^!lOQYuDcXLX| z5v2pYQwKMF8leIM?&|STl^-AOi`HlrT9JoF2S0{^a$;)ynuG@rx>yvc1B|y4+YHm+ zZO`!wQBSreaMYJ9IoRqR7!(9A!b4Qa#->OD%fP>1ChhMs3-A+i?2+fFyvb|^o{K2va0Z^{lu z?IZI3I$#(ML4Gk*zm)IfhU!E?+{B0lf&(5&!FcdcFro9t+7Nx%tTQySVGX6;^X5)u z>PXN+`w-MyBss7#fJ_vf>V84TiZo9CXjUr5qi}1yzBuO^6(jH`vNHe=)z0P|!osP_ zUb#CJ;2BqvNJgq z_GknJhPY-Mdp?(^nlgDZfQ(7F{!~sMNhl!-*V-kn{(84a5FnNAV>45mMj=0g363EH z01#QI;O^7m{nxDNXr_=F`7f}$EZ*zu=g+#4hH*q|n(@;+<3@lR7~$CtT>@R5&mQUV z*U1ujrEs1Kpl`sr#npPlP2Ft>Be_;je(Nn-;wF39+Eh(%Q|pF%ZNi+C=)4USZ*tzl zQ}Zaag%@$~;6W0RQ7ti|)?pJS*fGxU%NLFpfV@nK*_dvU#8<4d(1X1aI_ZDzx~g=F zG{?2^XPRRS|3BH5t^M<37JcphRvUPHQNJNG|Lq}<+_?G?-qcIK_7nA62gGP0F47Ho zn=MRq`u}$GdBd{h-Pm9#m^0Pj)`sXJ0ynt;GgiWXtIY}XUUAJ|(b1~MH$j~VrwU@n zJ`}m^`gIZS`8JNgx`Tsh(jPSPefu^zf_#l@#fHR$KuP>rrKL5qW{SQyK^}{gZc0y8 z#!GPT;~YzL1~&1ts*T>K?bA@t^3pxkdgJ3XoZDwXoQSdj*~w!8|^w%Kl_`G6!|Hi~QhQ z>q3@4sxmV*D#gTp#E5r10sOSOqU3gjdlU=O4|*B&id?S*;V^RjW<+GLyl@@VlNd7O z?IVwFZHi%hz5cVCWOU6p>|jj) zWl#%t1l8H8+y~dPI*`?vS{oS^@IDE6>!<}8_`?~3?Ln39KSAhQpxp+IK`&ldP{3Ra zqQE{-MkA$Sfhh|mtLLLsIuH{@8fr@oNI@d`pTGQ$Tk9jSrqpeE+INAm~2~Wet#4Ic=M}sm+xnHlh1CaObKRs2n?MyKXwzZ zxwm0L=O*k1d0)K0`DI4Ni0)jgxG)Ej8FT)gQhTixzBONszOpr6!oepiTa;S(#_W@uz?vZM7U4e9ZK zR}1r6uGq@XP8EGYbV}QFMl~oYeadbeUA(NRbq$6gDqX+Is6(ZvZrF1hUK~VM4$plo zIV8hbcXCV8@Q~Al?1XI=7SoV3*k6M)2@YrQV~^rWCadE$buw75YxXQSHW4dVd&~km zV5w(m_;8s^L%5khW9G@$ouqT{p>U!V2CA^hz{JU!z?Ux7@#F(3h=fiALyGtioS(3q zYLwfT{QWnvtvTnI9wKS`2E+8sjzuaeMb0C_$vg`m{v_>VY#6qpj-)Z%)r35V$ZW@! z`eN1j^WT@3Py+=71}2dj!dfEVf@-Opo8AxG3<`ku4Dm0XHtc3Zf=<^Wt9L;;h4%xA35M({aS`-82<6zCr6CbcY zIR($QSXqjOfOmluShDGyBu+?3d04Rfu%Dd*u4av)%bN4TG@*nJPDY5bl+=p$poh>#vzS5tG# zfKV>IT}8YGxceYY4}j_&CdGX&DOhEw+$2#3Lwx2 zsny(BLewFfviCHO7F)ocI+czK0^>BG;4u{M{;NfI7-`gt=nzWAj=PT6JM_UwAFkYH)5z}=# zAHKPL1jMe*pE3b4i|}#5Hhd%rk~;e(KovQ@=OwF8EhvJNtyAO+02-V)Pqlx@mrDN= z!#_lAQED36Ggg-B%#{!dS{t%&y~ML$6<`s$`6>E<0j0 z;pK}L)8@@vH_iw9hv!K*q!l!>|10X&#FU2}=U{bQKE@6y9^SYyl>v!bF2*i(mGo*7 z-Pg)r2)3B6{U%5Iiu8g-hbqqZ&jLA7zBQE%{OebFQz`ZF2;X7j+t_Xq3YDtocoV$B zbY-Le6$F#iKw5K=zGhKL_ForWD_p=(9O+Y#?hfsC#Auk#4hb@YFCJ1Fg%6>mVazWX z{AE=S<9nwAgc}?RfusgUU%#QOwAANC<_PYKS>OK&DW5gz08c|ks^Np@x-i4&LV@r9 zJu+1eP!4W?_AFzT*JZgpf5uikzkL5JHaPZk(b{3w>`;1?6j0d%1OY+Hk}A}NNezR0 zC&u2n0|#9g=Z4~I5d&F>|EM~;}&WJZpsX z64O6lQYd+9QTF&qVng`UbsB|MNbt!t@#2n+kKB{B!y@Rl&_hsetUGpb^JbvncN0KOZ6j5PW@?p`k5nivE4jpi!%3 z18#4@MLpv4vz0z{zkMyh~U+|IBfh zCYZK<_zwW)WWF0)6D|UTVCfaN0gjhMGmsJ=i38K;%1Uf)-jloV8jTYjrpm}z3Sn~TQqxm+S>MI=LUt%UCUahd z#~+&lXKd&{N>*TM*+6ao4m_ecFJ_@oc4qwCjpKgI&2x!x{wXnK=D|15r}NchMPZJ5 z=2(MD(?<2x%I^^YrFu*NWhA(HbuLs?qzXCli6o8jkf##9mY474YQ@lkmY|+KO=EC^ z(Z(Oyd<4XRT4H#6ebe1z9ja>&Kfyjq06mQ+2xrYLEX z-_ezX?nTnt3Fp$;F$3&X$c;YiapzQ8Kv7xD%8)>=r*@SuRE-~9E{Pb5_2wi|+`VD?(&oV7dc;-UFY`;pD+3aorr=e+{Eap%(IbQmdkx^w~~pr9i-V6XNdF%erf%9n2htmlsZrPQ3)E%@?Z*ETf#$c0jX z28x*z#>SLa6)M~Z;Tc#*Xv>}?>E~mZVjeqKQH)a%|Bqr zCLlZXO%z9l{7-(zD}zWwM($b zGu?=M@bHt}fb5k2WB_(NDo0;44vPqWJ*P}&s{JAuae}5BLk*J=2z5UG(=Oc9k%yyN z0j8|OX$d3<5*n(`F%us1!x=4xVsK)gcc3UUFz6#SqU0fDJYN}b`Em-Fw}uZx0%b-X z0h@QKdW#t8J?zydPfGY0#96BPJk~VAAUwnXli;>rW!``{SI-;0jsaPxTBH$_&j^=5 zKO>zDJbdaY){qL6$fupJv+T66t+qT+ke04mx7ZH2C@;9g#jNseC5;DW^6CCgOmzkK})pNc&vWZisPFN=GO{h)NA(0LueDCX+Dy#4b#5)m^2 zh6{x*zfs7BC=*}B8d0D;>hAjFocZ5tAQ!bD?L2z*F~Pvs!9eNw_0j1gpo*>ZxqR7H zP8=B!S^JdqMlzT+Kfa@jdMVUWZFsOQUy5OQoNT&4ubFF&j&JigGcLeqV#5wU88UwS z%t0yalIOlmT%H*9lCp3=w^${((>YTjX12)TX zA1<9%oes?^RFH2nG8AS3e%zK#hdHRHNwu|OlhfY4sPq-0m}WOAWdMW|dDW{PFbYjo zxl-oN($Ut&RNQxm3&hKiP}(N3vOjBb)6E96&Cl#`Ap#uj*_d?I#P16@`sfQnwW=JB zqrI08uFe7#xRL5a%Sqn7!@1$QdU)WZ+$vC;S!6v0I6%>4lzZ3?DOKMOa7sgp{=sM6Mo${-+f0?bakECa!3T1x8d30o+ z62fFv`6x>Ziv`y2Wi@=T3xK^7+`17ag|B3|phHY<@w0c~!@*LVtdY&#r!F`cP1X2F z3$?-Z5>-2^GW+k8#XgdbaHK%#pHF4ufY7|H<^F02F{3VkiABqW3~GL2JMA9L3$uba zD&z$gD&$7(CWoIuj_{==KIQ^4*wbSdX#0e6=Eis*A?_`aII*S5?%&*#MZb(uXT)csdjwrZyL^4*1zirhRD6 z=Zm^WdwS_bLd-XHzuevR^U*5_AhE#>Wk*}*jC}}{mOSproW34WgJ|eqKL`%AJldva8I+d~(_v7O;F?l5TfU3QoVf4^Rt9r!w z;7%Sl0YEU*wE##KJO#R;3yTik%^=-+_6~gGF+g=c$8)4+j+DXrm@nUgm0VtANI_0c zlYcvdc^A@26;$3dYL+crLwNMLzR;;Y+&H<7cJZ@kU-?nrC_1F;`G?OhlTBmc;5?Gl zAbpoB9x-~fOj~Tg$%fayzuxR-Y)_Ncf40{j*k2XDGs8Sw%YU~oi6#nvz(u8m;>~Z_Yafo}kcKHO zZ{yczkAG+KwBfmJ*Z#+PN`KakUN{`f=j&h&UKMH^`&Z*Ej%afJngX zwLf$-T0j|Kh0tFmzn9BzM%WG?=W$85b^V*epQP(m4%TbNnzi2l^6T{A{Kl^zPRIN) z$jMf&Tzl2THOsHgR5+ilz4^ut!;jmxD&AXbxHGKt+7P+FmaJaZbD^01u(74$=bxVa z^6R{-i4V-)Nt^u~>y*m<;Y&>Yq%m&MoW`F`E6wkI^j#m(a`#c^;tLxEj!4+79`gfU zTb5BwWh=PRdv(&C^4_GY@d6Ai2F`t}TEXEKeJ||amiAT=`^eC+IsnU;2Yrdpq~gij zhpLV?o2#l?Y#UD{(kduHarI+3uDW_E@R;m{^j6m{;ZMK)2<dPvb%*>W<_tcSBiZSIRzX-XHeOqwx6A8Rs{&-+gyK*&6VRmeWg|?&!wpU_3PILW0xaI3XBPW7N?ETIQ^MI`<`1H{w_z5 zdgp^|!5Y<} zK!2%~O=*q$LUs+FG@>7m;~~HZgBLgJ(sI$27BUmUIzgecCL&tVarNg0XGHlPq%H{h zcPNZ;R2=_lqlx4&CXow?BMS`P*z?d+qWGGxe22~_qzV*Q!4-nh7Mi2q7f`qB)T}(tW6B} z$19MaCT9sG*;lX3{e4FXfA{(5nQD{cfffIHd?$>5`A>saCQ{moK zB2m-v5$o%|cko-h4js7k^R2?EHL4=lX*iilcps}+%u3%(n3SXQr@L2b>5b(*MHg3w z-kIE6OdI04>@eGx&0cDg<=t*8o$f2z>VEd`6C(t%ve#c@|8!?qpGvT*V~`RepRFlp zd`@ImGRLGuwwLH}>6{B!isQ5c%QJx33-=oE6s9&dCqty6+lIC5tl<*5{Cc3j`=uKi zZ!&f&nBH?2ZM?(yl&qbS>Z3wj`G5wtGpeTT)4Fs6TST?VXLGH_j~U}o9MeOTkn{HK znL=AJPc_q+i!)PppXedlI%jugvx0(FduwZ!0yJZ}t*Ro~B~A|ux5~X$_{xBe=WV~! z7vFmJ;(@n1-Lo4_&aRRi(Qouo28!-0F5yS6G%=a*R4%0UlNDZEDV{fBg}g;kQ4t(E1uc-Y z?H}Al3AJz_fmfWBjkj*S5+DEb-XeF&U$}FZ6@IH)9J+0AyxSwdH%3rCmBVY2p5iwp z1JS|F+vUV*%1NJS#96kXp~Pv$rR!&@l@3;39ep%0*X`x3x<><^E*v)~c5D2n@q>;P z+6G^|C^zPm)5FCJ7cP{N>a%aXDB+y=QPSkm5)u_nt(;c^kE=@Xi?W7R*Ssrm9Muv z=B;XCT};c4UI`x9nb_)nKWK2f_|}+>8Rkvp<)N?5r&zz-70`ZpkCM1(qjKQ>VxRLp zJj;6bJcQB`?`Kc7f+4{Jo6lCDZ&a^!I_w^1)_u=j^`TEkp7~uZZS7bY#l``j-_PzX zy6mmiYIEc&-?n7p*0KX(Y_T^F3y$?wP~y~Q&VRvwn?15F$Q;;SK~@pH3_RW5b?C^=N1ncrK&`^Efx-iqYgY5mKm<|RzX6p7?^Eh@X0p_RL5 zd56DLiNh)On`o5hr&rB7V{a>cX%E_+mpP(#N>$&3mYNaOhGo02xrFVA@}KOk*3Ck! zDzJU4y0$=db#wit!jNcT%$I5?rt?tvMt zW3E`X!Gh&4j;VG`(#oNRvPhr&E!KLpqtX`%KYNMY13!;>Jnx|OXaD*)Zcl=Cwb_}^ zbvm@A!o1E|S)yHK?$q)-Cz}phZ_P69HP?4+j;(6{do}P&Q;)u{ZTYr8I)Chit7}e+ zMB^3-T!5;F^Q-OiDtkVjT=}k`IIcz2u!Z|#a^>YUkE~OQEhQ&7ZTomC@w=Qzba#aO z-RQu@1uIWul`v}NzCN8%S3A}W?XIi;Ttq0%begMm*xZn4cV|_?qN>V1Z8=d-w8TUe z*D?}w=LvE2MEl_`f5ZAa3koeC*7Ot=xX0{ST`MetcWb}4uv3je@Aq7lQ59uebxwS} zi*Fd^=`x_rPP|j+Aw9;;{e+7w;o8#fdkeOQ{^+q)<&|)oB&07@6|>Fe-$bH)&%0ln z{qfJgO8l`@aCs0NJhuEFe+Q1ne~gZBUij~v4Uxp4|Mn#ht(QBwi(!FLYuIKx~kN9>)ds&1y54)s(+$3P$;i;Hi%-|tAsjT`3N__W#L@1freFSM?|#-zkYrF=FOmi1Fw(jb1iFO&OF5l|9Sz$TE%Dec0VsbLxe3 zq1+nsXBY1ey6&LCgNxIybd%abux5-M>mX|=b*>t64NYXS7~nifwfp%tFa-I=nyRYr zEiGGOOnX0cgGIT0dl`;{6-rgyij+MSQP{!K)c70hi+7EZ!Th71F!yMb|C;-xaxROP z9qb*!G_#LaJRBTi!3u51Wrf_?eYmSPOIShvv#%ZZkx}7vPS$8?F+xd>sdRS8yguJjCuPSZEdM&#xi?+f6~{i6@Y76e`-30$4!Y_1}Kv3+8S#d9cHPh ztekY`jvTJBHa~z+Yg*cZyPCu3xfSRbO!qxDXgvA2lV(Q28MVn+7)j04#0ScvI3YH+ z5mvZhxRr0i_y;oaWmn~xKNlMMtoo>#!WgE|8BEXRy5TN1n2x5#Ji1FLP7`(?l6};0teKUZKx~r* z8CrPXzXh=5(j`SjMH2aI-YSaePnY9L&V;S+-|rOK4*vajCI{jV@Ol0C;_t)hLjoD} z@7E7PcDX~McG#2!L72s#UGA;Hlv0{f)BlF#*0YwbFkIOZU zksi_S@RNx{en$jYpZwDpQ2g8Ge`>I+d9`1i4&Y3HNx>>f2gTLplJsgwzOog@|I1+U z)wKK8_gErEd6qUdSi?K+iq-k}gBPiwps+CcU3RuU!-4YhEaD7d@S+jTyS=H5@L~Hs zd-ZI3WM3*P@%VSZtbf`x3tE;r5=#hKFi#@a*9vBBmoICNDP+3V4BuBJCEJ+2%VNU) z)p;1b>}mpAQag+pm8=|_oTFU+hcJYZ-CB5m*JgM3l(pYT#(uGLKBr{T8MRdyrZR1mt*M1m_=rAzwx&FShHwM@5nsy6rkqP#^<8Un z7o?#XzM7YAOdaO=w1g?!HTWX1Yh`4vN>4`-&B?&$UVEvBo7-ZSmi3RcI~>c4_B_23 zypw2u`}WD>$6>fr{oEml9J$H!j0_}ZM^Nv9<%OYg|CthaCPSY%2#?BPV4 zW-dX^x8(cROf{zk{%aWbWf$-uS!si7!x(VDx#1ha$2oocqPg{WIo z!>ed$YMhNR0%2fr%3U~NK zYANGO6OB~n%~Q|0x9lpDEun_yS;g_AV+|wGBu?&v2Y>eL&_hdxj2mYYv#~82+4RUi z`fLR2?{{P}CWTT`uo`dTG#;H-&%n;bP22|nDua$a{q#vmPHwlE8MFKTmg!x+dm+t* z@VwgIJ(t3Qcv>Q!@Mfn%?6Kc$5v=EY_;Ax;I<1YzZWmFlPaXKfOx}O(TMzh)v9a+WS`6C!U5t8pp{3Tl^=<9B^pG1)yLw+*5K zTO&(h{HRgSBD9M#vD~%_)cwUvi9D3}jedgkyy8RbbT}c1( zlZkAk-X)M17*Ae*oFtv}TnhyJI0w$~MT{^?WM=n$L)F`i1 zheTKUL^cCO>Uykz{&ZiSldk+i%lz=HodQ*s2>>Xr{nxDcvx_+xRP|>H8KkEfxt>C^ zu;$l*JzUHUAGUiF9?tgsa(J+cZnyye60c%v-#xU0?&c@a&0!`4ska=___YN+J8-?*{L(DL&vUa4u!@#p}j!vcet zF%q${4y}%LZ8bGk^j#)QFflMt`%@ex6SNS9A+UQF_hO%Mmu}sD#F8OsH*;%9*7`~~ zA5=Ct>Ftfr|9}Al0C7*betYd<%H@FJ8hEq?2!%h-A zNz?6)PEIu~`#sef>CB%y_m(8`N8P3)%O?41rZo*q2vVEuiKu{)H(0+7dVJkr`skx+ zuUKF7N)q5`YRvWZ5rQOyj4`3zX9)MpUeI+(SB_dImV5@&*KwWDUSXo* zZ#|bDIH2@YF7(Vcapnh{s*BPO|8ON(`48@;J9b5k-etN|A!)4#-{h76K7dM)FF$Agd_RK*gAE%pyDCDn(gDUl zlnjDz2Q6TYX1~VYvH!Q06Q^PPA(;g1_Ihh5=0Vj%1;BE0mtE#;@0Abs#Kp&-o5vno zaZPW!FVie88J$aRyEDV{Fjy+K!3PG@NMuU`q8*O}%pao?fMh4I&}EtejF*ypZgI;q;Q^TOtOXb-Gj zH&j(!k9aado!}NTatwVs$@!D#O^R>!1XNJ#F!NWN&cA4-k<5qQxS>DddR}d!lIC^& z`}tN#9YG5VZ5d%<=FPsBpFH_*tGF?zRt%ezZ*!k3h~p6a+DYiFGOpb;2EY4n0WV&> zxO-~%&P!7BTJa>hJ?V8Y9xF7AwcYJiAYumBzdP=_cklU-n~W3tA$g{&l%y93=5DZA z!gwSlQN3Eutd!4NF`C?g;sHL%RuB7_jY*968K$u(zPzawEOM@T2dFtbQ2vi8xm*fU zXMoYTw#uvP0h(^#o{o@H=>3lU6E0HFsX{oP5-#OKFWva!koXVcr62yzWJ4&X_Se!QjnbX6kNY36NGfq-9-xhxndHQY2Ltv_Q&MSoHhj-7wXjR097Ni?cBZ{b zOVjwK88)7Eps^JA?K2`V@`<`tInA&M5~4qT{lTrAJ;ut^^w0L}dwrK&9;R`_FGRXg z`tPZ}nu&T$h#lgxjBM95;#E%6o{vKdtVv9vHQb7hMz=FJ{4u2!MS-MaZFBQQ_woB) zW9RVp?U42l&l~AKe!ag`Za@=Ak+tU0j=(|34rf-L3w|+g^^?jbr1t~*98NR46ux;_zA)o%-=p@R z+6eGaoB3e8$kz3@Fi?6~&b?f#U6z)A3>^5ysaGjR5+-gfBmcNjIILMIs7K@PYOiE} zY))7|#%E9I-d4p)(Z&h3H#YPTDff}l4hl(L6d=?6;?l*7Ti5QD{Wc~d_m!LHf%GAs zYQTs3;d^f!cwHB)^wn8KS%RVEcAT8%9#dx6ZQP+1@|UHZh0SpfrQlT(sElW>`E=}w z`nfL6JNLQSm!g7Q@#*8ouJ+_ib)+YDFZH@go}3@(RnV7Dtse7vI=q>#^r% zZG!)nf_6{aEG)_O^tQzrCMP9H4%2`xOcSBDrAfg_Nh=4iLPJaID?8CDj?Z0w*i3%3 zWuWqe3H{XQ!g8Xg`(mbQ_F$HNuh0A4ZRUSMFrx9^X!2PyN;Tu3kYJ0-(`+m)=lHJ) z9dtD*$w=y4;^|TQ=icai`1!dV_zh(x;t2U>6;~dN%eDDBKZh35Flh{Wt|NUR^1ip21gV&-MAIzn{{8F z%8a}J9)$Rbg2kAmoqqeqjvj6A=K0Q$HA?t7yfD=&j#+sZ1|8cDC81z%-ME?PtD}1tX#X>i|uS}Ra^_{?XHRwS-$@==7Riy<$Z=tT;*&oCvH?6_{^^E z5RyzVXO8g0!f(45zPz~>9>_>9p6&q`d|aGwW@WMe)g2YdQ|jwpUa;G_bGKD%n67l4 z(*b?`Y}x!HsQNwC`#Atw&6#Ea)o?BxCKR4|nF!>UNQ z4n=eiYHA+j#!v1X4OAES{%&FSaY2RP=jU^Fn?^55`6AWPwpH^}`W0N%mBx-P{?k_y z?}s-c<4d^=7)u!{Hm6RX##^h3;=165AIQ(l znao|h9TH;7M60D!55exDIHJbZP&s^lC(Ke(B7#G~dz&Z=8b8u==k7s=1b|kzb}i#* zp}CYhEc!UH=fTQJy%O)m$0JC%^!nEs4MT`~JMQLle?O0$GBrLspsvr7$*X_fzZBF< zUj16C=^|z#`bS^*El6_ovq63LHNBiA%n?oT0HDlcBD8x3|dp0Rw&>nw((fwdoI=+@bt{hK{L(kMZ+%VGGvTu-hH3dTWMu3w?fFggxEHQm`00cvB?Yb- zj|vwsB*T1ec)rf+)n#uEKfU4n#lgkSZnpod7nVcD&eFdtxI{zi7uY~oZT_#$&i$pS zI1J+lBnVyJ645n`EL1Awl~$38Z$Q|n(iJ&ntz@pa1;anmZ!YaE`jfJ~xg(P7M1j2+93 zCd~~_3fD{oZmx(=WgJ22&xb`uT?`ROU)Mt!8B+P0UzNV!Jp=%jR6l+PeyB8=x!B&e z)9!i_=XuMkB47j%Icrrlz*&srB_c8~6Z?zGN!d(!u{Y6MpyT^(?RrXNWDnFpk@bmt zPQLl&_x{F@*wKNkFZHZP5J-|l{NiD zn;mnmK%qXxrxd4CgTb<}5G2(Q%5()AlM4&~AQ{D{J>4#sYKXNs);X+zQ2M?_%=g;x zs5J8EbUKQiz^V}3XtDHdSb0quYz%Ft_yyC+x=z+YUBRyw`oS(g|IExqO`oss;^Gnv zIs-FZE_*O=oDhtTSDbbs%3(oao|>{i6FGt9@5l%gSimeWN3or%x>~hVe^Hcb1I8D% z3EkY3Au<*r4Oo7>masb%N1PW`U$TKs$T2KSORz&mwfSE!G`H?K3~^Ki0qa_>$Sa^C zr?*izNM$lS;8_C%Gwl42&Qd|69uevwSxxe)I@&@$#Z#LSA;mK8KJ=E)St%Ap-Tn%1 z>$TBLvEt9zC$pNw3xWfVgW`o=-8QROxiGK%pP$49NmN~B)>{kHPLy87aX;(#)|eml URhkY*ib{fza3DFRZU33#f63z&YybcN literal 0 HcmV?d00001 diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..76ec1d2 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,73 @@ +# Installation + +Notey runs natively on macOS, Linux, and Windows. + +## Download a release + +The easiest way to install Notey is to grab a prebuilt bundle from the +[Releases page](https://github.com/pbean/notey/releases). Release builds are +produced for: + +- **macOS** — Apple Silicon (arm64) and Intel (x64) +- **Linux** — x64 and arm64 +- **Windows** — x64 + +Download the artifact for your platform and install it the usual way for your OS +(open the `.dmg` on macOS, the `.AppImage`/`.deb` on Linux, or the installer on +Windows). + +> Release artifacts are currently unsigned. Your OS may warn you on first launch; +> allow the app to run via your platform's standard "open anyway" flow. + +## First launch + +Notey starts **hidden** in the system tray — there is no window on screen at +first. To open it: + +- Press the global capture hotkey: **`Ctrl+Shift+N`** (**`Cmd+Shift+N`** on macOS), or +- Click the Notey tray icon and choose **Open Notey**. + +On first run you'll see a short onboarding overlay showing the capture hotkey, +which you can customize. On macOS you may be prompted to grant Accessibility +permission so the global hotkey can work. + +See the [user guide](user-guide.md) to learn your way around. + +## Auto-start on login + +To have Notey launch automatically when you log in, open **Settings** +(`Ctrl+,`) and enable **Start on login**. + +## Build from source + +You'll need: + +- **Node.js** 20+ and **npm** +- **Rust** via [rustup](https://rustup.rs) (the pinned toolchain in + `rust-toolchain.toml` is selected automatically) +- The [Tauri system dependencies](https://tauri.app/start/prerequisites/) for your OS + +On Debian/Ubuntu Linux: + +```sh +sudo apt-get install -y \ + libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libxdo-dev +``` + +Then: + +```sh +git clone https://github.com/pbean/notey.git +cd notey +npm install + +# Run in development (hot-reload): +npm run tauri dev + +# Or produce an optimized build for your platform: +npx tauri build +``` + +The bundled installers/binaries are written under `src-tauri/target/release/bundle/`. + +For the full contributor setup, see [CONTRIBUTING.md](../CONTRIBUTING.md). diff --git a/docs/keyboard-shortcuts.md b/docs/keyboard-shortcuts.md new file mode 100644 index 0000000..bb974ad --- /dev/null +++ b/docs/keyboard-shortcuts.md @@ -0,0 +1,44 @@ +# Keyboard shortcuts + +Notey is keyboard-first. On macOS, use **`Cmd`** wherever **`Ctrl`** is shown. + +## Global (works system-wide) + +| Action | Shortcut | Notes | +| --- | --- | --- | +| Summon / dismiss Notey | `Ctrl+Shift+N` | The global capture hotkey. Customizable in onboarding and Settings. Availability depends on the OS compositor. | + +## Configurable in-app shortcuts + +These act when Notey is focused and can be rebound in **Settings → Shortcuts** +(`Ctrl+,`). Defaults: + +| Action | Default | +| --- | --- | +| Command palette | `Ctrl+P` | +| Search notes | `Ctrl+F` | +| New note | `Ctrl+N` | +| Toggle note list | `Ctrl+B` | +| Toggle theme | `Ctrl+Shift+T` | +| Close tab | `Ctrl+W` | + +Rebindings must use `Ctrl`/`Cmd` with an optional `Shift`/`Alt` and a letter or +number; conflicting bindings are detected and rejected. + +## Reserved shortcuts (not rebindable) + +| Action | Shortcut | +| --- | --- | +| Open settings | `Ctrl+,` | +| Next tab | `Ctrl+Tab` | +| Previous tab | `Ctrl+Shift+Tab` | +| Jump to tab 1–9 | `Ctrl+1` … `Ctrl+9` | +| Close overlay / hide window | `Esc` | + +## In overlays and lists + +| Action | Key | +| --- | --- | +| Move selection | `↑` / `↓` | +| Open / confirm selection | `Enter` | +| Close overlay | `Esc` | diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..8f73f04 --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,110 @@ +# User guide + +This guide walks through Notey's features. For the complete shortcut list see +[Keyboard shortcuts](keyboard-shortcuts.md); for settings see +[Configuration](configuration.md). + +## First run + +On first launch Notey reveals itself and shows an onboarding overlay with the +capture hotkey. You can customize the hotkey there, then dismiss the overlay by +pressing `Esc` or the hotkey itself. On macOS, grant Accessibility permission when +prompted so the global hotkey works. + +![Onboarding overlay](images/onboarding.png) + +## Summoning and dismissing + +Notey lives in the system tray and stays out of your way until you need it. + +- **Summon:** press the global hotkey (`Ctrl+Shift+N`, or `Cmd+Shift+N` on macOS), + or click the tray icon → **Open Notey**. +- **Dismiss:** press `Esc`, or press the hotkey again. The window hides — it never + quits — so your work is right where you left it next time. +- **Quit:** use the tray menu → **Quit**. + +## Capturing notes + +Type into the editor and Notey saves automatically — there is no save button. The +status bar shows the save state (saving / **Saved** / failed) on the right. + +![Editor](images/editor-light.png) + +- **New note:** `Ctrl+N`. +- **Format:** toggle between **Markdown** and **Plain text** from the status bar or + the command palette. The choice is remembered per note. + +## Tabs + +Keep several notes open at once. The tab bar runs along the top. + +- **Next / previous tab:** `Ctrl+Tab` / `Ctrl+Shift+Tab`. +- **Jump to tab 1–9:** `Ctrl+1` … `Ctrl+9`. +- **Close tab:** `Ctrl+W`. + +Tabs, cursor position, and scroll are restored when you reopen Notey. + +## The command palette + +Press `Ctrl+P` to open the command palette — a fuzzy-searchable menu of every +action: new note, search, trash, export, settings, theme, layout, and more. + +![Command palette](images/command-palette.png) + +## Workspaces + +Notes are grouped into **workspaces**, typically tied to a project directory. The +workspace selector lives in the status bar (bottom-left). + +![Workspace selector](images/workspace-selector.png) + +- Switch the active workspace from the selector or the **Switch Workspace** command. +- Choose **All Workspaces** to see notes from every workspace at once. + +## Search + +Press `Ctrl+F` to open full-text search. Results rank by relevance (FTS5/BM25) and +show match snippets. Scope the search to the current workspace or all workspaces. + +![Search](images/search.png) + +## The note list + +Press `Ctrl+B` to slide out the note list for the current workspace. Navigate with +the arrow keys and press `Enter` to open a note. + +![Note list](images/note-list.png) + +## Trash & restore + +Deleting a note moves it to the trash (it isn't gone). Open **View Trash** from the +command palette to restore notes or delete them permanently. + +![Trash](images/trash.png) + +Trashed notes are automatically purged after the retention window (default 30 days +— see [Configuration](configuration.md)). + +## Export + +From the command palette: + +- **Export to Markdown** — writes one `.md` file per note into a folder you choose. +- **Export to JSON** — writes all your notes to a single JSON document. + +## Personalization + +Open **Settings** with `Ctrl+,`. + +![Settings](images/settings-general.png) + +- **Theme:** System / Dark / Light. Toggle quickly with `Ctrl+Shift+T`. +- **Layout:** Floating (the always-on-top capture window), Half-screen, or + Full-screen. +- **Font:** size and family (monospace or sans-serif). +- **Shortcuts:** rebind in-app shortcuts; conflicts are detected and rejected. +- **Start on login:** launch Notey automatically when you log in. + +Notey looks right in the dark, too: + +![Dark theme](images/editor-dark.png) diff --git a/e2e/driver.mjs b/e2e/driver.mjs index 40435ec..fb2a67a 100644 --- a/e2e/driver.mjs +++ b/e2e/driver.mjs @@ -179,6 +179,15 @@ export async function executeAsyncScript(sessionId, script, args = []) { return request('POST', `/session/${sessionId}/execute/async`, { script, args }); } +/** + * Capture the current viewport as a PNG via the W3C `Take Screenshot` command. + * Returns a base64-encoded PNG string (the W3C-specified encoding); callers + * decode with `Buffer.from(value, 'base64')` before writing to disk. + */ +export async function takeScreenshot(sessionId) { + return request('GET', `/session/${sessionId}/screenshot`); +} + export function pause(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/e2e/run.mjs b/e2e/run.mjs index ff064d2..f05df84 100644 --- a/e2e/run.mjs +++ b/e2e/run.mjs @@ -73,7 +73,7 @@ import { } from './driver.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const APP_PATH = path.resolve(__dirname, '..', 'src-tauri', 'target', 'debug', 'tauri-app'); +const APP_PATH = path.resolve(__dirname, '..', 'src-tauri', 'target', 'debug', 'notey'); const ESCAPE = '\uE00C'; const ENTER = '\uE007'; const CONTROL = '\uE009'; diff --git a/e2e/screenshots.mjs b/e2e/screenshots.mjs new file mode 100644 index 0000000..31501c4 --- /dev/null +++ b/e2e/screenshots.mjs @@ -0,0 +1,432 @@ +#!/usr/bin/env node +/** + * Documentation screenshot capture for Notey, via tauri-driver. + * + * Drives the real debug binary through the same tauri-driver + WebKitWebDriver + * stack as `e2e/run.mjs`, seeds deterministic sample data over the real IPC + * bridge, opens each view/overlay, and writes a PNG per view to `docs/images/`. + * + * Usage: node e2e/screenshots.mjs (or `npm run screenshots`) + * + * Prerequisites (identical to the E2E suite): + * - tauri-driver installed (cargo install --locked tauri-driver) + * - WebKitWebDriver available (webkit2gtk-driver package) + * - Debug binary built (npx tauri build --debug --no-bundle) → target/debug/notey + * - A display: a real X server (DISPLAY set) renders real pixels; under a + * headless box wrap with `xvfb-run -a node e2e/screenshots.mjs`. + * + * The capture window launches hidden (`visible:false`); this script shows it + * before each shot so the OS compositor renders real content. It writes to the + * app's own data dir (identifier com.pinkyd.notey) — running it adds the sample + * notes to your local Notey database. + */ + +import { spawn, spawnSync } from 'child_process'; +import path from 'path'; +import os from 'os'; +import fs from 'fs'; +import { fileURLToPath } from 'url'; + +// Force WebKitGTK software rendering before anything spawns the driver — same +// rationale as e2e/run.mjs: the accelerated-compositing + DMABUF paths crash the +// web process under a GPU-less framebuffer and wedge every WebDriver command. +process.env.WEBKIT_DISABLE_COMPOSITING_MODE ??= '1'; +process.env.WEBKIT_DISABLE_DMABUF_RENDERER ??= '1'; +process.env.LIBGL_ALWAYS_SOFTWARE ??= '1'; + +// Isolate the IPC socket so a real Notey instance on the default socket is never +// disturbed (mirrors run.mjs; carried to the app via --socket-path). +if (!process.env.NOTEY_SOCKET_PATH) { + process.env.NOTEY_SOCKET_PATH = path.join(os.tmpdir(), `notey-shots-${process.pid}.sock`); +} +const SOCKET_PATH = process.env.NOTEY_SOCKET_PATH; + +import { + createSession, + deleteSession, + executeScript, + executeAsyncScript, + findElement, + elementId, + sendKeysToElement, + sendSpecialKey, + sendChord, + takeScreenshot, + pause, +} from './driver.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const APP_PATH = path.resolve(__dirname, '..', 'src-tauri', 'target', 'debug', 'notey'); +const OUT_DIR = path.resolve(__dirname, '..', 'docs', 'images'); +const ESCAPE = ''; +const ENTER = ''; +const CONTROL = ''; + +let tauriDriver; +let sessionId; +let captured = 0; +let failed = 0; + +// --- Driver lifecycle (mirrors e2e/run.mjs) --- + +/** + * Best-effort reap of leftover driver processes. tauri-driver spawns a + * WebKitWebDriver child that does NOT exit when tauri-driver is killed, so a + * prior crashed/SIGKILLed run can orphan one holding port 4445 — which makes the + * next run's driver fail to bind and every command return "fetch failed". Clear + * them before starting so the script is self-healing across runs. + */ +function reapOrphanDrivers() { + for (const name of ['tauri-driver', 'WebKitWebDriver']) { + spawnSync('pkill', ['-9', '-f', name], { stdio: 'ignore' }); + } +} + +async function startDriver() { + reapOrphanDrivers(); + await pause(500); + tauriDriver = spawn('tauri-driver', [], { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + let spawnError = null; + tauriDriver.stderr.on('data', (c) => { stderr += c; }); + tauriDriver.on('error', (e) => { spawnError = e; }); + for (let i = 0; i < 60; i++) { + if (spawnError) break; + try { + await fetch('http://127.0.0.1:4444/status'); + return; + } catch { + await pause(500); + } + } + if (stderr) console.error('tauri-driver stderr:\n' + stderr); + throw spawnError || new Error('tauri-driver did not start within 30 seconds'); +} + +function stopDriver() { + if (tauriDriver) { + tauriDriver.kill('SIGKILL'); + tauriDriver = null; + } + // SIGKILL orphans the WebKitWebDriver child — reap it so the next run starts clean. + reapOrphanDrivers(); +} + +function launchSession() { + return createSession(APP_PATH, ['--socket-path', SOCKET_PATH]); +} + +// --- Helpers --- + +async function waitForCss(selector, timeoutMs = 8000) { + const deadline = Date.now() + timeoutMs; + let lastErr; + while (Date.now() < deadline) { + try { + return elementId(await findElement(sessionId, 'css selector', selector)); + } catch (e) { + lastErr = e; + await pause(150); + } + } + throw lastErr || new Error(`Timed out waiting for ${selector}`); +} + +async function clickCss(selector, timeoutMs = 8000) { + await waitForCss(selector, timeoutMs); + await executeScript(sessionId, 'document.querySelector(arguments[0]).click();', [selector]); +} + +/** Invoke a real Tauri command from the page; resolve `{ ok, value | err }`. */ +async function invokeCommand(cmd, args) { + return executeAsyncScript( + sessionId, + `var done = arguments[arguments.length - 1]; + window.__TAURI_INTERNALS__.invoke(arguments[0], arguments[1]) + .then(function (v) { done({ ok: true, value: v }); }) + .catch(function (e) { done({ ok: false, err: String((e && e.message) || e) }); });`, + [cmd, args], + ); +} + +/** Open the command palette and run the command labelled `label`. */ +async function runPaletteCommand(label) { + await sendChord(sessionId, CONTROL, 'p'); + const input = await waitForCss('[data-testid="command-input"]'); + await pause(120); + await sendKeysToElement(sessionId, input, label); + await pause(350); + await sendSpecialKey(sessionId, ENTER); + await pause(400); +} + +/** + * Show + focus the (hidden-on-launch) capture window so the compositor renders + * real pixels for the screenshot. Best-effort across the Tauri v2 internal + * window-command shapes; failures are non-fatal (a mapped X server still + * composites the webview surface for WebKitWebDriver). + */ +async function showWindow() { + for (const args of [{}, { label: 'main' }]) { + const r = await invokeCommand('plugin:window|show', args).catch(() => null); + if (r?.ok) break; + } + await invokeCommand('plugin:window|set_focus', {}).catch(() => null); + await pause(300); +} + +/** Capture the viewport to docs/images/.png. */ +async function capture(name) { + try { + await showWindow(); + const b64 = await takeScreenshot(sessionId); + const buf = Buffer.from(b64, 'base64'); + if (buf.length < 1000) throw new Error(`screenshot suspiciously small (${buf.length} bytes)`); + fs.writeFileSync(path.join(OUT_DIR, `${name}.png`), buf); + captured++; + console.log(` ✓ ${name}.png (${(buf.length / 1024).toFixed(0)} KB)`); + } catch (e) { + failed++; + console.log(` ✗ ${name}.png — ${e.message}`); + } +} + +/** Launch a fresh session, resetting onboarding so the first-run reveal maps the window. */ +async function newSession() { + forceFirstRunReveal(); + sessionId = await launchSession(); + await pause(3500); // app init + first-run reveal +} + +/** Tear down the current session and let the app/socket settle before the next. */ +async function endSession() { + if (sessionId) await deleteSession(sessionId).catch(() => {}); + sessionId = null; + await pause(1500); +} + +/** Dismiss the first-run onboarding overlay if present. */ +async function dismissOnboarding() { + try { + await waitForCss('[data-testid="onboarding-overlay"]', 5000); + await sendSpecialKey(sessionId, ESCAPE); + await pause(700); + } catch { + /* not shown — nothing to dismiss */ + } +} + +/** + * Run `fn` inside a fresh session, always tearing it down afterwards. Used for + * side-effecting steps (seeding) that don't capture. + */ +async function withSession(label, fn) { + try { + await newSession(); + await fn(); + } catch (e) { + console.log(` ! ${label}: ${e.message}`); + } finally { + await endSession(); + } +} + +/** + * Capture one view in its own fresh session. A new WebKitWebDriver web process + * per shot avoids the cumulative instability that crashes a long single session + * (the same reason e2e/run.mjs relaunches between suites). `navigate` opens the + * target view; `theme` is applied (and persisted) before navigating. + */ +async function shot(name, { theme, navigate } = {}) { + try { + await newSession(); + await dismissOnboarding(); + if (theme) { + await setTheme(theme); + await pause(400); + } + if (navigate) await navigate(); + await capture(name); + } catch (e) { + failed++; + console.log(` ✗ ${name}.png — ${e.message}`); + } finally { + await endSession(); + } +} + +/** Set the theme via the real config command and let the webview re-apply it. */ +async function setTheme(theme) { + await invokeCommand('update_config', { partial: { general: { theme } } }).catch(() => null); + await pause(500); +} + +/** + * Resolve the platform config dir the app will use (mirrors src-tauri's + * `resolve_config_dir("notey")` via the `dirs` crate) and remove + * `onboarding.toml`. This forces the app's first-run reveal, which *maps* the + * capture window — a never-mapped window makes WebKitWebDriver's screenshot + * command crash the session. Non-destructive: the notes database lives in the + * separate data dir and is untouched; onboarding state regenerates on dismiss. + */ +function forceFirstRunReveal() { + let base; + if (process.platform === 'darwin') { + base = path.join(os.homedir(), 'Library', 'Application Support'); + } else if (process.platform === 'win32') { + base = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + } else { + base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + } + const onboarding = path.join(base, 'notey', 'onboarding.toml'); + try { + fs.rmSync(onboarding, { force: true }); + console.log(`Reset onboarding state (${onboarding}) to force first-run reveal.`); + } catch (e) { + console.log(`Could not reset onboarding state: ${e.message}`); + } +} + +// --- Sample data --- + +async function seed() { + // Two workspaces rooted at real, existing directories (create_workspace + // canonicalizes the path). The repo dir and the home dir always exist. + const repoRoot = path.resolve(__dirname, '..'); + await invokeCommand('create_workspace', { name: 'Notey', path: repoRoot }).catch(() => null); + await invokeCommand('create_workspace', { name: 'Personal', path: os.homedir() }).catch(() => null); + + const notes = [ + `# Release checklist\n\n- [x] Tag v0.1.0\n- [ ] Draft GitHub release notes\n- [ ] Capture fresh screenshots\n- [ ] Announce on the changelog`, + `# Meeting notes — sync\n\nDecisions:\n- Ship the workspace selector in the status bar\n- Keep the capture window at 720×480\n\nFollow-ups:\n- Wire FTS5 snippets into search results`, + `# Ideas\n\nA quick-capture note tool that disappears when you Esc. Global hotkey summons it, tabs hold what you're juggling, and full-text search finds it later.`, + `# Shell snippets\n\n\`\`\`sh\nrg --files | fzf\ngit log --oneline --graph\n\`\`\``, + `Plain scratch buffer — todo: reply to the docs thread.`, + ]; + for (const content of notes) { + const created = await invokeCommand('create_note', { format: 'markdown', workspaceId: null }); + if (created?.ok && created.value?.id != null) { + await invokeCommand('update_note', { + id: created.value.id, + title: null, + content, + format: null, + }); + } + } + await pause(400); +} + +// --- Capture sequence --- + +// Navigation closures used by the per-view sessions. + +async function typeSampleNote() { + await sendChord(sessionId, CONTROL, 'n'); + await pause(400); + const content = await waitForCss('.cm-content'); + await sendKeysToElement( + sessionId, + content, + '# Standup\n\n- Shipped the trash lifecycle\n- Reviewing the CLI live-sync seam\n- Next: docs + screenshots', + ); + await pause(900); // auto-save debounce + round-trip +} + +async function openPalette() { + await sendChord(sessionId, CONTROL, 'p'); + await waitForCss('[data-testid="command-palette"]'); + await pause(300); +} + +async function openSearch() { + await runPaletteCommand('Search Notes'); + const search = await waitForCss('[data-testid="search-input"]'); + await sendKeysToElement(sessionId, search, 'release'); + await pause(500); +} + +async function openNoteList() { + await sendChord(sessionId, CONTROL, 'b'); + await waitForCss('[data-testid="note-list-panel"]'); + await pause(300); +} + +async function openSettings() { + await runPaletteCommand('Open Settings'); + await waitForCss('[data-testid="settings-overlay"]'); + await pause(300); +} + +async function openWorkspaceMenu() { + await executeScript(sessionId, 'document.querySelector(arguments[0]).focus();', [ + '[data-testid="workspace-name"]', + ]); + await sendSpecialKey(sessionId, ENTER); + await pause(400); +} + +async function openTrash() { + // Create a throwaway note, move it to trash, then view the panel. + await sendChord(sessionId, CONTROL, 'n'); + await pause(300); + const tmp = await waitForCss('.cm-content'); + await sendKeysToElement(sessionId, tmp, 'Draft to discard'); + await pause(800); + await runPaletteCommand('Move to Trash'); + await pause(400); + await runPaletteCommand('View Trash'); + await waitForCss('[data-testid="trash-panel"]'); + await pause(300); +} + +async function main() { + fs.mkdirSync(OUT_DIR, { recursive: true }); + + console.log('Starting tauri-driver...'); + await startDriver(); + + try { + // First-run onboarding overlay — captured in its own session before dismissal. + console.log('\nOnboarding (first run):'); + await withSession('onboarding', async () => { + await waitForCss('[data-testid="onboarding-overlay"]', 6000); + await capture('onboarding'); + }); + + // Seed sample notes + workspaces once; they persist in the notes DB and are + // reused by every subsequent view session. + console.log('\nSeeding sample data...'); + await withSession('seed', async () => { + await dismissOnboarding(); + await seed(); + }); + + console.log('\nLight theme:'); + await shot('editor-light', { theme: 'light', navigate: typeSampleNote }); + await shot('command-palette', { theme: 'light', navigate: openPalette }); + await shot('search', { theme: 'light', navigate: openSearch }); + await shot('note-list', { theme: 'light', navigate: openNoteList }); + await shot('settings-general', { theme: 'light', navigate: openSettings }); + await shot('workspace-selector', { theme: 'light', navigate: openWorkspaceMenu }); + await shot('trash', { theme: 'light', navigate: openTrash }); + + console.log('\nDark theme:'); + await shot('editor-dark', { theme: 'dark', navigate: typeSampleNote }); + await shot('command-palette-dark', { theme: 'dark', navigate: openPalette }); + } catch (e) { + console.error('Fatal error:', e.message); + failed++; + } finally { + if (sessionId) await deleteSession(sessionId).catch(() => {}); + stopDriver(); + } + + console.log( + `\nCaptured ${captured} screenshot(s) into ${path.relative(process.cwd(), OUT_DIR)}/` + + (failed ? `, ${failed} failed` : ''), + ); + process.exit(failed > 0 ? 1 : 0); +} + +main(); diff --git a/package.json b/package.json index 0473e67..b827a25 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,14 @@ { - "name": "tauri-app", + "name": "notey", "private": true, "version": "0.1.0", + "description": "Notey — a fast, keyboard-driven, workspace-aware note-capture desktop app.", + "author": "Paul Bean ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/pbean/notey.git" + }, "type": "module", "scripts": { "dev": "vite", @@ -9,7 +16,8 @@ "preview": "vite preview", "tauri": "tauri", "test": "vitest run", - "test:e2e": "node e2e/run.mjs" + "test:e2e": "node e2e/run.mjs", + "screenshots": "node e2e/screenshots.mjs" }, "dependencies": { "@base-ui/react": "^1.3.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 77a697d..03cebc2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2331,6 +2331,34 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "notey" +version = "0.1.0" +dependencies = [ + "chrono", + "dirs 5.0.1", + "dunce", + "interprocess", + "libc", + "rusqlite", + "rusqlite_migration", + "serde", + "serde_json", + "specta", + "specta-typescript", + "tauri", + "tauri-build", + "tauri-plugin-autostart", + "tauri-plugin-dialog", + "tauri-plugin-global-shortcut", + "tauri-plugin-opener", + "tauri-specta", + "tempfile", + "thiserror 1.0.69", + "toml 0.8.2", + "windows", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -4030,34 +4058,6 @@ dependencies = [ "windows", ] -[[package]] -name = "tauri-app" -version = "0.1.0" -dependencies = [ - "chrono", - "dirs 5.0.1", - "dunce", - "interprocess", - "libc", - "rusqlite", - "rusqlite_migration", - "serde", - "serde_json", - "specta", - "specta-typescript", - "tauri", - "tauri-build", - "tauri-plugin-autostart", - "tauri-plugin-dialog", - "tauri-plugin-global-shortcut", - "tauri-plugin-opener", - "tauri-specta", - "tempfile", - "thiserror 1.0.69", - "toml 0.8.2", - "windows", -] - [[package]] name = "tauri-build" version = "2.6.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7a1d9ca..92f8ae0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,8 +1,10 @@ [package] -name = "tauri-app" +name = "notey" version = "0.1.0" -description = "A Tauri App" -authors = ["you"] +description = "Notey — a fast, keyboard-driven, workspace-aware note-capture desktop app." +authors = ["Paul Bean "] +license = "MIT" +repository = "https://github.com/pbean/notey" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -11,7 +13,7 @@ edition = "2021" # The `_lib` suffix may seem redundant but it is necessary # to make the lib name unique and wouldn't conflict with the bin name. # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 -name = "tauri_app_lib" +name = "notey_lib" crate-type = ["staticlib", "cdylib", "rlib"] [features] diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 1590477..16c61eb 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,8 @@ "windows": ["main"], "permissions": [ "core:default", + "core:window:allow-show", + "core:window:allow-set-focus", "opener:default", "dialog:allow-open", "dialog:allow-save", diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2abccd9..531f025 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - tauri_app_lib::run() + notey_lib::run() } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 60a17d1..240aaaa 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "tauri-app", + "productName": "Notey", "version": "0.1.0", - "identifier": "com.pinkyd.tauri-app", + "identifier": "com.pinkyd.notey", "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", diff --git a/src-tauri/tests/autostart_tests.rs b/src-tauri/tests/autostart_tests.rs index ef6fac7..ad18943 100644 --- a/src-tauri/tests/autostart_tests.rs +++ b/src-tauri/tests/autostart_tests.rs @@ -12,8 +12,8 @@ use tempfile::TempDir; -use tauri_app_lib::models::config::AppConfig; -use tauri_app_lib::services::config; +use notey_lib::models::config::AppConfig; +use notey_lib::services::config; /// Persist a preference exactly as the `set_autostart` command does: set the field /// on the loaded config and save through the config service. diff --git a/src-tauri/tests/db_tests.rs b/src-tauri/tests/db_tests.rs index c7e33db..f8a484b 100644 --- a/src-tauri/tests/db_tests.rs +++ b/src-tauri/tests/db_tests.rs @@ -3,8 +3,8 @@ mod helpers; use rusqlite::params; use helpers::factories::{create_temp_db, NoteBuilder}; -use tauri_app_lib::errors::NoteyError; -use tauri_app_lib::services::notes; +use notey_lib::errors::NoteyError; +use notey_lib::services::notes; #[test] fn test_notes_table_creation() { @@ -163,7 +163,7 @@ fn test_db_write_survives_reopen() { drop(conn); // Reopen the same database - let conn2 = tauri_app_lib::db::init_db(dir.path().to_path_buf()).expect("reopen failed"); + let conn2 = notey_lib::db::init_db(dir.path().to_path_buf()).expect("reopen failed"); let reloaded = notes::get_note(&conn2, note_id).expect("get_note after reopen failed"); assert_eq!(reloaded.title, "durable title"); @@ -220,7 +220,7 @@ fn test_migration_idempotent_on_existing_db() { drop(conn); // Re-init the same DB (migrations should be a no-op) - let conn2 = tauri_app_lib::db::init_db(dir.path().to_path_buf()).expect("re-init failed"); + let conn2 = notey_lib::db::init_db(dir.path().to_path_buf()).expect("re-init failed"); let schema2: String = conn2 .query_row( "SELECT sql FROM sqlite_master WHERE type='table' AND name='notes'", diff --git a/src-tauri/tests/helpers/factories.rs b/src-tauri/tests/helpers/factories.rs index d1893f9..37ea69b 100644 --- a/src-tauri/tests/helpers/factories.rs +++ b/src-tauri/tests/helpers/factories.rs @@ -2,8 +2,8 @@ use chrono::Utc; use rusqlite::{params, Connection}; use tempfile::TempDir; -use tauri_app_lib::db; -use tauri_app_lib::models::Note; +use notey_lib::db; +use notey_lib::models::Note; /// Create a file-backed temp DB with full init (PRAGMAs + migrations). /// Returns the connection and a `TempDir` guard — the directory is automatically diff --git a/src-tauri/tests/ipc_tests.rs b/src-tauri/tests/ipc_tests.rs index 068b07a..aeeed2b 100644 --- a/src-tauri/tests/ipc_tests.rs +++ b/src-tauri/tests/ipc_tests.rs @@ -18,8 +18,8 @@ use tempfile::TempDir; use helpers::factories::create_temp_db; use interprocess::local_socket::Stream; -use tauri_app_lib::ipc::protocol::{IpcResponse, MAX_CONTENT_BYTES}; -use tauri_app_lib::ipc::socket_server::{self, Handler, IpcServer}; +use notey_lib::ipc::protocol::{IpcResponse, MAX_CONTENT_BYTES}; +use notey_lib::ipc::socket_server::{self, Handler, IpcServer}; /// A running server bound to a temp socket over a shared temp DB. The server is /// held only to keep it alive and to unlink its socket on drop. @@ -93,7 +93,7 @@ impl TestServer { let handler_conn = Arc::clone(&conn); let handler: Handler = Arc::new(move |raw: &[u8]| { let guard = handler_conn.lock().unwrap_or_else(|e| e.into_inner()); - tauri_app_lib::ipc::protocol::handle_request(&guard, raw) + notey_lib::ipc::protocol::handle_request(&guard, raw) }); // Held across env mutation AND `IpcServer::start` (which reads the env), @@ -272,7 +272,7 @@ fn socket_server_default_matches_platform() { // With no override seam set, socket_server must defer to the single source of // truth in the platform abstraction (Story 8.5). let from_server = socket_server::socket_path(); - let from_platform = tauri_app_lib::platform::current().socket_path(); + let from_platform = notey_lib::platform::current().socket_path(); assert_eq!(from_server, from_platform); } @@ -395,7 +395,7 @@ fn start_at(path: &Path, conn: Arc>) -> IpcServer { fn handler_for_conn(conn: Arc>) -> Handler { Arc::new(move |raw: &[u8]| { let guard = conn.lock().unwrap_or_else(|e| e.into_inner()); - tauri_app_lib::ipc::protocol::handle_request(&guard, raw) + notey_lib::ipc::protocol::handle_request(&guard, raw) }) } diff --git a/src-tauri/tests/onboarding_tests.rs b/src-tauri/tests/onboarding_tests.rs index 3427b3d..b1e8490 100644 --- a/src-tauri/tests/onboarding_tests.rs +++ b/src-tauri/tests/onboarding_tests.rs @@ -6,7 +6,7 @@ use tempfile::TempDir; -use tauri_app_lib::services::onboarding::{self, OnboardingState, COMMAND_HINT_SESSION_LIMIT}; +use notey_lib::services::onboarding::{self, OnboardingState, COMMAND_HINT_SESSION_LIMIT}; /// AC: "the application starts for the first time (no `onboarding_complete` flag)". /// A fresh config dir yields the default state — not complete, zero sessions. diff --git a/src-tauri/tests/platform_tests.rs b/src-tauri/tests/platform_tests.rs index 4c3fec8..8beec84 100644 --- a/src-tauri/tests/platform_tests.rs +++ b/src-tauri/tests/platform_tests.rs @@ -1,6 +1,6 @@ //! ATDD red-phase acceptance tests — Stories 8.6 (Cross-Platform Verification & //! Wayland Fallback), 8.5 (Per-User Data Isolation), and 8.2 (macOS Accessibility -//! Permission Guidance), exercised through the [`tauri_app_lib::platform`] +//! Permission Guidance), exercised through the [`notey_lib::platform`] //! abstraction. //! //! Assertions are `#[cfg(target_os = ...)]`-gated so each only compiles/runs on @@ -10,7 +10,7 @@ //! //! cargo test --test platform_tests -- --ignored -use tauri_app_lib::platform; +use notey_lib::platform; /// Serializes the two tests that read/mutate the process-global `NOTEY_DATA_DIR` /// env var, so the override test cannot leak into the standard-path test when the @@ -136,7 +136,7 @@ fn config_dir_is_user_scoped_and_standard() { #[test] fn config_dir_matches_services_config() { let via_service = - tauri_app_lib::services::config::config_dir().expect("services config_dir must resolve"); + notey_lib::services::config::config_dir().expect("services config_dir must resolve"); let via_trait = platform::current() .config_dir() .expect("platform config_dir must resolve"); diff --git a/src-tauri/tests/search_tests.rs b/src-tauri/tests/search_tests.rs index ab30118..d57ae20 100644 --- a/src-tauri/tests/search_tests.rs +++ b/src-tauri/tests/search_tests.rs @@ -3,9 +3,9 @@ mod helpers; use rusqlite::params; use helpers::factories::{create_temp_db, NoteBuilder}; -use tauri_app_lib::db; -use tauri_app_lib::services::notes; -use tauri_app_lib::services::search_service; +use notey_lib::db; +use notey_lib::services::notes; +use notey_lib::services::search_service; // P0-INT-005b: FTS5 virtual table and all 3 triggers exist in sqlite_master #[test] diff --git a/src-tauri/tests/workspace_tests.rs b/src-tauri/tests/workspace_tests.rs index c7bd7ce..7a1b285 100644 --- a/src-tauri/tests/workspace_tests.rs +++ b/src-tauri/tests/workspace_tests.rs @@ -1,8 +1,8 @@ mod helpers; use helpers::factories::{create_temp_db, setup_test_db, NoteBuilder}; -use tauri_app_lib::errors::NoteyError; -use tauri_app_lib::services::workspace_service; +use notey_lib::errors::NoteyError; +use notey_lib::services::workspace_service; use tempfile::TempDir; // UNIT-2.1-001: workspaces table created with correct schema by migration @@ -167,7 +167,7 @@ fn test_migration_applies_on_existing_db_with_notes() { // Verify re-init doesn't break anything drop(conn); - let conn2 = tauri_app_lib::db::init_db(dir.path().to_path_buf()).expect("re-init failed"); + let conn2 = notey_lib::db::init_db(dir.path().to_path_buf()).expect("re-init failed"); let note_count2: i64 = conn2 .query_row("SELECT COUNT(*) FROM notes", [], |row| row.get(0)) .expect("count notes after re-init"); @@ -674,7 +674,7 @@ fn test_resolve_workspace_then_create_note_with_workspace_id() { let ws = workspace_service::resolve_workspace(&conn, dir.path().to_str().unwrap()) .expect("resolve_workspace failed"); - let note = tauri_app_lib::services::notes::create_note(&conn, "markdown", Some(ws.id)) + let note = notey_lib::services::notes::create_note(&conn, "markdown", Some(ws.id)) .expect("create_note failed"); assert_eq!( @@ -685,7 +685,7 @@ fn test_resolve_workspace_then_create_note_with_workspace_id() { // Verify via independent get_note let fetched = - tauri_app_lib::services::notes::get_note(&conn, note.id).expect("get_note failed"); + notey_lib::services::notes::get_note(&conn, note.id).expect("get_note failed"); assert_eq!( fetched.workspace_id, Some(ws.id), @@ -722,7 +722,7 @@ fn test_list_notes_filtered_by_workspace_integration() { .insert(&conn); let _note_null = NoteBuilder::new().title("Unscoped").insert(&conn); // workspace_id = NULL - let result = tauri_app_lib::services::notes::list_notes(&conn, Some(ws_a.id)) + let result = notey_lib::services::notes::list_notes(&conn, Some(ws_a.id)) .expect("list_notes filtered"); assert_eq!(result.len(), 2, "should return only notes in ws_a"); assert!( @@ -758,7 +758,7 @@ fn test_list_notes_filtered_excludes_trashed_integration() { .trashed() .insert(&conn); - let result = tauri_app_lib::services::notes::list_notes(&conn, Some(ws.id)) + let result = notey_lib::services::notes::list_notes(&conn, Some(ws.id)) .expect("list_notes filtered"); assert_eq!(result.len(), 1, "trashed note should be excluded"); assert_eq!(result[0].title, "Active"); @@ -779,7 +779,7 @@ fn test_list_notes_preserves_workspace_id() { .insert(&conn); NoteBuilder::new().title("Unscoped").insert(&conn); - let notes = tauri_app_lib::services::notes::list_notes(&conn, None).expect("list_notes failed"); + let notes = notey_lib::services::notes::list_notes(&conn, None).expect("list_notes failed"); assert_eq!(notes.len(), 2); let scoped = notes @@ -810,12 +810,12 @@ fn test_update_note_preserves_workspace_id() { let ws = workspace_service::create_workspace(&conn, "Project", dir.path().to_str().unwrap()) .expect("create_workspace failed"); - let note = tauri_app_lib::services::notes::create_note(&conn, "markdown", Some(ws.id)) + let note = notey_lib::services::notes::create_note(&conn, "markdown", Some(ws.id)) .expect("create_note failed"); assert_eq!(note.workspace_id, Some(ws.id)); // Update title and content — workspace_id should remain unchanged - let updated = tauri_app_lib::services::notes::update_note( + let updated = notey_lib::services::notes::update_note( &conn, note.id, Some("Updated Title".to_string()),