diff --git a/.agents/skills/adapt/SKILL.md b/.agents/skills/adapt/SKILL.md index 165c66dc..af06c708 100644 --- a/.agents/skills/adapt/SKILL.md +++ b/.agents/skills/adapt/SKILL.md @@ -2,7 +2,7 @@ name: adapt description: Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility. user-invocable: true -argument-hint: "[target] [context (mobile, tablet, print...)]" +argument-hint: '[target] [context (mobile, tablet, print...)]' --- Adapt existing designs to work effectively across different contexts - different screen sizes, devices, platforms, or use cases. @@ -44,12 +44,14 @@ Create context-appropriate strategy: ### Mobile Adaptation (Desktop → Mobile) **Layout Strategy**: + - Single column instead of multi-column - Vertical stacking instead of side-by-side - Full-width components instead of fixed widths - Bottom navigation instead of top/side navigation **Interaction Strategy**: + - Touch targets 44x44px minimum (not hover-dependent) - Swipe gestures where appropriate (lists, carousels) - Bottom sheets instead of dropdowns @@ -57,12 +59,14 @@ Create context-appropriate strategy: - Larger tap areas with more spacing **Content Strategy**: + - Progressive disclosure (don't show everything at once) - Prioritize primary content (secondary content in tabs/accordions) - Shorter text (more concise) - Larger text (16px minimum) **Navigation Strategy**: + - Hamburger menu or bottom navigation - Reduce navigation complexity - Sticky headers for context @@ -71,12 +75,14 @@ Create context-appropriate strategy: ### Tablet Adaptation (Hybrid Approach) **Layout Strategy**: + - Two-column layouts (not single or three-column) - Side panels for secondary content - Master-detail views (list + detail) - Adaptive based on orientation (portrait vs landscape) **Interaction Strategy**: + - Support both touch and pointer - Touch targets 44x44px but allow denser layouts than phone - Side navigation drawers @@ -85,12 +91,14 @@ Create context-appropriate strategy: ### Desktop Adaptation (Mobile → Desktop) **Layout Strategy**: + - Multi-column layouts (use horizontal space) - Side navigation always visible - Multiple information panels simultaneously - Fixed widths with max-width constraints (don't stretch to 4K) **Interaction Strategy**: + - Hover states for additional information - Keyboard shortcuts - Right-click context menus @@ -98,6 +106,7 @@ Create context-appropriate strategy: - Multi-select with Shift/Cmd **Content Strategy**: + - Show more information upfront (less progressive disclosure) - Data tables with many columns - Richer visualizations @@ -106,12 +115,14 @@ Create context-appropriate strategy: ### Print Adaptation (Screen → Print) **Layout Strategy**: + - Page breaks at logical points - Remove navigation, footer, interactive elements - Black and white (or limited color) - Proper margins for binding **Content Strategy**: + - Expand shortened content (show full URLs, hidden sections) - Add page numbers, headers, footers - Include metadata (print date, page title) @@ -120,12 +131,14 @@ Create context-appropriate strategy: ### Email Adaptation (Web → Email) **Layout Strategy**: + - Narrow width (600px max) - Single column only - Inline CSS (no external stylesheets) - Table-based layouts (for email client compatibility) **Interaction Strategy**: + - Large, obvious CTAs (buttons not text links) - No hover states (not reliable) - Deep links to web app for complex interactions @@ -137,6 +150,7 @@ Apply changes systematically: ### Responsive Breakpoints Choose appropriate breakpoints: + - Mobile: 320px-767px - Tablet: 768px-1023px - Desktop: 1024px+ @@ -175,6 +189,7 @@ Choose appropriate breakpoints: **IMPORTANT**: Test on real devices, not just browser DevTools. Device emulation is helpful but not perfect. **NEVER**: + - Hide core functionality on mobile (if it matters, make it work) - Assume desktop = powerful device (consider accessibility, older machines) - Use different information architecture across contexts (confusing) @@ -195,4 +210,4 @@ Test thoroughly across contexts: - **Edge cases**: Very small screens (320px), very large screens (4K) - **Slow connections**: Test on throttled network -Remember: You're a cross-platform design expert. Make experiences that feel native to each context while maintaining brand and functionality consistency. Adapt intentionally, test thoroughly. \ No newline at end of file +Remember: You're a cross-platform design expert. Make experiences that feel native to each context while maintaining brand and functionality consistency. Adapt intentionally, test thoroughly. diff --git a/.agents/skills/clarify/SKILL.md b/.agents/skills/clarify/SKILL.md index 4db9228d..2a352bf4 100644 --- a/.agents/skills/clarify/SKILL.md +++ b/.agents/skills/clarify/SKILL.md @@ -2,7 +2,7 @@ name: clarify description: Improve unclear UX copy, error messages, microcopy, labels, and instructions to make interfaces easier to understand. Use when the user mentions confusing text, unclear labels, bad error messages, hard-to-follow instructions, or wanting better UX writing. user-invocable: true -argument-hint: "[target]" +argument-hint: '[target]' --- Identify and improve unclear, confusing, or poorly written interface text to make the product easier to understand and use. @@ -50,6 +50,7 @@ Create a strategy for clearer communication: Refine text across these common areas: ### Error Messages + **Bad**: "Error 403: Forbidden" **Good**: "You don't have permission to view this page. Contact your admin for access." @@ -57,6 +58,7 @@ Refine text across these common areas: **Good**: "Email addresses need an @ symbol. Try: name@example.com" **Principles**: + - Explain what went wrong in plain language - Suggest how to fix it - Don't blame the user @@ -64,6 +66,7 @@ Refine text across these common areas: - Link to help/support if applicable ### Form Labels & Instructions + **Bad**: "DOB (MM/DD/YYYY)" **Good**: "Date of birth" (with placeholder showing format) @@ -71,6 +74,7 @@ Refine text across these common areas: **Good**: "Your email address" or "Company name" **Principles**: + - Use clear, specific labels (not generic placeholders) - Show format expectations with examples - Explain why you're asking (when not obvious) @@ -78,69 +82,83 @@ Refine text across these common areas: - Keep required field indicators clear ### Button & CTA Text + **Bad**: "Click here" | "Submit" | "OK" **Good**: "Create account" | "Save changes" | "Got it, thanks" **Principles**: + - Describe the action specifically - Use active voice (verb + noun) - Match user's mental model - Be specific ("Save" is better than "OK") ### Help Text & Tooltips + **Bad**: "This is the username field" **Good**: "Choose a username. You can change this later in Settings." **Principles**: + - Add value (don't just repeat the label) - Answer the implicit question ("What is this?" or "Why do you need this?") - Keep it brief but complete - Link to detailed docs if needed ### Empty States + **Bad**: "No items" **Good**: "No projects yet. Create your first project to get started." **Principles**: + - Explain why it's empty (if not obvious) - Show next action clearly - Make it welcoming, not dead-end ### Success Messages + **Bad**: "Success" **Good**: "Settings saved! Your changes will take effect immediately." **Principles**: + - Confirm what happened - Explain what happens next (if relevant) - Be brief but complete - Match the user's emotional moment (celebrate big wins) ### Loading States + **Bad**: "Loading..." (for 30+ seconds) **Good**: "Analyzing your data... this usually takes 30-60 seconds" **Principles**: + - Set expectations (how long?) - Explain what's happening (when it's not obvious) - Show progress when possible - Offer escape hatch if appropriate ("Cancel") ### Confirmation Dialogs + **Bad**: "Are you sure?" **Good**: "Delete 'Project Alpha'? This can't be undone." **Principles**: + - State the specific action - Explain consequences (especially for destructive actions) - Use clear button labels ("Delete project" not "Yes") - Don't overuse confirmations (only for risky actions) ### Navigation & Wayfinding + **Bad**: Generic labels like "Items" | "Things" | "Stuff" **Good**: Specific labels like "Your projects" | "Team members" | "Settings" **Principles**: + - Be specific and descriptive - Use language users understand (not internal jargon) - Make hierarchy clear @@ -158,6 +176,7 @@ Every piece of copy should follow these rules: 6. **Be consistent**: Use same terms throughout (don't vary for variety) **NEVER**: + - Use jargon without explanation - Blame users ("You made an error" → "This field is required") - Be vague ("Something went wrong" without explanation) @@ -179,4 +198,4 @@ Test that copy improvements work: - **Consistency**: Does it match terminology elsewhere? - **Tone**: Is it appropriate for the situation? -Remember: You're a clarity expert with excellent communication skills. Write like you're explaining to a smart friend who's unfamiliar with the product. Be clear, be helpful, be human. \ No newline at end of file +Remember: You're a clarity expert with excellent communication skills. Write like you're explaining to a smart friend who's unfamiliar with the product. Be clear, be helpful, be human. diff --git a/.claude/skills/launch-video/SKILL.md b/.claude/skills/launch-video/SKILL.md index 34b5ca25..36bf54bf 100644 --- a/.claude/skills/launch-video/SKILL.md +++ b/.claude/skills/launch-video/SKILL.md @@ -2,7 +2,7 @@ name: launch-video description: Use when creating a launch, release, or announcement video for the Spool desktop app from real screen recordings. Covers the capture pipeline (Electron + native macOS window recording), the HyperFrames composition layout, common trailer-vs-PPT pitfalls, and the first-frame poster trick for social media. Invoke when the user mentions release video, launch video, announcement video, trailer, demo video, or wants to ship a video for a Spool version bump. user-invocable: true -argument-hint: "[version (e.g. v0.5.0)]" +argument-hint: '[version (e.g. v0.5.0)]' --- Build a release video for a Spool version. The output is a 1080p MP4 plus a poster JPG, intended for X / Twitter. Runtime scales with feature count: 20–40s, 3–8 feature beats + brand outro, optionally a stylised capstone scene before the outro for releases whose payoff is a tangible outcome the captured clips can't show on their own. @@ -15,6 +15,7 @@ A release video for Spool has two halves running in parallel on screen: 2. **Right two-thirds** — the real Spool app, recorded as a native macOS window. The camera (CSS `transform-origin + scale` on the `.window`) zooms into the specific UI region for each feature. Focus on the active feature is carried by one of two devices: + - **Amber annotation rectangles** inside `.window` that track the camera — good for "look at this strip" callouts. Cheap and precise when the feature is a single bounded region. - **Spotlight mask** — an SVG overlay that dims everything except 1–2 transparent "holes" punched through. Softer falloff means small coordinate drift disappears into the dim instead of reading as a misaligned outline. Dual holes let you keep the preview bright while a control on the right side lights up in sync with a click. See `references/spotlight.md`. @@ -41,7 +42,7 @@ Read these references before you start composing. They are not optional — each - Records one `.mov` per feature via `recordNativeWindow()` from `helpers/native-window-capture.ts` - Uses `cursorClick(window, selector, opts)` / `cursorTo(...)` / `cursorPark(x, y)` from the same helper instead of bare `.click()` so the cursor's path is visibly filmed - Outputs to `videos/spool-vX.Y.Z/assets/live/` (gitignored) - - If your release gates a feature behind a Vite flag, build the app with `VITE_FEATURE_=1 pnpm --filter @spool/app run build:electron` *before* running the spec — `import.meta.env.VITE_FEATURE_` is inlined at build time, not read at runtime + - If your release gates a feature behind a Vite flag, build the app with `VITE_FEATURE_=1 pnpm --filter @spool/app run build:electron` _before_ running the spec — `import.meta.env.VITE_FEATURE_` is inlined at build time, not read at runtime - Bump `--global-timeout` if pacing is relaxed: Playwright's default 300s isn't enough for 7-beat spec runs (~50s of `screencapture -V` plus warmup) 4. **Copy `videos/launch-template/` to `videos/spool-vX.Y.Z/`.** 5. **Customise the composition** (`index.html`): @@ -67,8 +68,8 @@ Read these references before you start composing. They are not optional — each ## Hard rules -- **Synthetic cursor is allowed but must track real input.** A floating GSAP cursor that arrives at random places looks uncanny — that rule from prior releases stands. What's *not* uncanny is the cursor-overlay technique in `references/cursor-overlay.md`: a DOM cursor injected into the page that follows Playwright's actual `mouse.move()` and pulses on `mousedown`. If a scene's whole point is a click, draw the cursor. Don't draw a cursor for scenes where nothing's being clicked. -- **Chain state across clips. Don't reset between recordings.** If clip N ends with the UI in some configuration, clip N+1 starts there. Pre-clip state resets (clicking around to clean up between recordings) cause a visible "flash" at the clipCut boundary even though both clips show valid app pixels. If a later beat needs a specific look, get to it *inside* an earlier recording (the user sees the click) or design the demo flow so the natural state at each beat is already what you want. +- **Synthetic cursor is allowed but must track real input.** A floating GSAP cursor that arrives at random places looks uncanny — that rule from prior releases stands. What's _not_ uncanny is the cursor-overlay technique in `references/cursor-overlay.md`: a DOM cursor injected into the page that follows Playwright's actual `mouse.move()` and pulses on `mousedown`. If a scene's whole point is a click, draw the cursor. Don't draw a cursor for scenes where nothing's being clicked. +- **Chain state across clips. Don't reset between recordings.** If clip N ends with the UI in some configuration, clip N+1 starts there. Pre-clip state resets (clicking around to clean up between recordings) cause a visible "flash" at the clipCut boundary even though both clips show valid app pixels. If a later beat needs a specific look, get to it _inside_ an earlier recording (the user sees the click) or design the demo flow so the natural state at each beat is already what you want. - **First frame must be the full hero state.** Brand mark + panel 1 + UI window all visible at `t=0`, not faded in. Twitter's auto-thumbnail grabs this frame. - **Annotations and spotlights live inside `.window`**, not on the outer canvas. They must move with the camera transform. - **Spotlight coords must be verified by overlay on the rendered frame.** Eyeballing them off a grid on a raw `.mov` is consistently 5–15% off. See `pitfalls.md` on the drawbox verification loop. diff --git a/.claude/skills/launch-video/references/capture.md b/.claude/skills/launch-video/references/capture.md index 0fd97b8a..8d15f120 100644 --- a/.claude/skills/launch-video/references/capture.md +++ b/.claude/skills/launch-video/references/capture.md @@ -33,7 +33,11 @@ const PROJECTS: ProjectSeed[] = [ name: 'spool', total: 79, leadSessions: [ - { title: 'Audit the share flow in a fresh worktree', source: 'claude', iso: '2026-05-13T15:12:00Z' }, + { + title: 'Audit the share flow in a fresh worktree', + source: 'claude', + iso: '2026-05-13T15:12:00Z', + }, // ... more titles per project ], fillerSources: ['claude', 'codex'], @@ -68,18 +72,19 @@ const PROJECTS = [/* per-release seed */] const OUT_DIR = path.join(__dirname, '../../../videos/spool-vX.Y.Z/assets/live') test('record release clips', async () => { - test.setTimeout(540_000) // ~9min — relaxed pacing across 7 beats can take longer than the default 30s test timeout + test.setTimeout(540_000) // ~9min — relaxed pacing across 7 beats can take longer than the default 30s test timeout const ctx = await launchDemoApp(PROJECTS) await setDemoWindowBounds(ctx, 1080, 740) await waitForDemoSync(ctx.window) - await installCursorOverlay(ctx.window) // synthetic cursor for the rest of the spec - await cursorPark(ctx.window, 120, 360) // park off to the side before clip 1 + await installCursorOverlay(ctx.window) // synthetic cursor for the rest of the spec + await cursorPark(ctx.window, 120, 360) // park off to the side before clip 1 await recordNativeWindow(ctx.app, path.join(OUT_DIR, '01-home.mov'), 4.0, async () => { - await ctx.window.waitForTimeout(500) // settle so the cursor is in the first filmed frame + await ctx.window.waitForTimeout(500) // settle so the cursor is in the first filmed frame await cursorClick(ctx.window, '[data-testid=""]', { - preClickPause: 260, postClickPause: 280, + preClickPause: 260, + postClickPause: 280, }) // ... drive the rest of the scene }) @@ -99,7 +104,7 @@ pnpm --filter @spool/app exec playwright test e2e/release-capture.spec.ts \ `--global-timeout` is required for relaxed-pacing releases — Playwright's config default is 300s and a 7-beat spec with relaxed `postClickPause` values can easily exceed that. `--retries=0` because a flaky capture (Electron failing to focus, screencapture permission glitch) on first run can spawn a second Electron whose window overlaps the first — the capture then records the wrong app state. Better to fail loudly and re-run by hand than auto-retry and ship wrong frames. -If the feature being demoed is behind a build-time flag (e.g. `VITE_FEATURE_`), build the app with the flag *before* running the spec — `import.meta.env.VITE_FEATURE_` is inlined at build time: +If the feature being demoed is behind a build-time flag (e.g. `VITE_FEATURE_`), build the app with the flag _before_ running the spec — `import.meta.env.VITE_FEATURE_` is inlined at build time: ```bash VITE_FEATURE_=1 pnpm --filter @spool/app run build:electron @@ -109,12 +114,12 @@ If the feature is also gated by a runtime opt-in stored in `agents.json` (e.g. a ## Capture-time constants -| | Value | Why | -|---|---|---| -| Window logical size | 1080×740 | Matches app default (`packages/app/src/main/index.ts`); the composition assumes this aspect | -| Theme | dark forced | `nativeTheme.themeSource = 'dark'` in `setDemoWindowBounds()` | -| GPU | disabled | `ELECTRON_DISABLE_GPU=1` for deterministic frames | -| `screencapture` cursor | off | Default; don't add `-C`. The real OS cursor doesn't move with Playwright, so capturing it would film a stationary system arrow in some corner. Use the synthetic DOM cursor from `cursor-overlay.md` instead | +| | Value | Why | +| ---------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Window logical size | 1080×740 | Matches app default (`packages/app/src/main/index.ts`); the composition assumes this aspect | +| Theme | dark forced | `nativeTheme.themeSource = 'dark'` in `setDemoWindowBounds()` | +| GPU | disabled | `ELECTRON_DISABLE_GPU=1` for deterministic frames | +| `screencapture` cursor | off | Default; don't add `-C`. The real OS cursor doesn't move with Playwright, so capturing it would film a stationary system arrow in some corner. Use the synthetic DOM cursor from `cursor-overlay.md` instead | ## Common capture issues diff --git a/.claude/skills/launch-video/references/composition.md b/.claude/skills/launch-video/references/composition.md index 8420b8f3..d982e4c1 100644 --- a/.claude/skills/launch-video/references/composition.md +++ b/.claude/skills/launch-video/references/composition.md @@ -1,16 +1,16 @@ # Composition -The proven HyperFrames composition layout. Copy from `videos/launch-template/index.html` and customise per release; this doc explains *why* the template looks the way it does so you don't accidentally revert hard-won decisions. +The proven HyperFrames composition layout. Copy from `videos/launch-template/index.html` and customise per release; this doc explains _why_ the template looks the way it does so you don't accidentally revert hard-won decisions. ## Canvas + window dimensions -| Element | Value | Notes | -|---|---|---| -| Canvas | 1920×1080 | 16:9, Twitter-friendly | -| Window logical size | 1000×685 | Renders the captured 1080×740 .mov at 1.46:1 aspect | -| Window position | left 700, top 197 | Right of canvas centre, vertical centre | -| Window `border-radius` | 8px | Matches macOS native window curve in the captured `.mov`. Smaller → `screencapture -R` desktop background bleeds into corners. Larger → app chrome gets clipped. | -| Drop shadow | three layers (60/120, 24/48, 6/12 px) | Gives a "floating" feel. Single-layer shadow looks flat. | +| Element | Value | Notes | +| ---------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Canvas | 1920×1080 | 16:9, Twitter-friendly | +| Window logical size | 1000×685 | Renders the captured 1080×740 .mov at 1.46:1 aspect | +| Window position | left 700, top 197 | Right of canvas centre, vertical centre | +| Window `border-radius` | 8px | Matches macOS native window curve in the captured `.mov`. Smaller → `screencapture -R` desktop background bleeds into corners. Larger → app chrome gets clipped. | +| Drop shadow | three layers (60/120, 24/48, 6/12 px) | Gives a "floating" feel. Single-layer shadow looks flat. | ## Text panel (left) @@ -29,11 +29,11 @@ The proven HyperFrames composition layout. Copy from `videos/launch-template/ind Camera = `transform-origin + scale` on the `.window`. The annotation rectangles inherit the same transform because they live inside `.window`. -| Intent | Pattern | -|---|---| -| First-screen / overview | No zoom (`scale: 1`) | -| Highlight a sidebar region | Light zoom (`scale: 1.12–1.18`) with origin near the region's centre | -| Punch into a small element (badge, banner) | Tight zoom (`scale: 1.25–1.5`) with origin over the element | +| Intent | Pattern | +| ------------------------------------------ | -------------------------------------------------------------------- | +| First-screen / overview | No zoom (`scale: 1`) | +| Highlight a sidebar region | Light zoom (`scale: 1.12–1.18`) with origin near the region's centre | +| Punch into a small element (badge, banner) | Tight zoom (`scale: 1.25–1.5`) with origin over the element | Many releases ship with `scale: 1` throughout and rely on the spotlight mask (`spotlight.md`) for focus. Reach for a camera punch only when you want the underlying UI itself to physically grow into frame — not as a substitute for spotlight focus. @@ -52,8 +52,16 @@ The amber outline that says "look here". Lives inside `.window`. Coords are in ` ``` ```css -#annot-feature-X { left: 1%; top: 15%; width: 21%; height: 16%; } -#annot-feature-X-label { left: 1%; top: 10.5%; } +#annot-feature-X { + left: 1%; + top: 15%; + width: 21%; + height: 16%; +} +#annot-feature-X-label { + left: 1%; + top: 10.5%; +} ``` **Measuring annotation coords (do this fresh every release):** @@ -96,7 +104,7 @@ t=0.80s click / state change ← action lands The 0.4s lead is the natural-feel range. Anything below 0.25s reads as simultaneous; above 0.6s reads as a delay. -If your scene has only one panel-led beat per scene, this is automatic. For multi-click scenes where the spotlight retargets per click, only the *first* spotlight needs the lead — subsequent retargets can fire ~0.3–0.4s before each click. +If your scene has only one panel-led beat per scene, this is automatic. For multi-click scenes where the spotlight retargets per click, only the _first_ spotlight needs the lead — subsequent retargets can fire ~0.3–0.4s before each click. ## Beat sync @@ -132,7 +140,7 @@ Always append a 0.6–0.8s `afade=t=out` tail so the music decays into the locku ## Optional capstone scene -Some releases earn an extra scene *between* the final feature beat and the brand outro — a stylised after-shot that shows the *outcome* of what the feature does (e.g. tangible exports fanned out as document cards, a list collapsing to "all clean", a generated artefact landing in a destination). Build it in pure HTML/CSS layered above `.window` so no extra captures are needed. +Some releases earn an extra scene _between_ the final feature beat and the brand outro — a stylised after-shot that shows the _outcome_ of what the feature does (e.g. tangible exports fanned out as document cards, a list collapsing to "all clean", a generated artefact landing in a destination). Build it in pure HTML/CSS layered above `.window` so no extra captures are needed. Only earn this scene when the outcome is the headline. For releases whose payoff already lives inside the final feature clip (a count collapsing, a list emptying, a toggle settling), cut straight from that beat to the lockup — adding a capstone there forces redundancy and bloats runtime. The decision is per-release, not a default. @@ -142,21 +150,24 @@ Already encoded in the template as `panelIn()`, `panelOut()`, `zoomTo()`, `clipF The `clipCut(outSel, inSel, at)` helper that swaps videos should bring the incoming clip's opacity to 1 at `at - 0.20` and drop the outgoing clip's opacity to 0 at `at + 0.10` — the 0.30s overlap gives the renderer time to decode the incoming clip's first frame before the outgoing one disappears. A tighter overlap can produce a one-frame dark gap that reads as a flash. See `pitfalls.md` on clip-boundary gaps. -Word-by-word stagger on `panel-headline` is the one place where motion is *allowed* to be slightly playful — keeps the text from feeling like a static slide. +Word-by-word stagger on `panel-headline` is the one place where motion is _allowed_ to be slightly playful — keeps the text from feeling like a static slide. ## End-of-timeline anchor The composition's last frame must be the lockup, not a fade-to-blank. GSAP's `fromTo` keeps a tween at its end-state after completion, but at the timeline's natural end the renderer may revert to the from-state if you don't pin the final state explicitly. Add zero-duration `.to()` tweens just before the composition boundary: ```js -tl.fromTo("#outro-lockup", +tl.fromTo( + '#outro-lockup', { y: 10, opacity: 0, scale: 0.98 }, - { y: 0, opacity: 1, scale: 1, duration: 0.55, ease: "power3.out" }, 33.80); + { y: 0, opacity: 1, scale: 1, duration: 0.55, ease: 'power3.out' }, + 33.8, +) // Anchor the held state at the composition boundary so the last // frame is the lockup, not a fade-to-blank. -tl.to("#outro-lockup", { opacity: 1, y: 0, scale: 1, duration: 0.001 }, 35.49); -tl.to("#outro", { opacity: 1, duration: 0.001 }, 35.49); -tl.to("#outro-fade", { opacity: 1, duration: 0.001 }, 35.49); +tl.to('#outro-lockup', { opacity: 1, y: 0, scale: 1, duration: 0.001 }, 35.49) +tl.to('#outro', { opacity: 1, duration: 0.001 }, 35.49) +tl.to('#outro-fade', { opacity: 1, duration: 0.001 }, 35.49) ``` The composition `data-duration` is 35.50 in this example; the anchor tweens fire at 35.49 to guarantee the last rendered frame holds the lockup's full opacity. diff --git a/.claude/skills/launch-video/references/cursor-overlay.md b/.claude/skills/launch-video/references/cursor-overlay.md index b3db5ff4..7d7a2a23 100644 --- a/.claude/skills/launch-video/references/cursor-overlay.md +++ b/.claude/skills/launch-video/references/cursor-overlay.md @@ -7,11 +7,11 @@ A synthetic DOM cursor that tracks Playwright's mouse position and pulses on cli Two facts about the capture pipeline: 1. **Playwright's `page.mouse.move()` / `mouse.click()` don't move the OS cursor.** They dispatch DOM events directly into the renderer. The real OS cursor stays where it was when you ran the spec. -2. **`screencapture -V` films native pixels.** Whatever the OS shows is what ends up in the `.mov`. Even with `-C`, only the *real* OS cursor would be captured, and it's not moving. +2. **`screencapture -V` films native pixels.** Whatever the OS shows is what ends up in the `.mov`. Even with `-C`, only the _real_ OS cursor would be captured, and it's not moving. So a scene like "click the + button, watch the picker open" reads as "the picker just appeared on its own" — no agency, no cause. The viewer doesn't see the click happen. -An earlier approach deleted the cursor entirely and used amber annotations to call out the *result* of the action. That works for changes that are visible at scale, but it falls apart when: +An earlier approach deleted the cursor entirely and used amber annotations to call out the _result_ of the action. That works for changes that are visible at scale, but it falls apart when: - The same UI region keeps appearing with different contents (a list cycling through options) - The click is on a tiny target (a + button, a tab, a row affordance that fades in on hover) @@ -26,9 +26,9 @@ The cursor-overlay technique fixes this. The trick is that the cursor must track ```ts import { installCursorOverlay, // call once after the page is ready - cursorClick, // move + pause + click + pulse ring - cursorTo, // move only (no click) - cursorPark, // move to absolute (x, y) coords — for idle parking + cursorClick, // move + pause + click + pulse ring + cursorTo, // move only (no click) + cursorPark, // move to absolute (x, y) coords — for idle parking } from './helpers/cursor-overlay' ``` @@ -86,7 +86,7 @@ Moves to absolute viewport coordinates (CSS pixels). Use to: **Important parking rules:** -- Don't park on the *next* clip's click target. A cursor that sits on `+` for 2s and then "clicks" reads as a delayed action — viewers parse it as a missed click. Park elsewhere (on an interior card, in empty chrome). +- Don't park on the _next_ clip's click target. A cursor that sits on `+` for 2s and then "clicks" reads as a delayed action — viewers parse it as a missed click. Park elsewhere (on an interior card, in empty chrome). - Park on real content, not gutters. If your park position lands between cards or in empty grey space, the cursor looks abandoned. Aim for the centre of a visible element. ## Timing rules of thumb diff --git a/.claude/skills/launch-video/references/pitfalls.md b/.claude/skills/launch-video/references/pitfalls.md index aece4a83..eea00c0a 100644 --- a/.claude/skills/launch-video/references/pitfalls.md +++ b/.claude/skills/launch-video/references/pitfalls.md @@ -4,7 +4,7 @@ What we tried during v0.4.11 that looked bad in motion, and what to do instead. ## 1. Cursors that don't track real input look uncanny -We built three iterations of a *standalone* GSAP cursor that flew across the screen on its own to "click" buttons. Every version felt wrong: +We built three iterations of a _standalone_ GSAP cursor that flew across the screen on its own to "click" buttons. Every version felt wrong: - Linear motion → robotic - Bezier + overshoot → bouncy in a video-game way @@ -14,14 +14,14 @@ We built three iterations of a *standalone* GSAP cursor that flew across the scr **Do instead, depending on the scene:** -- **No cursor at all** — for scenes where the change carries the story (new pinned strip appears, updater banner flips state). Use amber annotation rectangles to call out the *result*. -- **Synthetic cursor that tracks Playwright** — for scenes where the *click* is the story (clicking + opens a picker; clicking a template re-flows the preview). Inject a DOM cursor that follows real `mousemove` events and pulses on real `mousedown`. See `cursor-overlay.md`. The cursor is now driven by genuine input, so the click rhythm matches the UI's response. This was the v0.5.0 fix and it works. +- **No cursor at all** — for scenes where the change carries the story (new pinned strip appears, updater banner flips state). Use amber annotation rectangles to call out the _result_. +- **Synthetic cursor that tracks Playwright** — for scenes where the _click_ is the story (clicking + opens a picker; clicking a template re-flows the preview). Inject a DOM cursor that follows real `mousemove` events and pulses on real `mousedown`. See `cursor-overlay.md`. The cursor is now driven by genuine input, so the click rhythm matches the UI's response. This was the v0.5.0 fix and it works. The hard rule is: **a cursor in frame must trace real input.** A cursor that floats on its own is worse than no cursor at all. ## 2. Decorative chrome adds nothing -We tried, in turn: a vignette over the canvas, a top-right "callsign" (`SPOOL · v0.4.11 · LAUNCH`), a bottom 1px amber progress strip, a film grain overlay. Each one made the video feel more "designed" and *less* intentional — like a YouTuber's title sequence rather than a product announcement. +We tried, in turn: a vignette over the canvas, a top-right "callsign" (`SPOOL · v0.4.11 · LAUNCH`), a bottom 1px amber progress strip, a film grain overlay. Each one made the video feel more "designed" and _less_ intentional — like a YouTuber's title sequence rather than a product announcement. **Do instead:** trust the brand mark in the top-left and the text panel on the left to anchor the composition. The amber annotations on the demo window are the only accent layer that earns its place. @@ -78,11 +78,11 @@ Early attempts at hand-built HTML mockups of the Spool UI inside the composition **Lesson:** always record the real Electron app. The capture pipeline exists for a reason. Don't try to recreate the UI in HTML, ever. -(Exception: a stylised post-demo capstone card — a document or artefact summary that's *explicitly not the app UI* — is fine because the viewer reads it as a separate object, not a fake UI. See `composition.md` § Optional capstone scene.) +(Exception: a stylised post-demo capstone card — a document or artefact summary that's _explicitly not the app UI_ — is fine because the viewer reads it as a separate object, not a fake UI. See `composition.md` § Optional capstone scene.) ## 11. Clip-boundary gap (the silent full-screen flash) -Two adjacent video clips, both showing visually identical UI states at the boundary, *should* swap invisibly. But on rendered output you can see a single frame of near-black at the exact cut, reading as a full-screen flash. +Two adjacent video clips, both showing visually identical UI states at the boundary, _should_ swap invisibly. But on rendered output you can see a single frame of near-black at the exact cut, reading as a full-screen flash. **Root cause:** clip A's `data-duration` ended before clip B's `data-start` began. Even by 0.13s. During that gap, neither video element was "playing" — clip A had ended (browsers show a frozen last frame, but the HyperFrames renderer may release the buffer), clip B wasn't seekable yet. The result was a frame the renderer composed against an empty video element. @@ -96,12 +96,12 @@ In English: the previous clip must still be playing when the next clip starts to Also bring the incoming clip's opacity up 0.20s **before** the boundary (`clipCut` already does this), so the renderer has time to decode the first frame before the outgoing clip disappears. -**Bonus fix in the `clipCut` helper:** bring the incoming clip's opacity up *before* the boundary, not at it: +**Bonus fix in the `clipCut` helper:** bring the incoming clip's opacity up _before_ the boundary, not at it: ```js function clipCut(outSel, inSel, at) { - tl.set(inSel, { opacity: 1 }, at - 0.20); // ← lead by 0.20s - tl.set(outSel, { opacity: 0 }, at + 0.10); // ← outgoing lags 0.10s + tl.set(inSel, { opacity: 1 }, at - 0.2) // ← lead by 0.20s + tl.set(outSel, { opacity: 0 }, at + 0.1) // ← outgoing lags 0.10s } ``` @@ -111,12 +111,12 @@ This gives the renderer time to decode the incoming clip's first frame before th If you "reset" the demo's UI state between recordings (clicking different options to clean up for the next clip's start), the composition cuts from clip N's end-state to clip N+1's start-state and the UI visibly jumps. The viewer's brain registers it as a glitch even though both states were technically valid. -**Why:** the reset clicks happened *outside* the recordings, so the viewer never saw the cause. A state change with no cause reads as a bug. +**Why:** the reset clicks happened _outside_ the recordings, so the viewer never saw the cause. A state change with no cause reads as a bug. **Do instead, in order of preference:** 1. **Chain state.** Clip A ends with whatever state, clip B starts there. No reset between recordings. Design the demo flow so the natural state at each beat is what you want. -2. **Reset *inside* a recording.** If you must change state mid-demo to land a later beat on the right configuration, do it via visible cursor clicks during a recorded clip — the viewer sees the user making the choice. +2. **Reset _inside_ a recording.** If you must change state mid-demo to land a later beat on the right configuration, do it via visible cursor clicks during a recorded clip — the viewer sees the user making the choice. 3. **Never** reset between clips and hope the panel transition will mask it. It won't. ## 13. Cognitive budget per scene @@ -137,8 +137,8 @@ Synchronising `panelIn()` and `spotlightSnap()` at the same instant means the vi **Do:** fire `panelIn(...)` first, then `spotlightSnap(...)` 0.3–0.4s later, then the click another 0.3–0.5s after that. The viewer reads the headline → finds where to look → sees the action. ```js -panelIn("#panelN", T); // 0.00s: text anchors -spotlightSnap(F.primaryRegion, T + 0.40, F.controlPanel); // 0.40s: highlight arrives +panelIn('#panelN', T) // 0.00s: text anchors +spotlightSnap(F.primaryRegion, T + 0.4, F.controlPanel) // 0.40s: highlight arrives // click in the captured clip lands around T + 1.30s // ~1.30s: action lands ``` diff --git a/.claude/skills/launch-video/references/spotlight.md b/.claude/skills/launch-video/references/spotlight.md index 87c6466b..af633fb2 100644 --- a/.claude/skills/launch-video/references/spotlight.md +++ b/.claude/skills/launch-video/references/spotlight.md @@ -6,30 +6,34 @@ An SVG-mask overlay that dims everything outside 1–2 transparent "holes". An a Both techniques live inside `.window` so the camera transform applies to them. Pick based on what you want the viewer to do: -| Goal | Use | -|---|---| -| "Look at this strip" (specific feature region, no ambiguity) | Amber rectangle (precise, geometric) | -| "Read this preview while also watching this control" | Dual spotlight (preview hole always bright, control hole retargets) | -| "Focus tightens as the click happens" | Spotlight retarget (single hole moves) | -| The amber outline keeps reading as "misaligned" no matter how you measure | Spotlight (soft falloff hides drift) | +| Goal | Use | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------- | +| "Look at this strip" (specific feature region, no ambiguity) | Amber rectangle (precise, geometric) | +| "Read this preview while also watching this control" | Dual spotlight (preview hole always bright, control hole retargets) | +| "Focus tightens as the click happens" | Spotlight retarget (single hole moves) | +| The amber outline keeps reading as "misaligned" no matter how you measure | Spotlight (soft falloff hides drift) | Amber rectangles are unforgiving about pixel alignment — if your measured coords are 4px off, the outline reads as "wrong". Spotlight holes have a dim falloff around their edge that absorbs small drift; you can be ±10px and the eye still lands where you want it. ## The mask structure -A single SVG covers the window. Inside is a `` with a white "shows everything" rect and 1–2 black "punches a hole" rects. The output rectangle is filled with the dim colour and references the mask, so wherever the mask is black, the dim is *not* applied (those holes show the video underneath at full brightness). +A single SVG covers the window. Inside is a `` with a white "shows everything" rect and 1–2 black "punches a hole" rects. The output rectangle is filled with the dim colour and references the mask, so wherever the mask is black, the dim is _not_ applied (those holes show the video underneath at full brightness). ```html - + - - - + + + - + ``` @@ -42,24 +46,24 @@ Each hole's default size is 0×0 (invisible). GSAP retargets them via `attr` twe These belong in the composition script next to `panelIn` / `clipCut`: ```js -const NULL_HOLE = { x: 50, y: 50, w: 0, h: 0 }; +const NULL_HOLE = { x: 50, y: 50, w: 0, h: 0 } function setHole(sel, rect, at) { - const r = rect || NULL_HOLE; - tl.set(sel, { attr: { x: r.x, y: r.y, width: r.w, height: r.h } }, at); + const r = rect || NULL_HOLE + tl.set(sel, { attr: { x: r.x, y: r.y, width: r.w, height: r.h } }, at) } function spotlightOn(primary, at, secondary) { - setHole("#hole-primary", primary, at); - setHole("#hole-secondary", secondary, at); - tl.set("#spotlight", { opacity: 1 }, at); + setHole('#hole-primary', primary, at) + setHole('#hole-secondary', secondary, at) + tl.set('#spotlight', { opacity: 1 }, at) } function spotlightOff(at) { - tl.set("#spotlight", { opacity: 0 }, at); + tl.set('#spotlight', { opacity: 0 }, at) } function spotlightSnap(primary, at, secondary) { // Retarget without changing the overall on/off state. - setHole("#hole-primary", primary, at); - setHole("#hole-secondary", secondary, at); + setHole('#hole-primary', primary, at) + setHole('#hole-secondary', secondary, at) } ``` @@ -67,21 +71,21 @@ Define your focus rectangles once near the top of the script, in `% of .window`. ```js const F = { - sidebarTarget: { x: 0.6, y: 10.0, w: 20, h: 3.4 }, - primaryRegion: { x: 1, y: 1, w: 68, h: 98 }, - controlPanel: { x: 71.5, y: 11, w: 26.5, h: 41 }, + sidebarTarget: { x: 0.6, y: 10.0, w: 20, h: 3.4 }, + primaryRegion: { x: 1, y: 1, w: 68, h: 98 }, + controlPanel: { x: 71.5, y: 11, w: 26.5, h: 41 }, // ... -}; +} ``` Then in scene code: ```js -spotlightOn(F.sidebarTarget, 0.45); // single, dim everything else -spotlightSnap(F.primaryRegion, 1.55); // retarget, still on -spotlightSnap(F.primaryRegion, 10.15, F.controlPanel); // dual: preview + control -spotlightSnap(F.primaryRegion, 30.15); // collapse secondary, single again -spotlightOff(34.50); +spotlightOn(F.sidebarTarget, 0.45) // single, dim everything else +spotlightSnap(F.primaryRegion, 1.55) // retarget, still on +spotlightSnap(F.primaryRegion, 10.15, F.controlPanel) // dual: preview + control +spotlightSnap(F.primaryRegion, 30.15) // collapse secondary, single again +spotlightOff(34.5) ``` ## Hard snaps only — no morph @@ -112,10 +116,10 @@ When the cause-and-effect has played its loop, collapse the secondary to `NULL_H ```js // Dual: outcome + control both bright while the user toggles. -spotlightSnap(F.primaryRegion, 25.65, F.controlPanel); +spotlightSnap(F.primaryRegion, 25.65, F.controlPanel) // After the final click, kill the secondary. // Only the outcome stays lit — its state change reads as the answer. -spotlightSnap(F.primaryRegion, 29.65); +spotlightSnap(F.primaryRegion, 29.65) ``` This is a deliberate 0.5–1.0s moment of "look here, this is the answer." Particularly useful for features whose payoff is subtle (a count collapsing, a state flip, a value vanishing) and would get lost in a busy dual-spotlight frame. @@ -132,17 +136,17 @@ Same workflow as amber rectangles, but with one advantage: spotlight is forgivin ## clipCut interaction -`spotlightSnap` doesn't change which clip is visible — it just moves the holes. When a scene changes underlying clips, the spotlight coords usually don't need to change if the layout is the same (controls in the same place, outcome in the same place). Retarget only when the *active control* changes from one row/element to another. +`spotlightSnap` doesn't change which clip is visible — it just moves the holes. When a scene changes underlying clips, the spotlight coords usually don't need to change if the layout is the same (controls in the same place, outcome in the same place). Retarget only when the _active control_ changes from one row/element to another. Order of operations at scene boundaries: ```js // Panel anchors first (0.4s lead — see composition.md on panel-leads-spotlight) -panelIn("#panelN", 14.85); +panelIn('#panelN', 14.85) // Clip swaps next -clipCut("#clip-prev", "#clip-next", 14.65); +clipCut('#clip-prev', '#clip-next', 14.65) // Spotlight retargets last -spotlightSnap(F.primaryRegion, 15.25, F.controlPanel); +spotlightSnap(F.primaryRegion, 15.25, F.controlPanel) ``` ## Common pitfalls diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c2504b7c..f166d92b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: Bug report description: Something isn't working -labels: ["bug"] +labels: ['bug'] body: - type: textarea id: what-happened diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 12e5eecf..4c329557 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,6 @@ name: Feature request description: Suggest an idea -labels: ["enhancement"] +labels: ['enhancement'] body: - type: textarea id: problem diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml index 0b7b611a..ba2432ea 100644 --- a/.github/workflows/deploy-web.yml +++ b/.github/workflows/deploy-web.yml @@ -4,11 +4,11 @@ on: push: branches: [main] paths: - - "apps/web/**" - - "packages/session-kit/**" - - "packages/session-view/**" - - "packages/share-kit/**" - - ".github/workflows/deploy-web.yml" + - 'apps/web/**' + - 'packages/session-kit/**' + - 'packages/session-view/**' + - 'packages/share-kit/**' + - '.github/workflows/deploy-web.yml' workflow_dispatch: jobs: @@ -34,7 +34,7 @@ jobs: # libraries it imports (@spool/share-kit, @spool-lab/session-*) # ship dist/ artifacts that must exist first. - name: Build workspace dependencies - run: pnpm turbo run build --filter=@spool/web^... + run: pnpm vp run --filter '@spool/web^...' build - name: Deploy to Void env: diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 350373c6..861f4f01 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -4,16 +4,16 @@ on: push: branches: [main] paths-ignore: - - "apps/web/**" - - "videos/**" - - "**/*.md" - - "docs/**" + - 'apps/web/**' + - 'videos/**' + - '**/*.md' + - 'docs/**' pull_request: paths-ignore: - - "apps/web/**" - - "videos/**" - - "**/*.md" - - "docs/**" + - 'apps/web/**' + - 'videos/**' + - '**/*.md' + - 'docs/**' # Tests only read the repo; upload-artifact needs no extra scope. permissions: diff --git a/.oxlintrc.json b/.oxlintrc.json deleted file mode 100644 index c3ac2a14..00000000 --- a/.oxlintrc.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": [ - "typescript" - ], - "categories": { - "correctness": "off" - }, - "env": { - "builtin": true - }, - "ignorePatterns": [ - "**/node_modules/**", - "**/dist/**", - "**/out/**", - "**/test-results/**", - "**/.turbo/**", - "packages/app/release/**", - "packages/connectors/**/dist/**" - ], - "rules": { - "typescript/no-floating-promises": [ - "error", - { - "ignoreVoid": true - } - ] - }, - "overrides": [ - { - "files": [ - "packages/app/src/main/**/*.ts" - ], - "rules": { - "no-restricted-imports": [ - "error", - { - "paths": [ - { - "name": "node:child_process", - "importNames": [ - "execSync", - "spawnSync", - "execFileSync" - ], - "message": "Sync child_process APIs block the main-process event loop and produce a launch beachball. Use the async equivalents (and Promise.all when multiple lookups are independent), or move the work into a worker_thread." - }, - { - "name": "child_process", - "importNames": [ - "execSync", - "spawnSync", - "execFileSync" - ], - "message": "Sync child_process APIs block the main-process event loop and produce a launch beachball. Use the async equivalents (and Promise.all when multiple lookups are independent), or move the work into a worker_thread." - } - ] - } - ] - } - }, - { - "files": [ - "packages/app/src/main/e2e-mode/e2e-mode-clean.test.ts", - "packages/app/src/main/terminal.test.ts" - ], - "rules": { - "no-restricted-imports": "off" - } - } - ] -} diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit new file mode 100755 index 00000000..85fb65b4 --- /dev/null +++ b/.vite-hooks/pre-commit @@ -0,0 +1 @@ +vp staged diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..46cef3bd --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["VoidZero.vite-plus-extension-pack"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..aa66e0f6 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,21 @@ +{ + "editor.defaultFormatter": "oxc.oxc-vscode", + "[javascript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "oxc.oxc-vscode" + }, + "oxc.fmt.configPath": "./vite.config.ts", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "file", + "editor.codeActionsOnSave": { + "source.fixAll.oxc": "explicit" + } +} diff --git a/AGENTS.md b/AGENTS.md index 6b74d754..6d67e1e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ All font choices, colors, spacing, layout, and aesthetic direction are defined t Do not deviate without explicit user approval. Key rules at a glance: + - Search bar is centered on home screen (Google homepage feel), moves to top on results - Warm amber accent `#C85A00` (light) / `#F07020` (dark) — never blue or purple - Warm near-black `#141410` for dark mode — never pure `#000` or cold `#0A0A0A` @@ -14,4 +15,3 @@ Key rules at a glance: - "via ACP · local" label always shown on AI-mode answers — non-negotiable trust signal In QA mode, flag any code that doesn't match DESIGN.md. - diff --git a/CLAUDE.md b/CLAUDE.md index f696f989..715c1997 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,6 +5,7 @@ All font choices, colors, spacing, layout, and aesthetic direction are defined t Do not deviate without explicit user approval. Key rules at a glance: + - Search bar is centered on home screen (Google homepage feel), moves to top on results - Warm amber accent `#C85A00` (light) / `#F07020` (dark) — never blue or purple - Warm near-black `#141410` for dark mode — never pure `#000` or cold `#0A0A0A` @@ -38,4 +39,3 @@ poster trick. - The composition skeleton lives at `videos/launch-template/`. - Per-release assets (raw `.mov`, audio, renders) are gitignored — source of truth is the composition `index.html` plus the helpers. - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dde70db7..c2db766e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,15 +77,18 @@ app (vite, :3002), and the Electron app pointed at the local backend. 2. **Two gitignored config files** (wrangler and electron-vite each have their own loader): + ```bash cp apps/backend/.dev.vars.example apps/backend/.dev.vars cp apps/app/.env.development.local.example apps/app/.env.development.local ``` + Fill in the WorkOS values per the comments in each file — the API key goes only in `.dev.vars`; the app env needs just the (public) client id as `SPOOL_WORKOS_CLIENT_ID`. 3. **Local D1 schema**: + ```bash cd apps/backend corepack pnpm wrangler d1 migrations apply spool-share-db --local diff --git a/DESIGN.md b/DESIGN.md index 39d1ea37..805bed3e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,6 +1,7 @@ # Design System — Spool ## Product Context + - **What this is:** A local AI session library — an Electron macOS app that collects, organizes, and lets you revisit every Claude Code, Codex, and Gemini session you've ever had. - **Who it's for:** Developers who think with AI daily and have accumulated hundreds of sessions across multiple tools. The persona is overwhelmed by the archive itself, not only by re-explaining context. - **Space/industry:** Developer productivity / local-first tooling. Peers: Raycast, Spotlight, Obsidian, DevonThink — but none of them treat AI sessions as first-class library items. @@ -8,12 +9,14 @@ - **Core positioning:** "Your AI session library." The shell (sidebar of projects, main pane of sessions) is the home; ⌘K search is one of several entry points, not the front door. ## Aesthetic Direction + - **Direction:** Warm Index — library-warm, not terminal-cold. Function-first but with personality. - **Decoration level:** Minimal. Typography and color warmth carry everything. No gradients, no decorative blobs. - **Mood:** The feeling of finding something in a personal archive under a warm lamp. Intimate, fast, trustworthy. Not corporate, not clinical. - **Differentiation:** Every competitor (Raycast, Alfred, DevonThink) uses cold grays or pure blacks. Spool's warm near-blacks and amber accent are a deliberate departure — the product deals with memory and personal thinking, and should feel accordingly. ## Layout Philosophy + - **Core principle:** Spool is an AI session library. The sidebar (projects) and main pane (sessions) are the home; search is one of several entry points, reachable via ⌘K. - **Shell:** Persistent left sidebar (240px) + main pane. Sidebar lists projects derived from `project_groups_v` and is always visible across every main-pane state. - **Sidebar:** Warm surface background, soft right border. Top-left wordmark `Spool.`, then a `PROJECTS` section label with a sort menu, then project rows. A divider separates derived projects from the always-last `Loose` entry. @@ -28,6 +31,7 @@ - **Border radius:** 10px for cards / 8px for inputs / 6px for sidebar rows and buttons / 4px for badges. Pill (9999px) reserved for the ⌘K overlay search input and mode toggle. ## Typography + - **Logo/Display:** Geist Sans 700 — large, tight letter-spacing (−0.04em), the period after "Spool" in accent color. - **UI / Body:** Geist Sans 400/500/600 — readable at 11–15px, developer-native, not overused. Do NOT use Inter, Roboto, or system-ui as primary. - **Fragment content:** Geist Mono 400/500 — all indexed content (conversation fragments, URLs, code) rendered in monospace. This visually separates "Spool UI" from "your content." @@ -35,105 +39,116 @@ - **Loading:** Google Fonts CDN: `https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap` ### Type Scale -| Role | Size | Weight | Font | -|------|------|--------|------| -| Page title (Library Home, Settings) | 20px | 600 | Geist Sans, letter-spacing −0.01em | -| Sidebar wordmark | 18px | 700 | Geist Sans, letter-spacing −0.04em | -| Search input (⌘K overlay) | 15px | 400 | Geist Sans | -| Search input (results page) | 13px | 400 | Geist Sans | -| Body / result actions | 13px | 400/500 | Geist Sans | -| Fragment content | 12px | 400 | Geist Mono | -| Secondary / meta | 11px | 400/500 | Geist Sans | -| Labels / caps | 10px | 600 | Geist Sans, letter-spacing 0.08em | -| Badges / paths | 11px | 500/600 | Geist Mono | + +| Role | Size | Weight | Font | +| ----------------------------------- | ---- | ------- | ---------------------------------- | +| Page title (Library Home, Settings) | 20px | 600 | Geist Sans, letter-spacing −0.01em | +| Sidebar wordmark | 18px | 700 | Geist Sans, letter-spacing −0.04em | +| Search input (⌘K overlay) | 15px | 400 | Geist Sans | +| Search input (results page) | 13px | 400 | Geist Sans | +| Body / result actions | 13px | 400/500 | Geist Sans | +| Fragment content | 12px | 400 | Geist Mono | +| Secondary / meta | 11px | 400/500 | Geist Sans | +| Labels / caps | 10px | 600 | Geist Sans, letter-spacing 0.08em | +| Badges / paths | 11px | 500/600 | Geist Mono | **Floor:** 11px for body / UI / meta text. Labels-and-caps may use 10px (small uppercase reads cleanly even below the body floor). No half-pixel sizes (12.5, 13.5) — they fight sub-pixel rendering. ## Color + - **Approach:** Restrained — one amber accent, warm neutrals, color is rare and meaningful. ### Light Mode -| Token | Hex | Usage | -|-------|-----|-------| -| `--bg` | `#FAFAF8` | App background — warm off-white, never pure white | -| `--surface` | `#F4F4F0` | Cards, titlebar, status bar | -| `--surface2` | `#EEEEE9` | Hovered surfaces, mode pill background | -| `--border` | `#E8E8E2` | Dividers, card borders | -| `--border2` | `#D8D8D0` | Input borders, focused-adjacent | -| `--text` | `#1C1C18` | Primary text | -| `--muted` | `#6B6B60` | Secondary text, labels | -| `--faint` | `#ADADAA` | Placeholder text, disabled state | -| `--accent` | `#C85A00` | Primary accent — amber/orange | + +| Token | Hex | Usage | +| ------------- | --------- | ----------------------------------------------------- | +| `--bg` | `#FAFAF8` | App background — warm off-white, never pure white | +| `--surface` | `#F4F4F0` | Cards, titlebar, status bar | +| `--surface2` | `#EEEEE9` | Hovered surfaces, mode pill background | +| `--border` | `#E8E8E2` | Dividers, card borders | +| `--border2` | `#D8D8D0` | Input borders, focused-adjacent | +| `--text` | `#1C1C18` | Primary text | +| `--muted` | `#6B6B60` | Secondary text, labels | +| `--faint` | `#ADADAA` | Placeholder text, disabled state | +| `--accent` | `#C85A00` | Primary accent — amber/orange | | `--accent-bg` | `#FFF3E8` | Accent-tinted backgrounds (selected state, AI answer) | ### Dark Mode -| Token | Hex | Usage | -|-------|-----|-------| -| `--bg` | `#141410` | Warm near-black, never pure `#000` | -| `--surface` | `#1C1C18` | Cards, titlebar, status bar | -| `--surface2` | `#242420` | Hovered surfaces | -| `--border` | `#2E2E28` | Dividers | -| `--border2` | `#3A3A34` | Input borders | -| `--text` | `#F2F2EC` | Primary text — warm near-white | -| `--muted` | `#8A8A80` | Secondary text | -| `--faint` | `#505048` | Placeholder, disabled | -| `--accent` | `#F07020` | Accent brightened for dark — still amber, never blue | -| `--accent-bg` | `#2A1800` | Accent backgrounds on dark | + +| Token | Hex | Usage | +| ------------- | --------- | ---------------------------------------------------- | +| `--bg` | `#141410` | Warm near-black, never pure `#000` | +| `--surface` | `#1C1C18` | Cards, titlebar, status bar | +| `--surface2` | `#242420` | Hovered surfaces | +| `--border` | `#2E2E28` | Dividers | +| `--border2` | `#3A3A34` | Input borders | +| `--text` | `#F2F2EC` | Primary text — warm near-white | +| `--muted` | `#8A8A80` | Secondary text | +| `--faint` | `#505048` | Placeholder, disabled | +| `--accent` | `#F07020` | Accent brightened for dark — still amber, never blue | +| `--accent-bg` | `#2A1800` | Accent backgrounds on dark | ### Source Badge Colors + Each agent source has a fixed color used consistently across badges, chips, and dots. Only currently-supported agent sources are listed. New sources arrive via the daemon — add a row when shipped, never preemptively. -| Source | Light | Dark | -|--------|-------|------| +| Source | Light | Dark | +| ----------- | --------- | --------- | | Claude Code | `#C26A4E` | `#E89A7C` | -| Codex CLI | `#4A9670` | `#7CC9A2` | -| Gemini | `#5887D0` | `#8AB0E5` | -| OpenCode | `#8A6F3D` | `#C9A761` | +| Codex CLI | `#4A9670` | `#7CC9A2` | +| Gemini | `#5887D0` | `#8AB0E5` | +| OpenCode | `#8A6F3D` | `#C9A761` | ### Semantic States + Status colors are warm-tuned to match the rest of the palette — never use Tailwind defaults (`green-500`, `red-500`). -| State | Light | Dark | -|-------|-------|------| -| Success / synced | `#6BAF6B` (warm green) | `#7DC07D` | -| Warning / stale | `#E4A640` (warm amber) | `#F0B854` | +| State | Light | Dark | +| -------------------- | ---------------------- | --------- | +| Success / synced | `#6BAF6B` (warm green) | `#7DC07D` | +| Warning / stale | `#E4A640` (warm amber) | `#F0B854` | | Error / disconnected | `#C95A4F` (terracotta) | `#D67259` | ## Spacing ### Base + - **Unit:** 4px - **Density:** Compact. This is a library client, not a document editor — rows should fit at scale. - **Scale:** `2 · 4 · 8 · 12 · 16 · 20 · 24 · 32 · 48`. 6 is allowed only for icon dots and tight inline gaps. **Off-scale is a bug** — never use 10, 14, 18, 28, 36, 40, or any half-pixel (`py-0.5`, `py-2.5`, `px-3.5`). ### Component padding (canonical) -| Component | Padding | Inner gap | -|-----------|---------|-----------| -| Sidebar column | `px-3 py-3` (12 / 12) | — | -| Sidebar wordmark block | `px-4 pt-3 pb-3` (16 / 12) | — | -| Sidebar section label (PROJECTS) | `px-2 py-1` (8 / 4) | gap-1.5 (6) | -| Sidebar project row | `px-2 py-1` (8 / 4) | gap-2 (8) | -| Sidebar status bar | `h-[30px] pl-4 pr-2` (16 left so the status dot lines up with the project-row folder icon column; 8 right so the 24×24 settings button sits visually centered in the count column above) | gap-2 (8) | -| Search trigger (sidebar / top-right) | `h-8 px-2` (32 / 8) | gap-2 (8) | -| Library Home header (h1 + subtitle) | `px-6 pt-3 pb-3` (24 / 12) — top padding matches sidebar wordmark block so titles align | — | + +| Component | Padding | Inner gap | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| Sidebar column | `px-3 py-3` (12 / 12) | — | +| Sidebar wordmark block | `px-4 pt-3 pb-3` (16 / 12) | — | +| Sidebar section label (PROJECTS) | `px-2 py-1` (8 / 4) | gap-1.5 (6) | +| Sidebar project row | `px-2 py-1` (8 / 4) | gap-2 (8) | +| Sidebar status bar | `h-[30px] pl-4 pr-2` (16 left so the status dot lines up with the project-row folder icon column; 8 right so the 24×24 settings button sits visually centered in the count column above) | gap-2 (8) | +| Search trigger (sidebar / top-right) | `h-8 px-2` (32 / 8) | gap-2 (8) | +| Library Home header (h1 + subtitle) | `px-6 pt-3 pb-3` (24 / 12) — top padding matches sidebar wordmark block so titles align | — | | Library section label (PINNED / TODAY …) | `px-6 pt-3 pb-1` (24 horizontal; 12 above for clear section break, 4 below so the label hugs its rows — combined with row's own `py-3`, label-text-to-row-content is 16px and prior-row-to-label-text is 24px) | gap-1.5 (6) | -| Session row | `px-5 py-3` (20 / 12) | gap-3 (12) | -| Icon button (kebab, pin, gear, sort) | `w-6 h-6` hit-area, inner icon centered | — | -| Search input (⌘K overlay) | `h-12 px-4` (48 / 16) | gap-3 (12) | -| Search input (results page top-right) | `h-9 px-3` (36 / 12) | gap-2 (8) | -| AI answer card | `p-4` (16) | gap-3 (12) | +| Session row | `px-5 py-3` (20 / 12) | gap-3 (12) | +| Icon button (kebab, pin, gear, sort) | `w-6 h-6` hit-area, inner icon centered | — | +| Search input (⌘K overlay) | `h-12 px-4` (48 / 16) | gap-3 (12) | +| Search input (results page top-right) | `h-9 px-3` (36 / 12) | gap-2 (8) | +| AI answer card | `p-4` (16) | gap-3 (12) | ### Vertical rhythm + - Section label and its rows: zero margin between — the label's own `py-2` is the buffer - Consecutive sections (PINNED → TODAY → YESTERDAY): zero margin between — each section's header padding provides the separation - Sidebar groups (projects vs. Loose): single 1px divider with `my-2 mx-2` margins - Bucket labels: non-sticky on scroll; content moves as one ### Border radius + 10 cards / 8 inputs / 6 sidebar rows + buttons / 4 badges. Pill (9999) reserved for ⌘K input + mode toggle. **No 12, no 14.** ## Motion + - **Approach:** Minimal-functional — only transitions that aid comprehension. - **⌘K overlay open/close:** Backdrop fades in (120ms), overlay card scales from 0.98 → 1 + fades in (140ms ease-out). Reverse on close. - **Main-pane state changes (Library Home ↔ Project View ↔ Session Detail):** Instant. No slide or crossfade — sidebar context already tells the user where they are. @@ -145,6 +160,7 @@ Status colors are warm-tuned to match the rest of the palette — never use Tail ## UI States ### Search (⌘K overlay) + - **Trigger:** Global ⌘K from any main-pane state. The top-right `Search…` button is the visual hint when a pointer user hasn't discovered the keystroke. - **Overlay:** Centered card on dimmed backdrop, ~640px wide. Pill input at top, scope toggle (`All` / `[Project name]`) on the left, mode toggle on the right. - **Mode toggle:** Pill-within-pill. Active mode gets `surface` bg + shadow. `⚡ Fast` | `🤖 AI` — replace emoji with vector icons in implementation. @@ -152,12 +168,14 @@ Status colors are warm-tuned to match the rest of the palette — never use Tail - **Results page (after commit):** Top-right input persists with the current query; results fill the main pane below. Sidebar remains visible. ### Result Items + - **Default:** No background, left-padded 20px. - **Hovered:** `--surface` background. - **Selected (keyboard):** `--accent-bg` background. - **Action buttons:** Appear on hover/selection only (opacity 0 → 1). Primary action (Resume/Continue) uses accent border + color. ### Library Home (main pane default) + - Two stacked sections: `PINNED` (collapsible, only when non-empty) and `RECENT`, date-bucketed into: - `TODAY` - `YESTERDAY` @@ -168,26 +186,31 @@ Status colors are warm-tuned to match the rest of the palette — never use Tail - Each row uses the same `SessionRow` component as Project View, so visual rhythm is consistent across surfaces. ### Project View + - Header: project display name + session count + sort menu + source filter chips. - Body: optional `PINNED` segment, then sessions list under the active sort. - Empty filter state: friendly message, not a 404. ### Pin Button + - Icon-only toggle on session rows and the detail header. Filled state uses `--accent`. - A pinned session appears in its owning project's `PINNED` segment **and** in the global Library Home `PINNED` section. - Replaces the older Star concept; star-prefixed code/UI was migrated wholesale. ### Sources Panel (Settings tab) + - Lists the built-in agent sources with their session counts. - Status: `auto` label + green dot when watcher is healthy. ### AI Answer Card + - Left border: 3px solid `--accent`. Background: `--accent-bg`. - Header: `🤖 Claude says` label in accent + `via ACP · local · [agent-name]` chip on the right (always show "local" — this is a trust signal). - CTA button: outline style with accent color, not filled — keeps hierarchy below the answer. - Replace `🤖` emoji with vector icon in implementation. ### Status Bar + - Always visible, 30px height, `--surface` background. - Left: colored dot (green/yellow/red) + synced item count + last sync time. - Right: `Sources ⊕` button (replace `⊕` with vector icon). @@ -196,37 +219,44 @@ Status colors are warm-tuned to match the rest of the palette — never use Tail ## Icons ### Library + Lucide React (`lucide-react`) — consistent stroke weight, MIT licensed. Custom SVGs only when Lucide doesn't fit (mode toggle marks, source-specific glyphs). ### Size scale (by role) + These are the sizes actually in use, verified against the renderer. Treat them as a working set chosen by role — NOT a hard whitelist. The earlier "only 12/14/16/20, stroke 1.5" rule never held in practice and is retired (see Decisions log 2026-05-26). -| Size | Role | Examples | -|------|------|----------| -| 11px | Dense leading badges in tight rows | finding severity AlertTriangle/Info, inline meta accessories | -| 12–13px | Row & toolbar controls | section chevrons (12), per-row reveal eye · rescan · page meta-row controls (13) | -| 14px | Default UI | sidebar folder, search trigger, kebab, menu-item icons, source badges | -| 16px | Page-level | ⌘K overlay icon, settings tab icons, mode toggle marks | -| 20–22px | Hero / empty-state | illustration-tier accents only — rare | +| Size | Role | Examples | +| ------- | ---------------------------------- | -------------------------------------------------------------------------------- | +| 11px | Dense leading badges in tight rows | finding severity AlertTriangle/Info, inline meta accessories | +| 12–13px | Row & toolbar controls | section chevrons (12), per-row reveal eye · rescan · page meta-row controls (13) | +| 14px | Default UI | sidebar folder, search trigger, kebab, menu-item icons, source badges | +| 16px | Page-level | ⌘K overlay icon, settings tab icons, mode toggle marks | +| 20–22px | Hero / empty-state | illustration-tier accents only — rare | **Rule of thumb: match the icons already adjacent to yours.** A control sitting next to a 13px icon should be 13px, not 14px "because a table said so" — consistency within a row beats global uniformity. ### Stroke + - **1.6–1.8 is the working range** (1.6 most common; 1.7/1.75 for toolbar / row controls). 1.5 is fine for larger page-level icons. Match adjacent icons. - Filled state allowed only for active toggles (Pin filled = accent). - Avoid ≥2px except tiny check / close glyphs that need the weight. ### Icon-text gap + - `gap-2` (8px) — default for icon + label pairs (sidebar row, button with icon, AI answer header) - `gap-1.5` (6px) — tight inline groups (section label + chevron, source dots cluster) ### Status dot + 6px circle (`w-1.5 h-1.5`). Never resize; no other circle reuses this size. ### Hit target + Icon-only buttons (kebab, pin, settings gear, sort menu): minimum **24×24px** (`w-6 h-6`) tap area, regardless of inner icon size — pad with transparent space if needed. ### Specific assignments + - **Search (sidebar trigger):** `Search` (Lucide), 14px - **Settings (sidebar status bar):** `Settings2` (Lucide), 12px - **Folder (project row):** custom SVG, 14px @@ -236,6 +266,7 @@ Icon-only buttons (kebab, pin, settings gear, sort menu): minimum **24×24px** ( - **Source indicators:** purpose-drawn SVGs or Lucide equivalents — emoji are placeholders only ## AI Search (ACP Integration) + - Mode is toggled inside the ⌘K overlay — same input, different backend. - Agent selector lives in the overlay (right of the mode toggle): `Claude Code ▾` — dropdown lists all ACP-connected agents. - Status bar shows `🤖 ACP · [agent-name] · local` when AI mode is active. The word "local" is always present — it reinforces the trust proposition. @@ -243,19 +274,23 @@ Icon-only buttons (kebab, pin, settings gear, sort menu): minimum **24×24px** ( - "Continue in Claude Code →" CTA uses outline button style, opens a new Claude Code session with the synthesized answer + fragments as context. ## First-Person Language + The library is yours by definition — leverage that, but **don't prefix every list row** with "You discussed this". Repeating the frame on hundreds of rows clutters more than it warms. Use first-person framing **where it adds signal**: + - Empty states ("Nothing here yet — your first session will land here.") - Single-session detail header ("You discussed this on Mar 15 in Claude Code.") - Action confirmations ("Pinned by you", "You saved this in October") - Settings-level copy that addresses the user In list contexts (Library Home, Project View, search results), trust the surrounding chrome (sidebar, project name, source badge) to convey ownership; render metadata as compact facts: + - ✅ `spool · today · 12 messages · sonnet-4` - ❌ `You discussed this in spool · today · 12 messages · sonnet-4` ## Anti-patterns — Never Do + - Purple/violet gradients as accent - 3-column feature grid with icons in colored circles - Centered hero or search box anywhere in the shell — the library shell is left-aligned everywhere; the ⌘K overlay is the only centered surface @@ -266,21 +301,22 @@ In list contexts (Library Home, Project View, search results), trust the surroun - Emoji in production UI — replace all with vector icons ## Decisions Log -| Date | Decision | Rationale | -|------|----------|-----------| -| 2026-03-27 | Warm amber accent `#C85A00` | Every competitor is cold gray/blue. Amber evokes memory, archive, warmth — fits "personal thinking" positioning. Unique in the category. | -| 2026-03-27 | Google-homepage idle state (centered logo + search) | The search box IS the product. Empty space creates gravity. Google proved this mental model works at scale. | -| 2026-03-27 | Geist Mono for fragment content | Visually separates "Spool UI chrome" from "your indexed content." Monospace signals: this is real data, not a label. | -| 2026-03-27 | Source chips below home search bar | Sources are first-class — not buried in settings. Chips show what's in your index at a glance and invite expansion. | -| 2026-03-27 | First-person result language ("You discussed this") | Differentiates from a generic log viewer. Makes the product feel like it knows you. | -| 2026-03-27 | "via ACP · local" label on AI answers | Trust signal — users need to see that inference is local, not routed to a cloud. Core to local-first positioning. | -| 2026-03-27 | Emoji as placeholder icons only | Emojis are fast to prototype with but inconsistent across platforms. All production icons must be vector (Lucide or custom SVG). | -| 2026-04-30 | Library-first shell replaces centered search home | Session counts grew into the hundreds across multiple agents. Users need to browse and organize, not only search. Sidebar/projects/sessions become the home; search retreats to ⌘K. Reverses the 2026-03-27 "search box is the product" decision. | -| 2026-04-30 | Pin replaces Star | Per-project pin-to-top with a global Library Home `PINNED` segment. Star UI removed wholesale; underlying data migrated. Pin reads as a library verb (where Star reads as a feed verb) and matches the new framing. | -| 2026-04-30 | ⌘K overlay for search | Overlay scopes to All or the current project and hosts both Fast and AI modes. Persistent top-right `Search…` button is the discoverability hint. | -| 2026-05-08 | Locked icon scale (12 / 14 / 16 / 20) and component padding lookup table | Audit found six icon sizes (9/11/12/13/14/16) and seven off-scale paddings in use. Without per-component spec, the system was self-inconsistent. Fixed scale + canonical padding table makes drift detectable. | -| 2026-05-08 | Warm-tuned status colors replace Tailwind defaults | `green-400 / amber-400 / red-400` were the only un-tuned colors in the system. Replaced with warm green `#6BAF6B`, warm amber `#E4A640`, terracotta `#C95A4F` so status reads as part of the same palette. | -| 2026-05-08 | Page title type added (20px) | Library Home and Settings need a real h1 — previous scale topped at sidebar wordmark 16px, leaving the home pane labelless. | -| 2026-05-08 | First-person rule softened from "all metadata" to "where it adds signal" | Literal "You discussed this · Mar 15" prefix on every row was repetitive once a user had hundreds of sessions. Library context already conveys ownership; reserve first-person for empty states, detail headers, and confirmations. | -| 2026-05-08 | Source badge list trimmed to active agents only | Twitter / GitHub / YouTube / ChatGPT were carryover from the connector era. Spool ships only Claude Code / Codex CLI / Gemini today; new sources arrive via the daemon and get a row when shipped, not preemptively. | + +| Date | Decision | Rationale | +| ---------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-03-27 | Warm amber accent `#C85A00` | Every competitor is cold gray/blue. Amber evokes memory, archive, warmth — fits "personal thinking" positioning. Unique in the category. | +| 2026-03-27 | Google-homepage idle state (centered logo + search) | The search box IS the product. Empty space creates gravity. Google proved this mental model works at scale. | +| 2026-03-27 | Geist Mono for fragment content | Visually separates "Spool UI chrome" from "your indexed content." Monospace signals: this is real data, not a label. | +| 2026-03-27 | Source chips below home search bar | Sources are first-class — not buried in settings. Chips show what's in your index at a glance and invite expansion. | +| 2026-03-27 | First-person result language ("You discussed this") | Differentiates from a generic log viewer. Makes the product feel like it knows you. | +| 2026-03-27 | "via ACP · local" label on AI answers | Trust signal — users need to see that inference is local, not routed to a cloud. Core to local-first positioning. | +| 2026-03-27 | Emoji as placeholder icons only | Emojis are fast to prototype with but inconsistent across platforms. All production icons must be vector (Lucide or custom SVG). | +| 2026-04-30 | Library-first shell replaces centered search home | Session counts grew into the hundreds across multiple agents. Users need to browse and organize, not only search. Sidebar/projects/sessions become the home; search retreats to ⌘K. Reverses the 2026-03-27 "search box is the product" decision. | +| 2026-04-30 | Pin replaces Star | Per-project pin-to-top with a global Library Home `PINNED` segment. Star UI removed wholesale; underlying data migrated. Pin reads as a library verb (where Star reads as a feed verb) and matches the new framing. | +| 2026-04-30 | ⌘K overlay for search | Overlay scopes to All or the current project and hosts both Fast and AI modes. Persistent top-right `Search…` button is the discoverability hint. | +| 2026-05-08 | Locked icon scale (12 / 14 / 16 / 20) and component padding lookup table | Audit found six icon sizes (9/11/12/13/14/16) and seven off-scale paddings in use. Without per-component spec, the system was self-inconsistent. Fixed scale + canonical padding table makes drift detectable. | +| 2026-05-08 | Warm-tuned status colors replace Tailwind defaults | `green-400 / amber-400 / red-400` were the only un-tuned colors in the system. Replaced with warm green `#6BAF6B`, warm amber `#E4A640`, terracotta `#C95A4F` so status reads as part of the same palette. | +| 2026-05-08 | Page title type added (20px) | Library Home and Settings need a real h1 — previous scale topped at sidebar wordmark 16px, leaving the home pane labelless. | +| 2026-05-08 | First-person rule softened from "all metadata" to "where it adds signal" | Literal "You discussed this · Mar 15" prefix on every row was repetitive once a user had hundreds of sessions. Library context already conveys ownership; reserve first-person for empty states, detail headers, and confirmations. | +| 2026-05-08 | Source badge list trimmed to active agents only | Twitter / GitHub / YouTube / ChatGPT were carryover from the connector era. Spool ships only Claude Code / Codex CLI / Gemini today; new sources arrive via the daemon and get a row when shipped, not preemptively. | | 2026-05-26 | Retire the "icons only 12/14/16/20, stroke 1.5" rule — describe reality instead | The 2026-05-08 lock never held: the renderer actually uses 11/12/13/14 (13 & 14 dominant) at strokes 1.6–1.8 (1.6 most common, 1.5 a minority). The aspirational whitelist was actively misleading new work into picking sizes that clashed with adjacent icons. Replaced with a role-based working set + a "match adjacent icons" rule. Sizes/strokes are now chosen for local consistency, not global uniformity. | diff --git a/apps/app/build/notarize-dmg.js b/apps/app/build/notarize-dmg.js index 2023c74f..79e20f78 100644 --- a/apps/app/build/notarize-dmg.js +++ b/apps/app/build/notarize-dmg.js @@ -34,13 +34,22 @@ module.exports = async (context) => { for (const dmg of dmgs) { console.log(`[notarize-dmg] submitting ${dmg}`) - await execFileAsync('xcrun', [ - 'notarytool', 'submit', dmg, - '--apple-id', appleId, - '--password', password, - '--team-id', teamId, - '--wait', - ], { maxBuffer: 32 * 1024 * 1024 }) + await execFileAsync( + 'xcrun', + [ + 'notarytool', + 'submit', + dmg, + '--apple-id', + appleId, + '--password', + password, + '--team-id', + teamId, + '--wait', + ], + { maxBuffer: 32 * 1024 * 1024 }, + ) console.log(`[notarize-dmg] stapling ${dmg}`) await execFileAsync('xcrun', ['stapler', 'staple', dmg], { diff --git a/apps/app/e2e/agent-search.spec.ts b/apps/app/e2e/agent-search.spec.ts index a79781f4..d1312149 100644 --- a/apps/app/e2e/agent-search.spec.ts +++ b/apps/app/e2e/agent-search.spec.ts @@ -1,7 +1,8 @@ import { test, expect } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import type { Page } from '@playwright/test' +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + async function searchInAgentMode(window: Page, query: string) { const overlay = window.locator('[data-testid="search-overlay"]') if (!(await overlay.isVisible().catch(() => false))) { diff --git a/apps/app/e2e/copy-resume-command.spec.ts b/apps/app/e2e/copy-resume-command.spec.ts index 8b33b58a..2450b5cd 100644 --- a/apps/app/e2e/copy-resume-command.spec.ts +++ b/apps/app/e2e/copy-resume-command.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, search, waitForSync, type AppContext } from './helpers/launch' // Regression coverage for issue #248: every "Copy resume command" menu item @@ -85,7 +86,9 @@ test('Sidebar pinned row Copy resume command includes cd prefix', async () await firstRow.hover() await firstRow.locator('[data-testid="pin-button"]').click() - const pinnedRow = window.locator(`[data-testid="sidebar-pinned-row"][data-session-uuid="${uuid}"]`) + const pinnedRow = window.locator( + `[data-testid="sidebar-pinned-row"][data-session-uuid="${uuid}"]`, + ) await expect(pinnedRow).toBeVisible({ timeout: 5000 }) await pinnedRow.hover() await pinnedRow.locator('[data-testid="sidebar-pinned-menu-trigger"]').click() diff --git a/apps/app/e2e/dark-mode-root-bg.spec.ts b/apps/app/e2e/dark-mode-root-bg.spec.ts index 45e09af2..164297d6 100644 --- a/apps/app/e2e/dark-mode-root-bg.spec.ts +++ b/apps/app/e2e/dark-mode-root-bg.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, type AppContext } from './helpers/launch' // Regression test for a white flash visible at the window edge during diff --git a/apps/app/e2e/dev-db-isolation.spec.ts b/apps/app/e2e/dev-db-isolation.spec.ts index 80bd27c2..3802b351 100644 --- a/apps/app/e2e/dev-db-isolation.spec.ts +++ b/apps/app/e2e/dev-db-isolation.spec.ts @@ -1,7 +1,9 @@ -import { test, expect } from '@playwright/test' import { existsSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' + +import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' // Regression test for a bundler-hoisting bug that silently routed dev diff --git a/apps/app/e2e/fast-search.spec.ts b/apps/app/e2e/fast-search.spec.ts index 01a40cc9..012a37df 100644 --- a/apps/app/e2e/fast-search.spec.ts +++ b/apps/app/e2e/fast-search.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, search, type AppContext } from './helpers/launch' let ctx: AppContext @@ -125,7 +126,9 @@ test('session page can submit a new search without returning home first', async await search(window, 'TROMBONE_CANARY_99') await expect(window.locator('[data-testid="session-detail"]')).toHaveCount(0) - await expect(window.locator('[data-testid="fragment-row"]').first()).toContainText('TROMBONE_CANARY_99') + await expect(window.locator('[data-testid="fragment-row"]').first()).toContainText( + 'TROMBONE_CANARY_99', + ) }) test('clicking an All-view fragment row jumps to message with flash highlight', async () => { @@ -173,8 +176,13 @@ test('session page supports cmd or ctrl + f find-in-page', async () => { await expect(findInput).toBeFocused() await findInput.type('_CANARY_42') await expect(findInput).toHaveValue('XYLOPHONE_CANARY_42') - await expect(window.locator('[data-testid="session-find-status"]')).toContainText(/\d+\s+of\s+\d+/, { timeout: 5000 }) - await expect(window.locator('[data-testid="session-find-active-match"]').first()).toContainText('XYLOPHONE_CANARY_42') + await expect(window.locator('[data-testid="session-find-status"]')).toContainText( + /\d+\s+of\s+\d+/, + { timeout: 5000 }, + ) + await expect(window.locator('[data-testid="session-find-active-match"]').first()).toContainText( + 'XYLOPHONE_CANARY_42', + ) }) test('session find supports cmd or ctrl + arrow navigation', async () => { @@ -192,9 +200,13 @@ test('session find supports cmd or ctrl + arrow navigation', async () => { await findInput.fill('the') await expect(status).toContainText(/1\s+of\s+[2-9]\d*/, { timeout: 5000 }) - await window.keyboard.press(process.platform === 'darwin' ? 'Meta+ArrowRight' : 'Control+ArrowRight') + await window.keyboard.press( + process.platform === 'darwin' ? 'Meta+ArrowRight' : 'Control+ArrowRight', + ) await expect(status).toContainText(/2\s+of\s+[2-9]\d*/, { timeout: 5000 }) - await window.keyboard.press(process.platform === 'darwin' ? 'Meta+ArrowLeft' : 'Control+ArrowLeft') + await window.keyboard.press( + process.platform === 'darwin' ? 'Meta+ArrowLeft' : 'Control+ArrowLeft', + ) await expect(status).toContainText(/1\s+of\s+[2-9]\d*/, { timeout: 5000 }) }) diff --git a/apps/app/e2e/helpers/cursor-overlay.ts b/apps/app/e2e/helpers/cursor-overlay.ts index a90fa6f7..776f3257 100644 --- a/apps/app/e2e/helpers/cursor-overlay.ts +++ b/apps/app/e2e/helpers/cursor-overlay.ts @@ -99,7 +99,11 @@ async function centerOf(window: Page, selector: string): Promise<{ x: number; y: return { x: box.x + box.width / 2, y: box.y + box.height / 2 } } -export async function cursorTo(window: Page, selector: string, opts: MoveOptions = {}): Promise { +export async function cursorTo( + window: Page, + selector: string, + opts: MoveOptions = {}, +): Promise { const { x, y } = await centerOf(window, selector) await window.mouse.move(x, y, { steps: opts.steps ?? 16 }) if (opts.settle) await window.waitForTimeout(opts.settle) diff --git a/apps/app/e2e/helpers/demo-fixtures.ts b/apps/app/e2e/helpers/demo-fixtures.ts index ae71d271..06e65360 100644 --- a/apps/app/e2e/helpers/demo-fixtures.ts +++ b/apps/app/e2e/helpers/demo-fixtures.ts @@ -135,28 +135,30 @@ function makeClaudeSession( function makeCodexSession(sessionId: string, cwd: string, title: string, iso: string): string { const t0 = new Date(Date.parse(iso) + 2_000).toISOString() const t1 = new Date(Date.parse(iso) + 10_000).toISOString() - return [ - JSON.stringify({ - timestamp: iso, - type: 'session_meta', - payload: { id: sessionId, cwd }, - }), - JSON.stringify({ - timestamp: t0, - type: 'turn_context', - payload: { model: 'gpt-5.4', cwd }, - }), - JSON.stringify({ - timestamp: t0, - type: 'event_msg', - payload: { type: 'user_message', message: title }, - }), - JSON.stringify({ - timestamp: t1, - type: 'event_msg', - payload: { type: 'agent_message', message: `Shaping a rollout plan for ${title}.` }, - }), - ].join('\n') + '\n' + return ( + [ + JSON.stringify({ + timestamp: iso, + type: 'session_meta', + payload: { id: sessionId, cwd }, + }), + JSON.stringify({ + timestamp: t0, + type: 'turn_context', + payload: { model: 'gpt-5.4', cwd }, + }), + JSON.stringify({ + timestamp: t0, + type: 'event_msg', + payload: { type: 'user_message', message: title }, + }), + JSON.stringify({ + timestamp: t1, + type: 'event_msg', + payload: { type: 'agent_message', message: `Shaping a rollout plan for ${title}.` }, + }), + ].join('\n') + '\n' + ) } /** @@ -207,7 +209,9 @@ export function buildDemoFixtures( sessions.push({ title, source, - iso: new Date(Date.UTC(2026, 4, Math.max(1, 10 - dayOffset), hour, minute, 0)).toISOString(), + iso: new Date( + Date.UTC(2026, 4, Math.max(1, 10 - dayOffset), hour, minute, 0), + ).toISOString(), }) fillerTitleIndex += 1 } @@ -216,8 +220,14 @@ export function buildDemoFixtures( const sessionId = toUuid(sessionCounter++) if (session.source === 'claude') { const relDir = join(claudeDir, project.name.toLowerCase()) - const filePath = join(relDir, `${project.name.toLowerCase()}-${String(index + 1).padStart(3, '0')}.jsonl`) - writeText(filePath, makeClaudeSession(sessionId, cwd, session.title, session.iso, session.turns)) + const filePath = join( + relDir, + `${project.name.toLowerCase()}-${String(index + 1).padStart(3, '0')}.jsonl`, + ) + writeText( + filePath, + makeClaudeSession(sessionId, cwd, session.title, session.iso, session.turns), + ) return } diff --git a/apps/app/e2e/helpers/demo-interactions.ts b/apps/app/e2e/helpers/demo-interactions.ts index d7f2c078..2ecb2f1a 100644 --- a/apps/app/e2e/helpers/demo-interactions.ts +++ b/apps/app/e2e/helpers/demo-interactions.ts @@ -17,13 +17,16 @@ export type UpdateStatusPayload = * Push an updater-state IPC event into the renderer so the Auto-update banner * paints the requested state without needing a real download cycle. */ -export async function emitUpdateStatus(app: ElectronApplication, payload: UpdateStatusPayload): Promise { +export async function emitUpdateStatus( + app: ElectronApplication, + payload: UpdateStatusPayload, +): Promise { await app.evaluate(async ({ BrowserWindow }, data) => { const win = BrowserWindow.getAllWindows()[0] if (!win) throw new Error('No Electron window found') win.webContents.send('spool:update-status', data) }, payload) - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 200)) } /** @@ -32,7 +35,10 @@ export async function emitUpdateStatus(app: ElectronApplication, payload: Update * assertions. */ export async function pinFirstRowInProject(window: Page, projectName: string): Promise { - const projectRow = window.locator('[data-testid="sidebar-project-row"]').filter({ hasText: projectName }).first() + const projectRow = window + .locator('[data-testid="sidebar-project-row"]') + .filter({ hasText: projectName }) + .first() await expect(projectRow).toBeVisible({ timeout: 5000 }) await projectRow.click() const sessionRow = window.locator('[data-testid="session-row"]').first() diff --git a/apps/app/e2e/helpers/demo-launch.ts b/apps/app/e2e/helpers/demo-launch.ts index 201cbfb0..0f69b0f2 100644 --- a/apps/app/e2e/helpers/demo-launch.ts +++ b/apps/app/e2e/helpers/demo-launch.ts @@ -1,3 +1,7 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + /** * Launch the Spool Electron app pre-seeded with a programmatic project list * for release-video captures. Separate from `launchApp()` in `launch.ts`, @@ -5,9 +9,6 @@ */ import { _electron as electron, expect } from '@playwright/test' import type { ElectronApplication, Page } from '@playwright/test' -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { buildDemoFixtures, type ProjectSeed, type BuildDemoFixturesOptions } from './demo-fixtures' @@ -44,11 +45,15 @@ export async function launchDemoApp( if (launchOptions.agentsConfig) { const spoolHome = join(tmpDir, 'spool-home') mkdirSync(spoolHome, { recursive: true }) - writeFileSync(join(spoolHome, 'agents.json'), JSON.stringify(launchOptions.agentsConfig), 'utf8') + writeFileSync( + join(spoolHome, 'agents.json'), + JSON.stringify(launchOptions.agentsConfig), + 'utf8', + ) } const env: Record = { - ...process.env as Record, + ...(process.env as Record), SPOOL_DATA_DIR: join(tmpDir, 'data'), SPOOL_ELECTRON_USER_DATA_DIR: join(tmpDir, 'electron-user-data'), SPOOL_HOME: join(tmpDir, 'spool-home'), @@ -60,7 +65,7 @@ export async function launchDemoApp( SPOOL_E2E_TEST: '1', } - const args = [join(APP_DIR, 'out', 'main', 'index.js')] + const args = [join(APP_DIR, 'out', 'main', 'index.mjs')] if (process.platform === 'linux') args.unshift('--no-sandbox') const app = await electron.launch({ args, cwd: APP_DIR, env }) @@ -81,16 +86,23 @@ export async function launchDemoApp( * Force the Electron window into the given logical size + dark theme. * Use `1080×740` to match the app's documented default for release videos. */ -export async function setDemoWindowBounds(ctx: AppContext, width: number, height: number): Promise { - await ctx.app.evaluate(async ({ app, BrowserWindow, nativeTheme }, bounds) => { - nativeTheme.themeSource = 'dark' - const win = BrowserWindow.getAllWindows()[0] - if (!win) throw new Error('No Electron window found') - win.setBounds(bounds) - win.center() - win.show() - app.focus({ steal: true }) - }, { width, height }) +export async function setDemoWindowBounds( + ctx: AppContext, + width: number, + height: number, +): Promise { + await ctx.app.evaluate( + async ({ app, BrowserWindow, nativeTheme }, bounds) => { + nativeTheme.themeSource = 'dark' + const win = BrowserWindow.getAllWindows()[0] + if (!win) throw new Error('No Electron window found') + win.setBounds(bounds) + win.center() + win.show() + app.focus({ steal: true }) + }, + { width, height }, + ) await ctx.window.emulateMedia({ colorScheme: 'dark' }) await ctx.window.waitForTimeout(300) } @@ -100,5 +112,7 @@ export async function setDemoWindowBounds(ctx: AppContext, width: number, height * a non-zero session count. */ export async function waitForDemoSync(window: Page): Promise { - await expect(window.locator('[data-testid="status-text"]')).toContainText(/[1-9]\d*\s+session/, { timeout: 15000 }) + await expect(window.locator('[data-testid="status-text"]')).toContainText(/[1-9]\d*\s+session/, { + timeout: 15000, + }) } diff --git a/apps/app/e2e/helpers/launch-with-gpu.ts b/apps/app/e2e/helpers/launch-with-gpu.ts index 79d990d6..0572eff4 100644 --- a/apps/app/e2e/helpers/launch-with-gpu.ts +++ b/apps/app/e2e/helpers/launch-with-gpu.ts @@ -1,9 +1,10 @@ -import { _electron as electron } from '@playwright/test' -import type { ElectronApplication, Page } from '@playwright/test' import { mkdtempSync, mkdirSync, cpSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { _electron as electron } from '@playwright/test' +import type { ElectronApplication, Page } from '@playwright/test' + /** * Variant of `launchApp` that does NOT set `ELECTRON_DISABLE_GPU=1`. * Chromium's built-in PDF viewer needs GPU rasterisation; with the @@ -42,7 +43,7 @@ export async function launchAppWithGpu(): Promise { writeFileSync(join(spoolHome, 'agents.json'), JSON.stringify({}), 'utf8') const env: Record = { - ...process.env as Record, + ...(process.env as Record), SPOOL_DATA_DIR: join(tmpDir, 'data'), SPOOL_ELECTRON_USER_DATA_DIR: join(tmpDir, 'electron-user-data'), SPOOL_HOME: spoolHome, @@ -57,7 +58,7 @@ export async function launchAppWithGpu(): Promise { // viewer needs GPU rasterisation to paint. } - const args = [join(APP_DIR, 'out', 'main', 'index.js')] + const args = [join(APP_DIR, 'out', 'main', 'index.mjs')] if (process.platform === 'linux') args.unshift('--no-sandbox') args.unshift('--force-prefers-reduced-motion') diff --git a/apps/app/e2e/helpers/launch.ts b/apps/app/e2e/helpers/launch.ts index 7d1d537f..9d39a05e 100644 --- a/apps/app/e2e/helpers/launch.ts +++ b/apps/app/e2e/helpers/launch.ts @@ -1,9 +1,10 @@ -import { _electron as electron, expect } from '@playwright/test' -import type { ElectronApplication, Page } from '@playwright/test' import { mkdtempSync, mkdirSync, cpSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { _electron as electron, expect } from '@playwright/test' +import type { ElectronApplication, Page } from '@playwright/test' + const FIXTURES_DIR = join(__dirname, '..', 'fixtures') const MOCKS_DIR = join(__dirname, '..', 'mocks') const APP_DIR = join(__dirname, '..', '..') @@ -21,17 +22,25 @@ export interface AppContext { cleanup: () => Promise } -export async function launchApp(opts: { - mockAgent?: 'success' | 'error' - /** Mutate fixture dirs (e.g. inject extra sessions) after the base fixtures - * have been copied and before Electron starts. Receives the resolved dirs. */ - extraFixtures?: (dirs: { claudeDir: string; codexDir: string; geminiCliHome: string; opencodeDir: string; piDir: string }) => void - /** Extra env to merge into the Electron child process env. Used by the - * share-publish e2e to point SPOOL_SHARE_BACKEND at the in-process - * mock backend, which binds to a random port per test run and so - * can't be expressed via static playwright.config.ts env. */ - extraEnv?: Record -} = {}): Promise { +export async function launchApp( + opts: { + mockAgent?: 'success' | 'error' + /** Mutate fixture dirs (e.g. inject extra sessions) after the base fixtures + * have been copied and before Electron starts. Receives the resolved dirs. */ + extraFixtures?: (dirs: { + claudeDir: string + codexDir: string + geminiCliHome: string + opencodeDir: string + piDir: string + }) => void + /** Extra env to merge into the Electron child process env. Used by the + * share-publish e2e to point SPOOL_SHARE_BACKEND at the in-process + * mock backend, which binds to a random port per test run and so + * can't be expressed via static playwright.config.ts env. */ + extraEnv?: Record + } = {}, +): Promise { const tmpDir = mkdtempSync(join(tmpdir(), 'spool-e2e-')) const claudeDir = join(tmpDir, 'claude', 'projects') @@ -60,7 +69,7 @@ export async function launchApp(opts: { opts.extraFixtures?.({ claudeDir, codexDir, geminiCliHome, opencodeDir, piDir }) const env: Record = { - ...process.env as Record, + ...(process.env as Record), SPOOL_DATA_DIR: join(tmpDir, 'data'), SPOOL_ELECTRON_USER_DATA_DIR: join(tmpDir, 'electron-user-data'), SPOOL_HOME: join(tmpDir, 'spool-home'), @@ -78,9 +87,10 @@ export async function launchApp(opts: { // Fake `claude` binary on PATH so detectAgents() finds an agent env['PATH'] = `${MOCKS_DIR}:${env['PATH'] ?? ''}` // Point ACP extension resolution to our mock script - const mockScript = opts.mockAgent === 'error' - ? join(MOCKS_DIR, 'acp-mock-agent-error.mjs') - : join(MOCKS_DIR, 'acp-mock-agent.mjs') + const mockScript = + opts.mockAgent === 'error' + ? join(MOCKS_DIR, 'acp-mock-agent-error.mjs') + : join(MOCKS_DIR, 'acp-mock-agent.mjs') env['SPOOL_ACP_AGENT_BIN'] = mockScript } @@ -93,7 +103,7 @@ export async function launchApp(opts: { const spoolHome = join(tmpDir, 'spool-home') mkdirSync(spoolHome, { recursive: true }) - const args = [join(APP_DIR, 'out', 'main', 'index.js')] + const args = [join(APP_DIR, 'out', 'main', 'index.mjs')] if (process.platform === 'linux') args.unshift('--no-sandbox') // Force prefers-reduced-motion at the Chromium level so transitions / // animations resolve instantly — Playwright's "wait for element to be @@ -101,6 +111,11 @@ export async function launchApp(opts: { // contention. Only affects the Electron instance launched here, not // the production app. args.unshift('--force-prefers-reduced-motion') + // The product follows the user's OS language, but the E2E copy + // assertions are written against the English catalogue. Pin the + // launched Electron instance so developer-machine locale cannot + // turn semantic assertions into translation-dependent failures. + args.unshift('--lang=en-US') const app = await electron.launch({ args, cwd: APP_DIR, env }) @@ -127,9 +142,10 @@ export async function launchApp(opts: { */ export async function restartApp(ctx: AppContext): Promise { await ctx.app.close() - const args = [join(APP_DIR, 'out', 'main', 'index.js')] + const args = [join(APP_DIR, 'out', 'main', 'index.mjs')] if (process.platform === 'linux') args.unshift('--no-sandbox') args.unshift('--force-prefers-reduced-motion') + args.unshift('--lang=en-US') const app = await electron.launch({ args, cwd: APP_DIR, env: ctx.env }) const window = await app.firstWindow() return { @@ -150,7 +166,9 @@ export async function restartApp(ctx: AppContext): Promise { } export async function waitForSync(window: Page) { - await expect(window.locator('[data-testid="status-text"]')).toContainText(/[1-9]\d*\s+session/, { timeout: 15000 }) + await expect(window.locator('[data-testid="status-text"]')).toContainText(/[1-9]\d*\s+session/, { + timeout: 15000, + }) } export async function search(window: Page, query: string) { diff --git a/apps/app/e2e/helpers/native-window-capture.ts b/apps/app/e2e/helpers/native-window-capture.ts index 7c205ef2..7051cd67 100644 --- a/apps/app/e2e/helpers/native-window-capture.ts +++ b/apps/app/e2e/helpers/native-window-capture.ts @@ -1,3 +1,7 @@ +import { execFileSync, spawn } from 'node:child_process' +import { mkdirSync, rmSync } from 'node:fs' +import { dirname } from 'node:path' + /** * macOS-only helpers for capturing the Spool Electron window as a real OS window * (with traffic lights, rounded corners, shadow — not the WebContents-only @@ -7,9 +11,6 @@ * e2e suite. Requires `swift` and `screencapture` on PATH (Xcode CLT). */ import type { ElectronApplication } from '@playwright/test' -import { execFileSync, spawn } from 'node:child_process' -import { mkdirSync, rmSync } from 'node:fs' -import { dirname } from 'node:path' export interface NativeWindowInfo { id: string @@ -91,15 +92,19 @@ if let best { } ` - const raw = execFileSync('swift', [ - '-e', - swiftScript, - String(meta.pid), - String(meta.width), - String(meta.height), - String(meta.x), - String(meta.y), - ], { encoding: 'utf8' }).trim() + const raw = execFileSync( + 'swift', + [ + '-e', + swiftScript, + String(meta.pid), + String(meta.width), + String(meta.height), + String(meta.x), + String(meta.y), + ], + { encoding: 'utf8' }, + ).trim() return JSON.parse(raw) as NativeWindowInfo } @@ -108,7 +113,10 @@ if let best { * Capture a single PNG of the native window (includes traffic lights + rounded * corners + shadow). Output path is created if missing. */ -export async function captureNativeWindow(app: ElectronApplication, outputPath: string): Promise { +export async function captureNativeWindow( + app: ElectronApplication, + outputPath: string, +): Promise { await app.evaluate(async ({ app: electronApp, BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0] if (!win) throw new Error('No Electron window found') @@ -158,10 +166,10 @@ export async function recordNativeWindow( await new Promise((resolve, reject) => { let stderr = '' - proc.stderr?.on('data', chunk => { + proc.stderr?.on('data', (chunk) => { stderr += String(chunk) }) - proc.on('close', code => { + proc.on('close', (code) => { if (code === 0) { resolve() return diff --git a/apps/app/e2e/helpers/share.ts b/apps/app/e2e/helpers/share.ts index b9560313..1e7ebcd4 100644 --- a/apps/app/e2e/helpers/share.ts +++ b/apps/app/e2e/helpers/share.ts @@ -1,7 +1,8 @@ -import { expect, type Page } from '@playwright/test' import { mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' +import { expect, type Page } from '@playwright/test' + const FIXTURES_DIR = join(__dirname, '..', 'fixtures') /** @@ -10,7 +11,10 @@ const FIXTURES_DIR = join(__dirname, '..', 'fixtures') * header. Leaves the share editor open. Caller is responsible for * `expectShareEditorVisible` if it wants to await readiness. */ -export async function openShareEditorFromSessionDetail(window: Page, sessionUuid: string): Promise { +export async function openShareEditorFromSessionDetail( + window: Page, + sessionUuid: string, +): Promise { await openSessionDetail(window, sessionUuid) await window.locator('[data-testid="detail-share"]').click() await expect(window.locator('[data-testid="share-editor-page"]')).toBeVisible({ timeout: 5000 }) @@ -113,7 +117,9 @@ export async function installSaveFilePickerMock(window: Page): Promise { } /** Pop the most recent write captured by `installSaveFilePickerMock`. */ -export async function readLastSavedFile(window: Page): Promise<{ filename: string; bytes: Uint8Array } | null> { +export async function readLastSavedFile( + window: Page, +): Promise<{ filename: string; bytes: Uint8Array } | null> { const result = await window.evaluate(() => { const winAny = window as unknown as { __spoolE2EWrites?: { filename: string; bytes: number[] }[] @@ -189,10 +195,15 @@ export async function seedShareDraft( hideEmptyTurns: true, } const doc = { version: 1, conversation: convo, opts, exportedAt: new Date().toISOString() } - const preview = { ...doc, conversation: { ...convo, turns: (convo.turns as unknown[]).slice(0, 6) } } - await (window as unknown as { - spool: { shareDraft: { upsert: (input: unknown) => Promise } } - }).spool.shareDraft.upsert({ + const preview = { + ...doc, + conversation: { ...convo, turns: (convo.turns as unknown[]).slice(0, 6) }, + } + await ( + window as unknown as { + spool: { shareDraft: { upsert: (input: unknown) => Promise } } + } + ).spool.shareDraft.upsert({ draft_id: draftId, source_kind: payload.sourceKind ?? 'spool-session', source_origin: payload.sourceOrigin ?? null, @@ -205,7 +216,9 @@ export async function seedShareDraft( } /** A minimal SpoolDocument suitable for drop-import tests. */ -export function buildSampleSpoolDocument(opts: { title?: string; bodySuffix?: string } = {}): string { +export function buildSampleSpoolDocument( + opts: { title?: string; bodySuffix?: string } = {}, +): string { const conversation = { source: 'claude', sourceLabel: 'Claude', diff --git a/apps/app/e2e/home-preview.spec.ts b/apps/app/e2e/home-preview.spec.ts index ee552b44..639fd5e4 100644 --- a/apps/app/e2e/home-preview.spec.ts +++ b/apps/app/e2e/home-preview.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' let ctx: AppContext diff --git a/apps/app/e2e/markdown-table.spec.ts b/apps/app/e2e/markdown-table.spec.ts index c41feee2..bd412229 100644 --- a/apps/app/e2e/markdown-table.spec.ts +++ b/apps/app/e2e/markdown-table.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Locator } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { openShareEditorFromSessionDetail } from './helpers/share' diff --git a/apps/app/e2e/mocks/acp-base.mjs b/apps/app/e2e/mocks/acp-base.mjs index c30c6c26..429f2b43 100644 --- a/apps/app/e2e/mocks/acp-base.mjs +++ b/apps/app/e2e/mocks/acp-base.mjs @@ -34,25 +34,41 @@ export function runMockAgent(promptHandler) { let pending = 0 let stdinClosed = false - function maybeExit() { if (stdinClosed && pending === 0) process.exit(0) } + function maybeExit() { + if (stdinClosed && pending === 0) process.exit(0) + } const rl = createInterface({ input: process.stdin }) rl.on('line', (line) => { let msg - try { msg = JSON.parse(line) } catch { return } + try { + msg = JSON.parse(line) + } catch { + return + } const handler = handlers[msg.method] if (handler) { const result = handler(msg.id, msg.params ?? {}) if (result && typeof result.then === 'function') { pending++ - result.finally(() => { pending--; maybeExit() }) + result.finally(() => { + pending-- + maybeExit() + }) } } else if (msg.id !== undefined) { - send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } }) + send({ + jsonrpc: '2.0', + id: msg.id, + error: { code: -32601, message: `Method not found: ${msg.method}` }, + }) } }) - rl.on('close', () => { stdinClosed = true; maybeExit() }) + rl.on('close', () => { + stdinClosed = true + maybeExit() + }) } diff --git a/apps/app/e2e/mocks/acp-mock-agent-error.mjs b/apps/app/e2e/mocks/acp-mock-agent-error.mjs index 7ff579cf..a092eebd 100755 --- a/apps/app/e2e/mocks/acp-mock-agent-error.mjs +++ b/apps/app/e2e/mocks/acp-mock-agent-error.mjs @@ -3,5 +3,9 @@ import { send, runMockAgent } from './acp-base.mjs' runMockAgent((id) => { - send({ jsonrpc: '2.0', id, error: { code: -32603, message: 'Internal agent error: model unavailable' } }) + send({ + jsonrpc: '2.0', + id, + error: { code: -32603, message: 'Internal agent error: model unavailable' }, + }) }) diff --git a/apps/app/e2e/mocks/acp-mock-agent.mjs b/apps/app/e2e/mocks/acp-mock-agent.mjs index 2b739940..ee50b319 100755 --- a/apps/app/e2e/mocks/acp-mock-agent.mjs +++ b/apps/app/e2e/mocks/acp-mock-agent.mjs @@ -8,21 +8,32 @@ const CHUNKS = [ 'This relates to the search indexing feature you were working on.', ] -const sleep = (ms) => new Promise(r => setTimeout(r, ms)) +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) runMockAgent(async (id, params) => { const sessionId = params.sessionId ?? 'mock-session-001' notify('session/update', { sessionId, - update: { sessionUpdate: 'tool_call', toolCallId: 'mock-tool-001', title: 'Searching knowledge base', kind: 'search', status: 'in_progress' }, + update: { + sessionUpdate: 'tool_call', + toolCallId: 'mock-tool-001', + title: 'Searching knowledge base', + kind: 'search', + status: 'in_progress', + }, }) await sleep(50) notify('session/update', { sessionId, - update: { sessionUpdate: 'tool_call_update', toolCallId: 'mock-tool-001', title: 'Searching knowledge base', status: 'completed' }, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'mock-tool-001', + title: 'Searching knowledge base', + status: 'completed', + }, }) for (const text of CHUNKS) { diff --git a/apps/app/e2e/pdf-iframe-with-gpu.spec.ts b/apps/app/e2e/pdf-iframe-with-gpu.spec.ts index 4653b35d..b2f39c58 100644 --- a/apps/app/e2e/pdf-iframe-with-gpu.spec.ts +++ b/apps/app/e2e/pdf-iframe-with-gpu.spec.ts @@ -1,9 +1,11 @@ +import { mkdirSync } from 'node:fs' +import { dirname, join } from 'node:path' + import { test, expect } from '@playwright/test' + +import { waitForSync } from './helpers/launch' import { launchAppWithGpu, type AppContext } from './helpers/launch-with-gpu' import { openShareEditorFromSessionDetail } from './helpers/share' -import { waitForSync } from './helpers/launch' -import { mkdirSync } from 'node:fs' -import { dirname, join } from 'node:path' // Diagnostic for the PDF preview iframe path with full GPU enabled. // Chromium's built-in PDF viewer needs GPU rasterisation; the regular @@ -48,7 +50,5 @@ test('PDF iframe renders with GPU enabled', async () => { await window.waitForTimeout(5000) mkdirSync(dirname(ARTIFACT_PATH), { recursive: true }) - await window - .locator('.fixed.inset-0.z-50') - .screenshot({ path: ARTIFACT_PATH }) + await window.locator('.fixed.inset-0.z-50').screenshot({ path: ARTIFACT_PATH }) }) diff --git a/apps/app/e2e/picker-browse-scope.spec.ts b/apps/app/e2e/picker-browse-scope.spec.ts index 371f9a79..b3e345e8 100644 --- a/apps/app/e2e/picker-browse-scope.spec.ts +++ b/apps/app/e2e/picker-browse-scope.spec.ts @@ -1,6 +1,8 @@ -import { test, expect } from '@playwright/test' import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' + +import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' // Browser-side guard for the new pagination + virtualization behavior in @@ -65,7 +67,9 @@ test.afterAll(async () => { async function openPicker(): Promise { await ctx.window.locator('[data-testid="sidebar-shares"]').click() await expect( - ctx.window.locator('[data-testid="shares-empty-start"], [data-testid="shares-draft-row"]').first(), + ctx.window + .locator('[data-testid="shares-empty-start"], [data-testid="shares-draft-row"]') + .first(), ).toBeVisible({ timeout: 5000 }) const empty = ctx.window.locator('[data-testid="shares-empty-start"]') const plus = ctx.window.locator('[data-testid="shares-new-draft"]') @@ -74,7 +78,9 @@ async function openPicker(): Promise { } else { await plus.click() } - await expect(ctx.window.locator('[data-testid="new-draft-picker"]')).toBeVisible({ timeout: 5000 }) + await expect(ctx.window.locator('[data-testid="new-draft-picker"]')).toBeVisible({ + timeout: 5000, + }) } async function closePicker(): Promise { @@ -90,7 +96,10 @@ async function scopeToBigProject(): Promise { const popover = ctx.window.locator('[data-testid="new-draft-picker-scope-popover"]') await expect(popover).toBeVisible() // Find the project option whose label contains our bulk slug. - const option = popover.locator('[data-testid="new-draft-picker-scope-option"][data-identity-key]:not([data-identity-key=""])') + const option = popover + .locator( + '[data-testid="new-draft-picker-scope-option"][data-identity-key]:not([data-identity-key=""])', + ) .filter({ hasText: /spool-e2e-big-project/i }) .first() await expect(option).toBeVisible({ timeout: 3000 }) @@ -107,24 +116,31 @@ test('scoping to a >50-session project paginates rather than capping at 30', asy // Newest fixture (i=74) sits at row 0; its presence confirms the picker // bound to the scoped list. await expect( - ctx.window.locator('[data-testid="new-draft-picker-row"]').filter({ hasText: 'Bulk session 074' }), + ctx.window + .locator('[data-testid="new-draft-picker-row"]') + .filter({ hasText: 'Bulk session 074' }), ).toBeVisible({ timeout: 5000 }) // Trigger pagination by scrolling to the oldest end (ordinal 000 lives // beyond the first 50 rows; scroll re-tries because a lone programmatic // scrollTo occasionally loses the race against Virtuoso's measurement). - await expect.poll(async () => { - await virtual.evaluate((node) => { - const scroller = node.matches('[data-virtuoso-scroller]') - ? node - : node.querySelector('[data-virtuoso-scroller]') - ;(scroller ?? node).scrollTo({ top: 1e7 }) - }) - return ctx.window - .locator('[data-testid="new-draft-picker-row"]') - .filter({ hasText: 'Bulk session 000' }) - .count() - }, { timeout: 10000, intervals: [200, 300, 500, 500, 1000] }).toBeGreaterThan(0) + await expect + .poll( + async () => { + await virtual.evaluate((node) => { + const scroller = node.matches('[data-virtuoso-scroller]') + ? node + : node.querySelector('[data-virtuoso-scroller]') + ;(scroller ?? node).scrollTo({ top: 1e7 }) + }) + return ctx.window + .locator('[data-testid="new-draft-picker-row"]') + .filter({ hasText: 'Bulk session 000' }) + .count() + }, + { timeout: 10000, intervals: [200, 300, 500, 500, 1000] }, + ) + .toBeGreaterThan(0) await closePicker() }) @@ -142,7 +158,9 @@ test('switching scope back to Any project resets the list to page 1', async () = // sessions are now interleaved with other fixtures, but the list must // not get stuck on the previous scope's row count. await expect(virtual).toBeVisible({ timeout: 3000 }) - await expect(ctx.window.locator('[data-testid="new-draft-picker-row"]').first()).toBeVisible({ timeout: 3000 }) + await expect(ctx.window.locator('[data-testid="new-draft-picker-row"]').first()).toBeVisible({ + timeout: 3000, + }) await closePicker() }) diff --git a/apps/app/e2e/pin.spec.ts b/apps/app/e2e/pin.spec.ts index 9e0bce73..5ec36fd4 100644 --- a/apps/app/e2e/pin.spec.ts +++ b/apps/app/e2e/pin.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' let ctx: AppContext @@ -81,7 +82,8 @@ test('directory chip count matches the actual number of rows for that cwd', asyn const chips = window.locator('[data-testid="project-directory-chip"]') const chipCount = await chips.count() - if (chipCount < 2) test.skip(true, 'project has no sub-directories in fixtures — chip strip absent') + if (chipCount < 2) + test.skip(true, 'project has no sub-directories in fixtures — chip strip absent') // Pick the first non-"All" chip; its badge value must equal the number of // session-row entries that follow when isolated to that cwd. @@ -101,7 +103,7 @@ test('pin then unpin keeps the session visible in recent (no vanishing)', async await window.locator('[data-testid="sidebar-project-row"]').first().click() // Make sure we start from a clean state: clear any pre-existing pins. let stalePinned = window.locator('[data-testid="session-row"][data-pinned]').first() - while (await stalePinned.count() > 0) { + while ((await stalePinned.count()) > 0) { await stalePinned.locator('[data-testid="pin-button"]').click() await expect(stalePinned).toBeHidden({ timeout: 2000 }) stalePinned = window.locator('[data-testid="session-row"][data-pinned]').first() @@ -128,6 +130,8 @@ test('pin then unpin keeps the session visible in recent (no vanishing)', async // forgetting to reinsert into the recent list. await expect(window.locator(pinnedRowSel)).toBeHidden({ timeout: 5000 }) await expect( - window.locator(`[data-testid="session-row"][data-session-uuid="${targetUuid}"]:not([data-pinned])`), + window.locator( + `[data-testid="session-row"][data-session-uuid="${targetUuid}"]:not([data-pinned])`, + ), ).toBeVisible({ timeout: 5000 }) }) diff --git a/apps/app/e2e/project-view.spec.ts b/apps/app/e2e/project-view.spec.ts index 9e7827e9..d345a701 100644 --- a/apps/app/e2e/project-view.spec.ts +++ b/apps/app/e2e/project-view.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' let ctx: AppContext diff --git a/apps/app/e2e/project-worktree-grouping.spec.ts b/apps/app/e2e/project-worktree-grouping.spec.ts index 85722501..4eb9f13b 100644 --- a/apps/app/e2e/project-worktree-grouping.spec.ts +++ b/apps/app/e2e/project-worktree-grouping.spec.ts @@ -1,8 +1,10 @@ -import { test, expect } from '@playwright/test' import { execFileSync } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' + +import { test, expect } from '@playwright/test' + import { launchApp, restartApp, waitForSync, type AppContext } from './helpers/launch' test('historical Orca and Codex worktrees collapse into one sidebar project after restart', async () => { @@ -12,11 +14,10 @@ test('historical Orca and Codex worktrees collapse into one sidebar project afte const repoPath = join(testHome, 'work', 'paperboy') mkdirSync(repoPath, { recursive: true }) execFileSync('git', ['init', '-b', 'main'], { cwd: repoPath, stdio: 'ignore' }) - execFileSync( - 'git', - ['remote', 'add', 'origin', 'git@github.com:paperboytm/paperboy.git'], - { cwd: repoPath, stdio: 'ignore' }, - ) + execFileSync('git', ['remote', 'add', 'origin', 'git@github.com:paperboytm/paperboy.git'], { + cwd: repoPath, + stdio: 'ignore', + }) let ctx: AppContext | null = null try { @@ -49,12 +50,20 @@ test('historical Orca and Codex worktrees collapse into one sidebar project afte ...(repositoryUrl ? { git: { repository_url: repositoryUrl } } : {}), }, }, - { timestamp: `2026-07-17T10:0${index}:01Z`, type: 'event_msg', payload: { type: 'user_message', message: `worktree ${index}` } }, - { timestamp: `2026-07-17T10:0${index}:02Z`, type: 'event_msg', payload: { type: 'agent_message', message: 'done' } }, + { + timestamp: `2026-07-17T10:0${index}:01Z`, + type: 'event_msg', + payload: { type: 'user_message', message: `worktree ${index}` }, + }, + { + timestamp: `2026-07-17T10:0${index}:02Z`, + type: 'event_msg', + payload: { type: 'agent_message', message: 'done' }, + }, ] writeFileSync( join(sessionDir, `rollout-2026-07-17T10-0${index}-00-${uuid}.jsonl`), - lines.map(line => JSON.stringify(line)).join('\n') + '\n', + lines.map((line) => JSON.stringify(line)).join('\n') + '\n', ) } stalePaperboyCwds.forEach((cwd, index) => writeSession(cwd, index)) @@ -84,10 +93,13 @@ test('historical Orca and Codex worktrees collapse into one sidebar project afte // the production path that reopens the DB and reconciles old path rows. const orcaDir = join(testHome, 'Library', 'Application Support', 'orca') mkdirSync(orcaDir, { recursive: true }) - writeFileSync(join(orcaDir, 'orca-data.json'), JSON.stringify({ - repos: [{ id: 'repo-paperboy', path: repoPath, displayName: 'paperboy' }], - worktreeMeta: {}, - })) + writeFileSync( + join(orcaDir, 'orca-data.json'), + JSON.stringify({ + repos: [{ id: 'repo-paperboy', path: repoPath, displayName: 'paperboy' }], + worktreeMeta: {}, + }), + ) ctx = await restartApp(ctx) await waitForSync(ctx.window) diff --git a/apps/app/e2e/search-overlay-scope.spec.ts b/apps/app/e2e/search-overlay-scope.spec.ts index 2f3b9a94..1b158e39 100644 --- a/apps/app/e2e/search-overlay-scope.spec.ts +++ b/apps/app/e2e/search-overlay-scope.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' let ctx: AppContext @@ -85,12 +86,16 @@ test('scope popover lists projects and can be selected', async () => { const popover = ctx.window.locator('[data-testid="search-overlay-scope-popover"]') await expect(popover).toBeVisible({ timeout: 2000 }) const projectOption = popover - .locator('[data-testid="search-overlay-scope-option"][data-identity-key]:not([data-identity-key=""])') + .locator( + '[data-testid="search-overlay-scope-option"][data-identity-key]:not([data-identity-key=""])', + ) .first() await expect(projectOption).toBeVisible() await projectOption.click() await expect(popover).toBeHidden({ timeout: 2000 }) - await expect(ctx.window.locator('[data-testid="search-overlay-scope-trigger"]')).not.toContainText(/Any project/i) + await expect( + ctx.window.locator('[data-testid="search-overlay-scope-trigger"]'), + ).not.toContainText(/Any project/i) await expect(ctx.window.locator('[data-testid="search-overlay-scope-clear"]')).toBeVisible() await closeOverlay() }) @@ -108,11 +113,14 @@ test('after popover close, focus returns to the search input', async () => { await ctx.window.locator('[data-testid="search-overlay-scope-trigger"]').click() await expect(popover).toBeVisible({ timeout: 2000 }) await popover - .locator('[data-testid="search-overlay-scope-option"][data-identity-key]:not([data-identity-key=""])') - .first().click() + .locator( + '[data-testid="search-overlay-scope-option"][data-identity-key]:not([data-identity-key=""])', + ) + .first() + .click() await expect(popover).toBeHidden({ timeout: 2000 }) - let focused = await ctx.window.evaluate(() => - (document.activeElement as HTMLElement | null)?.getAttribute('data-testid') ?? null, + let focused = await ctx.window.evaluate( + () => (document.activeElement as HTMLElement | null)?.getAttribute('data-testid') ?? null, ) expect(focused).toBe('search-overlay-input') @@ -121,8 +129,8 @@ test('after popover close, focus returns to the search input', async () => { await expect(popover).toBeVisible({ timeout: 2000 }) await ctx.window.keyboard.press('Escape') await expect(popover).toBeHidden({ timeout: 2000 }) - focused = await ctx.window.evaluate(() => - (document.activeElement as HTMLElement | null)?.getAttribute('data-testid') ?? null, + focused = await ctx.window.evaluate( + () => (document.activeElement as HTMLElement | null)?.getAttribute('data-testid') ?? null, ) expect(focused).toBe('search-overlay-input') @@ -154,8 +162,9 @@ test('options toggle: clicking with scope set keeps scope active even when row h // options row back open should reveal the same label. await ctx.window.locator('[data-testid="search-overlay-options-toggle"]').click() await expect(ctx.window.locator('[data-testid="search-overlay-options-row"]')).toBeVisible() - await expect(ctx.window.locator('[data-testid="search-overlay-scope-trigger"]')) - .toHaveText(scopeLabelBefore ?? '') + await expect(ctx.window.locator('[data-testid="search-overlay-scope-trigger"]')).toHaveText( + scopeLabelBefore ?? '', + ) await closeOverlay() }) @@ -168,7 +177,9 @@ test('Shift+Enter from FTS results commits to results page', async () => { await expect(firstResult).toBeVisible({ timeout: 3000 }) await input.press('Shift+Enter') await expect(ctx.window.locator('[data-testid="search-overlay"]')).toBeHidden({ timeout: 2000 }) - await expect(ctx.window.locator('[data-testid="results-scope-chip"]')).toBeVisible({ timeout: 3000 }) + await expect(ctx.window.locator('[data-testid="results-scope-chip"]')).toBeVisible({ + timeout: 3000, + }) }) test('clicking the results-total badge commits to results page', async () => { @@ -180,7 +191,9 @@ test('clicking the results-total badge commits to results page', async () => { await expect(badge).toBeVisible({ timeout: 3000 }) await badge.click() await expect(ctx.window.locator('[data-testid="search-overlay"]')).toBeHidden({ timeout: 2000 }) - await expect(ctx.window.locator('[data-testid="results-scope-chip"]')).toBeVisible({ timeout: 3000 }) + await expect(ctx.window.locator('[data-testid="results-scope-chip"]')).toBeVisible({ + timeout: 3000, + }) }) test('results-total badge is hidden in recents mode and appears in search mode', async () => { @@ -191,7 +204,9 @@ test('results-total badge is hidden in recents mode and appears in search mode', await expect(ctx.window.locator('[data-testid="search-overlay-results-total"]')).toHaveCount(0) const input = ctx.window.locator('[data-testid="search-overlay-input"]') await input.fill('XYLOPHONE_CANARY_42') - await expect(ctx.window.locator('[data-testid="search-overlay-results-total"]')).toBeVisible({ timeout: 3000 }) + await expect(ctx.window.locator('[data-testid="search-overlay-results-total"]')).toBeVisible({ + timeout: 3000, + }) // Clearing the query reverts to recents — badge disappears again. await input.fill('') await expect(ctx.window.locator('[data-testid="search-overlay-results-total"]')).toHaveCount(0) diff --git a/apps/app/e2e/security-badge.spec.ts b/apps/app/e2e/security-badge.spec.ts index faf9f2fb..fb77fa26 100644 --- a/apps/app/e2e/security-badge.spec.ts +++ b/apps/app/e2e/security-badge.spec.ts @@ -1,8 +1,10 @@ -import { test, expect } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // Fake AWS access-key-shaped fixture. Built at module load so neither // GitHub push-protection nor our `isVendorExampleKey` validator // (drops AWS docs' `*EXAMPLE` literal) filters it. @@ -17,29 +19,32 @@ test.beforeAll(async () => { // user content. The regex provider matches `AKIA[0-9A-Z]{16}` // and classifies it as `api-key` (high-severity). const sessionFile = join(claudeDir, 'test-project', 'test-session-security.jsonl') - writeFileSync(sessionFile, [ - JSON.stringify({ - type: 'user', - sessionId: 'security-fixture-session', - cwd: '/tmp/test-project', - uuid: 'sec-msg-1', - timestamp: '2026-05-19T10:00:00Z', - message: { - role: 'user', - content: `I leaked ${FAKE_AKIA} to a log, please rotate it.`, - }, - }), - JSON.stringify({ - type: 'assistant', - uuid: 'sec-msg-2', - timestamp: '2026-05-19T10:00:05Z', - message: { - role: 'assistant', - model: 'claude-sonnet-4', - content: 'Rotating that AWS key now.', - }, - }), - ].join('\n')) + writeFileSync( + sessionFile, + [ + JSON.stringify({ + type: 'user', + sessionId: 'security-fixture-session', + cwd: '/tmp/test-project', + uuid: 'sec-msg-1', + timestamp: '2026-05-19T10:00:00Z', + message: { + role: 'user', + content: `I leaked ${FAKE_AKIA} to a log, please rotate it.`, + }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'sec-msg-2', + timestamp: '2026-05-19T10:00:05Z', + message: { + role: 'assistant', + model: 'claude-sonnet-4', + content: 'Rotating that AWS key now.', + }, + }), + ].join('\n'), + ) }, }) }) @@ -61,12 +66,27 @@ test('Library row shows a high-severity Security Scan badge for a session with a // Wait for the scan worker to fully drain — backfill runs async // after sync, so we poll status until idle, then expect the badge. - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise<{ queued: number; scanning: number | null; backfillRemaining: number }> } } }).spool - if (!api?.security) return false - const s = await api.security.getScanStatus() - return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { + spool?: { + security?: { + getScanStatus: () => Promise<{ + queued: number + scanning: number | null + backfillRemaining: number + }> + } + } + } + ).spool + if (!api?.security) return false + const s = await api.security.getScanStatus() + return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 + }, + { timeout: 30_000, polling: 250 }, + ) // The polling fallback in ProjectView refetches every 750ms while // the worker is busy, so by now the badge should be in the DOM. diff --git a/apps/app/e2e/security-blast-radius.spec.ts b/apps/app/e2e/security-blast-radius.spec.ts index 4f5824b0..bd2d0cec 100644 --- a/apps/app/e2e/security-blast-radius.spec.ts +++ b/apps/app/e2e/security-blast-radius.spec.ts @@ -1,8 +1,10 @@ -import { test, expect, type Page } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect, type Page } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // Layer 2 — cross-session blast radius. // // The same fake AWS key leaks in TWO different sessions. On the @@ -19,39 +21,91 @@ let ctx: AppContext test.beforeAll(async () => { ctx = await launchApp({ extraFixtures: ({ claudeDir }) => { - writeFileSync(join(claudeDir, 'test-project', 'blast-a.jsonl'), [ - JSON.stringify({ type: 'user', sessionId: SID_A, cwd: '/tmp/test-project', uuid: 'ba-1', timestamp: '2026-05-21T10:00:00Z', message: { role: 'user', content: `key ${FAKE_AKIA} and email me@x.io` } }), - JSON.stringify({ type: 'assistant', uuid: 'ba-2', timestamp: '2026-05-21T10:00:05Z', message: { role: 'assistant', model: 'claude-sonnet-4', content: 'ok' } }), - ].join('\n')) - writeFileSync(join(claudeDir, 'test-project', 'blast-b.jsonl'), [ - JSON.stringify({ type: 'user', sessionId: SID_B, cwd: '/tmp/test-project', uuid: 'bb-1', timestamp: '2026-05-22T10:00:00Z', message: { role: 'user', content: `same key ${FAKE_AKIA} again` } }), - JSON.stringify({ type: 'assistant', uuid: 'bb-2', timestamp: '2026-05-22T10:00:05Z', message: { role: 'assistant', model: 'claude-sonnet-4', content: 'ok' } }), - ].join('\n')) + writeFileSync( + join(claudeDir, 'test-project', 'blast-a.jsonl'), + [ + JSON.stringify({ + type: 'user', + sessionId: SID_A, + cwd: '/tmp/test-project', + uuid: 'ba-1', + timestamp: '2026-05-21T10:00:00Z', + message: { role: 'user', content: `key ${FAKE_AKIA} and email me@x.io` }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'ba-2', + timestamp: '2026-05-21T10:00:05Z', + message: { role: 'assistant', model: 'claude-sonnet-4', content: 'ok' }, + }), + ].join('\n'), + ) + writeFileSync( + join(claudeDir, 'test-project', 'blast-b.jsonl'), + [ + JSON.stringify({ + type: 'user', + sessionId: SID_B, + cwd: '/tmp/test-project', + uuid: 'bb-1', + timestamp: '2026-05-22T10:00:00Z', + message: { role: 'user', content: `same key ${FAKE_AKIA} again` }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'bb-2', + timestamp: '2026-05-22T10:00:05Z', + message: { role: 'assistant', model: 'claude-sonnet-4', content: 'ok' }, + }), + ].join('\n'), + ) }, }) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) async function waitForWorkerIdle(window: Page): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise<{ queued: number; scanning: number | null; backfillRemaining: number }> } } }).spool - if (!api?.security) return false - const s = await api.security.getScanStatus() - return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { + spool?: { + security?: { + getScanStatus: () => Promise<{ + queued: number + scanning: number | null + backfillRemaining: number + }> + } + } + } + ).spool + if (!api?.security) return false + const s = await api.security.getScanStatus() + return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 + }, + { timeout: 30_000, polling: 250 }, + ) } // Worker-idle can be a FALSE idle (queued=0 in the window before the new // session's scan is enqueued), so findings/risk categories aren't there // yet. Wait until they've actually been produced before driving the UI. async function waitForFindings(window: Page): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { riskByCategory: () => Promise } } }).spool - if (!api?.security) return false - const cats = await api.security.riskByCategory() - return Array.isArray(cats) && cats.length > 0 - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { spool?: { security?: { riskByCategory: () => Promise } } } + ).spool + if (!api?.security) return false + const cats = await api.security.riskByCategory() + return Array.isArray(cats) && cats.length > 0 + }, + { timeout: 30_000, polling: 250 }, + ) } test('credential finding shows cross-session blast radius; PII does not', async () => { @@ -72,9 +126,12 @@ test('credential finding shows cross-session blast radius; PII does not', async // The api-key value row carries a quiet ⧉N badge. The same key leaks // in 2 sessions; the count is framed around the OTHER session (the // finding's own is excluded), so the badge reports 1. - const apiRow = window.locator('[data-testid="finding-row-wrap"]').filter({ - has: window.locator('[data-testid="finding-row"][data-kind="api-key"]'), - }).first() + const apiRow = window + .locator('[data-testid="finding-row-wrap"]') + .filter({ + has: window.locator('[data-testid="finding-row"][data-kind="api-key"]'), + }) + .first() const badge = apiRow.locator('[data-testid="blast-badge"]') await expect(badge).toBeVisible({ timeout: 10_000 }) await expect(badge).toHaveAttribute('data-sessions', '1') @@ -84,7 +141,9 @@ test('credential finding shows cross-session blast radius; PII does not', async await badge.click() const radius = apiRow.locator('[data-testid="blast-radius"]') await expect(radius.locator('[data-testid="blast-radius-row"]')).toHaveCount(1) - await expect(radius.locator('[data-testid="blast-radius-row"] [data-testid="source-dot"]')).toHaveCount(1) + await expect( + radius.locator('[data-testid="blast-radius-row"] [data-testid="source-dot"]'), + ).toHaveCount(1) }) test('purge everywhere scrubs every copy and collapses the radius', async () => { @@ -92,9 +151,12 @@ test('purge everywhere scrubs every copy and collapses the radius', async () => await window.locator('[data-testid="sidebar-security"]').click() await expect(window.locator('[data-testid="security-page"]')).toBeVisible() - const apiRow = window.locator('[data-testid="finding-row-wrap"]').filter({ - has: window.locator('[data-testid="finding-row"][data-kind="api-key"]'), - }).first() + const apiRow = window + .locator('[data-testid="finding-row-wrap"]') + .filter({ + has: window.locator('[data-testid="finding-row"][data-kind="api-key"]'), + }) + .first() const badge = apiRow.locator('[data-testid="blast-badge"]') await expect(badge).toBeVisible({ timeout: 10_000 }) // Expand idempotently — the badge's `expanded` state may already be diff --git a/apps/app/e2e/security-blur-per-surface.spec.ts b/apps/app/e2e/security-blur-per-surface.spec.ts index a270f970..71c12f6b 100644 --- a/apps/app/e2e/security-blur-per-surface.spec.ts +++ b/apps/app/e2e/security-blur-per-surface.spec.ts @@ -1,8 +1,10 @@ -import { test, expect } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // Regression for the per-surface blur prefs introduced 2026-05. // // Pre-fix: a single `revealValuesOnHoverOnly` flag existed on disk but @@ -30,39 +32,59 @@ test.beforeAll(async () => { ctx = await launchApp({ extraFixtures: ({ claudeDir }) => { const file = join(claudeDir, 'test-project', 'blur-fixture.jsonl') - writeFileSync(file, [ - JSON.stringify({ - type: 'user', - sessionId: 'blur-fixture-session', - cwd: '/tmp/test-project', - uuid: 'bf-msg-1', - timestamp: '2026-05-21T10:00:00Z', - message: { role: 'user', content: `please rotate ${FAKE_AKIA}` }, - }), - JSON.stringify({ - type: 'assistant', - uuid: 'bf-msg-2', - timestamp: '2026-05-21T10:00:05Z', - message: { - role: 'assistant', - model: 'claude-sonnet-4', - content: 'rotated', - }, - }), - ].join('\n')) + writeFileSync( + file, + [ + JSON.stringify({ + type: 'user', + sessionId: 'blur-fixture-session', + cwd: '/tmp/test-project', + uuid: 'bf-msg-1', + timestamp: '2026-05-21T10:00:00Z', + message: { role: 'user', content: `please rotate ${FAKE_AKIA}` }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'bf-msg-2', + timestamp: '2026-05-21T10:00:05Z', + message: { + role: 'assistant', + model: 'claude-sonnet-4', + content: 'rotated', + }, + }), + ].join('\n'), + ) }, }) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) async function waitForWorkerIdle(window: AppContext['window']): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise<{ queued: number; scanning: number | null; backfillRemaining: number }> } } }).spool - if (!api?.security) return false - const s = await api.security.getScanStatus() - return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { + spool?: { + security?: { + getScanStatus: () => Promise<{ + queued: number + scanning: number | null + backfillRemaining: number + }> + } + } + } + ).spool + if (!api?.security) return false + const s = await api.security.getScanStatus() + return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 + }, + { timeout: 30_000, polling: 250 }, + ) } test('Per-surface blur prefs are independent and bidirectional with the in-page icons', async () => { diff --git a/apps/app/e2e/security-empty-detector-settings.spec.ts b/apps/app/e2e/security-empty-detector-settings.spec.ts index 3c5cbeb4..f9df7f70 100644 --- a/apps/app/e2e/security-empty-detector-settings.spec.ts +++ b/apps/app/e2e/security-empty-detector-settings.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' // Regression for the "Detector settings" button on the Security page's @@ -15,7 +16,9 @@ test.beforeAll(async () => { ctx = await launchApp() }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) test('Empty Security page exposes a working Detector settings shortcut', async () => { const { window } = ctx @@ -35,5 +38,7 @@ test('Empty Security page exposes a working Detector settings shortcut', async ( // The opened tab must be Security — verify by the presence of the // pane-specific rescan button (a unique testid only present inside // the Security pane). - await expect(window.locator('[data-testid="settings-rescan-all"]')).toBeVisible({ timeout: 5_000 }) + await expect(window.locator('[data-testid="settings-rescan-all"]')).toBeVisible({ + timeout: 5_000, + }) }) diff --git a/apps/app/e2e/security-empty-state-copy.spec.ts b/apps/app/e2e/security-empty-state-copy.spec.ts index eff3f21a..8eab78d5 100644 --- a/apps/app/e2e/security-empty-state-copy.spec.ts +++ b/apps/app/e2e/security-empty-state-copy.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' // EmptyState copy split: the empty Security page must distinguish a @@ -25,8 +26,12 @@ import { launchApp, waitForSync, type AppContext } from './helpers/launch' let ctx: AppContext -test.beforeAll(async () => { ctx = await launchApp() }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.beforeAll(async () => { + ctx = await launchApp() +}) +test.afterAll(async () => { + await ctx?.cleanup() +}) test('Empty Security page reads "clean" after a scan completes with no findings', async () => { const { window } = ctx diff --git a/apps/app/e2e/security-findings-actions.spec.ts b/apps/app/e2e/security-findings-actions.spec.ts index 02d17e14..c9a5df34 100644 --- a/apps/app/e2e/security-findings-actions.spec.ts +++ b/apps/app/e2e/security-findings-actions.spec.ts @@ -1,8 +1,10 @@ -import { test, expect, type Page } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect, type Page } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // Covers the unified findings-action surface work (2026-05): // // * FindingsStrip dedupes identical (kind, value) occurrences into one @@ -29,25 +31,69 @@ let ctx: AppContext test.beforeAll(async () => { ctx = await launchApp({ extraFixtures: ({ claudeDir }) => { - writeFileSync(join(claudeDir, 'test-project', 'findings-actions.jsonl'), [ - JSON.stringify({ type: 'user', sessionId: SID, cwd: '/tmp/test-project', uuid: 'fa-1', timestamp: '2026-05-21T10:00:00Z', message: { role: 'user', content: `rotate ${FAKE_AKIA} please` } }), - JSON.stringify({ type: 'assistant', uuid: 'fa-2', timestamp: '2026-05-21T10:00:05Z', message: { role: 'assistant', model: 'claude-sonnet-4', content: 'ok' } }), - JSON.stringify({ type: 'user', sessionId: SID, cwd: '/tmp/test-project', uuid: 'fa-3', timestamp: '2026-05-21T10:00:10Z', message: { role: 'user', content: `again, the key ${FAKE_AKIA} and email dev@fly.io` } }), - JSON.stringify({ type: 'assistant', uuid: 'fa-4', timestamp: '2026-05-21T10:00:15Z', message: { role: 'assistant', model: 'claude-sonnet-4', content: 'done' } }), - ].join('\n')) + writeFileSync( + join(claudeDir, 'test-project', 'findings-actions.jsonl'), + [ + JSON.stringify({ + type: 'user', + sessionId: SID, + cwd: '/tmp/test-project', + uuid: 'fa-1', + timestamp: '2026-05-21T10:00:00Z', + message: { role: 'user', content: `rotate ${FAKE_AKIA} please` }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'fa-2', + timestamp: '2026-05-21T10:00:05Z', + message: { role: 'assistant', model: 'claude-sonnet-4', content: 'ok' }, + }), + JSON.stringify({ + type: 'user', + sessionId: SID, + cwd: '/tmp/test-project', + uuid: 'fa-3', + timestamp: '2026-05-21T10:00:10Z', + message: { role: 'user', content: `again, the key ${FAKE_AKIA} and email dev@fly.io` }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'fa-4', + timestamp: '2026-05-21T10:00:15Z', + message: { role: 'assistant', model: 'claude-sonnet-4', content: 'done' }, + }), + ].join('\n'), + ) }, }) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) async function waitForWorkerIdle(window: Page): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise<{ queued: number; scanning: number | null; backfillRemaining: number }> } } }).spool - if (!api?.security) return false - const s = await api.security.getScanStatus() - return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { + spool?: { + security?: { + getScanStatus: () => Promise<{ + queued: number + scanning: number | null + backfillRemaining: number + }> + } + } + } + ).spool + if (!api?.security) return false + const s = await api.security.getScanStatus() + return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 + }, + { timeout: 30_000, polling: 250 }, + ) } async function openStrip(window: Page): Promise { @@ -119,7 +165,11 @@ test('REGRESSION: Dismiss all clears the strip and refreshes the meta-row pill', await window.locator('[role="menu"] [role="menuitem"]').first().click() // Strip flips to its cleared state… - await expect(window.locator('[data-testid="findings-strip"]')).toHaveAttribute('data-cleared', '1', { timeout: 10_000 }) + await expect(window.locator('[data-testid="findings-strip"]')).toHaveAttribute( + 'data-cleared', + '1', + { timeout: 10_000 }, + ) // …and the pill must disappear (0 active, 0 purged). Pre-fix it stayed // visible with the stale count because no findings-changed event fired. await expect(riskPill).toBeHidden({ timeout: 10_000 }) diff --git a/apps/app/e2e/security-findings-value-width.spec.ts b/apps/app/e2e/security-findings-value-width.spec.ts index 2da5d7a9..b124d060 100644 --- a/apps/app/e2e/security-findings-value-width.spec.ts +++ b/apps/app/e2e/security-findings-value-width.spec.ts @@ -1,8 +1,10 @@ -import { test, expect } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // Regression test for the finding-row value over-truncation (May 2026): // // The hover actions (Dismiss / Everywhere / Purge) used to live in an @@ -25,15 +27,29 @@ test.beforeAll(async () => { writeFileSync( join(claudeDir, 'test-project', 'value-width-fixture.jsonl'), [ - JSON.stringify({ type: 'user', sessionId: 'value-width', cwd: '/tmp/test-project', uuid: 'vw-1', timestamp: '2026-05-20T10:00:00Z', message: { role: 'user', content: 'key AKIAV3QFKW72ZDLNP4XX leaked, rotate it' } }), - JSON.stringify({ type: 'assistant', uuid: 'vw-2', timestamp: '2026-05-20T10:00:01Z', message: { role: 'assistant', model: 'claude-sonnet-4', content: 'rotating' } }), + JSON.stringify({ + type: 'user', + sessionId: 'value-width', + cwd: '/tmp/test-project', + uuid: 'vw-1', + timestamp: '2026-05-20T10:00:00Z', + message: { role: 'user', content: 'key AKIAV3QFKW72ZDLNP4XX leaked, rotate it' }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'vw-2', + timestamp: '2026-05-20T10:00:01Z', + message: { role: 'assistant', model: 'claude-sonnet-4', content: 'rotating' }, + }), ].join('\n'), ) }, }) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) test('finding value spans the full row width and actions stay reachable on hover', async () => { const { window } = ctx @@ -53,7 +69,7 @@ test('finding value spans the full row width and actions stay reachable on hover const cell = row.locator('[data-testid="finding-value-cell"]') const rowBox = (await row.boundingBox())! const cellBox = (await cell.boundingBox())! - expect((rowBox.x + rowBox.width) - (cellBox.x + cellBox.width)).toBeLessThan(40) + expect(rowBox.x + rowBox.width - (cellBox.x + cellBox.width)).toBeLessThan(40) // The value text is shown (not clipped to nothing by a reserved column). await expect(value).toBeVisible() diff --git a/apps/app/e2e/security-ignored-entry.spec.ts b/apps/app/e2e/security-ignored-entry.spec.ts index b5f1b27d..fd9e72de 100644 --- a/apps/app/e2e/security-ignored-entry.spec.ts +++ b/apps/app/e2e/security-ignored-entry.spec.ts @@ -1,8 +1,10 @@ -import { test, expect, type Page } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect, type Page } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // The "Ignored items" review used to be reachable only via Settings → // Security → Ignored items → Review (three levels deep). This spec // gates the shallow entry surfaced on the main Security page: @@ -24,23 +26,55 @@ let ctx: AppContext test.beforeAll(async () => { ctx = await launchApp({ extraFixtures: ({ claudeDir }) => { - writeFileSync(join(claudeDir, 'test-project', 'ignored-entry.jsonl'), [ - JSON.stringify({ type: 'user', sessionId: SID, cwd: '/tmp/test-project', uuid: 'ie-1', timestamp: '2026-05-21T10:00:00Z', message: { role: 'user', content: `leaked ${FAKE_AKIA}, rotate it` } }), - JSON.stringify({ type: 'assistant', uuid: 'ie-2', timestamp: '2026-05-21T10:00:05Z', message: { role: 'assistant', model: 'claude-sonnet-4', content: 'ok' } }), - ].join('\n')) + writeFileSync( + join(claudeDir, 'test-project', 'ignored-entry.jsonl'), + [ + JSON.stringify({ + type: 'user', + sessionId: SID, + cwd: '/tmp/test-project', + uuid: 'ie-1', + timestamp: '2026-05-21T10:00:00Z', + message: { role: 'user', content: `leaked ${FAKE_AKIA}, rotate it` }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'ie-2', + timestamp: '2026-05-21T10:00:05Z', + message: { role: 'assistant', model: 'claude-sonnet-4', content: 'ok' }, + }), + ].join('\n'), + ) }, }) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) async function waitForWorkerIdle(window: Page): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise<{ queued: number; scanning: number | null; backfillRemaining: number }> } } }).spool - if (!api?.security) return false - const s = await api.security.getScanStatus() - return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { + spool?: { + security?: { + getScanStatus: () => Promise<{ + queued: number + scanning: number | null + backfillRemaining: number + }> + } + } + } + ).spool + if (!api?.security) return false + const s = await api.security.getScanStatus() + return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 + }, + { timeout: 30_000, polling: 250 }, + ) } test('Security page surfaces an Ignored entry that opens the manage modal', async () => { @@ -56,10 +90,16 @@ test('Security page surfaces an Ignored entry that opens the manage modal', asyn // Dismiss the fixture's api-key finding (writes an allowlist row). await window.evaluate(async () => { - const api = (globalThis as { spool?: { security?: { - listFindings: (f: unknown) => Promise> - dismissFinding: (id: number, scope: string) => Promise - } } }).spool + const api = ( + globalThis as { + spool?: { + security?: { + listFindings: (f: unknown) => Promise> + dismissFinding: (id: number, scope: string) => Promise + } + } + } + ).spool const rows = await api!.security!.listFindings({ state: 'active' }) const target = rows.find((r) => r.kind === 'api-key') ?? rows[0] if (target) await api!.security!.dismissFinding(target.id, 'global') diff --git a/apps/app/e2e/security-ignored-filters.spec.ts b/apps/app/e2e/security-ignored-filters.spec.ts index 4e0190f7..4f1f57b9 100644 --- a/apps/app/e2e/security-ignored-filters.spec.ts +++ b/apps/app/e2e/security-ignored-filters.spec.ts @@ -1,8 +1,10 @@ -import { test, expect, type Page } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect, type Page } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // Exercises the "Ignored items" modal toolbar end-to-end: the scope // dropdown, the type dropdown, the free-text filter, and the // "Stop ignoring" row action. The modal is a flat recency list, so each @@ -25,24 +27,61 @@ test.beforeAll(async () => { // Give the session a clean custom title so it does NOT echo the // secrets — otherwise the title (which falls back to first-message // text) would itself match the value text filter. - writeFileSync(join(claudeDir, 'test-project', 'ignored-filter.jsonl'), [ - JSON.stringify({ type: 'custom-title', sessionId: SID, cwd: '/tmp/test-project', customTitle: 'Credentials review' }), - JSON.stringify({ type: 'user', sessionId: SID, cwd: '/tmp/test-project', uuid: 'if-1', timestamp: '2026-05-21T10:00:00Z', message: { role: 'user', content: `key ${FAKE_AKIA} and contact ${FAKE_EMAIL}` } }), - JSON.stringify({ type: 'assistant', uuid: 'if-2', timestamp: '2026-05-21T10:00:05Z', message: { role: 'assistant', model: 'claude-sonnet-4', content: 'noted' } }), - ].join('\n')) + writeFileSync( + join(claudeDir, 'test-project', 'ignored-filter.jsonl'), + [ + JSON.stringify({ + type: 'custom-title', + sessionId: SID, + cwd: '/tmp/test-project', + customTitle: 'Credentials review', + }), + JSON.stringify({ + type: 'user', + sessionId: SID, + cwd: '/tmp/test-project', + uuid: 'if-1', + timestamp: '2026-05-21T10:00:00Z', + message: { role: 'user', content: `key ${FAKE_AKIA} and contact ${FAKE_EMAIL}` }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'if-2', + timestamp: '2026-05-21T10:00:05Z', + message: { role: 'assistant', model: 'claude-sonnet-4', content: 'noted' }, + }), + ].join('\n'), + ) }, }) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) async function waitForWorkerIdle(window: Page): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise<{ queued: number; scanning: number | null; backfillRemaining: number }> } } }).spool - if (!api?.security) return false - const s = await api.security.getScanStatus() - return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { + spool?: { + security?: { + getScanStatus: () => Promise<{ + queued: number + scanning: number | null + backfillRemaining: number + }> + } + } + } + ).spool + if (!api?.security) return false + const s = await api.security.getScanStatus() + return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 + }, + { timeout: 30_000, polling: 250 }, + ) } test('Ignored items modal: scope / type / text filters narrow the list and Stop ignoring removes a row', async () => { @@ -52,10 +91,16 @@ test('Ignored items modal: scope / type / text filters narrow the list and Stop // Ignore the api-key everywhere and the email in this session. await window.evaluate(async () => { - const api = (globalThis as { spool?: { security?: { - listFindings: (f: unknown) => Promise> - dismissFinding: (id: number, scope: string) => Promise - } } }).spool + const api = ( + globalThis as { + spool?: { + security?: { + listFindings: (f: unknown) => Promise> + dismissFinding: (id: number, scope: string) => Promise + } + } + } + ).spool const rows = await api!.security!.listFindings({ state: 'active' }) const key = rows.find((r) => r.kind === 'api-key') const email = rows.find((r) => r.kind === 'email') @@ -92,7 +137,9 @@ test('Ignored items modal: scope / type / text filters narrow the list and Stop await modal.locator('[data-testid="ignored-kind-filter"]').click() await window.getByRole('menuitem', { name: 'Email', exact: true }).click() await expect(rows).toHaveCount(1) - await expect(rows.first().locator('[data-testid="ignored-value"]')).toContainText('@ignored-filter.test') + await expect(rows.first().locator('[data-testid="ignored-value"]')).toContainText( + '@ignored-filter.test', + ) // Reset type back to all. await modal.locator('[data-testid="ignored-kind-filter"]').click() diff --git a/apps/app/e2e/security-manual-rescan-ack.spec.ts b/apps/app/e2e/security-manual-rescan-ack.spec.ts index 65aecee2..e79ade45 100644 --- a/apps/app/e2e/security-manual-rescan-ack.spec.ts +++ b/apps/app/e2e/security-manual-rescan-ack.spec.ts @@ -1,8 +1,10 @@ -import { test, expect } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // Per-iter AKIA-shaped fixtures (5 sessions, 5 distinct keys). Each // is exactly `AKIA + 16 [A-Z0-9]` so the regex matches and the new // validator (drops `*EXAMPLE` suffix) doesn't. @@ -69,15 +71,35 @@ test.beforeAll(async () => { }) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) async function waitForWorkerIdle(window: AppContext['window']): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise<{ queued: number; scanning: number | null; backfillRemaining: number; manualBurstInFlight: boolean }> } } }).spool - if (!api?.security) return false - const s = await api.security.getScanStatus() - return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 && !s.manualBurstInFlight - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { + spool?: { + security?: { + getScanStatus: () => Promise<{ + queued: number + scanning: number | null + backfillRemaining: number + manualBurstInFlight: boolean + }> + } + } + } + ).spool + if (!api?.security) return false + const s = await api.security.getScanStatus() + return ( + s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 && !s.manualBurstInFlight + ) + }, + { timeout: 30_000, polling: 250 }, + ) } test('Rescan all surfaces a Scan-complete banner that survives a follow-up auto burst', async () => { @@ -97,9 +119,9 @@ test('Rescan all surfaces a Scan-complete banner that survives a follow-up auto // Result banner must show — this is the regression site. Generous // timeout because the manual ACK needs the busy→idle event to land // and the React state to flush. - await expect( - window.locator('[data-testid="security-scan-result-banner"]'), - ).toBeVisible({ timeout: 10_000 }) + await expect(window.locator('[data-testid="security-scan-result-banner"]')).toBeVisible({ + timeout: 10_000, + }) // The reported scanned count matches the worker's reported high-water // mark — at LEAST the fixture count (the boot backfill might have @@ -117,7 +139,9 @@ test('Rescan all surfaces a Scan-complete banner that survives a follow-up auto // take on a live archive. Before the fix, this would silently // clear `scanResult` via the renderer's idle→busy edge handler. await window.evaluate(async () => { - const api = (globalThis as { spool?: { security?: { rescanSession: (id: number) => Promise } } }).spool + const api = ( + globalThis as { spool?: { security?: { rescanSession: (id: number) => Promise } } } + ).spool await api!.security!.rescanSession(1) }) @@ -127,15 +151,11 @@ test('Rescan all surfaces a Scan-complete banner that survives a follow-up auto // Banner stays — the auto burst's busy→idle does not consume the // manual ACK. - await expect( - window.locator('[data-testid="security-scan-result-banner"]'), - ).toBeVisible() + await expect(window.locator('[data-testid="security-scan-result-banner"]')).toBeVisible() // The × dismisses it (and only the ×). await window.locator('[data-testid="security-scan-result-dismiss"]').click() - await expect( - window.locator('[data-testid="security-scan-result-banner"]'), - ).toBeHidden() + await expect(window.locator('[data-testid="security-scan-result-banner"]')).toBeHidden() }) // Companion test for the opposite half of the contract: a purely @@ -164,7 +184,9 @@ test('A purely background scan never surfaces the Scan-complete banner', async ( // Syncer uses for sync-driven enqueues) — bypasses the click // handler entirely. await window.evaluate(async () => { - const api = (globalThis as { spool?: { security?: { rescanSession: (id: number) => Promise } } }).spool + const api = ( + globalThis as { spool?: { security?: { rescanSession: (id: number) => Promise } } } + ).spool await api!.security!.rescanSession(1) }) diff --git a/apps/app/e2e/security-mute-refresh.spec.ts b/apps/app/e2e/security-mute-refresh.spec.ts index d0dbc78f..4c698bfa 100644 --- a/apps/app/e2e/security-mute-refresh.spec.ts +++ b/apps/app/e2e/security-mute-refresh.spec.ts @@ -1,8 +1,10 @@ -import { test, expect } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // Fake AWS access-key fixture — see security-badge.spec.ts. const FAKE_AKIA = 'AKIA' + 'V3QFKW72ZDLNP4XR' @@ -24,42 +26,62 @@ test.beforeAll(async () => { // One session with a single high-severity api-key. The mute // target is `api-key` so visibleActive should drop 1 → 0. const file = join(claudeDir, 'test-project', 'test-session-mute-refresh.jsonl') - writeFileSync(file, [ - JSON.stringify({ - type: 'user', - sessionId: 'mute-refresh-fixture', - cwd: '/tmp/test-project', - uuid: 'mr-msg-1', - timestamp: '2026-05-20T10:00:00Z', - message: { - role: 'user', - content: `leaked ${FAKE_AKIA} to a log, rotate it`, - }, - }), - JSON.stringify({ - type: 'assistant', - uuid: 'mr-msg-2', - timestamp: '2026-05-20T10:00:05Z', - message: { - role: 'assistant', - model: 'claude-sonnet-4', - content: 'rotating now', - }, - }), - ].join('\n')) + writeFileSync( + file, + [ + JSON.stringify({ + type: 'user', + sessionId: 'mute-refresh-fixture', + cwd: '/tmp/test-project', + uuid: 'mr-msg-1', + timestamp: '2026-05-20T10:00:00Z', + message: { + role: 'user', + content: `leaked ${FAKE_AKIA} to a log, rotate it`, + }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'mr-msg-2', + timestamp: '2026-05-20T10:00:05Z', + message: { + role: 'assistant', + model: 'claude-sonnet-4', + content: 'rotating now', + }, + }), + ].join('\n'), + ) }, }) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) async function waitForWorkerIdle(window: AppContext['window']): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise<{ queued: number; scanning: number | null; backfillRemaining: number }> } } }).spool - if (!api?.security) return false - const s = await api.security.getScanStatus() - return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { + spool?: { + security?: { + getScanStatus: () => Promise<{ + queued: number + scanning: number | null + backfillRemaining: number + }> + } + } + } + ).spool + if (!api?.security) return false + const s = await api.security.getScanStatus() + return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 + }, + { timeout: 30_000, polling: 250 }, + ) } async function openSecurityTab(window: AppContext['window']) { @@ -69,7 +91,9 @@ async function openSecurityTab(window: AppContext['window']) { .locator('[data-testid="settings-panel"] [aria-pressed]') .filter({ hasText: /Security|安全|セキュリティ|보안|Sécurité|Sicherheit/ }) .click() - await expect(window.locator('[data-testid="settings-rescan-all"]')).toBeVisible({ timeout: 10_000 }) + await expect(window.locator('[data-testid="settings-rescan-all"]')).toBeVisible({ + timeout: 10_000, + }) } test('Muting a kind from Settings refreshes the Security page automatically', async () => { @@ -85,7 +109,13 @@ test('Muting a kind from Settings refreshes the Security page automatically', as async function activeApiKey(): Promise { return await window.evaluate(async () => { - const api = (globalThis as { spool?: { security?: { riskByCategory: () => Promise> } } }).spool + const api = ( + globalThis as { + spool?: { + security?: { riskByCategory: () => Promise> } + } + } + ).spool const rows = await api!.security!.riskByCategory() return rows.find((r) => r.kind === 'api-key')?.count ?? 0 }) diff --git a/apps/app/e2e/security-pf-download-card.spec.ts b/apps/app/e2e/security-pf-download-card.spec.ts index 480c6b18..f5d21ce1 100644 --- a/apps/app/e2e/security-pf-download-card.spec.ts +++ b/apps/app/e2e/security-pf-download-card.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, type AppContext } from './helpers/launch' // Security IPC handlers are registered inside `bootScanWorker().then(...)` @@ -7,16 +8,21 @@ import { launchApp, type AppContext } from './helpers/launch' // registerSecurityIpc has finished — any handler being available // implies all of them are. async function waitForSecurityIpc(window: AppContext['window']): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise } } }).spool?.security - if (!api) return false - try { - await api.getScanStatus() - return true - } catch { - return false - } - }, { timeout: 30_000, polling: 100 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { spool?: { security?: { getScanStatus: () => Promise } } } + ).spool?.security + if (!api) return false + try { + await api.getScanStatus() + return true + } catch { + return false + } + }, + { timeout: 30_000, polling: 100 }, + ) } // PF download card surface coverage (PR 5e): @@ -40,7 +46,9 @@ test.beforeAll(async () => { ctx = await launchApp({}) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) async function openSecurityTab(window: AppContext['window']) { await window.locator('[data-testid="settings-button"]').click() @@ -49,7 +57,9 @@ async function openSecurityTab(window: AppContext['window']) { .locator('[data-testid="settings-panel"] [aria-pressed]') .filter({ hasText: /Security|安全|セキュリティ|보안|Sécurité|Sicherheit/ }) .click() - await expect(window.locator('[data-testid="settings-rescan-all"]')).toBeVisible({ timeout: 10_000 }) + await expect(window.locator('[data-testid="settings-rescan-all"]')).toBeVisible({ + timeout: 10_000, + }) } // Surface-shape + state-machine wiring in one launch. diff --git a/apps/app/e2e/security-rotation-link.spec.ts b/apps/app/e2e/security-rotation-link.spec.ts index 51fb0a72..f283ac21 100644 --- a/apps/app/e2e/security-rotation-link.spec.ts +++ b/apps/app/e2e/security-rotation-link.spec.ts @@ -1,8 +1,10 @@ -import { test, expect, type Page } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect, type Page } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // Layer 1 — one-click rotate deep-links. // // A credential finding whose value resolves to a known vendor surfaces @@ -20,23 +22,55 @@ let ctx: AppContext test.beforeAll(async () => { ctx = await launchApp({ extraFixtures: ({ claudeDir }) => { - writeFileSync(join(claudeDir, 'test-project', 'rotation-link.jsonl'), [ - JSON.stringify({ type: 'user', sessionId: SID, cwd: '/tmp/test-project', uuid: 'rl-1', timestamp: '2026-05-21T10:00:00Z', message: { role: 'user', content: `my token is ${FAKE_GHP} and email dev@fly.io` } }), - JSON.stringify({ type: 'assistant', uuid: 'rl-2', timestamp: '2026-05-21T10:00:05Z', message: { role: 'assistant', model: 'claude-sonnet-4', content: 'ok' } }), - ].join('\n')) + writeFileSync( + join(claudeDir, 'test-project', 'rotation-link.jsonl'), + [ + JSON.stringify({ + type: 'user', + sessionId: SID, + cwd: '/tmp/test-project', + uuid: 'rl-1', + timestamp: '2026-05-21T10:00:00Z', + message: { role: 'user', content: `my token is ${FAKE_GHP} and email dev@fly.io` }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'rl-2', + timestamp: '2026-05-21T10:00:05Z', + message: { role: 'assistant', model: 'claude-sonnet-4', content: 'ok' }, + }), + ].join('\n'), + ) }, }) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) async function waitForWorkerIdle(window: Page): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise<{ queued: number; scanning: number | null; backfillRemaining: number }> } } }).spool - if (!api?.security) return false - const s = await api.security.getScanStatus() - return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { + spool?: { + security?: { + getScanStatus: () => Promise<{ + queued: number + scanning: number | null + backfillRemaining: number + }> + } + } + } + ).spool + if (!api?.security) return false + const s = await api.security.getScanStatus() + return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 + }, + { timeout: 30_000, polling: 250 }, + ) } // Worker-idle alone can be a FALSE idle: status can read queued=0 in the @@ -45,19 +79,27 @@ async function waitForWorkerIdle(window: Page): Promise { // openStrip's pill wait times out on a cold launch. Wait until findings // have actually been produced before driving the UI. async function waitForFindings(window: Page): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { riskByCategory: () => Promise } } }).spool - if (!api?.security) return false - const cats = await api.security.riskByCategory() - return Array.isArray(cats) && cats.length > 0 - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { spool?: { security?: { riskByCategory: () => Promise } } } + ).spool + if (!api?.security) return false + const cats = await api.security.riskByCategory() + return Array.isArray(cats) && cats.length > 0 + }, + { timeout: 30_000, polling: 250 }, + ) } async function openStrip(window: Page): Promise { await window.locator('[data-testid="sidebar-library"]').click() // The fixture session lives under the "test-project" Claude project, // not the alphabetically-first row, so target it explicitly. - await window.locator('[data-testid="sidebar-project-row"]', { hasText: 'test-project' }).first().click() + await window + .locator('[data-testid="sidebar-project-row"]', { hasText: 'test-project' }) + .first() + .click() await window.locator(`[data-testid="session-row"][data-session-uuid="${SID}"]`).first().click() const riskPill = window.locator('[data-testid="session-risk-pill"]').first() await expect(riskPill).toBeVisible({ timeout: 10_000 }) diff --git a/apps/app/e2e/security-row-risk-icon-toggle.spec.ts b/apps/app/e2e/security-row-risk-icon-toggle.spec.ts index 932d5efc..749a6f4a 100644 --- a/apps/app/e2e/security-row-risk-icon-toggle.spec.ts +++ b/apps/app/e2e/security-row-risk-icon-toggle.spec.ts @@ -1,8 +1,10 @@ -import { test, expect } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // Regression for the "Risk icon on session rows" toggle added in // Settings → Security (PR introducing `sessionRowRiskIconVisible`). // @@ -30,39 +32,59 @@ test.beforeAll(async () => { ctx = await launchApp({ extraFixtures: ({ claudeDir }) => { const file = join(claudeDir, 'test-project', 'row-risk-icon-fixture.jsonl') - writeFileSync(file, [ - JSON.stringify({ - type: 'user', - sessionId: 'row-risk-icon-session', - cwd: '/tmp/test-project', - uuid: 'rri-msg-1', - timestamp: '2026-05-28T10:00:00Z', - message: { role: 'user', content: `please rotate ${FAKE_AKIA}` }, - }), - JSON.stringify({ - type: 'assistant', - uuid: 'rri-msg-2', - timestamp: '2026-05-28T10:00:05Z', - message: { - role: 'assistant', - model: 'claude-sonnet-4', - content: 'rotated', - }, - }), - ].join('\n')) + writeFileSync( + file, + [ + JSON.stringify({ + type: 'user', + sessionId: 'row-risk-icon-session', + cwd: '/tmp/test-project', + uuid: 'rri-msg-1', + timestamp: '2026-05-28T10:00:00Z', + message: { role: 'user', content: `please rotate ${FAKE_AKIA}` }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'rri-msg-2', + timestamp: '2026-05-28T10:00:05Z', + message: { + role: 'assistant', + model: 'claude-sonnet-4', + content: 'rotated', + }, + }), + ].join('\n'), + ) }, }) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) async function waitForWorkerIdle(window: AppContext['window']): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise<{ queued: number; scanning: number | null; backfillRemaining: number }> } } }).spool - if (!api?.security) return false - const s = await api.security.getScanStatus() - return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { + spool?: { + security?: { + getScanStatus: () => Promise<{ + queued: number + scanning: number | null + backfillRemaining: number + }> + } + } + } + ).spool + if (!api?.security) return false + const s = await api.security.getScanStatus() + return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 + }, + { timeout: 30_000, polling: 250 }, + ) } test('Settings toggle hides the row-level risk badge on Project view, Security page is unaffected', async () => { @@ -118,5 +140,7 @@ test('Settings toggle hides the row-level risk badge on Project view, Security p await window.keyboard.press('Escape') await window.locator('[data-testid="sidebar-project-row"]').first().click() - await expect(row.locator('[data-testid="security-badge"][data-severity="high"]')).toBeVisible({ timeout: 5_000 }) + await expect(row.locator('[data-testid="security-badge"][data-severity="high"]')).toBeVisible({ + timeout: 5_000, + }) }) diff --git a/apps/app/e2e/security-scan-meta-stability.spec.ts b/apps/app/e2e/security-scan-meta-stability.spec.ts index 5a8a1889..2935dbc8 100644 --- a/apps/app/e2e/security-scan-meta-stability.spec.ts +++ b/apps/app/e2e/security-scan-meta-stability.spec.ts @@ -1,8 +1,10 @@ -import { test, expect } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // Regression test for the meta-row layout shift (May 2026): // // The meta row reads "N risk · M info · scanned X ago · [detectors @@ -62,15 +64,35 @@ test.beforeAll(async () => { }) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) async function waitForWorkerIdle(window: AppContext['window']): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise<{ queued: number; scanning: number | null; backfillRemaining: number; manualBurstInFlight: boolean }> } } }).spool - if (!api?.security) return false - const s = await api.security.getScanStatus() - return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 && !s.manualBurstInFlight - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { + spool?: { + security?: { + getScanStatus: () => Promise<{ + queued: number + scanning: number | null + backfillRemaining: number + manualBurstInFlight: boolean + }> + } + } + } + ).spool + if (!api?.security) return false + const s = await api.security.getScanStatus() + return ( + s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 && !s.manualBurstInFlight + ) + }, + { timeout: 30_000, polling: 250 }, + ) } test('Meta row keeps the timestamp and holds the rescan button still during a scan', async () => { @@ -93,7 +115,9 @@ test('Meta row keeps the timestamp and holds the rescan button still during a sc // The scanning banner must come up — this is the state that used to // hide the timestamp. - await expect(window.locator('[data-testid="security-scan-banner"]')).toBeVisible({ timeout: 10_000 }) + await expect(window.locator('[data-testid="security-scan-banner"]')).toBeVisible({ + timeout: 10_000, + }) // Regression: the timestamp must NOT disappear under the banner... await expect(scanState).toBeVisible() diff --git a/apps/app/e2e/security-session-card-refresh.spec.ts b/apps/app/e2e/security-session-card-refresh.spec.ts index 8c9cf09f..51f455a4 100644 --- a/apps/app/e2e/security-session-card-refresh.spec.ts +++ b/apps/app/e2e/security-session-card-refresh.spec.ts @@ -1,8 +1,10 @@ -import { test, expect, type Page } from '@playwright/test' -import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { writeFileSync } from 'node:fs' import { join } from 'node:path' +import { test, expect, type Page } from '@playwright/test' + +import { launchApp, waitForSync, type AppContext } from './helpers/launch' + // Regression: SessionCard inside SecurityPage must refetch its // findings list when EVT_FINDINGS_CHANGED arrives for this session // (background rescan after a profile version bump, a programmatic @@ -29,35 +31,55 @@ test.beforeAll(async () => { // listSessionsWithFindings filter could unmount the card and // confound the test. const file = join(claudeDir, 'test-project', 'card-refresh.jsonl') - writeFileSync(file, [ - JSON.stringify({ - type: 'user', - sessionId: SID, - cwd: '/tmp/test-project', - uuid: 'cr-1', - timestamp: '2026-05-22T10:00:00Z', - message: { role: 'user', content: `rotate ${FAKE_AKIA} and email dev@fly.io` }, - }), - JSON.stringify({ - type: 'assistant', - uuid: 'cr-2', - timestamp: '2026-05-22T10:00:05Z', - message: { role: 'assistant', model: 'claude-sonnet-4', content: 'ok' }, - }), - ].join('\n')) + writeFileSync( + file, + [ + JSON.stringify({ + type: 'user', + sessionId: SID, + cwd: '/tmp/test-project', + uuid: 'cr-1', + timestamp: '2026-05-22T10:00:00Z', + message: { role: 'user', content: `rotate ${FAKE_AKIA} and email dev@fly.io` }, + }), + JSON.stringify({ + type: 'assistant', + uuid: 'cr-2', + timestamp: '2026-05-22T10:00:05Z', + message: { role: 'assistant', model: 'claude-sonnet-4', content: 'ok' }, + }), + ].join('\n'), + ) }, }) }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) async function waitForWorkerIdle(window: Page): Promise { - await window.waitForFunction(async () => { - const api = (globalThis as { spool?: { security?: { getScanStatus: () => Promise<{ queued: number; scanning: number | null; backfillRemaining: number }> } } }).spool - if (!api?.security) return false - const s = await api.security.getScanStatus() - return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 - }, { timeout: 30_000, polling: 250 }) + await window.waitForFunction( + async () => { + const api = ( + globalThis as { + spool?: { + security?: { + getScanStatus: () => Promise<{ + queued: number + scanning: number | null + backfillRemaining: number + }> + } + } + } + ).spool + if (!api?.security) return false + const s = await api.security.getScanStatus() + return s.queued === 0 && s.scanning === null && s.backfillRemaining === 0 + }, + { timeout: 30_000, polling: 250 }, + ) } test('SessionCard refetches findings when EVT_FINDINGS_CHANGED arrives for this session', async () => { @@ -80,13 +102,21 @@ test('SessionCard refetches findings when EVT_FINDINGS_CHANGED arrives for this // thing that turns the IPC event into a reload — without it the // row stays put forever. await window.evaluate(async (sid: string) => { - const api = (globalThis as { spool: { - security: { - listFindings: (f: { sessionId?: number; state?: string; limit?: number }) => Promise> - dismissFinding: (id: number, scope: 'session' | 'global') => Promise + const api = ( + globalThis as { + spool: { + security: { + listFindings: (f: { + sessionId?: number + state?: string + limit?: number + }) => Promise> + dismissFinding: (id: number, scope: 'session' | 'global') => Promise + } + listSessions: () => Promise<{ sessions: Array<{ id: number; sessionUuid: string }> }> + } } - listSessions: () => Promise<{ sessions: Array<{ id: number; sessionUuid: string }> }> - } }).spool + ).spool const page = await api.listSessions() const sessionId = page.sessions.find((s) => s.sessionUuid === sid)?.id if (sessionId === undefined) throw new Error(`session ${sid} not synced`) diff --git a/apps/app/e2e/session-detail.spec.ts b/apps/app/e2e/session-detail.spec.ts index d2a79c85..155c2db8 100644 --- a/apps/app/e2e/session-detail.spec.ts +++ b/apps/app/e2e/session-detail.spec.ts @@ -1,6 +1,8 @@ -import { test, expect } from '@playwright/test' import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' + +import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' let ctx: AppContext @@ -80,7 +82,9 @@ test('pinning from session detail persists', async () => { const pinButton = window.locator('[data-testid="session-detail"] [data-testid="pin-button"]') const initialState = await pinButton.getAttribute('data-pinned') await pinButton.click() - await expect(pinButton).toHaveAttribute('data-pinned', initialState === '1' ? '0' : '1', { timeout: 2000 }) + await expect(pinButton).toHaveAttribute('data-pinned', initialState === '1' ? '0' : '1', { + timeout: 2000, + }) }) test('renders markdown: bold, headings, code blocks', async () => { @@ -153,7 +157,9 @@ test('find keeps the previous result visible and navigable while retyping', asyn if (!el) throw new Error('find status missing') const w = window as unknown as { __findLog?: (string | null)[]; __findObs?: MutationObserver } w.__findLog = [el.textContent] - w.__findObs = new MutationObserver(() => { w.__findLog?.push(el.textContent) }) + w.__findObs = new MutationObserver(() => { + w.__findLog?.push(el.textContent) + }) w.__findObs.observe(el, { childList: true, characterData: true, subtree: true }) }) @@ -190,7 +196,12 @@ test('find refocuses after its own buttons but not after clicks into the list', await expect(status).toContainText(/^1 of \d+$/, { timeout: 5000 }) const focusedTestId = () => - window.evaluate(() => (document.activeElement as HTMLElement | null)?.dataset?.['testid'] ?? document.activeElement?.tagName ?? '') + window.evaluate( + () => + (document.activeElement as HTMLElement | null)?.dataset?.['testid'] ?? + document.activeElement?.tagName ?? + '', + ) // Clicking the bar's own next button advances and hands focus back to the // input so typing stays seamless. @@ -222,11 +233,9 @@ test('handles 1500-message session: virtualization + deep find', async () => { // macOS CI runners that occasionally land at 36 due to slower culling. // Polled to absorb the post-mount transient. await expect - .poll( - async () => - window.locator('[data-testid="message-list-scroll"] [data-index]').count(), - { timeout: 3000 }, - ) + .poll(async () => window.locator('[data-testid="message-list-scroll"] [data-index]').count(), { + timeout: 3000, + }) .toBeLessThan(40) const isMac = process.platform === 'darwin' @@ -251,10 +260,9 @@ test('custom session scrollbar thumb follows pointer while dragging long session const scroller = window.locator('[data-testid="message-list-scroll"]') const thumb = window.locator('[data-testid="message-scrollbar-thumb"]') await expect - .poll( - async () => scroller.evaluate((el) => el.scrollHeight - el.clientHeight), - { timeout: 3000 }, - ) + .poll(async () => scroller.evaluate((el) => el.scrollHeight - el.clientHeight), { + timeout: 3000, + }) .toBeGreaterThan(0) await expect(thumb).toBeVisible() @@ -305,10 +313,9 @@ test('clicking the scrollbar track jumps the message list to that position', asy const scroller = window.locator('[data-testid="message-list-scroll"]') const track = window.locator('[data-testid="message-scrollbar-track"]') await expect - .poll( - async () => scroller.evaluate((el) => el.scrollHeight - el.clientHeight), - { timeout: 3000 }, - ) + .poll(async () => scroller.evaluate((el) => el.scrollHeight - el.clientHeight), { + timeout: 3000, + }) .toBeGreaterThan(0) await expect(track).toBeVisible() @@ -317,10 +324,7 @@ test('clicking the scrollbar track jumps the message list to that position', asy const trackBox = await track.boundingBox() if (!trackBox) throw new Error('missing scrollbar track box') - await window.mouse.click( - trackBox.x + trackBox.width / 2, - trackBox.y + trackBox.height * 0.75, - ) + await window.mouse.click(trackBox.x + trackBox.width / 2, trackBox.y + trackBox.height * 0.75) await expect .poll(async () => scroller.evaluate((el) => el.scrollTop), { timeout: 3000 }) @@ -340,10 +344,9 @@ test('dragging the scrollbar thumb to the bottom reaches the actual list end', a const scroller = window.locator('[data-testid="message-list-scroll"]') const thumb = window.locator('[data-testid="message-scrollbar-thumb"]') await expect - .poll( - async () => scroller.evaluate((el) => el.scrollHeight - el.clientHeight), - { timeout: 3000 }, - ) + .poll(async () => scroller.evaluate((el) => el.scrollHeight - el.clientHeight), { + timeout: 3000, + }) .toBeGreaterThan(0) await expect(thumb).toBeVisible() @@ -366,10 +369,8 @@ test('dragging the scrollbar thumb to the bottom reaches the actual list end', a // smaller than the real measured total once markdown rows expand. With // scrollToIndex(rowCount - 1), the scroller must land at the true max. await expect - .poll( - async () => - scroller.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop), - { timeout: 5000 }, - ) + .poll(async () => scroller.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop), { + timeout: 5000, + }) .toBeLessThan(16) }) diff --git a/apps/app/e2e/settings-security-toggle-mount-flash.spec.ts b/apps/app/e2e/settings-security-toggle-mount-flash.spec.ts index cddd3005..078ede32 100644 --- a/apps/app/e2e/settings-security-toggle-mount-flash.spec.ts +++ b/apps/app/e2e/settings-security-toggle-mount-flash.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' // Regression: when a Security pref is persisted as `true`, opening the @@ -20,7 +21,9 @@ test.beforeAll(async () => { ctx = await launchApp() }) -test.afterAll(async () => { await ctx?.cleanup() }) +test.afterAll(async () => { + await ctx?.cleanup() +}) test('Security toggles never render in fallback off-state when the pref is on', async () => { const { window } = ctx @@ -29,7 +32,13 @@ test('Security toggles never render in fallback off-state when the pref is on', // Seed the pref so a flash would be visible (default is false; the // fallback also lands at false, so the flash needs `true` to surface). await window.evaluate(async () => { - const api = (globalThis as { spool?: { security?: { setPrefs: (p: { securityPageValuesBlurred: boolean }) => Promise } } }).spool?.security + const api = ( + globalThis as { + spool?: { + security?: { setPrefs: (p: { securityPageValuesBlurred: boolean }) => Promise } + } + } + ).spool?.security await api!.setPrefs({ securityPageValuesBlurred: true }) }) @@ -75,12 +84,21 @@ test('Security toggles never render in fallback off-state when the pref is on', w.__flashObs?.disconnect() return w.__flashSeen === true }) - expect(sawFlash, 'toggle was rendered in aria-checked="false" before its persisted true state arrived').toBe(false) + expect( + sawFlash, + 'toggle was rendered in aria-checked="false" before its persisted true state arrived', + ).toBe(false) // Reset so we don't leave the user-data dir in a non-default state // for any follow-up tests in this file. await window.evaluate(async () => { - const api = (globalThis as { spool?: { security?: { setPrefs: (p: { securityPageValuesBlurred: boolean }) => Promise } } }).spool?.security + const api = ( + globalThis as { + spool?: { + security?: { setPrefs: (p: { securityPageValuesBlurred: boolean }) => Promise } + } + } + ).spool?.security await api!.setPrefs({ securityPageValuesBlurred: false }) }) }) diff --git a/apps/app/e2e/settings-security.spec.ts b/apps/app/e2e/settings-security.spec.ts index 03c3c471..97d6482c 100644 --- a/apps/app/e2e/settings-security.spec.ts +++ b/apps/app/e2e/settings-security.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' let ctx: AppContext @@ -15,7 +16,12 @@ test.afterEach(async () => { // Close any leftover modals so the next test starts from Library home. const { window } = ctx for (let i = 0; i < 3; i++) { - if (await window.locator('[data-testid="settings-panel"]').isVisible().catch(() => false)) { + if ( + await window + .locator('[data-testid="settings-panel"]') + .isVisible() + .catch(() => false) + ) { await window.keyboard.press('Escape') await window.waitForTimeout(50) } else { @@ -31,7 +37,9 @@ async function openSecurityTab(window: AppContext['window']) { .locator('[data-testid="settings-panel"] [aria-pressed]') .filter({ hasText: /Security|安全|セキュリティ|보안|Sécurité|Sicherheit/ }) .click() - await expect(window.locator('[data-testid="settings-rescan-all"]')).toBeVisible({ timeout: 10_000 }) + await expect(window.locator('[data-testid="settings-rescan-all"]')).toBeVisible({ + timeout: 10_000, + }) } test('Settings → Security pane: toggles persist across re-open', async () => { @@ -51,7 +59,10 @@ test('Settings → Security pane: toggles persist across re-open', async () => { await expect(window.locator('[data-testid="settings-panel"]')).toBeHidden() await openSecurityTab(window) - await expect(window.locator('[data-testid="settings-info-default"]')).toHaveAttribute('aria-checked', 'true') + await expect(window.locator('[data-testid="settings-info-default"]')).toHaveAttribute( + 'aria-checked', + 'true', + ) }) test('Settings → Security pane: Allowlist Manage modal opens with empty state', async () => { diff --git a/apps/app/e2e/settings.spec.ts b/apps/app/e2e/settings.spec.ts index 7607dcaf..71750219 100644 --- a/apps/app/e2e/settings.spec.ts +++ b/apps/app/e2e/settings.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' let ctx: AppContext diff --git a/apps/app/e2e/share-autosave.spec.ts b/apps/app/e2e/share-autosave.spec.ts index b2f0b5bb..238904b0 100644 --- a/apps/app/e2e/share-autosave.spec.ts +++ b/apps/app/e2e/share-autosave.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, restartApp, waitForSync, type AppContext } from './helpers/launch' import { openShareEditorFromSessionDetail, navigateToShares } from './helpers/share' @@ -30,7 +31,9 @@ test('opts mutations + title rename survive a full app restart', async () => { await expect(input).toBeVisible() await input.fill('Autosave checkpoint') await ctx.window.locator('[data-testid="rename-draft-save"]').click() - await expect(ctx.window.locator('[data-testid="share-editor-title"]')).toHaveText('Autosave checkpoint') + await expect(ctx.window.locator('[data-testid="share-editor-title"]')).toHaveText( + 'Autosave checkpoint', + ) // Autosave is debounced at 400ms — give it a safe margin before // tearing down the renderer. @@ -47,7 +50,9 @@ test('opts mutations + title rename survive a full app restart', async () => { await expect(draftCard).toBeVisible({ timeout: 5000 }) await draftCard.click() - await expect(ctx.window.locator('[data-testid="share-editor-title"]')).toHaveText('Autosave checkpoint') + await expect(ctx.window.locator('[data-testid="share-editor-title"]')).toHaveText( + 'Autosave checkpoint', + ) const preview = ctx.window.locator('[data-testid="share-preview-render"]') await expect(preview).toHaveAttribute('data-template', 'forum') await expect(preview).toHaveAttribute('data-paper', 'ink') @@ -72,7 +77,9 @@ test('rename modal updates the live preview render (not just the topbar)', async await ctx.window.locator('[data-testid="rename-draft-save"]').click() // Topbar reflects immediately. - await expect(ctx.window.locator('[data-testid="share-editor-title"]')).toHaveText('Live preview rename') + await expect(ctx.window.locator('[data-testid="share-editor-title"]')).toHaveText( + 'Live preview rename', + ) // The rendered template's title heading must also show the new title. const preview = ctx.window.locator('[data-testid="share-preview-render"]') diff --git a/apps/app/e2e/share-delete.spec.ts b/apps/app/e2e/share-delete.spec.ts index f691e0e7..bd15a394 100644 --- a/apps/app/e2e/share-delete.spec.ts +++ b/apps/app/e2e/share-delete.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { navigateToShares, openShareEditorFromSessionDetail, seedShareDraft } from './helpers/share' @@ -59,6 +60,8 @@ test('editor topbar delete modal closes the editor and removes the draft', async // The session-derived draft should not appear on Shares. await navigateToShares(window) await expect( - window.locator(`[data-testid="shares-draft-row"]`).filter({ hasText: /XYLOPHONE|test-session-001/ }), + window + .locator(`[data-testid="shares-draft-row"]`) + .filter({ hasText: /XYLOPHONE|test-session-001/ }), ).toHaveCount(0) }) diff --git a/apps/app/e2e/share-editor.spec.ts b/apps/app/e2e/share-editor.spec.ts index 7fa1d7b3..dd9a9709 100644 --- a/apps/app/e2e/share-editor.spec.ts +++ b/apps/app/e2e/share-editor.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { openShareEditorFromSessionDetail } from './helpers/share' diff --git a/apps/app/e2e/share-entry-points.spec.ts b/apps/app/e2e/share-entry-points.spec.ts index dd2daa08..9cd1b411 100644 --- a/apps/app/e2e/share-entry-points.spec.ts +++ b/apps/app/e2e/share-entry-points.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { navigateToShares, @@ -54,7 +55,9 @@ test('entry point: NewDraftPicker opens (via empty-state CTA or + button)', asyn await expect(plus).toBeVisible({ timeout: 5000 }) await plus.click() } - await expect(ctx.window.locator('[data-testid="new-draft-picker"]')).toBeVisible({ timeout: 5000 }) + await expect(ctx.window.locator('[data-testid="new-draft-picker"]')).toBeVisible({ + timeout: 5000, + }) await ctx.window.keyboard.press('Escape') await expect(ctx.window.locator('[data-testid="new-draft-picker"]')).toBeHidden({ timeout: 5000 }) }) @@ -69,6 +72,8 @@ test('entry point: LibraryLanding ⋯ menu Share opens editor', async () => { await row.hover() await row.getByLabel('More actions').click() await ctx.window.getByRole('menuitem', { name: 'Edit share draft' }).click() - await expect(ctx.window.locator('[data-testid="share-editor-page"]')).toBeVisible({ timeout: 5000 }) + await expect(ctx.window.locator('[data-testid="share-editor-page"]')).toBeVisible({ + timeout: 5000, + }) await closeEditorIfOpen() }) diff --git a/apps/app/e2e/share-export-pdf.spec.ts b/apps/app/e2e/share-export-pdf.spec.ts index c643b263..e49daa65 100644 --- a/apps/app/e2e/share-export-pdf.spec.ts +++ b/apps/app/e2e/share-export-pdf.spec.ts @@ -1,12 +1,14 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { installSaveFilePickerMock, openShareEditorFromSessionDetail, waitForSavedFile, } from './helpers/share' -import { mkdirSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' // End-to-end regression for the PDF export pipeline: the renderer // serializes the preview DOM, main renders it in a hidden SANDBOXED diff --git a/apps/app/e2e/share-exports.spec.ts b/apps/app/e2e/share-exports.spec.ts index 462444c0..92318774 100644 --- a/apps/app/e2e/share-exports.spec.ts +++ b/apps/app/e2e/share-exports.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { installSaveFilePickerMock, @@ -18,7 +19,10 @@ test.afterAll(async () => { await ctx?.cleanup() }) -async function exportAs(window: Awaited>['window'], k: 'png' | 'pdf' | 'md' | 'spool') { +async function exportAs( + window: Awaited>['window'], + k: 'png' | 'pdf' | 'md' | 'spool', +) { // Old surface: exposed a flat trigger + per-format option. // New surface: popover. When VITE_FEATURE_SHAREPUBLISH is // unset, the popover opens directly on the Export tab (no tab strip). diff --git a/apps/app/e2e/share-import.spec.ts b/apps/app/e2e/share-import.spec.ts index f84879db..de13527a 100644 --- a/apps/app/e2e/share-import.spec.ts +++ b/apps/app/e2e/share-import.spec.ts @@ -1,10 +1,7 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' -import { - buildSampleSpoolDocument, - dropFileOn, - navigateToShares, -} from './helpers/share' +import { buildSampleSpoolDocument, dropFileOn, navigateToShares } from './helpers/share' let ctx: AppContext @@ -26,7 +23,9 @@ test('drop .spool on Shares opens editor with imported content', async () => { // The drop hook is wired on the SharesPage root; dropping on the empty // state's button bubbles up to the same handler. await expect(window.locator('[data-testid="share-editor-page"]')).toBeVisible({ timeout: 5000 }) - await expect(window.locator('[data-testid="share-editor-title"]')).toHaveText('Imported sample one') + await expect(window.locator('[data-testid="share-editor-title"]')).toHaveText( + 'Imported sample one', + ) }) test('dropping the same content again opens the same draft (content-hash dedup)', async () => { diff --git a/apps/app/e2e/share-new-draft-picker-scope.spec.ts b/apps/app/e2e/share-new-draft-picker-scope.spec.ts index 6d9d34a1..9448ff04 100644 --- a/apps/app/e2e/share-new-draft-picker-scope.spec.ts +++ b/apps/app/e2e/share-new-draft-picker-scope.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, type AppContext } from './helpers/launch' let ctx: AppContext @@ -14,7 +15,9 @@ test.afterAll(async () => { async function openPicker(): Promise { await ctx.window.locator('[data-testid="sidebar-shares"]').click() await expect( - ctx.window.locator('[data-testid="shares-empty-start"], [data-testid="shares-draft-row"]').first(), + ctx.window + .locator('[data-testid="shares-empty-start"], [data-testid="shares-draft-row"]') + .first(), ).toBeVisible({ timeout: 5000 }) const empty = ctx.window.locator('[data-testid="shares-empty-start"]') const plus = ctx.window.locator('[data-testid="shares-new-draft"]') @@ -23,7 +26,9 @@ async function openPicker(): Promise { } else { await plus.click() } - await expect(ctx.window.locator('[data-testid="new-draft-picker"]')).toBeVisible({ timeout: 5000 }) + await expect(ctx.window.locator('[data-testid="new-draft-picker"]')).toBeVisible({ + timeout: 5000, + }) } async function closePicker(): Promise { @@ -94,7 +99,9 @@ test('outside click closes popover but not the modal', async () => { // Click somewhere inside the picker but outside the popover (the search // input strip is a safe target — clicking it doesn't fire a session pick). await ctx.window.locator('[data-testid="new-draft-picker"] input[type="text"]').first().click() - await expect(ctx.window.locator('[data-testid="new-draft-picker-scope-popover"]')).toBeHidden({ timeout: 2000 }) + await expect(ctx.window.locator('[data-testid="new-draft-picker-scope-popover"]')).toBeHidden({ + timeout: 2000, + }) await expect(ctx.window.locator('[data-testid="new-draft-picker"]')).toBeVisible() await closePicker() }) @@ -105,14 +112,20 @@ test('selecting a project scopes the picker and hides row breadcrumbs', async () const popover = ctx.window.locator('[data-testid="new-draft-picker-scope-popover"]') await expect(popover).toBeVisible() // First non-"Any project" option corresponds to a real project group. - const projectOption = popover.locator('[data-testid="new-draft-picker-scope-option"][data-identity-key]:not([data-identity-key=""])').first() + const projectOption = popover + .locator( + '[data-testid="new-draft-picker-scope-option"][data-identity-key]:not([data-identity-key=""])', + ) + .first() await expect(projectOption).toBeVisible() const projectName = (await projectOption.locator('span').first().textContent())?.trim() ?? '' await projectOption.click() await expect(popover).toBeHidden({ timeout: 2000 }) // Chip now reflects the scoped project and a clear button appears. - await expect(ctx.window.locator('[data-testid="new-draft-picker-scope-trigger"]')).toContainText(projectName) + await expect(ctx.window.locator('[data-testid="new-draft-picker-scope-trigger"]')).toContainText( + projectName, + ) await expect(ctx.window.locator('[data-testid="new-draft-picker-scope-clear"]')).toBeVisible() // Row breadcrumb suffix is suppressed under scope (project name would be @@ -123,7 +136,9 @@ test('selecting a project scopes the picker and hides row breadcrumbs', async () // Clear scope; chip returns to default. await ctx.window.locator('[data-testid="new-draft-picker-scope-clear"]').click() - await expect(ctx.window.locator('[data-testid="new-draft-picker-scope-trigger"]')).toContainText(/Any project/i) + await expect(ctx.window.locator('[data-testid="new-draft-picker-scope-trigger"]')).toContainText( + /Any project/i, + ) await expect(ctx.window.locator('[data-testid="new-draft-picker-scope-clear"]')).toHaveCount(0) await closePicker() }) diff --git a/apps/app/e2e/share-new-draft-picker.spec.ts b/apps/app/e2e/share-new-draft-picker.spec.ts index c1a6cdda..b841f40b 100644 --- a/apps/app/e2e/share-new-draft-picker.spec.ts +++ b/apps/app/e2e/share-new-draft-picker.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { navigateToShares } from './helpers/share' @@ -22,7 +23,9 @@ test('Recent mode lists sessions; Esc closes', async () => { await expect(picker).toBeVisible({ timeout: 5000 }) // Recent: at least one row (we have 6 fixtures including the large one). - await expect(picker.locator('[data-testid="new-draft-picker-row"]').first()).toBeVisible({ timeout: 5000 }) + await expect(picker.locator('[data-testid="new-draft-picker-row"]').first()).toBeVisible({ + timeout: 5000, + }) await window.keyboard.press('Escape') await expect(picker).toBeHidden({ timeout: 5000 }) @@ -38,7 +41,9 @@ test('FTS search narrows results; keyboard nav + Enter opens editor', async () = await picker.locator('input').fill('XYLOPHONE_CANARY_42') // Allow debounce + FTS roundtrip. Two fixtures mention this token, so // we just require *some* match rather than an exact count. - await expect(picker.locator('[data-testid="new-draft-picker-row"]').first()).toBeVisible({ timeout: 5000 }) + await expect(picker.locator('[data-testid="new-draft-picker-row"]').first()).toBeVisible({ + timeout: 5000, + }) // Active row is row 0 after a result set lands. await window.keyboard.press('ArrowDown') diff --git a/apps/app/e2e/share-privacy.spec.ts b/apps/app/e2e/share-privacy.spec.ts index 90ab0239..911f58b4 100644 --- a/apps/app/e2e/share-privacy.spec.ts +++ b/apps/app/e2e/share-privacy.spec.ts @@ -1,4 +1,5 @@ import { test, expect, type Page } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { installSaveFilePickerMock, @@ -87,7 +88,10 @@ async function openSeededDraft(window: Page, title: string): Promise { // SharesPage is already mounted (after a previous test's safeExit // left us there), the cached draft list does NOT auto-refresh and // the newly-seeded card never appears. - await window.locator('[data-testid="sidebar-library"]').click().catch(() => {}) + await window + .locator('[data-testid="sidebar-library"]') + .click() + .catch(() => {}) await navigateToShares(window) const card = window .locator(`[data-testid="shares-draft-row"][aria-label="Open ${title}"]`) @@ -156,11 +160,15 @@ test('Privacy tab lists every detected category for a mixed-sensitive draft', as await expect(panel.locator(`[data-testid="share-editor-privacy-row-${slug}"]`)).toBeVisible() } // Synthetic row from the bracketed [Maya] author. - await expect(panel.locator('[data-testid="share-editor-privacy-row-author-name"]')).toBeVisible() + await expect( + panel.locator('[data-testid="share-editor-privacy-row-author-name"]'), + ).toBeVisible() // All bulk checkboxes default to 'all' (every match will be masked). - await expect(panel.locator('[data-testid="share-editor-privacy-bulk-email"]')) - .toHaveAttribute('aria-checked', 'true') + await expect(panel.locator('[data-testid="share-editor-privacy-bulk-email"]')).toHaveAttribute( + 'aria-checked', + 'true', + ) }) }) @@ -179,8 +187,9 @@ test('Master toggle off hides the summary and shows the visible-count warning', await expect(warn).toContainText('will be visible') await expect(warn).toContainText('Enable redaction before sharing or exporting') - await expect(window.locator('[data-testid="share-editor-privacy-count"]')) - .toContainText('will be visible') + await expect(window.locator('[data-testid="share-editor-privacy-count"]')).toContainText( + 'will be visible', + ) }) }) @@ -197,8 +206,9 @@ test('Bulk checkbox toggles whole category and surfaces Reset; Reset clears', as await emailBulk.click() await expect(emailBulk).toHaveAttribute('aria-checked', 'false') await expect(reset).toBeVisible() - await expect(window.locator('[data-testid="share-editor-privacy-count"]')) - .toContainText('visible') + await expect(window.locator('[data-testid="share-editor-privacy-count"]')).toContainText( + 'visible', + ) // Click again returns to all-masked. await emailBulk.click() @@ -225,9 +235,7 @@ test('Expanding a category reveals the real value rows', async () => { // STRIPE_SECRET_KEY=… line is grabbed by env-var, not api-key. await window.locator('[data-testid="share-editor-privacy-row-api-key-header"]').click() await expect(window.getByTitle(FAKE_AKIA, { exact: true })).toBeVisible() - await expect( - window.getByTitle(STRIPE_RK, { exact: true }), - ).toBeVisible() + await expect(window.getByTitle(STRIPE_RK, { exact: true })).toBeVisible() }) }) @@ -309,8 +317,9 @@ test('Per-item opt-out keeps that value verbatim in the sanitised .spool', async await window.getByTitle(EMAIL, { exact: true }).click() await expect(window.locator('[data-testid="share-editor-privacy-reset"]')).toBeVisible() - await expect(window.locator('[data-testid="share-editor-privacy-count"]')) - .toContainText('visible') + await expect(window.locator('[data-testid="share-editor-privacy-count"]')).toContainText( + 'visible', + ) await installSaveFilePickerMock(window) await window.locator('[data-testid="share-menu-trigger"]').click() @@ -388,10 +397,10 @@ test('No sensitive data → empty state', async () => { await gotoPrivacyTab(ctx.window) await expect(ctx.window.locator('[data-testid="share-editor-privacy-clean"]')).toBeVisible() - await expect(ctx.window.locator('[data-testid="share-editor-privacy-count"]')) - .toContainText('none detected') - await expect(ctx.window.locator('[data-testid^="share-editor-privacy-row-"]')) - .toHaveCount(0) + await expect(ctx.window.locator('[data-testid="share-editor-privacy-count"]')).toContainText( + 'none detected', + ) + await expect(ctx.window.locator('[data-testid^="share-editor-privacy-row-"]')).toHaveCount(0) } finally { await safeExit(ctx.window) } diff --git a/apps/app/e2e/share-publish.spec.ts b/apps/app/e2e/share-publish.spec.ts index 3635ae1a..a1db1777 100644 --- a/apps/app/e2e/share-publish.spec.ts +++ b/apps/app/e2e/share-publish.spec.ts @@ -36,21 +36,10 @@ // match by text — the i18n migration lands strings behind t() and any // text-based selector would flake the moment the en.json copy edits. -import { test, expect, type Page } from '@playwright/test' import { writeFileSync, mkdirSync } from 'node:fs' import { join } from 'node:path' -import { - launchApp, - restartApp, - waitForSync, - type AppContext, -} from './helpers/launch' -import { - startSharePublishMockBackend, - type SharePublishMockHandle, -} from './helpers/share-publish-mock-backend' -import { navigateToShares, openShareEditorFromSessionDetail } from './helpers/share' +import { test, expect, type Page } from '@playwright/test' // Error-copy assertions read the expected strings from the same en.json // the renderer renders from — the selector discipline below bans @@ -58,6 +47,12 @@ import { navigateToShares, openShareEditorFromSessionDetail } from './helpers/sh // rendered?" is only observable through copy, so we source it from the // single source of truth instead of duplicating it here. import en from '../src/renderer/i18n/locales/en.json' +import { launchApp, restartApp, waitForSync, type AppContext } from './helpers/launch' +import { navigateToShares, openShareEditorFromSessionDetail } from './helpers/share' +import { + startSharePublishMockBackend, + type SharePublishMockHandle, +} from './helpers/share-publish-mock-backend' const SESSION_UUID = 'test-session-uuid-001' @@ -112,9 +107,7 @@ test('Share popover opens with the ConnectCard when signed out', async () => { await window.locator('[data-testid="share-menu-trigger"]').click() await window.locator('[data-testid="share-menu-popover"]').waitFor({ state: 'visible' }) - await expect( - window.locator('[data-testid="connect-card-signin"]'), - ).toBeVisible() + await expect(window.locator('[data-testid="connect-card-signin"]')).toBeVisible() }) test('Sign-in transitions the popover to the publish form', async () => { @@ -123,22 +116,14 @@ test('Sign-in transitions the popover to the publish form', async () => { await window.locator('[data-testid="connect-card-signin"]').click() // Form renders only once useShareAuth resolves to a signed-in user; // covers the auth-bus EventTarget sync between main and renderer. - await expect( - window.locator('[data-testid="share-menu-form"]'), - ).toBeVisible({ timeout: 5_000 }) + await expect(window.locator('[data-testid="share-menu-form"]')).toBeVisible({ timeout: 5_000 }) // Profiles are cut from launch: the visibility picker must stay // hidden and every publish goes out link-only (asserted on the mock // backend in the publish test below). The snapshot summary card // replaces the picker as the form's body. - await expect( - window.locator('[data-testid="share-menu-visibility-link-only"]'), - ).toHaveCount(0) - await expect( - window.locator('[data-testid="share-menu-snapshot-card"]'), - ).toBeVisible() - await expect( - window.locator('[data-testid="share-menu-submit"]'), - ).toBeEnabled() + await expect(window.locator('[data-testid="share-menu-visibility-link-only"]')).toHaveCount(0) + await expect(window.locator('[data-testid="share-menu-snapshot-card"]')).toBeVisible() + await expect(window.locator('[data-testid="share-menu-submit"]')).toBeEnabled() }) test('Settings → Account offers no handle claim while profiles are cut', async () => { @@ -157,12 +142,8 @@ test('Settings → Account offers no handle claim while profiles are cut', async // Signed in without a handle — the pre-cut UI showed the claim // input here. Sign-out row proves the pane rendered (absence of the // input isn't a pass if the whole pane failed to mount). - await expect( - window.locator('[data-testid="settings-account-signout"]'), - ).toBeVisible() - await expect( - window.locator('[data-testid="settings-account-handle-input"]'), - ).toHaveCount(0) + await expect(window.locator('[data-testid="settings-account-signout"]')).toBeVisible() + await expect(window.locator('[data-testid="settings-account-handle-input"]')).toHaveCount(0) // The delete-account confirm must not mention a handle either: it // renders exactly the two bullets that survive the profile cut. @@ -170,9 +151,7 @@ test('Settings → Account offers no handle claim while profiles are cut', async const modal = window.locator('[data-testid="delete-account-confirm"]') await expect(modal).toBeVisible() await expect(modal.locator('ul > li')).toHaveCount(2) - await expect(modal.locator('ul')).not.toContainText( - en.settings.account.deleteConfirm_item_handle, - ) + await expect(modal.locator('ul')).not.toContainText(en.settings.account.deleteConfirm_item_handle) await window.locator('[data-testid="delete-account-confirm-cancel"]').click() }) @@ -183,9 +162,9 @@ test('Publish lands in the manage view with slug + copy-link + unpublish', async // Submit. The renderer derives idempotency_key from the snapshot; // we only need to assert the manage view eventually appears. await window.locator('[data-testid="share-menu-submit"]').click() - await expect( - window.locator('[data-testid="share-menu-manage-view"]'), - ).toBeVisible({ timeout: 10_000 }) + await expect(window.locator('[data-testid="share-menu-manage-view"]')).toBeVisible({ + timeout: 10_000, + }) await expect(window.locator('[data-testid="share-menu-copy"]')).toBeVisible() await expect(window.locator('[data-testid="share-menu-republish"]')).toBeVisible() @@ -209,19 +188,13 @@ test('Unpublish requires confirm and tombstones the share', async () => { // The dedicated centered modal — NOT a popover-internal click-twice. // This is the regression guard for the destructive-action discipline // codified in PR #371. - await expect( - window.locator('[data-testid="unpublish-confirm"]'), - ).toBeVisible() + await expect(window.locator('[data-testid="unpublish-confirm"]')).toBeVisible() await window.locator('[data-testid="unpublish-confirm-yes"]').click() - await expect( - window.locator('[data-testid="unpublish-confirm"]'), - ).toBeHidden() + await expect(window.locator('[data-testid="unpublish-confirm"]')).toBeHidden() // Popover falls back to the publish form once the live share is gone. - await expect( - window.locator('[data-testid="share-menu-form"]'), - ).toBeVisible() + await expect(window.locator('[data-testid="share-menu-form"]')).toBeVisible() // Backend state: row carries revoked_at. const shares = Array.from(mock.state.shares.values()) @@ -238,18 +211,12 @@ test('Cancel on the unpublish modal leaves the share live', async () => { await signInAndPublish(window) await window.locator('[data-testid="share-menu-unpublish"]').click() - await expect( - window.locator('[data-testid="unpublish-confirm"]'), - ).toBeVisible() + await expect(window.locator('[data-testid="unpublish-confirm"]')).toBeVisible() await window.locator('[data-testid="unpublish-confirm-cancel"]').click() - await expect( - window.locator('[data-testid="unpublish-confirm"]'), - ).toBeHidden() + await expect(window.locator('[data-testid="unpublish-confirm"]')).toBeHidden() // Manage view still rendered, share state unchanged. - await expect( - window.locator('[data-testid="share-menu-manage-view"]'), - ).toBeVisible() + await expect(window.locator('[data-testid="share-menu-manage-view"]')).toBeVisible() const shares = Array.from(mock.state.shares.values()) expect(shares).toHaveLength(1) expect(shares[0]!.revoked_at).toBeNull() @@ -303,9 +270,9 @@ test('High-risk PII gate: override does not re-arm the same "Publish anyway" but // The explicit confirm publishes. await confirm.click() - await expect( - window.locator('[data-testid="share-menu-manage-view"]'), - ).toBeVisible({ timeout: 10_000 }) + await expect(window.locator('[data-testid="share-menu-manage-view"]')).toBeVisible({ + timeout: 10_000, + }) expect(mock.state.shares.size).toBe(1) }) @@ -354,9 +321,9 @@ test('Publish rate-limit (429) shows the error inline and retry succeeds', async // Backend recovers → the same submit button retries to success. mock.state.failures.publish = null await window.locator('[data-testid="share-menu-submit"]').click() - await expect( - window.locator('[data-testid="share-menu-manage-view"]'), - ).toBeVisible({ timeout: 10_000 }) + await expect(window.locator('[data-testid="share-menu-manage-view"]')).toBeVisible({ + timeout: 10_000, + }) expect(mock.state.shares.size).toBe(1) }) @@ -402,13 +369,9 @@ test('Published tab lists a live share with open/copy/unpublish affordances', as // Leave the share editor — the full-page editor covers the sidebar, // so close the popover (Esc) and back out before navigating. await window.keyboard.press('Escape') - await expect( - window.locator('[data-testid="share-menu-popover"]'), - ).toBeHidden() + await expect(window.locator('[data-testid="share-menu-popover"]')).toBeHidden() await window.locator('[data-testid="share-editor-back"]').click() - await expect( - window.locator('[data-testid="share-editor-page"]'), - ).toBeHidden() + await expect(window.locator('[data-testid="share-editor-page"]')).toBeHidden() await navigateToShares(window) await window.locator('[data-testid="shares-tab-published"]').click() @@ -436,9 +399,7 @@ test('Published tab lists a live share with open/copy/unpublish affordances', as await window.keyboard.press('Escape') // Live row — no stale banner, no revoked styling. await expect(row).not.toHaveAttribute('data-revoked', '') - await expect( - window.locator('[data-testid="published-stale-banner"]'), - ).toBeHidden() + await expect(window.locator('[data-testid="published-stale-banner"]')).toBeHidden() }) test('Row menu lists a share on the profile and back', async () => { @@ -472,7 +433,9 @@ test('Row menu lists a share on the profile and back', async () => { // And back. await row.hover() await row.getByLabel(en.common.moreActions).click() - await window.getByRole('menuitem', { name: en.shares.publishedTab.action_unlistFromProfile }).click() + await window + .getByRole('menuitem', { name: en.shares.publishedTab.action_unlistFromProfile }) + .click() await expect(row.getByLabel(en.shares.publishedTab.vis_unlisted)).toBeVisible({ timeout: 5_000 }) expect(Array.from(mock.state.shares.values())[0]!.visibility).toBe('unlisted') }) @@ -544,9 +507,7 @@ test('Revoked row offers Open draft — back into the editor, or a toast when th // leave their own drafts in the shared tmpdir DB, so `.first()` can // re-resolve to a survivor after the delete and fail the hidden // assertion. - const card = window.locator( - '[data-testid="shares-draft-row"][aria-label*="XYLOPHONE_CANARY_42"]', - ) + const card = window.locator('[data-testid="shares-draft-row"][aria-label*="XYLOPHONE_CANARY_42"]') await expect(card).toBeVisible({ timeout: 5_000 }) // Hover the wrapping
so the DeleteChip mounts, then the // click-twice-in-place confirm pattern. @@ -564,7 +525,9 @@ test('Revoked row offers Open draft — back into the editor, or a toast when th .locator('[data-testid="published-row"][data-revoked] [data-testid="revoked-open-draft"]') .first() .click() - await expect(window.getByText(en.shares.publishedTab.draftMissing)).toBeVisible({ timeout: 5_000 }) + await expect(window.getByText(en.shares.publishedTab.draftMissing)).toBeVisible({ + timeout: 5_000, + }) // Still on the Shares page — no broken navigation into a dead editor. await expect(window.locator('[data-testid="shares-page"]')).toBeVisible() }) diff --git a/apps/app/e2e/share-stale-defenses.spec.ts b/apps/app/e2e/share-stale-defenses.spec.ts index 09e7cddc..7d83ee03 100644 --- a/apps/app/e2e/share-stale-defenses.spec.ts +++ b/apps/app/e2e/share-stale-defenses.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { dropFileOn, navigateToShares, seedShareDraft } from './helpers/share' @@ -97,6 +98,8 @@ test('dropping a .spool with valid JSON but missing required fields surfaces a r 'application/spool+json', ) - await expect(window.getByText(/Couldn't import shape-mismatch\.spool/)).toBeVisible({ timeout: 5000 }) + await expect(window.getByText(/Couldn't import shape-mismatch\.spool/)).toBeVisible({ + timeout: 5000, + }) await expect(window.locator('[data-testid="share-editor-page"]')).toBeHidden() }) diff --git a/apps/app/e2e/share-turn-selector.spec.ts b/apps/app/e2e/share-turn-selector.spec.ts index 39a93729..a1d387b7 100644 --- a/apps/app/e2e/share-turn-selector.spec.ts +++ b/apps/app/e2e/share-turn-selector.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' import { openShareEditorFromSessionDetail } from './helpers/share' diff --git a/apps/app/e2e/sidebar-pinned.spec.ts b/apps/app/e2e/sidebar-pinned.spec.ts index 7ba834bc..d27a8a95 100644 --- a/apps/app/e2e/sidebar-pinned.spec.ts +++ b/apps/app/e2e/sidebar-pinned.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' let ctx: AppContext @@ -34,7 +35,9 @@ test('sidebar pinned section appears after pinning and lists the session', async const pinnedToggle = window.locator('[data-testid="sidebar-pinned-toggle"]') await expect(pinnedToggle).toBeVisible({ timeout: 5000 }) - const pinnedRow = window.locator(`[data-testid="sidebar-pinned-row"][data-session-uuid="${uuid}"]`) + const pinnedRow = window.locator( + `[data-testid="sidebar-pinned-row"][data-session-uuid="${uuid}"]`, + ) await expect(pinnedRow).toBeVisible({ timeout: 5000 }) // Cleanup @@ -51,7 +54,9 @@ test('clicking a sidebar pinned row opens the session detail', async () => { await window.locator('[data-testid="sidebar-library"]').click() - const pinnedRow = window.locator(`[data-testid="sidebar-pinned-row"][data-session-uuid="${uuid}"]`) + const pinnedRow = window.locator( + `[data-testid="sidebar-pinned-row"][data-session-uuid="${uuid}"]`, + ) await expect(pinnedRow).toBeVisible({ timeout: 5000 }) await pinnedRow.click() @@ -79,7 +84,9 @@ test('unpinning from sidebar also clears the Library pinned segment', async () = ).toBeVisible() // Unpin from sidebar - const pinnedRow = window.locator(`[data-testid="sidebar-pinned-row"][data-session-uuid="${uuid}"]`) + const pinnedRow = window.locator( + `[data-testid="sidebar-pinned-row"][data-session-uuid="${uuid}"]`, + ) await pinnedRow.hover() await pinnedRow.locator('[data-testid="sidebar-pinned-unpin"]').click() @@ -102,7 +109,9 @@ test('sidebar pinned kebab menu exposes Resume and Copy actions', async () => { const uuid = await pinFirstSessionInProject(window) - const pinnedRow = window.locator(`[data-testid="sidebar-pinned-row"][data-session-uuid="${uuid}"]`) + const pinnedRow = window.locator( + `[data-testid="sidebar-pinned-row"][data-session-uuid="${uuid}"]`, + ) await expect(pinnedRow).toBeVisible({ timeout: 5000 }) await pinnedRow.hover() await pinnedRow.locator('[data-testid="sidebar-pinned-menu-trigger"]').click() @@ -138,20 +147,22 @@ test('sidebar pinned sort menu changes the visible order', async () => { await rowB.hover() await rowB.locator('[data-testid="pin-button"]').click() - await expect(window.locator('[data-testid="sidebar-pinned-row"]')).toHaveCount(2, { timeout: 5000 }) + await expect(window.locator('[data-testid="sidebar-pinned-row"]')).toHaveCount(2, { + timeout: 5000, + }) const sidebar = window.locator('[data-testid="sidebar"]') - const orderRecentPinned = await sidebar.locator('[data-testid="sidebar-pinned-row"]').evaluateAll( - nodes => nodes.map(n => n.getAttribute('data-session-uuid')), - ) + const orderRecentPinned = await sidebar + .locator('[data-testid="sidebar-pinned-row"]') + .evaluateAll((nodes) => nodes.map((n) => n.getAttribute('data-session-uuid'))) // Switch sort order to Name (A–Z) and confirm visible order changes (or stays consistent) await sidebar.locator('[data-testid="sidebar-pinned-sort-trigger"]').click() await window.getByRole('menuitem', { name: /Name \(A.{1,3}Z\)/ }).click() - const orderByName = await sidebar.locator('[data-testid="sidebar-pinned-row"]').evaluateAll( - nodes => nodes.map(n => n.getAttribute('data-session-uuid')), - ) + const orderByName = await sidebar + .locator('[data-testid="sidebar-pinned-row"]') + .evaluateAll((nodes) => nodes.map((n) => n.getAttribute('data-session-uuid'))) expect(orderByName).toHaveLength(2) // Both sets contain the same two uuids, but the array shapes should be valid @@ -163,5 +174,7 @@ test('sidebar pinned sort menu changes the visible order', async () => { await row.hover() await row.locator('[data-testid="sidebar-pinned-unpin"]').click() } - await expect(window.locator('[data-testid="sidebar-pinned-row"]')).toHaveCount(0, { timeout: 5000 }) + await expect(window.locator('[data-testid="sidebar-pinned-row"]')).toHaveCount(0, { + timeout: 5000, + }) }) diff --git a/apps/app/e2e/sidebar.spec.ts b/apps/app/e2e/sidebar.spec.ts index 4bead770..31233f9e 100644 --- a/apps/app/e2e/sidebar.spec.ts +++ b/apps/app/e2e/sidebar.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, waitForSync, type AppContext } from './helpers/launch' let ctx: AppContext diff --git a/apps/app/e2e/smoke-demo-launch.spec.ts b/apps/app/e2e/smoke-demo-launch.spec.ts index cca47582..545d3cd9 100644 --- a/apps/app/e2e/smoke-demo-launch.spec.ts +++ b/apps/app/e2e/smoke-demo-launch.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from '@playwright/test' -import { launchDemoApp, setDemoWindowBounds, waitForDemoSync } from './helpers/demo-launch' + import type { ProjectSeed } from './helpers/demo-fixtures' +import { launchDemoApp, setDemoWindowBounds, waitForDemoSync } from './helpers/demo-launch' const PROJECTS: ProjectSeed[] = [ { diff --git a/apps/app/e2e/startup-no-beachball.spec.ts b/apps/app/e2e/startup-no-beachball.spec.ts index f1fc87a5..4b18d74b 100644 --- a/apps/app/e2e/startup-no-beachball.spec.ts +++ b/apps/app/e2e/startup-no-beachball.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp } from './helpers/launch.js' /** @@ -56,9 +57,12 @@ test('main-process event loop does not stall on cold launch', async () => { // Wait for the renderer to be interactive — that's the window in // which a regressed sync IPC handler would stall things. await ctx.window.waitForLoadState('domcontentloaded') - await ctx.window.waitForSelector('[data-testid="library-landing"], [data-testid="library-empty"], [data-testid="shares-page"]', { - timeout: 15_000, - }) + await ctx.window.waitForSelector( + '[data-testid="library-landing"], [data-testid="library-empty"], [data-testid="shares-page"]', + { + timeout: 15_000, + }, + ) // Give the mount-time IPC storm and any background warm-up a // chance to clear before we read the histogram — otherwise we diff --git a/apps/app/e2e/window-lifecycle.spec.ts b/apps/app/e2e/window-lifecycle.spec.ts index d77b97b8..1eb88a9e 100644 --- a/apps/app/e2e/window-lifecycle.spec.ts +++ b/apps/app/e2e/window-lifecycle.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test' + import { launchApp, type AppContext } from './helpers/launch' let ctx: AppContext @@ -22,7 +23,9 @@ test('window recovers after close → activate cycle', async () => { // Second cycle — this is where the original bug manifested. await closeAndAwaitDestroy(restored, app) const restoredAgain = await activateAndAwaitNewWindow(app) - await expect(restoredAgain.locator('[data-testid="library-landing"]')).toBeVisible({ timeout: 10000 }) + await expect(restoredAgain.locator('[data-testid="library-landing"]')).toBeVisible({ + timeout: 10000, + }) }) // page.close() returns when the renderer Page is gone, but the main-process @@ -30,12 +33,19 @@ test('window recovers after close → activate cycle', async () => { // Without this poll, showOrCreateWindow can race and hit the `.show()` branch // on a half-destroyed window — no new BrowserWindow is created, so the // 'window' event we arm below never fires and the test stalls until timeout. -async function closeAndAwaitDestroy(page: import('@playwright/test').Page, app: import('@playwright/test').ElectronApplication) { +async function closeAndAwaitDestroy( + page: import('@playwright/test').Page, + app: import('@playwright/test').ElectronApplication, +) { await page.close() - await app.evaluate(({ BrowserWindow }) => new Promise((resolve) => { - const tick = () => BrowserWindow.getAllWindows().length === 0 ? resolve() : setTimeout(tick, 25) - tick() - })) + await app.evaluate( + ({ BrowserWindow }) => + new Promise((resolve) => { + const tick = () => + BrowserWindow.getAllWindows().length === 0 ? resolve() : setTimeout(tick, 25) + tick() + }), + ) } async function activateAndAwaitNewWindow(app: import('@playwright/test').ElectronApplication) { diff --git a/apps/app/electron.vite.config.ts b/apps/app/electron.vite.config.ts index 265eff8b..f4818bff 100644 --- a/apps/app/electron.vite.config.ts +++ b/apps/app/electron.vite.config.ts @@ -1,8 +1,9 @@ import { resolve } from 'path' + +import tailwindcss from '@tailwindcss/vite' +import react from '@vitejs/plugin-react' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { loadEnv } from 'vite' -import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' import type { Plugin } from 'vite' const coreAlias = { @@ -10,14 +11,22 @@ const coreAlias = { '@spool-lab/redact': resolve(__dirname, '../../packages/redact/dist/index.js'), } -// better-sqlite3 uses 'bindings' at runtime to locate the .node native addon. -// It must NOT be bundled — it must stay as a real require() in the output. -function nativeExternalPlugin(): Plugin { +// Runtime-owned modules must stay external to the Electron bundles: +// - electron is provided by the Electron executable. Bundling the npm +// downloader package makes the launched app try to install Electron again. +// - better-sqlite3 uses 'bindings' at runtime to locate the .node native addon. +// It must stay as a real require() so the addon can be discovered. +function runtimeExternalPlugin(): Plugin { return { - name: 'native-external', + name: 'runtime-external', enforce: 'pre', resolveId(id) { - if (id === 'better-sqlite3' || id.startsWith('better-sqlite3/')) { + if ( + id === 'electron' || + id.startsWith('electron/') || + id === 'better-sqlite3' || + id.startsWith('better-sqlite3/') + ) { return { id, external: true } } return null @@ -27,7 +36,7 @@ function nativeExternalPlugin(): Plugin { // Main-process env vars whose values get inlined into the bundle at // build time via Rollup `define`. Renderer reads its own env via -// `import.meta.env.VITE_*`, but the main process bundle is plain CJS — +// `import.meta.env.VITE_*`, but the main process bundle is plain ESM — // it has no equivalent at runtime, so we substitute `process.env.X` // references with literal strings before they reach the output. // @@ -118,8 +127,15 @@ function inlineMainEnvPlugin(env: Map): Plugin { const dotRe = new RegExp(`process\\.env\\.${key}\\b`, 'g') // process.env['X'] and process.env["X"] const bracketRe = new RegExp(`process\\.env\\[(['"])${key}\\1\\]`, 'g') - const replaced = out.replace(dotRe, () => { changed = true; return literal }) - .replace(bracketRe, () => { changed = true; return literal }) + const replaced = out + .replace(dotRe, () => { + changed = true + return literal + }) + .replace(bracketRe, () => { + changed = true + return literal + }) out = replaced } return changed ? { code: out, map: null } : null @@ -137,7 +153,7 @@ export default defineConfig(({ mode }) => ({ // those branches contain) from the output bundle. The test-mode // modules under src/main/e2e-mode/ are therefore never loaded — they // don't even resolve through Rollup — in any production binary. A - // ci unit test grep-asserts this invariant against out/main/index.js. + // ci unit test grep-asserts this invariant against out/main/index.mjs. define: { __SPOOL_E2E__: JSON.stringify(process.env['SPOOL_E2E_TEST'] === '1'), }, @@ -163,24 +179,38 @@ export default defineConfig(({ mode }) => ({ 'electron-store', ], }), - nativeExternalPlugin(), + runtimeExternalPlugin(), ], build: { rollupOptions: { + output: { + format: 'es', + // electron-vite's generated ESM shim can land inside a retained + // third-party JSDoc block after Rolldown combines the main graph. + // Define the Node path globals up front so bundled app modules do + // not depend on the placement of that generated shim. + banner: + 'globalThis.__filename ??= import.meta.filename;\n' + + 'globalThis.__dirname ??= import.meta.dirname;', + }, input: { index: resolve(__dirname, 'src/main/index.ts'), - 'sync-worker': resolve(__dirname, 'src/main/sync-worker.ts'), - 'scan-worker-thread': resolve(__dirname, 'src/main/scan-worker-thread.ts'), - 'mutation-worker-thread': resolve(__dirname, 'src/main/mutation-worker-thread.ts'), }, }, }, resolve: { alias: coreAlias }, }, preload: { - plugins: [externalizeDepsPlugin({ exclude: ['@spool-lab/core', '@spool-lab/redact'] })], + plugins: [ + externalizeDepsPlugin({ exclude: ['@spool-lab/core', '@spool-lab/redact'] }), + runtimeExternalPlugin(), + ], build: { rollupOptions: { + // BrowserWindow renderers stay sandboxed. Electron runs sandboxed + // preloads as plain JavaScript without an ESM loader, so these entries + // must remain CommonJS and load the Electron bridge via require(). + output: { format: 'cjs' }, input: { index: resolve(__dirname, 'src/preload/index.ts'), // Hidden Privacy Filter inference window has its own preload — diff --git a/apps/app/electron.workers.vite.config.ts b/apps/app/electron.workers.vite.config.ts new file mode 100644 index 00000000..7bfeb1ac --- /dev/null +++ b/apps/app/electron.workers.vite.config.ts @@ -0,0 +1,57 @@ +import { resolve } from 'node:path' + +import { defineConfig, externalizeDepsPlugin } from 'electron-vite' +import type { Plugin } from 'vite' + +const coreAlias = { + '@spool-lab/core': resolve(__dirname, '../../packages/core/dist/index.js'), + '@spool-lab/redact': resolve(__dirname, '../../packages/redact/dist/index.js'), +} + +function runtimeExternalPlugin(): Plugin { + return { + name: 'worker-runtime-external', + enforce: 'pre', + resolveId(id) { + if (id === 'better-sqlite3' || id.startsWith('better-sqlite3/')) { + return { id, external: true } + } + return null + }, + } +} + +// Worker entries are built separately from the Electron main process. Keeping +// them in one Rollup graph with index.ts allowed E2E-only Electron modules to +// become shared dependencies of a worker chunk, so the worker crashed before +// it could report ready. This graph contains worker-safe modules only. +export default defineConfig({ + main: { + plugins: [ + externalizeDepsPlugin({ + exclude: [ + '@spool-lab/cli', + '@spool-lab/core', + '@spool-lab/redact', + '@spool-lab/session-kit', + ], + }), + runtimeExternalPlugin(), + ], + build: { + emptyOutDir: false, + rollupOptions: { + output: { + format: 'es', + chunkFileNames: 'worker-chunks/[name]-[hash].mjs', + }, + input: { + 'sync-worker': resolve(__dirname, 'src/main/sync-worker.ts'), + 'scan-worker-thread': resolve(__dirname, 'src/main/scan-worker-thread.ts'), + 'mutation-worker-thread': resolve(__dirname, 'src/main/mutation-worker-thread.ts'), + }, + }, + }, + resolve: { alias: coreAlias }, + }, +}) diff --git a/apps/app/package.json b/apps/app/package.json index 6ced51e6..f1a954f6 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -2,23 +2,38 @@ "name": "@spool/app", "version": "0.6.0", "private": true, - "productName": "Spool", - "main": "./out/main/index.js", + "homepage": "https://github.com/spool-lab/spool#readme", + "bugs": { + "url": "https://github.com/spool-lab/spool/issues" + }, + "license": "MIT", + "author": { + "name": "xinyao", + "email": "hi@xinyao.me" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/spool-lab/spool.git" + }, + "funding": "https://github.com/sponsors/xinyao27", + "main": "./out/main/index.mjs", "scripts": { + "ensure:electron-runtime": "node -e \"require('electron')\"", "rebuild:native": "pnpm run rebuild:native:electron", "rebuild:native:electron": "electron-rebuild -f -w better-sqlite3", - "build:core": "pnpm --filter @spool-lab/core build", - "build:redact": "pnpm --filter @spool-lab/redact build", - "build:session-kit": "pnpm --filter @spool-lab/session-kit build", - "build:cli": "pnpm --filter @spool-lab/cli build", - "build:share-kit": "pnpm --filter @spool/share-kit build", - "build:session-view": "pnpm --filter @spool-lab/session-view build", + "build:core": "vp run --filter @spool-lab/core build", + "build:redact": "vp run --filter @spool-lab/redact build", + "build:session-kit": "vp run --filter @spool-lab/session-kit build", + "build:cli": "vp run --filter @spool-lab/cli build", + "build:share-kit": "vp run --filter @spool/share-kit build", + "build:session-view": "vp run --filter @spool-lab/session-view build", "build:deps": "pnpm run build:redact && pnpm run build:session-kit && pnpm run build:core && pnpm run build:cli && pnpm run build:share-kit && pnpm run build:session-view", "dev": "pnpm run build:deps && node ../../scripts/with-electron-native.mjs node scripts/dev.mjs", "dev:reset-db": "node scripts/dev-reset-db.mjs", "dev:seed-from-prod": "node scripts/dev-seed-from-prod.mjs", - "build": "electron-vite build", - "build:electron": "pnpm run build:deps && electron-vite build", + "build": "electron-vite build && pnpm run build:workers", + "build:workers": "electron-vite build --config electron.workers.vite.config.ts", + "build:electron": "pnpm run build:deps && pnpm run build", "package": "node ../../scripts/with-electron-native.mjs pnpm exec electron-builder --publish never", "package:mac": "node ../../scripts/with-electron-native.mjs pnpm exec electron-builder --mac --arm64 --publish never", "package:linux": "node ../../scripts/with-electron-native.mjs pnpm exec electron-builder --linux --publish never", @@ -28,18 +43,20 @@ "preview": "electron-vite preview", "clean": "rm -rf out dist-electron", "typecheck": "tsc --noEmit", - "test": "vitest run", + "pretest": "pnpm run ensure:electron-runtime", + "test": "vp test run --no-file-parallelism", "rebuild:native:node": "node ../../scripts/rebuild-better-sqlite3-node.mjs", + "pretest:e2e": "pnpm run ensure:electron-runtime", "test:e2e": "node ../../scripts/with-electron-native.mjs pnpm run test:e2e:raw", - "test:e2e:raw": "pnpm run build:deps && SPOOL_E2E_TEST=1 VITE_FEATURE_SHAREPUBLISH=1 electron-vite build && playwright test --config e2e/playwright.config.ts" + "test:e2e:raw": "pnpm run build:deps && SPOOL_E2E_TEST=1 VITE_FEATURE_SHAREPUBLISH=1 pnpm run build && playwright test --config e2e/playwright.config.ts" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.17.1", + "@agentclientprotocol/sdk": "^1.2.1", "@fontsource-variable/fraunces": "^5.2.9", - "@fontsource-variable/geist": "^5.2.8", + "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/hanken-grotesk": "^5.2.8", "@fontsource-variable/inter": "^5.2.8", - "@fontsource/geist-mono": "^5.2.7", + "@fontsource/geist-mono": "^5.2.8", "@huggingface/transformers": "^4.2.0", "@spool-lab/cli": "workspace:*", "@spool-lab/core": "workspace:*", @@ -47,32 +64,55 @@ "@spool-lab/session-kit": "workspace:*", "@spool-lab/session-view": "workspace:*", "@spool/share-kit": "workspace:*", - "@tanstack/react-virtual": "^3.13.6", - "acp-extension-claude": "^0.21.0", - "acp-extension-codex": "^0.10.0", - "better-sqlite3": "11.10.0", + "@tanstack/react-virtual": "^3.14.6", + "acp-extension-claude": "^0.54.3", + "acp-extension-codex": "^1.1.2", + "better-sqlite3": "12.11.1", "bindings": "^1.5.0", - "effect": "^3.21.2", - "electron-store": "^10.0.0", - "electron-updater": "^6.8.3", - "fast-check": "^3.23.2", + "effect": "^3.22.0", + "electron-store": "^11.0.2", + "electron-updater": "^6.8.9", + "fast-check": "^4.9.0", "file-uri-to-path": "^2.0.0", - "i18next": "^25.10.10", - "lucide-react": "^1.8.0", + "i18next": "^26.3.6", + "lucide-react": "^1.25.0", "mdast-util-to-string": "^4", - "pure-rand": "^6.1.0", - "react": "^19.2.5", - "react-dom": "^19.2.5", - "react-i18next": "^16.6.6", - "react-markdown": "^9", - "react-virtuoso": "^4.18.6", + "pure-rand": "^8.4.2", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-i18next": "^17.0.10", + "react-markdown": "^10.1.0", + "react-virtuoso": "^4.18.11", "rehype-sanitize": "^6", - "remark": "^15", - "remark-gfm": "^4", - "shiki": "^1", + "remark": "^15.0.1", + "remark-gfm": "^4.0.1", + "shiki": "^4.3.1", "sonner": "^2.0.7", - "ws": "^8.20.0", - "zustand": "^5.0.3" + "ws": "^8.21.1", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@electron/asar": "^4.2.0", + "@electron/rebuild": "^4.2.0", + "@playwright/test": "^1.61.1", + "@tailwindcss/vite": "^4.3.3", + "@types/better-sqlite3": "^7.6.13", + "@types/node": "^26.1.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@types/ws": "^8.18.1", + "@vitejs/plugin-react": "^6.0.3", + "electron": "^43.1.1", + "electron-builder": "^26.15.3", + "electron-vite": "^5.0.0", + "tailwindcss": "^4.3.3", + "vite": "catalog:", + "vite-plus": "catalog:", + "vitest": "catalog:" + }, + "optionalDependencies": { + "acp-extension-codex-darwin-arm64": "^0.16.0", + "acp-extension-codex-linux-x64": "^0.16.0" }, "build": { "appId": "com.linkclaw.spool", @@ -158,26 +198,5 @@ ] } }, - "optionalDependencies": { - "acp-extension-codex-darwin-arm64": "^0.10.0", - "acp-extension-codex-linux-x64": "^0.10.0" - }, - "devDependencies": { - "@electron/asar": "^3.4.1", - "@electron/rebuild": "^3.7.1", - "@playwright/test": "^1.59.1", - "@tailwindcss/vite": "^4.1.4", - "@types/better-sqlite3": "^7.6.13", - "@types/node": "^22.19.17", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", - "@types/ws": "^8.18.1", - "@vitejs/plugin-react": "^4.4.1", - "electron": "^34.5.0", - "electron-builder": "^26.0.12", - "electron-vite": "^3.1.0", - "tailwindcss": "^4.1.4", - "vite": "^6.3.3", - "vitest": "^4.1.4" - } + "productName": "Spool" } diff --git a/apps/app/scripts/dev.mjs b/apps/app/scripts/dev.mjs index e6df5c3d..b3a71b83 100755 --- a/apps/app/scripts/dev.mjs +++ b/apps/app/scripts/dev.mjs @@ -10,6 +10,7 @@ // production DB. Don't do that.) import { spawn } from 'node:child_process' + import { resolveSpoolDataDir } from './lib/spool-data-dir.mjs' const { value, source } = resolveSpoolDataDir(process.env) diff --git a/apps/app/scripts/lib/spool-data-dir.test.mjs b/apps/app/scripts/lib/spool-data-dir.test.mjs index 84595eb0..cf372818 100644 --- a/apps/app/scripts/lib/spool-data-dir.test.mjs +++ b/apps/app/scripts/lib/spool-data-dir.test.mjs @@ -1,4 +1,5 @@ -import { describe, test, expect } from 'vitest' +import { describe, test, expect } from 'vite-plus/test' + import { resolveSpoolDataDir } from './spool-data-dir.mjs' describe('resolveSpoolDataDir', () => { diff --git a/apps/app/scripts/package-size-report.mjs b/apps/app/scripts/package-size-report.mjs index 38809ac9..47a36df9 100644 --- a/apps/app/scripts/package-size-report.mjs +++ b/apps/app/scripts/package-size-report.mjs @@ -1,8 +1,11 @@ import { lstat, readdir } from 'node:fs/promises' import { join, resolve } from 'node:path' + import { getRawHeader } from '@electron/asar' -const appPath = resolve(process.argv.slice(2).find(arg => !arg.startsWith('-')) ?? 'dist/mac-arm64/Spool.app') +const appPath = resolve( + process.argv.slice(2).find((arg) => !arg.startsWith('-')) ?? 'dist/mac-arm64/Spool.app', +) const json = process.argv.includes('--json') const contents = join(appPath, 'Contents') const resources = join(contents, 'Resources') @@ -35,7 +38,10 @@ if (json) { console.log(JSON.stringify(report, null, 2)) } else { console.log(`Package size report: ${appPath}`) - printEntries('Totals', Object.entries(report.totals).map(([name, bytes]) => ({ name, bytes }))) + printEntries( + 'Totals', + Object.entries(report.totals).map(([name, bytes]) => ({ name, bytes })), + ) printEntries('Largest frameworks', report.largestFrameworks) printEntries('Largest resources', report.largestResources) printEntries('Largest packaged modules', report.largestModules) @@ -64,10 +70,12 @@ function packageName(path) { async function largestChildren(path, limit) { const entries = await readdir(path, { withFileTypes: true }) - const sizes = await Promise.all(entries.map(async entry => ({ - name: entry.name, - bytes: await sizeOf(join(path, entry.name)), - }))) + const sizes = await Promise.all( + entries.map(async (entry) => ({ + name: entry.name, + bytes: await sizeOf(join(path, entry.name)), + })), + ) return sizes.sort((a, b) => b.bytes - a.bytes).slice(0, limit) } @@ -79,12 +87,16 @@ async function sizeOf(path, seen = new Set()) { seen.add(identity) if (!info.isDirectory()) return info.size const entries = await readdir(path, { withFileTypes: true }) - return (await Promise.all(entries.map(entry => sizeOf(join(path, entry.name), seen)))).reduce((sum, size) => sum + size, 0) + return (await Promise.all(entries.map((entry) => sizeOf(join(path, entry.name), seen)))).reduce( + (sum, size) => sum + size, + 0, + ) } function printEntries(title, entries) { console.log(`\n${title}`) - for (const entry of entries) console.log(`${formatBytes(entry.bytes).padStart(10)} ${entry.name}`) + for (const entry of entries) + console.log(`${formatBytes(entry.bytes).padStart(10)} ${entry.name}`) } function formatBytes(bytes) { diff --git a/apps/app/scripts/smoke-packaged.mjs b/apps/app/scripts/smoke-packaged.mjs index a2d9241a..9cd2434b 100644 --- a/apps/app/scripts/smoke-packaged.mjs +++ b/apps/app/scripts/smoke-packaged.mjs @@ -1,17 +1,36 @@ -import { _electron as electron } from '@playwright/test' -import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -const appDir = resolve(process.argv.slice(2).find(arg => !arg.startsWith('-')) ?? 'dist/mac-arm64/Spool.app') +import { _electron as electron } from '@playwright/test' + +const appDir = resolve( + process.argv.slice(2).find((arg) => !arg.startsWith('-')) ?? 'dist/mac-arm64/Spool.app', +) const fullPrivacyFilter = process.argv.includes('--full-pf') const keepProfile = process.argv.includes('--keep-profile') const executablePath = join(appDir, 'Contents', 'MacOS', 'Spool') if (!existsSync(executablePath)) throw new Error(`Packaged executable not found: ${executablePath}`) const unpackedModules = join(appDir, 'Contents', 'Resources', 'app.asar.unpacked', 'node_modules') -assert(existsSync(join(unpackedModules, 'acp-extension-claude', 'dist', 'index.js')), 'packaged Claude ACP extension is missing') -assert(existsSync(join(unpackedModules, 'acp-extension-codex-darwin-arm64', 'bin', 'acp-extension-codex')), 'packaged Codex ACP extension is missing') +assert( + existsSync(join(unpackedModules, 'acp-extension-claude', 'dist', 'index.js')), + 'packaged Claude ACP extension is missing', +) +assert( + existsSync( + join(unpackedModules, 'acp-extension-codex-darwin-arm64', 'bin', 'acp-extension-codex'), + ), + 'packaged Codex ACP extension is missing', +) const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') const fixturesDir = join(packageDir, 'e2e', 'fixtures') @@ -37,15 +56,24 @@ mkdirSync(join(claudeDir, 'package-smoke'), { recursive: true }) mkdirSync(opencodeDir, { recursive: true }) mkdirSync(spoolHome, { recursive: true }) mkdirSync(mockBinDir, { recursive: true }) -writeFileSync(sensitiveFixture, JSON.stringify({ - type: 'user', - uuid: 'packaged-sensitive-message', - timestamp: '2026-07-12T00:00:00.000Z', - message: { role: 'user', content: `PACKAGED_SMOKE_SECRET ${secret}. My name is Harry Potter and my email is harry.potter@hogwarts.edu.` }, - sessionId: 'packaged-sensitive-session', - cwd: '/tmp/package-smoke', -}) + '\n') -writeFileSync(join(spoolHome, 'agents.json'), JSON.stringify({ securityEnabled: true, defaultAgent: 'claude' })) +writeFileSync( + sensitiveFixture, + JSON.stringify({ + type: 'user', + uuid: 'packaged-sensitive-message', + timestamp: '2026-07-12T00:00:00.000Z', + message: { + role: 'user', + content: `PACKAGED_SMOKE_SECRET ${secret}. My name is Harry Potter and my email is harry.potter@hogwarts.edu.`, + }, + sessionId: 'packaged-sensitive-session', + cwd: '/tmp/package-smoke', + }) + '\n', +) +writeFileSync( + join(spoolHome, 'agents.json'), + JSON.stringify({ securityEnabled: true, defaultAgent: 'claude' }), +) for (const name of ['claude', 'codex']) { const path = join(mockBinDir, name) writeFileSync(path, '#!/bin/sh\nexit 0\n') @@ -77,34 +105,49 @@ try { timeout: 30_000, }) if (fullPrivacyFilter) { - app.process().stdout?.on('data', chunk => process.stdout.write(`[packaged-main] ${chunk}`)) - app.process().stderr?.on('data', chunk => process.stderr.write(`[packaged-main] ${chunk}`)) + app.process().stdout?.on('data', (chunk) => process.stdout.write(`[packaged-main] ${chunk}`)) + app.process().stderr?.on('data', (chunk) => process.stderr.write(`[packaged-main] ${chunk}`)) } const window = await app.firstWindow() const searchResults = await poll(async () => { - const results = await window.evaluate(async () => globalThis.spool?.search('XYLOPHONE_CANARY_42', 5) ?? []) + const results = await window.evaluate( + async () => globalThis.spool?.search('XYLOPHONE_CANARY_42', 5) ?? [], + ) return results.length > 0 ? results : null }, 'fixture indexing') assert(searchResults.length > 0, 'packaged SQLite search returned no fixture result') const agents = await window.evaluate(async () => globalThis.spool.getAiAgents()) for (const agentId of ['claude', 'codex']) { - assert(agents.some(agent => agent.id === agentId && agent.status === 'ready'), `${agentId} was not detected`) - const response = await window.evaluate(async (id) => globalThis.spool.aiSearch('packaged ACP smoke', id, []), agentId) - assert(response.ok && response.fullText?.includes('MOCK_ACP_RESPONSE_42'), `${agentId} ACP launch failed: ${response.error ?? 'empty response'}`) + assert( + agents.some((agent) => agent.id === agentId && agent.status === 'ready'), + `${agentId} was not detected`, + ) + const response = await window.evaluate( + async (id) => globalThis.spool.aiSearch('packaged ACP smoke', id, []), + agentId, + ) + assert( + response.ok && response.fullText?.includes('MOCK_ACP_RESPONSE_42'), + `${agentId} ACP launch failed: ${response.error ?? 'empty response'}`, + ) } - await poll(async () => window.evaluate(async () => { - const security = globalThis.spool?.security - if (!security) return false - try { - await security.getScanStatus() - return true - } catch { - return false - } - }), 'Security IPC') + await poll( + async () => + window.evaluate(async () => { + const security = globalThis.spool?.security + if (!security) return false + try { + await security.getScanStatus() + return true + } catch { + return false + } + }), + 'Security IPC', + ) const ortResource = await app.evaluate(async ({ net }) => { const response = await net.fetch('pf-model:///ort/ort-wasm-simd-threaded.mjs') @@ -113,31 +156,50 @@ try { assert(ortResource.ok && ortResource.bytes > 1000, 'packaged ORT Web runtime is unavailable') await window.evaluate(async () => globalThis.spool.security.rescanAll()) - await poll(async () => window.evaluate(async () => { - const status = await globalThis.spool.security.getScanStatus() - return status.queued === 0 && status.scanning === null && status.backfillRemaining === 0 - }), 'Security scan completion') - const sensitiveResults = await window.evaluate(async () => globalThis.spool.search('PACKAGED_SMOKE_SECRET', 5)) + await poll( + async () => + window.evaluate(async () => { + const status = await globalThis.spool.security.getScanStatus() + return status.queued === 0 && status.scanning === null && status.backfillRemaining === 0 + }), + 'Security scan completion', + ) + const sensitiveResults = await window.evaluate(async () => + globalThis.spool.search('PACKAGED_SMOKE_SECRET', 5), + ) assert(sensitiveResults.length > 0, 'sensitive package-smoke session was not indexed') const sensitiveSessionId = sensitiveResults[0].sessionId const findings = await window.evaluate( async (sessionId) => globalThis.spool.security.listFindings({ sessionId, state: 'active' }), sensitiveSessionId, ) - const apiKey = findings.find(finding => finding.kind === 'api-key') + const apiKey = findings.find((finding) => finding.kind === 'api-key') assert(apiKey, 'packaged Security scanner did not detect the fixture API key') - await window.evaluate(async (findingId) => globalThis.spool.security.purgeFinding(findingId), apiKey.id) - const purgedSession = await window.evaluate(async () => globalThis.spool.getSession('packaged-sensitive-session')) - const purgedText = purgedSession?.messages.map(message => message.contentText).join('\n') ?? '' - assert(!purgedText.includes(secret) && purgedText.includes('[redacted:'), 'packaged purge did not mask the indexed message') - const postPurgeSearch = await window.evaluate(async (query) => globalThis.spool.search(query, 5), secret) + await window.evaluate( + async (findingId) => globalThis.spool.security.purgeFinding(findingId), + apiKey.id, + ) + const purgedSession = await window.evaluate(async () => + globalThis.spool.getSession('packaged-sensitive-session'), + ) + const purgedText = purgedSession?.messages.map((message) => message.contentText).join('\n') ?? '' + assert( + !purgedText.includes(secret) && purgedText.includes('[redacted:'), + 'packaged purge did not mask the indexed message', + ) + const postPurgeSearch = await window.evaluate( + async (query) => globalThis.spool.search(query, 5), + secret, + ) assert(postPurgeSearch.length === 0, 'purged secret remains searchable in packaged FTS') let pfState = await window.evaluate(async () => globalThis.spool.security.pfGetState()) let pfRuntime = null if (fullPrivacyFilter) { if (pfState.phase !== 'installed') { - const download = await window.evaluate(async () => globalThis.spool.security.pfDownloadStart()) + const download = await window.evaluate(async () => + globalThis.spool.security.pfDownloadStart(), + ) assert(download.ok, `Privacy Filter download failed: ${download.reason ?? 'unknown error'}`) pfState = await window.evaluate(async () => globalThis.spool.security.pfGetState()) } @@ -149,28 +211,45 @@ try { 180_000, ) await window.evaluate(async () => globalThis.spool.security.rescanAll()) - const pfScanStatus = await poll(async () => window.evaluate(async () => { - const status = await globalThis.spool.security.getScanStatus() - return status.currentProfile.includes('pf@') - && status.queued === 0 - && status.scanning === null - && status.backfillRemaining === 0 - ? status - : null - }), 'Privacy Filter scan completion', 180_000) - assert(pfScanStatus.currentProfile.includes('pf@'), `scan profile did not enable Privacy Filter: ${pfScanStatus.currentProfile}`) + const pfScanStatus = await poll( + async () => + window.evaluate(async () => { + const status = await globalThis.spool.security.getScanStatus() + return status.currentProfile.includes('pf@') && + status.queued === 0 && + status.scanning === null && + status.backfillRemaining === 0 + ? status + : null + }), + 'Privacy Filter scan completion', + 180_000, + ) + assert( + pfScanStatus.currentProfile.includes('pf@'), + `scan profile did not enable Privacy Filter: ${pfScanStatus.currentProfile}`, + ) } - assert(['not-installed', 'installed'].includes(pfState.phase), `unexpected Privacy Filter state: ${pfState.phase}`) - console.log(JSON.stringify({ - app: appDir, - sessionsIndexed: true, - search: true, - acp: ['claude', 'codex'], - privacyFilterRuntimeBytes: ortResource.bytes, - privacyFilterRuntime: pfRuntime, - securityScanAndPurge: true, - privacyFilterState: pfState.phase, - }, null, 2)) + assert( + ['not-installed', 'installed'].includes(pfState.phase), + `unexpected Privacy Filter state: ${pfState.phase}`, + ) + console.log( + JSON.stringify( + { + app: appDir, + sessionsIndexed: true, + search: true, + acp: ['claude', 'codex'], + privacyFilterRuntimeBytes: ortResource.bytes, + privacyFilterRuntime: pfRuntime, + securityScanAndPurge: true, + privacyFilterState: pfState.phase, + }, + null, + 2, + ), + ) } finally { await app?.close().catch(() => {}) if (keepProfile) console.log(`Kept packaged smoke profile: ${tempDir}`) @@ -186,7 +265,7 @@ async function poll(check, label, timeoutMs = 30_000) { while (Date.now() < deadline) { const value = await check() if (value) return value - await new Promise(resolve => setTimeout(resolve, 200)) + await new Promise((resolve) => setTimeout(resolve, 200)) } throw new Error(`Timed out waiting for ${label}`) } diff --git a/apps/app/src/main/acp.test.ts b/apps/app/src/main/acp.test.ts index 273d4e66..4a3546b1 100644 --- a/apps/app/src/main/acp.test.ts +++ b/apps/app/src/main/acp.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vite-plus/test' + import { AcpManager } from './acp.js' describe('AcpManager builtin agents', () => { diff --git a/apps/app/src/main/acp.ts b/apps/app/src/main/acp.ts index bb37f0e7..2809ce9e 100644 --- a/apps/app/src/main/acp.ts +++ b/apps/app/src/main/acp.ts @@ -4,19 +4,9 @@ import { spawn, execSync, type ChildProcess } from 'node:child_process' import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs' import { readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' import { homedir } from 'node:os' -import WebSocketImpl from 'ws' -import { cachedResolveAsyncPersistent } from './binaryCache.js' -import { - getDB, - getOrCreateAskProject, - getSourceId, - insertSpoolAuthoredSession, - wrapSpoolSystemPrelude, - type FragmentResult, - type SessionSource, -} from '@spool-lab/core' +import { join, resolve } from 'node:path' + import type { Client as AcpClient, CreateTerminalRequest, @@ -27,6 +17,18 @@ import type { TerminalOutputRequest, TerminalOutputResponse, } from '@agentclientprotocol/sdk' +import { + getDB, + getOrCreateAskProject, + getSourceId, + insertSpoolAuthoredSession, + wrapSpoolSystemPrelude, + type FragmentResult, + type SessionSource, +} from '@spool-lab/core' +import WebSocketImpl from 'ws' + +import { cachedResolveAsyncPersistent } from './binaryCache.js' export interface AgentInfo { id: string @@ -64,14 +66,17 @@ export interface AgentsConfig { /** UI language; 'system' follows OS preference (default). */ language?: 'system' | 'en' | 'zh-CN' | 'zh-TW' | 'ja' | 'ko' | 'de' | 'fr' /** Custom agent definitions (extend beyond builtins) */ - customAgents?: Record + customAgents?: Record< + string, + { + name?: string + bin: string + acpMode: AcpMode + acpArgs?: string[] + wsEndpoint?: string + healthCheck?: string + } + > } interface AcpSession { @@ -101,13 +106,20 @@ function getLoginShellEnv(): Record { const shells = [process.env['SHELL'] ?? 'zsh', 'bash'] for (const sh of shells) { try { - const raw = execSync(`${sh} -lic "env"`, { encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }).trim() + const raw = execSync(`${sh} -lic "env"`, { + encoding: 'utf8', + timeout: 5000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim() const env: Record = {} for (const line of raw.split('\n')) { const idx = line.indexOf('=') if (idx > 0) env[line.slice(0, idx)] = line.slice(idx + 1) } - if (env['PATH']) { _loginShellEnv = env; return env } + if (env['PATH']) { + _loginShellEnv = env + return env + } } catch {} } return {} @@ -119,9 +131,9 @@ interface AgentConfig { name: string bin: string acpMode: AcpMode - acpArgs?: string[] // native mode: args to start ACP server (default: ['acp']) - wsEndpoint?: string // websocket mode: WebSocket URL - healthCheck?: string // websocket mode: HTTP health check URL + acpArgs?: string[] // native mode: args to start ACP server (default: ['acp']) + wsEndpoint?: string // websocket mode: WebSocket URL + healthCheck?: string // websocket mode: HTTP health check URL envSetup?: () => Promise> } @@ -195,7 +207,8 @@ function ensureAgentSearchCwd(): string { * the Spool-authored session write. */ function agentIdToSource(agentId: string): SessionSource | null { - if (agentId === 'claude' || agentId === 'codex' || agentId === 'gemini' || agentId === 'opencode') return agentId + if (agentId === 'claude' || agentId === 'codex' || agentId === 'gemini' || agentId === 'opencode') + return agentId return null } @@ -299,7 +312,7 @@ export class AcpManager { onSessionStarted?: (info: { sessionUuid: string; source: SessionSource; cwd: string }) => void, ): Promise { const agents = await this.detectAgents() - const agent = agents.find(a => a.id === agentId) + const agent = agents.find((a) => a.id === agentId) if (!agent) throw new Error(`Agent "${agentId}" not found. Install ${agentId} CLI first.`) // Cancel any running query @@ -314,7 +327,15 @@ export class AcpManager { if (config.acpMode === 'websocket') { return this.queryViaWebSocket(config, prompt, onChunk, onToolCall) } - return this.queryViaAcp(agentId, config, prompt, onChunk, onToolCall, userQuery, onSessionStarted) + return this.queryViaAcp( + agentId, + config, + prompt, + onChunk, + onToolCall, + userQuery, + onSessionStarted, + ) } /** @@ -338,7 +359,7 @@ export class AcpManager { const shellEnv = getLoginShellEnv() const agentEnv = { - ...process.env as Record, + ...(process.env as Record), ...shellEnv, ...(config.envSetup ? await config.envSetup() : {}), } @@ -348,7 +369,8 @@ export class AcpManager { if (config.acpMode === 'native') { // Native ACP: the CLI itself is the ACP server const binPath = await cachedResolveAsyncPersistent(config.bin) - if (!binPath) throw new Error(`Could not find ${config.bin}. Ensure it is installed and in PATH.`) + if (!binPath) + throw new Error(`Could not find ${config.bin}. Ensure it is installed and in PATH.`) const acpArgs = config.acpArgs ?? ['acp'] proc = spawn(binPath, acpArgs, { stdio: ['pipe', 'pipe', 'pipe'], @@ -365,7 +387,8 @@ export class AcpManager { }) } else { const nodePath = await cachedResolveAsyncPersistent('node') - if (!nodePath) throw new Error('Could not find Node.js. Ensure node is installed and in PATH.') + if (!nodePath) + throw new Error('Could not find Node.js. Ensure node is installed and in PATH.') proc = spawn(nodePath, [agentBin], { stdio: ['pipe', 'pipe', 'pipe'], env: agentEnv, @@ -405,17 +428,23 @@ export class AcpManager { let fullText = '' const MAX_TERMINAL_OUTPUT = 1024 * 1024 // 1 MB cap - const terminals = new Map() + const terminals = new Map< + string, + { + proc: ChildProcess + output: string + exitCode: number | null + signal: NodeJS.Signals | null + truncated: boolean + } + >() let terminalCounter = 0 function killTerminalProc(t: { proc: ChildProcess }) { - if (t.proc.exitCode === null) try { t.proc.kill() } catch {} + if (t.proc.exitCode === null) + try { + t.proc.kill() + } catch {} } function cleanupAllTerminals() { @@ -423,138 +452,149 @@ export class AcpManager { terminals.clear() } - const conn = new acp.ClientSideConnection((): AcpClient => ({ - requestPermission: async (params: { options?: Array<{ optionId: string; kind?: string }> }) => { - try { - // Auto-approve: pick the first allow/approve option from the agent's list - const options = params.options ?? [] - const allowOption = options.find(o => o.kind?.startsWith('allow')) ?? options[0] - console.log(`[ACP] requestPermission: picking optionId=${allowOption?.optionId}`) - return { outcome: { outcome: 'selected' as const, optionId: allowOption?.optionId ?? 'allow' } } - } catch (e) { - console.error('[ACP] requestPermission error:', e) - return { outcome: { outcome: 'selected' as const, optionId: 'allow' } } - } - }, - extMethod: async () => ({}), - createTerminal: async (params: CreateTerminalRequest): Promise => { - const id = `term-${++terminalCounter}` - const termProc = spawn(params.command, params.args ?? [], { - cwd: params.cwd ?? process.cwd(), - env: process.env, - stdio: ['pipe', 'pipe', 'pipe'], - shell: true, - }) - const state = { - proc: termProc, - output: '', - exitCode: null as number | null, - signal: null as NodeJS.Signals | null, - truncated: false, - } - const append = (d: Buffer) => { - if (state.output.length < MAX_TERMINAL_OUTPUT) { - state.output += d.toString() - if (state.output.length > MAX_TERMINAL_OUTPUT) { - state.output = state.output.slice(0, MAX_TERMINAL_OUTPUT) + const conn = new acp.ClientSideConnection( + (): AcpClient => ({ + requestPermission: async (params: { + options?: Array<{ optionId: string; kind?: string }> + }) => { + try { + // Auto-approve: pick the first allow/approve option from the agent's list + const options = params.options ?? [] + const allowOption = options.find((o) => o.kind?.startsWith('allow')) ?? options[0] + console.log(`[ACP] requestPermission: picking optionId=${allowOption?.optionId}`) + return { + outcome: { outcome: 'selected' as const, optionId: allowOption?.optionId ?? 'allow' }, + } + } catch (e) { + console.error('[ACP] requestPermission error:', e) + return { outcome: { outcome: 'selected' as const, optionId: 'allow' } } + } + }, + extMethod: async () => ({}), + createTerminal: async (params: CreateTerminalRequest): Promise => { + const id = `term-${++terminalCounter}` + const termProc = spawn(params.command, params.args ?? [], { + cwd: params.cwd ?? process.cwd(), + env: process.env, + stdio: ['pipe', 'pipe', 'pipe'], + shell: true, + }) + const state = { + proc: termProc, + output: '', + exitCode: null as number | null, + signal: null as NodeJS.Signals | null, + truncated: false, + } + const append = (d: Buffer) => { + if (state.output.length < MAX_TERMINAL_OUTPUT) { + state.output += d.toString() + if (state.output.length > MAX_TERMINAL_OUTPUT) { + state.output = state.output.slice(0, MAX_TERMINAL_OUTPUT) + state.truncated = true + } + } else { state.truncated = true } - } else { - state.truncated = true } - } - termProc.stdout?.on('data', append) - termProc.stderr?.on('data', append) - termProc.on('close', (code, signal) => { - state.exitCode = code - state.signal = signal - }) - terminals.set(id, state) - return { terminalId: id } - }, - terminalOutput: async (params: TerminalOutputRequest): Promise => { - const t = terminals.get(params.terminalId) - return { - output: t?.output ?? '', - truncated: t?.truncated ?? false, - ...(t && (t.exitCode !== null || t.signal !== null) - ? { exitStatus: { exitCode: t.exitCode, signal: t.signal ?? null } } - : {}), - } - }, - waitForTerminalExit: async (params) => { - const t = terminals.get(params.terminalId) - if (!t) return { exitCode: -1 } - if (t.exitCode !== null || t.signal !== null) return { exitCode: t.exitCode, signal: t.signal ?? null } - return new Promise((resolve) => { - t.proc.on('close', (code, signal) => resolve({ exitCode: code ?? null, signal: signal ?? null })) - }) - }, - killTerminal: async (params) => { - const t = terminals.get(params.terminalId) - if (t) killTerminalProc(t) - return {} - }, - releaseTerminal: async (params) => { - const t = terminals.get(params.terminalId) - if (t) killTerminalProc(t) - terminals.delete(params.terminalId) - return {} - }, - readTextFile: async (params: ReadTextFileRequest): Promise => { - try { - return { content: await readFile(params.path, 'utf8') } - } catch { - return { content: '' } - } - }, - sessionUpdate: async (notification: AcpSessionNotification) => { - try { - const update = notification.update as { - sessionUpdate: string - content?: { type: string; text?: string } - toolCallId?: string - title?: string | null - status?: string | null - kind?: string | null + termProc.stdout?.on('data', append) + termProc.stderr?.on('data', append) + termProc.on('close', (code, signal) => { + state.exitCode = code + state.signal = signal + }) + terminals.set(id, state) + return { terminalId: id } + }, + terminalOutput: async (params: TerminalOutputRequest): Promise => { + const t = terminals.get(params.terminalId) + return { + output: t?.output ?? '', + truncated: t?.truncated ?? false, + ...(t && (t.exitCode !== null || t.signal !== null) + ? { exitStatus: { exitCode: t.exitCode, signal: t.signal ?? null } } + : {}), } + }, + waitForTerminalExit: async (params) => { + const t = terminals.get(params.terminalId) + if (!t) return { exitCode: -1 } + if (t.exitCode !== null || t.signal !== null) + return { exitCode: t.exitCode, signal: t.signal ?? null } + return new Promise((resolve) => { + t.proc.on('close', (code, signal) => + resolve({ exitCode: code ?? null, signal: signal ?? null }), + ) + }) + }, + killTerminal: async (params) => { + const t = terminals.get(params.terminalId) + if (t) killTerminalProc(t) + return {} + }, + releaseTerminal: async (params) => { + const t = terminals.get(params.terminalId) + if (t) killTerminalProc(t) + terminals.delete(params.terminalId) + return {} + }, + readTextFile: async (params: ReadTextFileRequest): Promise => { + try { + return { content: await readFile(params.path, 'utf8') } + } catch { + return { content: '' } + } + }, + sessionUpdate: async (notification: AcpSessionNotification) => { + try { + const update = notification.update as { + sessionUpdate: string + content?: { type: string; text?: string } + toolCallId?: string + title?: string | null + status?: string | null + kind?: string | null + } - switch (update.sessionUpdate) { - case 'agent_message_chunk': { - const content = update.content - if (content?.type === 'text' && content.text) { - const text = typeof content.text === 'string' ? content.text : JSON.stringify(content.text) - fullText += text - onChunk(text) + switch (update.sessionUpdate) { + case 'agent_message_chunk': { + const content = update.content + if (content?.type === 'text' && content.text) { + const text = + typeof content.text === 'string' ? content.text : JSON.stringify(content.text) + fullText += text + onChunk(text) + } + break } - break - } - case 'tool_call': { - const toolCall = { - toolCallId: update.toolCallId ?? `tool-${Date.now()}`, - title: update.title ?? 'Tool call', - status: update.status ?? 'in_progress', - ...(update.kind ? { kind: update.kind } : {}), + case 'tool_call': { + const toolCall = { + toolCallId: update.toolCallId ?? `tool-${Date.now()}`, + title: update.title ?? 'Tool call', + status: update.status ?? 'in_progress', + ...(update.kind ? { kind: update.kind } : {}), + } + onToolCall?.(toolCall) + break } - onToolCall?.(toolCall) - break - } - case 'tool_call_update': { - const toolCall = { - toolCallId: update.toolCallId ?? `tool-${Date.now()}`, - title: update.title ?? '', - status: update.status ?? 'in_progress', - ...(update.kind ? { kind: update.kind } : {}), + case 'tool_call_update': { + const toolCall = { + toolCallId: update.toolCallId ?? `tool-${Date.now()}`, + title: update.title ?? '', + status: update.status ?? 'in_progress', + ...(update.kind ? { kind: update.kind } : {}), + } + onToolCall?.(toolCall) + break } - onToolCall?.(toolCall) - break } + } catch (e) { + console.error('[ACP sessionUpdate] handler error:', e) } - } catch (e) { - console.error('[ACP sessionUpdate] handler error:', e) - } - }, - }), stream) + }, + }), + stream, + ) this.activeSession = { proc, conn, sessionId: null, initialized: false } @@ -589,7 +629,8 @@ export class AcpManager { const sourceId = getSourceId(db, source) const projectId = getOrCreateAskProject(db, source) insertSpoolAuthoredSession(db, { - projectId, sourceId, + projectId, + sourceId, sessionUuid: sessionId, title: userQuery, cwd: stableCwd, @@ -610,7 +651,12 @@ export class AcpManager { return fullText } catch (err) { if (fullText) return fullText - const msg = err instanceof Error ? err.message : (typeof err === 'object' && err !== null && 'message' in err) ? String((err as Record).message) : String(err) + const msg = + err instanceof Error + ? err.message + : typeof err === 'object' && err !== null && 'message' in err + ? String((err as Record).message) + : String(err) throw new Error(msg) } finally { cleanupAllTerminals() @@ -639,7 +685,8 @@ export class AcpManager { // For codex, resolve the platform-specific native binary directly if (name === 'codex') { const platformPkg = `acp-extension-codex-${process.platform}-${process.arch}` - const binaryName = process.platform === 'win32' ? 'acp-extension-codex.exe' : 'acp-extension-codex' + const binaryName = + process.platform === 'win32' ? 'acp-extension-codex.exe' : 'acp-extension-codex' for (const root of roots) { const candidate = join(root, platformPkg, 'bin', binaryName) if (existsSync(candidate)) { @@ -672,7 +719,8 @@ export class AcpManager { onChunk: (text: string) => void, onToolCall?: (event: ToolCallEvent) => void, ): Promise { - const baseUrl = config.wsEndpoint?.replace(/^ws/, 'http').replace(/\/ws\/.*$/, '') ?? 'http://localhost:23001' + const baseUrl = + config.wsEndpoint?.replace(/^ws/, 'http').replace(/\/ws\/.*$/, '') ?? 'http://localhost:23001' const wsUrl = config.wsEndpoint ?? 'ws://localhost:23001/ws/threads' // Health check @@ -695,7 +743,7 @@ export class AcpManager { body: JSON.stringify({ title: `[spool] ${title}` }), }) if (!threadResp.ok) throw new Error(`Failed to create thread: HTTP ${threadResp.status}`) - const thread = await threadResp.json() as { id: string } + const thread = (await threadResp.json()) as { id: string } const threadId = thread.id return new Promise((resolvePromise, rejectPromise) => { @@ -708,29 +756,40 @@ export class AcpManager { } const ws = new WebSocketImpl(wsUrl) - this.activeWs = { close: () => { try { ws.close() } catch {} } } + this.activeWs = { + close: () => { + try { + ws.close() + } catch {} + }, + } - const timeout = setTimeout(() => { - if (!settled) { - settled = true - ws.close() - cleanup() - if (fullText) resolvePromise(fullText) - else rejectPromise(new Error(`${config.name} query timed out (5 min)`)) - } - }, 5 * 60 * 1000) + const timeout = setTimeout( + () => { + if (!settled) { + settled = true + ws.close() + cleanup() + if (fullText) resolvePromise(fullText) + else rejectPromise(new Error(`${config.name} query timed out (5 min)`)) + } + }, + 5 * 60 * 1000, + ) ws.on('open', () => { - ws.send(JSON.stringify({ - type: 'generate_response', - data: { - threadId, - userMessage: { - role: 'user', - parts: [{ type: 'text', text: prompt }], + ws.send( + JSON.stringify({ + type: 'generate_response', + data: { + threadId, + userMessage: { + role: 'user', + parts: [{ type: 'text', text: prompt }], + }, }, - }, - })) + }), + ) }) ws.on('error', (err) => { @@ -738,7 +797,9 @@ export class AcpManager { settled = true clearTimeout(timeout) cleanup() - rejectPromise(new Error(`${config.name} WebSocket error: ${err.message ?? 'connection failed'}`)) + rejectPromise( + new Error(`${config.name} WebSocket error: ${err.message ?? 'connection failed'}`), + ) } }) @@ -759,17 +820,24 @@ export class AcpManager { if (type === 'message_delta' && payload?.threadId === threadId) { if (Array.isArray(payload.deltas)) { for (const delta of payload.deltas) { - if (delta.type === 'text_append' && (!delta.partType || delta.partType === 'text')) { + if ( + delta.type === 'text_append' && + (!delta.partType || delta.partType === 'text') + ) { // Filter out ... blocks const text = delta.text?.replace(/[\s\S]*?<\/think>/g, '') ?? '' if (text) { fullText += text onChunk(text) } - } else if (delta.type === 'tool_call_start' || (delta.type === 'part_add' && delta.part?.type?.startsWith('tool-'))) { + } else if ( + delta.type === 'tool_call_start' || + (delta.type === 'part_add' && delta.part?.type?.startsWith('tool-')) + ) { // Alma uses part_add with type "tool-Task" etc., others use tool_call_start const toolType = delta.part?.type ?? delta.name ?? 'Tool call' - const toolId = delta.part?.toolCallId ?? delta.partIndex?.toString() ?? `tool-${Date.now()}` + const toolId = + delta.part?.toolCallId ?? delta.partIndex?.toString() ?? `tool-${Date.now()}` onToolCall?.({ toolCallId: toolId, title: toolType.replace(/^tool-/, ''), @@ -788,7 +856,10 @@ export class AcpManager { } } } - } else if ((type === 'generation_done' || type === 'generation_completed') && payload?.threadId === threadId) { + } else if ( + (type === 'generation_done' || type === 'generation_completed') && + payload?.threadId === threadId + ) { if (!settled) { settled = true clearTimeout(timeout) @@ -806,7 +877,9 @@ export class AcpManager { else rejectPromise(new Error(payload.error ?? `${config.name} generation error`)) } } - } catch { /* ignore parse errors */ } + } catch { + /* ignore parse errors */ + } }) }) } @@ -827,7 +900,11 @@ export class AcpManager { if (this.activeSession) { const { proc } = this.activeSession if (proc && proc.exitCode === null) { - try { proc.kill() } catch { /* */ } + try { + proc.kill() + } catch { + /* */ + } } this.activeSession = null } @@ -844,7 +921,7 @@ export class AcpManager { // after stripping the prelude only the bare query remains as the first // user message — clean derived title, clean FTS, clean session detail. const systemBody = [ - 'You have access to a local knowledge base called Spool that indexes the user\'s AI coding sessions (Claude Code, Codex CLI, Gemini CLI, OpenCode).', + "You have access to a local knowledge base called Spool that indexes the user's AI coding sessions (Claude Code, Codex CLI, Gemini CLI, OpenCode).", '', 'The database is at ~/.spool/spool.db (SQLite with FTS5). You can query it directly with the `sqlite3` CLI.', '', @@ -863,19 +940,19 @@ export class AcpManager { ' sqlite3 ~/.spool/spool.db "SELECT session_uuid, title, started_at, message_count FROM sessions ORDER BY started_at DESC LIMIT 20"', '', 'Important:', - '- Interpret the user\'s intent and decide what to search. Don\'t just match their exact words.', + "- Interpret the user's intent and decide what to search. Don't just match their exact words.", '- If the user names a specific source (claude/codex/gemini/opencode), only return results from that source unless they explicitly ask for cross-source search.', '- For cross-source questions, first identify the relevant sources, then query each source separately, confirm hits or no-hits per source, and only then merge them into one answer.', '- For temporal queries ("what did I do recently"), use explicit date filters and be conservative when comparing times across different sources.', '- You may run multiple queries to find relevant information.', '- Synthesize a concise answer. Reference specific items, URLs, or sessions when relevant.', - '- Keep the result layer separate from the explanation layer: answer the user\'s question directly first, then add brief notes about what you checked if helpful.', + "- Keep the result layer separate from the explanation layer: answer the user's question directly first, then add brief notes about what you checked if helpful.", '- If no results are found, say so clearly and keep the result set empty in your wording. Do not present checked-but-non-matching candidates as if they were hits.', '- If helpful, you may mention checked sources or near misses as notes, but clearly label them as context rather than results.', '- If only some requested sources matched, say which sources matched and which did not.', - '- ALWAYS reply in the same language as the user\'s query.', + "- ALWAYS reply in the same language as the user's query.", '', - 'The text following this block (outside the prelude marker) is the user\'s actual query — respond to it directly.', + "The text following this block (outside the prelude marker) is the user's actual query — respond to it directly.", ].join('\n') return wrapSpoolSystemPrelude(systemBody, userQuery) } diff --git a/apps/app/src/main/auth/deep-link.ts b/apps/app/src/main/auth/deep-link.ts index 69387a81..403e703d 100644 --- a/apps/app/src/main/auth/deep-link.ts +++ b/apps/app/src/main/auth/deep-link.ts @@ -16,9 +16,10 @@ // index.ts owns wiring those events to dispatchDeepLink; this module is // electron-event-free so tests can drive it directly. -import { app } from 'electron' import { resolve } from 'node:path' +import { app } from 'electron' + export const DEEP_LINK_SCHEME = 'spool' /** Return true to consume the URL (stops further listeners). */ diff --git a/apps/app/src/main/auth/session-store.test.ts b/apps/app/src/main/auth/session-store.test.ts index d2351225..3d72a5f1 100644 --- a/apps/app/src/main/auth/session-store.test.ts +++ b/apps/app/src/main/auth/session-store.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vite-plus/test' let encryptionAvailable = true diff --git a/apps/app/src/main/auth/workos-auth.test.ts b/apps/app/src/main/auth/workos-auth.test.ts index b46f9bf8..f9a6d439 100644 --- a/apps/app/src/main/auth/workos-auth.test.ts +++ b/apps/app/src/main/auth/workos-auth.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test' import { dispatchDeepLink } from './deep-link.js' @@ -91,9 +91,7 @@ describe('signInWithWorkos (PKCE + spool:// callback orchestrator)', () => { expect(opened).toHaveLength(1) const auth = new URL(opened[0]!) - expect(auth.origin + auth.pathname).toBe( - 'https://api.workos.com/user_management/authorize', - ) + expect(auth.origin + auth.pathname).toBe('https://api.workos.com/user_management/authorize') expect(auth.searchParams.get('client_id')).toBe('client_test_123') expect(auth.searchParams.get('provider')).toBe('authkit') expect(auth.searchParams.get('response_type')).toBe('code') diff --git a/apps/app/src/main/auth/workos-auth.ts b/apps/app/src/main/auth/workos-auth.ts index 4d6f0e7b..05d76db2 100644 --- a/apps/app/src/main/auth/workos-auth.ts +++ b/apps/app/src/main/auth/workos-auth.ts @@ -10,12 +10,12 @@ // WorkOS tokens never land on this machine — the app stores exactly one // credential (the spool session token, in the OS keychain). -import { shell } from 'electron' import crypto from 'node:crypto' +import { shell } from 'electron' + import { fetchOnce } from '../net/robust-fetch.js' import { backendUrl } from '../share/backend-url.js' - import { onDeepLink } from './deep-link.js' const AUTHORIZE_URL = 'https://api.workos.com/user_management/authorize' @@ -69,9 +69,7 @@ function awaitAuthCallback(state: string): Promise<{ code: string }> { off() const error = url.searchParams.get('error') if (error) { - reject( - new Error(url.searchParams.get('error_description') ?? `sign-in failed: ${error}`), - ) + reject(new Error(url.searchParams.get('error_description') ?? `sign-in failed: ${error}`)) return true } const code = url.searchParams.get('code') diff --git a/apps/app/src/main/binaryCache.ts b/apps/app/src/main/binaryCache.ts index 72e8b3c7..42da82ea 100644 --- a/apps/app/src/main/binaryCache.ts +++ b/apps/app/src/main/binaryCache.ts @@ -1,5 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' + import { SPOOL_DIR, cachedResolveAsync, @@ -58,7 +59,10 @@ export function hydrateBinaryCache(): void { /** Resolve a binary, persisting the result to disk after a successful * miss-then-resolve so the next process start can read it back. */ -export async function cachedResolveAsyncPersistent(name: string, extras: string[] = []): Promise { +export async function cachedResolveAsyncPersistent( + name: string, + extras: string[] = [], +): Promise { const before = getResolveCacheSnapshot() const p = await cachedResolveAsync(name, extras) const after = getResolveCacheSnapshot() diff --git a/apps/app/src/main/e2e-mode/e2e-mode-clean.test.ts b/apps/app/src/main/e2e-mode/e2e-mode-clean.test.ts index 1b54e920..4d447147 100644 --- a/apps/app/src/main/e2e-mode/e2e-mode-clean.test.ts +++ b/apps/app/src/main/e2e-mode/e2e-mode-clean.test.ts @@ -24,10 +24,17 @@ import { execFileSync } from 'node:child_process' import { existsSync, readFileSync, rmSync } from 'node:fs' import { join, resolve } from 'node:path' -import { describe, expect, it } from 'vitest' + +import { describe, expect, it } from 'vite-plus/test' const APP_ROOT = resolve(__dirname, '..', '..', '..') const OUT_DIR = resolve(APP_ROOT, 'out-e2e-clean-check') +const ELECTRON_VITE = resolve( + APP_ROOT, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'electron-vite.CMD' : 'electron-vite', +) const FORBIDDEN_TOKENS = [ // Strings unique to share-auth-e2e.ts. If any of these survive into @@ -40,7 +47,7 @@ const FORBIDDEN_TOKENS = [ ] describe('production bundle is free of e2e-mode/', () => { - it('main/index.js contains zero references to the e2e composition root', () => { + it('main/index.mjs contains zero references to the e2e composition root', () => { // Clean any prior tmp out before building so we're never grepping a // stale file from a previous test run. rmSync(OUT_DIR, { recursive: true, force: true }) @@ -55,22 +62,14 @@ describe('production bundle is free of e2e-mode/', () => { // well inside the unit-test budget. const env = { ...process.env } delete env['SPOOL_E2E_TEST'] - execFileSync( - 'npx', - [ - 'electron-vite', - 'build', - '--outDir', - OUT_DIR, - ], - { - cwd: APP_ROOT, - env, - stdio: 'pipe', - }, - ) + env['ELECTRON_SKIP_BINARY_DOWNLOAD'] = '1' + execFileSync(ELECTRON_VITE, ['build', '--outDir', OUT_DIR], { + cwd: APP_ROOT, + env, + stdio: 'pipe', + }) - const bundlePath = join(OUT_DIR, 'main', 'index.js') + const bundlePath = join(OUT_DIR, 'main', 'index.mjs') expect(existsSync(bundlePath), `expected ${bundlePath} to exist after build`).toBe(true) const bundle = readFileSync(bundlePath, 'utf8') diff --git a/apps/app/src/main/esm-workspace-bundle.test.ts b/apps/app/src/main/esm-workspace-bundle.test.ts index 030d7e77..cedfcf60 100644 --- a/apps/app/src/main/esm-workspace-bundle.test.ts +++ b/apps/app/src/main/esm-workspace-bundle.test.ts @@ -1,17 +1,23 @@ import { execFile } from 'node:child_process' -import { readFileSync, rmSync } from 'node:fs' +import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs' import { join, resolve } from 'node:path' import { promisify } from 'node:util' -import { afterEach, describe, expect, it } from 'vitest' + +import { afterEach, describe, expect, it } from 'vite-plus/test' const APP_ROOT = resolve(__dirname, '..', '..') const OUT_DIR = resolve(APP_ROOT, 'out-esm-workspace-bundle-check') +const ELECTRON_VITE = resolve( + APP_ROOT, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'electron-vite.CMD' : 'electron-vite', +) +const WORKER_CONFIG = resolve(APP_ROOT, 'electron.workers.vite.config.ts') const execFileAsync = promisify(execFile) -const IMPORT_ONLY_WORKSPACE_PACKAGES = [ - '@spool-lab/cli/hub', - '@spool-lab/session-kit', -] +const IMPORT_ONLY_WORKSPACE_PACKAGES = ['@spool-lab/cli/hub', '@spool-lab/session-kit'] +const WORKER_ENTRIES = ['sync-worker.mjs', 'scan-worker-thread.mjs', 'mutation-worker-thread.mjs'] afterEach(() => { rmSync(OUT_DIR, { recursive: true, force: true }) @@ -21,18 +27,93 @@ describe('main-process ESM workspace bundles', () => { it('does not leave import-only packages behind as CommonJS requires', async () => { rmSync(OUT_DIR, { recursive: true, force: true }) - await execFileAsync('npx', ['electron-vite', 'build', '--outDir', OUT_DIR], { + const buildEnv = { + ...process.env, + ELECTRON_SKIP_BINARY_DOWNLOAD: '1', + // Reproduce the graph that previously pulled an Electron-only auth + // chunk into the scan worker through generated interop helpers. + SPOOL_E2E_TEST: '1', + } + + await execFileAsync(ELECTRON_VITE, ['build', '--outDir', OUT_DIR], { cwd: APP_ROOT, + env: buildEnv, }) + await execFileAsync(ELECTRON_VITE, ['build', '--config', WORKER_CONFIG, '--outDir', OUT_DIR], { + cwd: APP_ROOT, + env: buildEnv, + }) + + const workerChunksDir = join(OUT_DIR, 'main', 'worker-chunks') + const workerBundlePaths = [ + ...WORKER_ENTRIES.map((entry) => join(OUT_DIR, 'main', entry)), + ...readdirSync(workerChunksDir).map((entry) => join(workerChunksDir, entry)), + ] + for (const workerBundlePath of workerBundlePaths) { + const workerBundle = readFileSync(workerBundlePath, 'utf8') + expect(workerBundle, 'worker bundle must not import Electron runtime APIs').not.toMatch( + /from\s+['"]electron['"]/, + ) + expect(workerBundle, 'worker bundle must not contain Electron npm code').not.toContain( + 'node_modules/.pnpm/electron@', + ) + } - const bundle = readFileSync(join(OUT_DIR, 'main', 'index.js'), 'utf8') + const mainEntry = join(OUT_DIR, 'main', 'index.mjs') + const preloadEntry = join(OUT_DIR, 'preload', 'index.js') + const inferencePreloadEntry = join(OUT_DIR, 'preload', 'inference.js') + expect(existsSync(mainEntry), `expected ${mainEntry} to exist after build`).toBe(true) + expect(existsSync(preloadEntry), `expected ${preloadEntry} to exist after build`).toBe(true) + expect( + existsSync(inferencePreloadEntry), + `expected ${inferencePreloadEntry} to exist after build`, + ).toBe(true) + for (const workerEntry of WORKER_ENTRIES) { + const workerPath = join(OUT_DIR, 'main', workerEntry) + expect(existsSync(workerPath), `expected ${workerPath} to exist after build`).toBe(true) + } + + const bundle = readFileSync(mainEntry, 'utf8') + expect(bundle).toContain('globalThis.__filename ??= import.meta.filename') + expect(bundle).toContain('globalThis.__dirname ??= import.meta.dirname') + expect(bundle).toContain('../preload/index.js') + expect(bundle).not.toContain('../preload/index.mjs') + for (const workerEntry of WORKER_ENTRIES) { + expect(bundle, `main bundle should launch ${workerEntry}`).toContain(workerEntry) + expect(bundle, `main bundle should not launch a .js build of ${workerEntry}`).not.toContain( + workerEntry.replace(/\.mjs$/, '.js'), + ) + } for (const packageName of IMPORT_ONLY_WORKSPACE_PACKAGES) { - const hasRuntimeRequire = bundle.includes(`require("${packageName}")`) - || bundle.includes(`require('${packageName}')`) + const hasRuntimeRequire = + bundle.includes(`require("${packageName}")`) || bundle.includes(`require('${packageName}')`) expect( hasRuntimeRequire, `main bundle externalized import-only ${packageName} as a CommonJS require`, ).toBe(false) } + + for (const entry of [mainEntry, preloadEntry, inferencePreloadEntry]) { + const output = readFileSync(entry, 'utf8') + expect(output, `${entry} bundled Electron's npm downloader`).not.toContain( + 'Downloading Electron binary...', + ) + expect(output, `${entry} bundled Electron's npm package`).not.toContain( + 'node_modules/.pnpm/electron@', + ) + expect(output, `${entry} should import Electron from the runtime`).toMatch( + /(?:from\s+|require\()['"]electron['"]\)?/, + ) + } + + for (const entry of [preloadEntry, inferencePreloadEntry]) { + const output = readFileSync(entry, 'utf8') + expect(output, `${entry} must use CommonJS inside Electron's sandbox`).toMatch( + /require\(['"]electron['"]\)/, + ) + expect(output, `${entry} must not use ESM imports inside Electron's sandbox`).not.toMatch( + /from\s+['"]electron['"]/, + ) + } }, 60_000) }) diff --git a/apps/app/src/main/index.ts b/apps/app/src/main/index.ts index fded1ebc..bbd80a68 100644 --- a/apps/app/src/main/index.ts +++ b/apps/app/src/main/index.ts @@ -1,8 +1,19 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, nativeImage, net, shell } from 'electron' -import type { MenuItemConstructorOptions } from 'electron' import { join } from 'node:path' import { Worker } from 'node:worker_threads' +import { + app, + BrowserWindow, + dialog, + ipcMain, + Menu, + nativeTheme, + nativeImage, + net, + shell, +} from 'electron' +import type { MenuItemConstructorOptions } from 'electron' + // Install global error handlers as the very first thing in the file. Node 22 // defaults to --unhandled-rejections=strict, which means a single unhandled // rejection — anywhere in this process or any worker_threads child — aborts @@ -25,54 +36,87 @@ process.on('uncaughtException', (err) => { `${err instanceof Error ? err.message : String(err)}\n\n` + `Spool will keep running, but if you see this repeatedly please restart the app.`, ) - } catch { /* dialog unavailable — log already happened */ } + } catch { + /* dialog unavailable — log already happened */ + } }) import { - getDB, Syncer, SpoolWatcher, - searchFragments, searchSessionPreview, listRecentSessionsPage, getSessionWithMessages, getStatus, - pinSession, unpinSession, getPinnedUuids, listPinnedSessions, - listProjectGroups, listSessionsByIdentity, listPinnedSessionsByIdentity, listProjectDirectoryCounts, - listShareDrafts, getShareDraft, upsertShareDraft, deleteShareDraft, countDraftsBySession, + getDB, + Syncer, + SpoolWatcher, + searchFragments, + searchSessionPreview, + listRecentSessionsPage, + getSessionWithMessages, + getStatus, + pinSession, + unpinSession, + getPinnedUuids, + listPinnedSessions, + listProjectGroups, + listSessionsByIdentity, + listPinnedSessionsByIdentity, + listProjectDirectoryCounts, + listShareDrafts, + getShareDraft, + upsertShareDraft, + deleteShareDraft, + countDraftsBySession, invalidateSessionScanProfile, makeObservabilityRuntime, SPOOL_DIR, } from '@spool-lab/core' -import { spawnScanWorker, type ScanWorkerProxy } from './scan-worker-proxy.js' -import { spawnMutationWorker, type MutationWorkerProxy } from './mutation-worker-proxy.js' -import { Effect } from 'effect' -import { registerSecurityIpc, registerSecurityReadinessIpc, SECURITY_IPC_CHANNELS, type SecurityReadiness } from './ipc/security.js' -import { loadSecurityPreferences, saveSecurityPreferences } from './securityPreferences.js' -import { makePfRuntime, pfModelInstalled } from './security/pf-runtime.js' -import { registerPfModelProtocol, registerPfModelScheme } from './security/pf-model-protocol.js' -import { makePfCoordinator } from './security/pf-coordinator.js' -import { shouldAutoActivatePf } from './security/pf-activation.js' -import { pfModelDir } from './security/model-paths.js' import type { - FragmentResult, SessionSource, ListSessionsByIdentityOptions, SessionsCursor, - ShareDraftRow, UpsertShareDraftInput, + FragmentResult, + SessionSource, + ListSessionsByIdentityOptions, + SessionsCursor, + ShareDraftRow, + UpsertShareDraftInput, } from '@spool-lab/core' -import { setupTray } from './tray.js' -import { AcpManager } from './acp.js' -import { setupAutoUpdater, downloadUpdate, quitAndInstall } from './updater.js' -import { openTerminal } from './terminal.js' +import type Database from 'better-sqlite3' +import { Effect } from 'effect' + import { getSessionResumeCommand } from '../shared/resumeCommand.js' -import { resolveResumeWorkingDirectory } from './sessionResume.js' -import { loadUIPreferences, saveThemeEditor, saveThemeSource, saveSidebarCollapsed } from './uiPreferences.js' -import { hydrateBinaryCache } from './binaryCache.js' -import { snapshotEventLoopLag, startEventLoopMonitor } from './eventLoopMonitor.js' -import { registerShareAuthIpc } from './ipc/share-auth.js' +import { AcpManager } from './acp.js' import { dispatchDeepLink, dispatchDeepLinkFromArgv, registerDeepLinkScheme, } from './auth/deep-link.js' +import { hydrateBinaryCache } from './binaryCache.js' +import { snapshotEventLoopLag, startEventLoopMonitor } from './eventLoopMonitor.js' +import { registerHubShareIpc } from './ipc/hub-share.js' +import { + registerSecurityIpc, + registerSecurityReadinessIpc, + SECURITY_IPC_CHANNELS, + type SecurityReadiness, +} from './ipc/security.js' +import { registerShareAuthIpc } from './ipc/share-auth.js' import { registerShareProfileIpc } from './ipc/share-profile.js' -import { installRendererCsp } from './security/csp.js' import { registerSharePublishIpc } from './ipc/share-publish.js' -import { registerHubShareIpc } from './ipc/hub-share.js' -import type Database from 'better-sqlite3' +import { spawnMutationWorker, type MutationWorkerProxy } from './mutation-worker-proxy.js' +import { spawnScanWorker, type ScanWorkerProxy } from './scan-worker-proxy.js' +import { installRendererCsp } from './security/csp.js' +import { pfModelDir } from './security/model-paths.js' +import { shouldAutoActivatePf } from './security/pf-activation.js' +import { makePfCoordinator } from './security/pf-coordinator.js' +import { registerPfModelProtocol, registerPfModelScheme } from './security/pf-model-protocol.js' +import { makePfRuntime, pfModelInstalled } from './security/pf-runtime.js' +import { loadSecurityPreferences, saveSecurityPreferences } from './securityPreferences.js' +import { resolveResumeWorkingDirectory } from './sessionResume.js' import type { SyncWorkerMessage } from './sync-worker.js' +import { openTerminal } from './terminal.js' +import { setupTray } from './tray.js' +import { + loadUIPreferences, + saveThemeEditor, + saveThemeSource, + saveSidebarCollapsed, +} from './uiPreferences.js' +import { setupAutoUpdater, downloadUpdate, quitAndInstall } from './updater.js' // Privilege the `pf-model://` scheme before app.ready so the hidden // inference renderer can fetch model files through it. Has to happen @@ -86,7 +130,8 @@ registerPfModelScheme() // reach with `electronApp.evaluate(...)` — no production IPC surface. startEventLoopMonitor() if (process.env['SPOOL_E2E_TEST'] === '1') { - ;(globalThis as { __spoolEventLoopLag?: typeof snapshotEventLoopLag }).__spoolEventLoopLag = snapshotEventLoopLag + ;(globalThis as { __spoolEventLoopLag?: typeof snapshotEventLoopLag }).__spoolEventLoopLag = + snapshotEventLoopLag } const isDevMode = Boolean(process.env['ELECTRON_RENDERER_URL']) @@ -99,7 +144,12 @@ if (customUserDataDir) { const { run: runWithObservability } = makeObservabilityRuntime( isDevMode ? { serviceName: 'spool-app-main', serviceVersion: app.getVersion(), env: 'dev' } - : { serviceName: 'spool-app-main', serviceVersion: app.getVersion(), env: 'prod', logsDir: join(SPOOL_DIR, 'logs') }, + : { + serviceName: 'spool-app-main', + serviceVersion: app.getVersion(), + env: 'prod', + logsDir: join(SPOOL_DIR, 'logs'), + }, ) // macOS menu bar shows the first menu's label as the app name app.setName(isDevMode ? 'Spool DEV' : 'Spool') @@ -211,10 +261,17 @@ async function bootScanWorker(): Promise { // through pfRuntime.analyze → hidden inference window. When // pfRuntime isn't active, analyze() returns [] so the worker // gets an empty result quickly instead of blocking. - scanWorker = await spawnScanWorker(join(__dirname, 'scan-worker-thread.js'), { - analyze: (text) => pfRuntime.analyze(text) as Promise>, + scanWorker = await spawnScanWorker(join(__dirname, 'scan-worker-thread.mjs'), { + analyze: (text) => + pfRuntime.analyze(text) as Promise< + Array<{ + class: string + value: string + start: number + end: number + score: number + }> + >, }) } catch (err) { console.error('[security] scan worker failed to boot:', err) @@ -229,7 +286,7 @@ async function bootScanWorker(): Promise { * in-process on the main DB handle, which is the legacy behaviour. */ async function bootMutationWorker(): Promise { try { - mutationWorker = await spawnMutationWorker(join(__dirname, 'mutation-worker-thread.js')) + mutationWorker = await spawnMutationWorker(join(__dirname, 'mutation-worker-thread.mjs')) } catch (err) { console.error('[security] mutation worker failed to boot:', err) mutationWorker = null @@ -238,27 +295,43 @@ async function bootMutationWorker(): Promise { async function shutdownScanWorker(): Promise { if (disposeSecurityIpc) { - try { disposeSecurityIpc() } catch { /* best effort */ } + try { + disposeSecurityIpc() + } catch { + /* best effort */ + } disposeSecurityIpc = null } if (disposeSecurityReadinessIpc) { - try { disposeSecurityReadinessIpc() } catch { /* best effort */ } + try { + disposeSecurityReadinessIpc() + } catch { + /* best effort */ + } disposeSecurityReadinessIpc = null setSecurityReadiness = null } if (scanWorker) { try { await scanWorker.shutdown() - } catch { /* best effort */ } + } catch { + /* best effort */ + } scanWorker = null } if (mutationWorker) { try { await mutationWorker.shutdown() - } catch { /* best effort */ } + } catch { + /* best effort */ + } mutationWorker = null } - try { await pfRuntime.stop() } catch { /* best effort */ } + try { + await pfRuntime.stop() + } catch { + /* best effort */ + } } /** Idempotent boot of the Security feature: scan worker + its IPC, plus @@ -286,9 +359,11 @@ async function ensureSecurityBooted(): Promise { // globalThis.fetch (undici) bypasses both (see bug_electron_proxy). // E2E exception: SPOOL_E2E_TEST swaps in an immediate-503 fake so the // download state machine is deterministic without a real network hop. - const pfFetchImpl: typeof globalThis.fetch = process.env['SPOOL_E2E_TEST'] === '1' - ? (async () => new Response(null, { status: 503, statusText: 'e2e-fake' })) as typeof globalThis.fetch - : ((url, init) => net.fetch(url as string, init)) as typeof globalThis.fetch + const pfFetchImpl: typeof globalThis.fetch = + process.env['SPOOL_E2E_TEST'] === '1' + ? ((async () => + new Response(null, { status: 503, statusText: 'e2e-fake' })) as typeof globalThis.fetch) + : (((url, init) => net.fetch(url as string, init)) as typeof globalThis.fetch) pfCoordinator = makePfCoordinator({ modelDir: pfModelDir(), fetch: pfFetchImpl, @@ -303,12 +378,15 @@ async function ensureSecurityBooted(): Promise { // shouldAutoActivatePf gates on securityBooted so a download // that completes in the brief pre-boot window can't spawn a // hidden inference window before the feature is up. - if (!shouldAutoActivatePf({ - phase: s.phase, - securityBooted, - pfActivationPending: prefs.pfActivationPending, - pfEnabled: prefs.pfEnabled, - })) return + if ( + !shouldAutoActivatePf({ + phase: s.phase, + securityBooted, + pfActivationPending: prefs.pfActivationPending, + pfEnabled: prefs.pfEnabled, + }) + ) + return void (async () => { const next = saveSecurityPreferences({ pfEnabled: true }) mainWindow?.webContents.send(SECURITY_IPC_CHANNELS.EVT_PREFS_CHANGED, next) @@ -429,13 +507,15 @@ async function syncPfRuntime(pfEnabled: boolean): Promise { // pfActivationPending clears on the way out (success OR fail) so // the callout's "Activating…" state stops hanging on a permanent // failure. ScanBanner takes over visually once backfill enqueues. - Effect.ensuring(Effect.sync(() => { - const cur = loadSecurityPreferences() - if (cur.pfActivationPending) { - const next = saveSecurityPreferences({ pfActivationPending: false }) - mainWindow?.webContents.send(SECURITY_IPC_CHANNELS.EVT_PREFS_CHANGED, next) - } - })), + Effect.ensuring( + Effect.sync(() => { + const cur = loadSecurityPreferences() + if (cur.pfActivationPending) { + const next = saveSecurityPreferences({ pfActivationPending: false }) + mainWindow?.webContents.send(SECURITY_IPC_CHANNELS.EVT_PREFS_CHANGED, next) + } + }), + ), Effect.withSpan('pf.sync_runtime'), ), ) @@ -459,6 +539,7 @@ function createWindow(): BrowserWindow { preload: join(__dirname, '../preload/index.js'), contextIsolation: true, nodeIntegration: false, + sandbox: true, }, }) @@ -488,7 +569,8 @@ function createWindow(): BrowserWindow { const isInternal = url === current || url.startsWith('file://') || - (!!process.env['ELECTRON_RENDERER_URL'] && url.startsWith(process.env['ELECTRON_RENDERER_URL'])) + (!!process.env['ELECTRON_RENDERER_URL'] && + url.startsWith(process.env['ELECTRON_RENDERER_URL'])) if (isInternal) return if (/^https?:/i.test(url) || /^mailto:/i.test(url)) { event.preventDefault() @@ -544,179 +626,187 @@ let activeSyncPromise: Promise<{ added: number; updated: number; errors: number function runSyncWorker(): Promise<{ added: number; updated: number; errors: number }> { if (activeSyncPromise) return activeSyncPromise - activeSyncPromise = new Promise<{ added: number; updated: number; errors: number }>((resolve, reject) => { - const workerPath = join(__dirname, 'sync-worker.js') - const worker = new Worker(workerPath) - worker.on('message', (msg: SyncWorkerMessage) => { - if (msg.type === 'progress') { - isSyncActive = msg.data.phase !== 'done' - searchCache.clear() - mainWindow?.webContents.send('spool:sync-progress', msg.data) - } else if (msg.type === 'done') { - isSyncActive = false - searchCache.clear() - resolve(msg.result) - } else if (msg.type === 'error') { - isSyncActive = false - reject(new Error(msg.error)) - } - }) - worker.on('error', reject) - worker.on('exit', (code) => { - if (code !== 0) reject(new Error(`Sync worker exited with code ${code}`)) - }) - }).finally(() => { + activeSyncPromise = new Promise<{ added: number; updated: number; errors: number }>( + (resolve, reject) => { + const workerPath = join(__dirname, 'sync-worker.mjs') + const worker = new Worker(workerPath) + worker.on('message', (msg: SyncWorkerMessage) => { + if (msg.type === 'progress') { + isSyncActive = msg.data.phase !== 'done' + searchCache.clear() + mainWindow?.webContents.send('spool:sync-progress', msg.data) + } else if (msg.type === 'done') { + isSyncActive = false + searchCache.clear() + resolve(msg.result) + } else if (msg.type === 'error') { + isSyncActive = false + reject(new Error(msg.error)) + } + }) + worker.on('error', reject) + worker.on('exit', (code) => { + if (code !== 0) reject(new Error(`Sync worker exited with code ${code}`)) + }) + }, + ).finally(() => { activeSyncPromise = null }) return activeSyncPromise } -app.whenReady().then(async () => { - // Renderer CSP — has to land BEFORE any BrowserWindow loads its URL, - // otherwise the first response slips through with whatever Vite (or - // the bundled file:// loader) emits and Electron's "Insecure CSP" - // warning fires once before our header takes over. - installRendererCsp({ dev: isDevMode }) - - // Hydrate the agent-binary path cache from disk before anything has a - // chance to call `cachedResolveAsync`. Without this every cold launch - // re-runs ` -ilc 'command -v ...'` once per agent — three - // serialised execSync-style spawns on a slow .zshrc are the dominant - // contributor to the launch beachball. - hydrateBinaryCache() - - // Set dock icon (dev mode doesn't pick up build config) - const dockIconPath = join(__dirname, '../../resources/icon.icns') - try { app.dock?.setIcon(nativeImage.createFromPath(dockIconPath)) } catch {} - - Menu.setApplicationMenu(buildApplicationMenu()) - - db = getDB() - acpManager = new AcpManager() - - syncer = new Syncer(db, undefined, (sessionId) => { - // Sync mutated this session's messages; existing findings now have - // stale offsets. The Syncer already nulled scan_profile inside the - // commit txn; here we just re-enqueue so the worker picks it up — - // unless the user opted out of auto-rescan in Settings → Security, - // in which case we leave scan_profile dirty for the next manual - // Rescan all click. - // `invalidateSessionScanProfile` updates the v12 `scan_profile` - // column regardless of the feature flag — the column lives in - // every user's schema. Re-enqueuing only runs when the worker - // is booted (i.e. the flag is on); the column resets either - // way, which keeps state consistent if the flag flips on later. - invalidateSessionScanProfile(db, sessionId) - if (scanWorker && loadSecurityPreferences().rescanAfterSync === 'auto') { - // Promise-shaped so a worker-thread rejection (e.g. the child - // died) surfaces in the log instead of vanishing silently - // through Effect.runFork. The Effect itself is failure-free - // shape (Effect) — `.catch` here is the safety - // net for runtime promise rejections from the underlying - // postMessage round-trip. - runWithObservability(scanWorker.enqueue(sessionId)).catch((err) => { - console.error('[security] scan-worker enqueue failed:', err) - }) - } - }) - watcher = new SpoolWatcher(syncer) - watcher.on('new-sessions', (_event, data) => { - searchCache.clear() - mainWindow?.webContents.send('spool:new-sessions', data) - }) - watcher.on('error', (_event, data) => { - console.error('[watcher]', data.error, data.root ? `(root=${data.root})` : '') - }) +app + .whenReady() + .then(async () => { + // Renderer CSP — has to land BEFORE any BrowserWindow loads its URL, + // otherwise the first response slips through with whatever Vite (or + // the bundled file:// loader) emits and Electron's "Insecure CSP" + // warning fires once before our header takes over. + installRendererCsp({ dev: isDevMode }) + + // Hydrate the agent-binary path cache from disk before anything has a + // chance to call `cachedResolveAsync`. Without this every cold launch + // re-runs ` -ilc 'command -v ...'` once per agent — three + // serialised execSync-style spawns on a slow .zshrc are the dominant + // contributor to the launch beachball. + hydrateBinaryCache() + + // Set dock icon (dev mode doesn't pick up build config) + const dockIconPath = join(__dirname, '../../resources/icon.icns') + try { + app.dock?.setIcon(nativeImage.createFromPath(dockIconPath)) + } catch {} + + Menu.setApplicationMenu(buildApplicationMenu()) + + db = getDB() + acpManager = new AcpManager() + + syncer = new Syncer(db, undefined, (sessionId) => { + // Sync mutated this session's messages; existing findings now have + // stale offsets. The Syncer already nulled scan_profile inside the + // commit txn; here we just re-enqueue so the worker picks it up — + // unless the user opted out of auto-rescan in Settings → Security, + // in which case we leave scan_profile dirty for the next manual + // Rescan all click. + // `invalidateSessionScanProfile` updates the v12 `scan_profile` + // column regardless of the feature flag — the column lives in + // every user's schema. Re-enqueuing only runs when the worker + // is booted (i.e. the flag is on); the column resets either + // way, which keeps state consistent if the flag flips on later. + invalidateSessionScanProfile(db, sessionId) + if (scanWorker && loadSecurityPreferences().rescanAfterSync === 'auto') { + // Promise-shaped so a worker-thread rejection (e.g. the child + // died) surfaces in the log instead of vanishing silently + // through Effect.runFork. The Effect itself is failure-free + // shape (Effect) — `.catch` here is the safety + // net for runtime promise rejections from the underlying + // postMessage round-trip. + runWithObservability(scanWorker.enqueue(sessionId)).catch((err) => { + console.error('[security] scan-worker enqueue failed:', err) + }) + } + }) + watcher = new SpoolWatcher(syncer) + watcher.on('new-sessions', (_event, data) => { + searchCache.clear() + mainWindow?.webContents.send('spool:new-sessions', data) + }) + watcher.on('error', (_event, data) => { + console.error('[watcher]', data.error, data.root ? `(root=${data.root})` : '') + }) - // Initial sync in worker thread (non-blocking) - runSyncWorker().then((result) => { - watcher.start() - // Sessions were inserted by the worker thread which has its own - // DB handle, so the renderer never got an onNewSessions push for - // them. Without an explicit signal here, any view that listed - // sessions BEFORE sync finished would stay empty until the next - // file-watcher event. Emit new-sessions so LibraryLanding / - // ProjectView refetch — same code path that already handles - // post-startup inserts. - if (result.added > 0) { - mainWindow?.webContents.send('spool:new-sessions', { count: result.added }) - } - // Sessions were inserted by the worker thread which has its own DB - // handle — no onSessionChanged callbacks reached this process. Kick - // off a backfill round now that the sessions table is populated. - if (scanWorker) { - runWithObservability(scanWorker.backfill()).catch((err) => { - console.error('[security] post-sync backfill failed:', err) + // Initial sync in worker thread (non-blocking) + runSyncWorker() + .then((result) => { + watcher.start() + // Sessions were inserted by the worker thread which has its own + // DB handle, so the renderer never got an onNewSessions push for + // them. Without an explicit signal here, any view that listed + // sessions BEFORE sync finished would stay empty until the next + // file-watcher event. Emit new-sessions so LibraryLanding / + // ProjectView refetch — same code path that already handles + // post-startup inserts. + if (result.added > 0) { + mainWindow?.webContents.send('spool:new-sessions', { count: result.added }) + } + // Sessions were inserted by the worker thread which has its own DB + // handle — no onSessionChanged callbacks reached this process. Kick + // off a backfill round now that the sessions table is populated. + if (scanWorker) { + runWithObservability(scanWorker.backfill()).catch((err) => { + console.error('[security] post-sync backfill failed:', err) + }) + } + }) + .catch((err) => { + console.error('[sync-worker] failed:', err) }) - } - }).catch((err) => { - console.error('[sync-worker] failed:', err) - }) - - mainWindow = createWindow() - - // Boot the Security Scan worker AFTER createWindow so the renderer - // mount + initial sync don't wait on it. The Syncer's - // onSessionChanged callback and the post-sync backfill are both - // guarded by `if (scanWorker)`, so any session changes that fire - // before the worker is ready are no-ops — backfill catches up once - // boot completes. - void ensureSecurityBooted() - - // Share-auth IPC (PKCE loopback OAuth + safeStorage session). - // The build-time __SPOOL_E2E__ switch is the ONE place the production - // binary chooses between real OAuth + safeStorage and the e2e-mode - // in-memory store + fake-id-token. Prod builds get - // `if (false) { ... }` here, which terser deletes outright; the - // dynamic import to ./e2e-mode/share-auth-e2e is never resolved by - // rollup, so its source never ships in any production bundle. - if (__SPOOL_E2E__) { - const { registerShareAuthIpcForE2E } = await import('./e2e-mode/share-auth-e2e.js') - registerShareAuthIpcForE2E() - } else { - registerShareAuthIpc() - } - // Share-publish IPC (publish / revoke / republish + handles) - registerSharePublishIpc() - // Share-profile IPC (display name + avatar upload / delete / visibility) - registerShareProfileIpc() - // v2 hub share IPC (one-click records share to spool.pro) - registerHubShareIpc() - - // Auto-updater (only runs in packaged builds) - setupAutoUpdater(() => mainWindow) - - function showOrCreateWindow() { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.show() + mainWindow = createWindow() + + // Boot the Security Scan worker AFTER createWindow so the renderer + // mount + initial sync don't wait on it. The Syncer's + // onSessionChanged callback and the post-sync backfill are both + // guarded by `if (scanWorker)`, so any session changes that fire + // before the worker is ready are no-ops — backfill catches up once + // boot completes. + void ensureSecurityBooted() + + // Share-auth IPC (PKCE loopback OAuth + safeStorage session). + // The build-time __SPOOL_E2E__ switch is the ONE place the production + // binary chooses between real OAuth + safeStorage and the e2e-mode + // in-memory store + fake-id-token. Prod builds get + // `if (false) { ... }` here, which terser deletes outright; the + // dynamic import to ./e2e-mode/share-auth-e2e is never resolved by + // rollup, so its source never ships in any production bundle. + if (__SPOOL_E2E__) { + const { registerShareAuthIpcForE2E } = await import('./e2e-mode/share-auth-e2e.js') + registerShareAuthIpcForE2E() } else { - mainWindow = createWindow() + registerShareAuthIpc() } - void app.dock?.show() - } - focusExistingWindow = showOrCreateWindow + // Share-publish IPC (publish / revoke / republish + handles) + registerSharePublishIpc() + // Share-profile IPC (display name + avatar upload / delete / visibility) + registerShareProfileIpc() + // v2 hub share IPC (one-click records share to spool.pro) + registerHubShareIpc() + + // Auto-updater (only runs in packaged builds) + setupAutoUpdater(() => mainWindow) + + function showOrCreateWindow() { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.show() + } else { + mainWindow = createWindow() + } + void app.dock?.show() + } + focusExistingWindow = showOrCreateWindow - if (!isDevMode) { - setupTray(showOrCreateWindow, () => { - void runSyncWorker().catch((error) => { - console.error('[sync-worker] tray sync failed:', error) + if (!isDevMode) { + setupTray(showOrCreateWindow, () => { + void runSyncWorker().catch((error) => { + console.error('[sync-worker] tray sync failed:', error) + }) }) - }) - } + } - app.on('activate', showOrCreateWindow) -}).catch((err) => { - // Without this catch, any rejection from the startup sequence becomes an - // unhandled promise rejection — Node 20+ terminates the process with SIGTRAP, - // producing an opaque EXC_BREAKPOINT crash with only `PromiseRejectCallback` - // in the stack. Logging the error here gives users something actionable. - console.error('[startup] fatal error during app initialization:', err) - if (err instanceof Error && err.stack) console.error(err.stack) - dialog.showErrorBox('Spool failed to start', err instanceof Error ? err.message : String(err)) - app.exit(1) -}) + app.on('activate', showOrCreateWindow) + }) + .catch((err) => { + // Without this catch, any rejection from the startup sequence becomes an + // unhandled promise rejection — Node 20+ terminates the process with SIGTRAP, + // producing an opaque EXC_BREAKPOINT crash with only `PromiseRejectCallback` + // in the stack. Logging the error here gives users something actionable. + console.error('[startup] fatal error during app initialization:', err) + if (err instanceof Error && err.stack) console.error(err.stack) + dialog.showErrorBox('Spool failed to start', err instanceof Error ? err.message : String(err)) + app.exit(1) + }) app.on('window-all-closed', () => { if (isDevMode) { @@ -734,68 +824,107 @@ app.on('before-quit', (event) => { if (scanWorker) { event.preventDefault() shutdownScanWorker() - .catch((err) => { console.error('[security] shutdown failed:', err) }) - .finally(() => { app.exit(0) }) + .catch((err) => { + console.error('[security] shutdown failed:', err) + }) + .finally(() => { + app.exit(0) + }) } }) // ── IPC Handlers ────────────────────────────────────────────────────────────── -ipcMain.handle('spool:search', (_e, { query, limit = 10, source, onlyPinned, identityKey }: { query: string; limit?: number; source?: string; onlyPinned?: boolean; identityKey?: string }) => { - const cacheKey = `${source ?? 'all'}|${identityKey ?? 'any'}|${limit}|${onlyPinned ? 'pinned' : 'full'}|${query}` - if (!isSyncActive) { - const cached = searchCache.get(cacheKey) - if (cached) return cached - } +ipcMain.handle( + 'spool:search', + ( + _e, + { + query, + limit = 10, + source, + onlyPinned, + identityKey, + }: { + query: string + limit?: number + source?: string + onlyPinned?: boolean + identityKey?: string + }, + ) => { + const cacheKey = `${source ?? 'all'}|${identityKey ?? 'any'}|${limit}|${onlyPinned ? 'pinned' : 'full'}|${query}` + if (!isSyncActive) { + const cached = searchCache.get(cacheKey) + if (cached) return cached + } - const sessionSource = source === 'claude' || source === 'codex' || source === 'gemini' || source === 'opencode' - ? source - : undefined - const results = searchFragments(db, query, { - limit, - ...(sessionSource ? { source: sessionSource } : {}), - ...(onlyPinned ? { onlyPinned: true } : {}), - ...(identityKey ? { identityKey } : {}), - }).map(f => ({ ...f, kind: 'fragment' as const })) - - if (!isSyncActive) { - searchCache.set(cacheKey, results) - } + const sessionSource = + source === 'claude' || source === 'codex' || source === 'gemini' || source === 'opencode' + ? source + : undefined + const results = searchFragments(db, query, { + limit, + ...(sessionSource ? { source: sessionSource } : {}), + ...(onlyPinned ? { onlyPinned: true } : {}), + ...(identityKey ? { identityKey } : {}), + }).map((f) => ({ ...f, kind: 'fragment' as const })) + + if (!isSyncActive) { + searchCache.set(cacheKey, results) + } - return results -}) + return results + }, +) -ipcMain.handle('spool:search-preview', (_e, { query, limit = 5, source }: { query: string; limit?: number; source?: string }) => { - const cacheKey = `preview|${source ?? 'all'}|${limit}|${query}` - const cached = searchCache.get(cacheKey) - if (cached) return cached - - const sessionSource = source === 'claude' || source === 'codex' || source === 'gemini' || source === 'opencode' - ? source - : undefined - const fragments = searchSessionPreview(db, query, { - limit, - ...(sessionSource ? { source: sessionSource } : {}), - }).map(f => ({ ...f, kind: 'fragment' as const })) - searchCache.set(cacheKey, fragments) - return fragments -}) +ipcMain.handle( + 'spool:search-preview', + (_e, { query, limit = 5, source }: { query: string; limit?: number; source?: string }) => { + const cacheKey = `preview|${source ?? 'all'}|${limit}|${query}` + const cached = searchCache.get(cacheKey) + if (cached) return cached -ipcMain.handle('spool:list-sessions', (_e, args: { limit?: number; cursor?: SessionsCursor } = {}) => { - return listRecentSessionsPage(db, args) -}) + const sessionSource = + source === 'claude' || source === 'codex' || source === 'gemini' || source === 'opencode' + ? source + : undefined + const fragments = searchSessionPreview(db, query, { + limit, + ...(sessionSource ? { source: sessionSource } : {}), + }).map((f) => ({ ...f, kind: 'fragment' as const })) + searchCache.set(cacheKey, fragments) + return fragments + }, +) + +ipcMain.handle( + 'spool:list-sessions', + (_e, args: { limit?: number; cursor?: SessionsCursor } = {}) => { + return listRecentSessionsPage(db, args) + }, +) ipcMain.handle('spool:list-project-groups', () => { return listProjectGroups(db) }) -ipcMain.handle('spool:list-sessions-by-identity', (_e, { identityKey, options }: { identityKey: string; options?: ListSessionsByIdentityOptions }) => { - return listSessionsByIdentity(db, identityKey, options) -}) +ipcMain.handle( + 'spool:list-sessions-by-identity', + ( + _e, + { identityKey, options }: { identityKey: string; options?: ListSessionsByIdentityOptions }, + ) => { + return listSessionsByIdentity(db, identityKey, options) + }, +) -ipcMain.handle('spool:list-project-directory-counts', (_e, { identityKey, sources }: { identityKey: string; sources?: SessionSource[] }) => { - return listProjectDirectoryCounts(db, identityKey, sources ? { sources } : {}) -}) +ipcMain.handle( + 'spool:list-project-directory-counts', + (_e, { identityKey, sources }: { identityKey: string; sources?: SessionSource[] }) => { + return listProjectDirectoryCounts(db, identityKey, sources ? { sources } : {}) + }, +) ipcMain.handle('spool:get-session', (_e, { sessionUuid }: { sessionUuid: string }) => { return getSessionWithMessages(db, sessionUuid) @@ -825,9 +954,12 @@ ipcMain.handle('spool:list-pinned-sessions', () => { return listPinnedSessions(db) }) -ipcMain.handle('spool:list-pinned-sessions-by-identity', (_e, { identityKey }: { identityKey: string }) => { - return listPinnedSessionsByIdentity(db, identityKey) -}) +ipcMain.handle( + 'spool:list-pinned-sessions-by-identity', + (_e, { identityKey }: { identityKey: string }) => { + return listPinnedSessionsByIdentity(db, identityKey) + }, +) ipcMain.handle('spool:list-share-drafts', (_e, { limit }: { limit?: number } = {}) => { const opts: { limit?: number } = {} @@ -910,15 +1042,14 @@ ipcMain.handle('spool:force-resync-session', (_e, { sessionUuid }: { sessionUuid .prepare('SELECT file_path, source_id FROM sessions WHERE session_uuid = ?') .get(sessionUuid) as { file_path: string; source_id: number } | undefined if (!row) return { ok: false as const, error: 'session-not-found' } - const sourceRow = db - .prepare('SELECT name FROM sources WHERE id = ?') - .get(row.source_id) as { name: SessionSource } | undefined + const sourceRow = db.prepare('SELECT name FROM sources WHERE id = ?').get(row.source_id) as + | { name: SessionSource } + | undefined if (!sourceRow) return { ok: false as const, error: 'source-not-found' } try { - const result = syncer.syncFile( - row.file_path, sourceRow.name, undefined, undefined, - { forceMode: 'rewrite' }, - ) + const result = syncer.syncFile(row.file_path, sourceRow.name, undefined, undefined, { + forceMode: 'rewrite', + }) if (result === 'error') return { ok: false as const, error: 'sync-error' } return { ok: true as const, result } } catch (err) { @@ -927,29 +1058,32 @@ ipcMain.handle('spool:force-resync-session', (_e, { sessionUuid }: { sessionUuid } }) -ipcMain.handle('spool:resume-cli', (_e, { sessionUuid, source, cwd }: { sessionUuid: string; source: string; cwd?: string }) => { - try { - const command = getSessionResumeCommand(source, sessionUuid) - if (!command) { - return { ok: false, error: `Session source "${source}" cannot be resumed from the CLI.` } +ipcMain.handle( + 'spool:resume-cli', + (_e, { sessionUuid, source, cwd }: { sessionUuid: string; source: string; cwd?: string }) => { + try { + const command = getSessionResumeCommand(source, sessionUuid) + if (!command) { + return { ok: false, error: `Session source "${source}" cannot be resumed from the CLI.` } + } + const session = getSessionWithMessages(db, sessionUuid)?.session + const resumeCwd = session + ? resolveResumeWorkingDirectory(session) + : resolveResumeWorkingDirectory({ + source: source as SessionSource, + cwd: cwd ?? null, + projectDisplayPath: '', + filePath: '', + }) + const terminal = acpManager.getAgentsConfig().terminal + openTerminal(command, terminal, resumeCwd) + return { ok: true } + } catch (err) { + console.error('[spool:resume-cli]', err) + return { ok: false, error: String(err) } } - const session = getSessionWithMessages(db, sessionUuid)?.session - const resumeCwd = session - ? resolveResumeWorkingDirectory(session) - : resolveResumeWorkingDirectory({ - source: source as SessionSource, - cwd: cwd ?? null, - projectDisplayPath: '', - filePath: '', - }) - const terminal = acpManager.getAgentsConfig().terminal - openTerminal(command, terminal, resumeCwd) - return { ok: true } - } catch (err) { - console.error('[spool:resume-cli]', err) - return { ok: false, error: String(err) } - } -}) + }, +) ipcMain.handle('spool:copy-fragment', (_e, { text }: { text: string }) => { const { clipboard } = require('electron') @@ -972,11 +1106,14 @@ ipcMain.handle('spool:get-theme-editor-state', () => { return uiPreferences.themeEditor }) -ipcMain.handle('spool:set-theme-editor-state', (_e, { state }: { state: import('../renderer/theme/editorTypes.js').ThemeEditorStateV1 }) => { - uiPreferences.themeEditor = state - saveThemeEditor(state) - return { ok: true } -}) +ipcMain.handle( + 'spool:set-theme-editor-state', + (_e, { state }: { state: import('../renderer/theme/editorTypes.js').ThemeEditorStateV1 }) => { + uiPreferences.themeEditor = state + saveThemeEditor(state) + return { ok: true } + }, +) // ── AI / ACP Handlers ──────────────────────────────────────────────────────── @@ -992,30 +1129,55 @@ ipcMain.handle('spool:ai-get-config', () => { return acpManager.getAgentsConfig() }) -ipcMain.handle('spool:ai-set-config', (_e, { config }: { config: import('./acp.js').AgentsConfig }) => { - acpManager.saveAgentsConfig(config) - return { ok: true } -}) +ipcMain.handle( + 'spool:ai-set-config', + (_e, { config }: { config: import('./acp.js').AgentsConfig }) => { + acpManager.saveAgentsConfig(config) + return { ok: true } + }, +) -ipcMain.handle('spool:ai-search', async (_e, { query, agentId, context }: { query: string; agentId: string; context: import('@spool-lab/core').FragmentResult[] }) => { - try { - const fullText = await acpManager.query(agentId, query, context, (text) => { - mainWindow?.webContents.send('spool:ai-chunk', { text }) - }, (toolCall) => { - mainWindow?.webContents.send('spool:ai-tool-call', toolCall) - }, (info) => { - mainWindow?.webContents.send('spool:ai-session-started', info) - }) - mainWindow?.webContents.send('spool:ai-done', { fullText }) - return { ok: true, fullText } - } catch (err) { - const error = err instanceof Error ? err.message : (typeof err === 'object' && err !== null && 'message' in err) ? String((err as any).message) : String(err) - console.error('[spool:ai-search] Agent query failed:', error) - if (err instanceof Error && err.stack) console.error(err.stack) - mainWindow?.webContents.send('spool:ai-done', { fullText: '', error }) - return { ok: false, error } - } -}) +ipcMain.handle( + 'spool:ai-search', + async ( + _e, + { + query, + agentId, + context, + }: { query: string; agentId: string; context: import('@spool-lab/core').FragmentResult[] }, + ) => { + try { + const fullText = await acpManager.query( + agentId, + query, + context, + (text) => { + mainWindow?.webContents.send('spool:ai-chunk', { text }) + }, + (toolCall) => { + mainWindow?.webContents.send('spool:ai-tool-call', toolCall) + }, + (info) => { + mainWindow?.webContents.send('spool:ai-session-started', info) + }, + ) + mainWindow?.webContents.send('spool:ai-done', { fullText }) + return { ok: true, fullText } + } catch (err) { + const error = + err instanceof Error + ? err.message + : typeof err === 'object' && err !== null && 'message' in err + ? String((err as any).message) + : String(err) + console.error('[spool:ai-search] Agent query failed:', error) + if (err instanceof Error && err.stack) console.error(err.stack) + mainWindow?.webContents.send('spool:ai-done', { fullText: '', error }) + return { ok: false, error } + } + }, +) ipcMain.handle('spool:ai-cancel', () => { acpManager.cancel() diff --git a/apps/app/src/main/ipc/hub-share.test.ts b/apps/app/src/main/ipc/hub-share.test.ts index d581b75e..e02272a7 100644 --- a/apps/app/src/main/ipc/hub-share.test.ts +++ b/apps/app/src/main/ipc/hub-share.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' + import { sequenceRoot } from '@spool-lab/session-kit' +import { describe, it, expect, beforeEach, vi } from 'vite-plus/test' import type { HubSharePrepareResult, HubSharePublishResult } from '../../shared/hub-share.js' @@ -54,11 +55,13 @@ function makeHub() { const match = url.pathname.match(/^\/api\/hub\/v1\/sessions\/([^/]+)\/(push|head)$/) if (match && method === 'POST') { const body = JSON.parse(String(init?.body)) as StoredHead - const wanted = [...new Set([ - ...body.manifest, - body.viewOid, - ...(body.spoolFileOid ? [body.spoolFileOid] : []), - ])] + const wanted = [ + ...new Set([ + ...body.manifest, + body.viewOid, + ...(body.spoolFileOid ? [body.spoolFileOid] : []), + ]), + ] const missing = wanted.filter((oid) => !objects.has(oid)) if (match[2] === 'push') return json({ missing }) if (missing.length > 0) return json({ error: 'CONFLICT' }, 409) @@ -74,16 +77,25 @@ const SESSION_UUID = '6f9a1b2c-3d4e-5f60-8a9b-0c1d2e3f4a5b' function writeFixture(dir: string): string { const line = (r: Record) => JSON.stringify(r) - const jsonl = [ - line({ - type: 'user', uuid: 'u-1', sessionId: 'orig', cwd: dir, timestamp: '2026-07-16T10:00:00.000Z', - message: { role: 'user', content: 'hello, ship the demo. token AKIAABCDEFGHIJKLMNOP' }, - }), - line({ - type: 'assistant', uuid: 'u-2', parentUuid: 'u-1', sessionId: 'orig', timestamp: '2026-07-16T10:00:05.000Z', - message: { role: 'assistant', content: [{ type: 'text', text: 'Shipped.' }] }, - }), - ].join('\n') + '\n' + const jsonl = + [ + line({ + type: 'user', + uuid: 'u-1', + sessionId: 'orig', + cwd: dir, + timestamp: '2026-07-16T10:00:00.000Z', + message: { role: 'user', content: 'hello, ship the demo. token AKIAABCDEFGHIJKLMNOP' }, + }), + line({ + type: 'assistant', + uuid: 'u-2', + parentUuid: 'u-1', + sessionId: 'orig', + timestamp: '2026-07-16T10:00:05.000Z', + message: { role: 'assistant', content: [{ type: 'text', text: 'Shipped.' }] }, + }), + ].join('\n') + '\n' const filePath = join(dir, 'session.jsonl') writeFileSync(filePath, jsonl, 'utf8') return filePath @@ -115,7 +127,9 @@ describe('hub-share IPC', () => { it('prepare computes counts, diffstat, redact findings, and a note prefill locally', async () => { setup() - const result = await invoke('hub-share:prepare', { sessionUuid: SESSION_UUID }) + const result = await invoke('hub-share:prepare', { + sessionUuid: SESSION_UUID, + }) if (!result.ok) throw new Error(result.error) expect(result.prepared.sid).toBe(`claude_${SESSION_UUID}`) expect(result.prepared.count).toBe(2) diff --git a/apps/app/src/main/ipc/hub-share.ts b/apps/app/src/main/ipc/hub-share.ts index f8212b06..a5b030d7 100644 --- a/apps/app/src/main/ipc/hub-share.ts +++ b/apps/app/src/main/ipc/hub-share.ts @@ -1,19 +1,6 @@ -import { ipcMain, net } from 'electron' import { readFileSync } from 'node:fs' import { homedir } from 'node:os' -import { getDB, getSessionWithMessages } from '@spool-lab/core' -import { - canonicalizeRecord, - parseClaudeSessionText, - parseCodexSessionLines, -} from '@spool-lab/session-kit' -// Type-only: share-kit's runtime bundle needs a DOM at import time and -// must never be required from the main process. The document is -// constructed as plain JSON matching the .spool v2 shape; sanitization -// runs through @spool-lab/redact directly (the same detectors share-kit's -// redact pipeline wraps). -import type { SpoolDocument } from '@spool/share-kit' -import { detectSensitiveSpans, maskValueByKind } from '@spool-lab/redact' + import { HubClient, HubHttpError, @@ -26,13 +13,24 @@ import { type PreparedShare, type WorkspaceCard, } from '@spool-lab/cli/hub' +import { getDB, getSessionWithMessages } from '@spool-lab/core' +import { detectSensitiveSpans, maskValueByKind } from '@spool-lab/redact' +import { + canonicalizeRecord, + parseClaudeSessionText, + parseCodexSessionLines, +} from '@spool-lab/session-kit' +// Type-only: share-kit's runtime bundle needs a DOM at import time and +// must never be required from the main process. The document is +// constructed as plain JSON matching the .spool v2 shape; sanitization +// runs through @spool-lab/redact directly (the same detectors share-kit's +// redact pipeline wraps). +import type { SpoolDocument } from '@spool/share-kit' +import { ipcMain, net } from 'electron' +import type { HubSharePrepareResult, HubSharePublishResult } from '../../shared/hub-share.js' import { loadToken } from '../auth/session-store.js' import { backendUrl } from '../share/backend-url.js' -import type { - HubSharePrepareResult, - HubSharePublishResult, -} from '../../shared/hub-share.js' // One-click share to the hub: the same pipeline `spool share` runs, driven // from the app. Auth is the app's existing sign-in session — the hub @@ -71,7 +69,9 @@ function resolveTargetFromIndex(sessionUuid: string) { if (!found) throw new Error(`Session not found in the local index: ${sessionUuid}`) const { session } = found if (session.source !== 'claude' && session.source !== 'codex') { - throw new Error(`Sharing ${session.source} sessions is not supported yet (claude and codex only)`) + throw new Error( + `Sharing ${session.source} sessions is not supported yet (claude and codex only)`, + ) } if (session.filePath.startsWith('spool:')) { throw new Error('This session has no provider file on disk yet') @@ -84,10 +84,7 @@ function resolveTargetFromIndex(sessionUuid: string) { } } -async function prepareEntry( - sessionUuid: string, - deps: HubShareIpcDeps, -): Promise { +async function prepareEntry(sessionUuid: string, deps: HubShareIpcDeps): Promise { const resolveTarget = deps.resolveTarget ?? resolveTargetFromIndex const target = resolveTarget(sessionUuid) const workspaceRoot = detectWorkspaceRoot(target.cwd ?? process.cwd()) @@ -118,16 +115,19 @@ async function buildAttachedSpoolFile( target: { provider: 'claude' | 'codex'; sessionUuid: string; filePath: string }, jsonl: string, ): Promise<{ oid: string; data: string } | null> { - const result = target.provider === 'claude' - ? parseClaudeSessionText(jsonl, target.filePath) - : parseCodexSessionLines(jsonl.split('\n'), target.filePath) + const result = + target.provider === 'claude' + ? parseClaudeSessionText(jsonl, target.filePath) + : parseCodexSessionLines(jsonl.split('\n'), target.filePath) if (result.kind !== 'parsed') return null const turns = result.session.messages - .filter((message) => - !message.isSidechain - && (message.role === 'user' || message.role === 'assistant') - && message.contentText.trim() !== '') + .filter( + (message) => + !message.isSidechain && + (message.role === 'user' || message.role === 'assistant') && + message.contentText.trim() !== '', + ) .map((message) => ({ role: message.role as 'user' | 'assistant', body: sanitizeBody(message.contentText), @@ -181,8 +181,9 @@ async function buildAttachedSpoolFile( function sanitizeBody(body: string): string { const matches = detectSensitiveSpans(body) if (matches.length === 0) return body - const literals = [...new Set(matches.map((match) => match.value))] - .sort((a, b) => b.length - a.length) + const literals = [...new Set(matches.map((match) => match.value))].sort( + (a, b) => b.length - a.length, + ) let out = body for (const literal of literals) { const kind = matches.find((match) => match.value === literal)?.kind @@ -193,73 +194,80 @@ function sanitizeBody(body: string): string { } export function registerHubShareIpc(deps: HubShareIpcDeps = {}): void { - ipcMain.handle('hub-share:prepare', async (_e, args: { sessionUuid: string }): Promise => { - try { - const { prepared, card } = await prepareEntry(args.sessionUuid, deps) - const secrets = scanRecordsForSecrets(prepared.records.map((record) => record.data)) - return { - ok: true, - prepared: { - sid: prepared.sid, - count: prepared.count, - files: prepared.view.diffstat.files, - adds: prepared.view.diffstat.adds, - dels: prepared.view.diffstat.dels, - secrets: { total: secrets.total, high: secrets.high, byKind: secrets.byKind }, - notePrefill: buildNotePrefill({ view: prepared.view, card, count: prepared.count }), - }, + ipcMain.handle( + 'hub-share:prepare', + async (_e, args: { sessionUuid: string }): Promise => { + try { + const { prepared, card } = await prepareEntry(args.sessionUuid, deps) + const secrets = scanRecordsForSecrets(prepared.records.map((record) => record.data)) + return { + ok: true, + prepared: { + sid: prepared.sid, + count: prepared.count, + files: prepared.view.diffstat.files, + adds: prepared.view.diffstat.adds, + dels: prepared.view.diffstat.dels, + secrets: { total: secrets.total, high: secrets.high, byKind: secrets.byKind }, + notePrefill: buildNotePrefill({ view: prepared.view, card, count: prepared.count }), + }, + } + } catch (cause) { + return { ok: false, error: cause instanceof Error ? cause.message : String(cause) } } - } catch (cause) { - return { ok: false, error: cause instanceof Error ? cause.message : String(cause) } - } - }) + }, + ) - ipcMain.handle('hub-share:publish', async (_e, args: { sessionUuid: string; note: string }): Promise => { - try { - const token = (deps.loadTokenFn ?? loadToken)() - if (!token) return { ok: false, error: 'UNAUTHENTICATED' } + ipcMain.handle( + 'hub-share:publish', + async (_e, args: { sessionUuid: string; note: string }): Promise => { + try { + const token = (deps.loadTokenFn ?? loadToken)() + if (!token) return { ok: false, error: 'UNAUTHENTICATED' } - const entry = preparedCache.get(args.sessionUuid) ?? await prepareEntry(args.sessionUuid, deps) - const { prepared, card, spoolFile } = entry + const entry = + preparedCache.get(args.sessionUuid) ?? (await prepareEntry(args.sessionUuid, deps)) + const { prepared, card, spoolFile } = entry - const client = new HubClient({ - hubUrl: backendUrl(), - token, - fetch: deps.fetchFn ?? defaultFetch, - }) + const client = new HubClient({ + hubUrl: backendUrl(), + token, + fetch: deps.fetchFn ?? defaultFetch, + }) - const head = { - root: prepared.root, - count: prepared.count, - manifest: prepared.manifest, - sig: null, - cardJson: card === null ? null : JSON.stringify(card), - noteMd: args.note.trim() === '' ? null : args.note, - lineageJson: prepared.lineageJson, - viewOid: prepared.viewOid, - spoolFileOid: spoolFile === null ? null : spoolFile.oid, - } + const head = { + root: prepared.root, + count: prepared.count, + manifest: prepared.manifest, + sig: null, + cardJson: card === null ? null : JSON.stringify(card), + noteMd: args.note.trim() === '' ? null : args.note, + lineageJson: prepared.lineageJson, + viewOid: prepared.viewOid, + spoolFileOid: spoolFile === null ? null : spoolFile.oid, + } - const { missing } = await client.pushSession(prepared.sid, head) - const missingSet = new Set(missing) - const uploads = [ - ...prepared.records.map((record) => ({ oid: record.oid, data: record.data })), - { oid: prepared.viewOid, data: prepared.viewData }, - ...(spoolFile === null ? [] : [spoolFile]), - ].filter((object) => missingSet.has(object.oid)) + const { missing } = await client.pushSession(prepared.sid, head) + const missingSet = new Set(missing) + const uploads = [ + ...prepared.records.map((record) => ({ oid: record.oid, data: record.data })), + { oid: prepared.viewOid, data: prepared.viewData }, + ...(spoolFile === null ? [] : [spoolFile]), + ].filter((object) => missingSet.has(object.oid)) - for (const batch of chunkUploads(uploads)) { - await client.uploadObjects(batch) - } + for (const batch of chunkUploads(uploads)) { + await client.uploadObjects(batch) + } - const { url } = await client.commitSessionHead(prepared.sid, head) - preparedCache.delete(args.sessionUuid) - return { ok: true, url } - } catch (cause) { - if (cause instanceof HubHttpError && cause.status === 401) { - return { ok: false, error: 'UNAUTHENTICATED' } + const { url } = await client.commitSessionHead(prepared.sid, head) + preparedCache.delete(args.sessionUuid) + return { ok: true, url } + } catch (cause) { + if (cause instanceof HubHttpError && cause.status === 401) { + return { ok: false, error: 'UNAUTHENTICATED' } + } + return { ok: false, error: cause instanceof Error ? cause.message : String(cause) } } - return { ok: false, error: cause instanceof Error ? cause.message : String(cause) } - } - }) + }, + ) } diff --git a/apps/app/src/main/ipc/security.test.ts b/apps/app/src/main/ipc/security.test.ts index 4c033365..88a4c8ed 100644 --- a/apps/app/src/main/ipc/security.test.ts +++ b/apps/app/src/main/ipc/security.test.ts @@ -8,12 +8,10 @@ // shape, error mapping, and event-publish behaviour without // spinning up an actual app. -import { describe, it, expect, beforeAll, beforeEach, vi, afterAll } from 'vitest' import { mkdtempSync, rmSync, existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import Database from 'better-sqlite3' -import { Effect, PubSub, Stream } from 'effect' + import { runMigrations, insertFindings, @@ -24,6 +22,10 @@ import { type ScanStatus, type FindingsChange, } from '@spool-lab/core' +import Database from 'better-sqlite3' +import { Effect, PubSub, Stream } from 'effect' +import { describe, it, expect, beforeAll, beforeEach, vi, afterAll } from 'vite-plus/test' + import type { MutationWorkerProxy } from '../mutation-worker-proxy.js' // ─── electron mock ──────────────────────────────────────────────── @@ -43,9 +45,9 @@ vi.mock('electron', () => ({ })) // Imported AFTER vi.mock so the stub is the one electron resolves to. -let registerSecurityIpc: typeof import('./security.js')['registerSecurityIpc'] -let registerSecurityReadinessIpc: typeof import('./security.js')['registerSecurityReadinessIpc'] -let SECURITY_IPC_CHANNELS: typeof import('./security.js')['SECURITY_IPC_CHANNELS'] +let registerSecurityIpc: (typeof import('./security.js'))['registerSecurityIpc'] +let registerSecurityReadinessIpc: (typeof import('./security.js'))['registerSecurityReadinessIpc'] +let SECURITY_IPC_CHANNELS: (typeof import('./security.js'))['SECURITY_IPC_CHANNELS'] let tmp: string @@ -105,14 +107,35 @@ async function setupFixture(): Promise { const searchInvalidations = { count: 0 } // PubSub-backed stream so we can assert change-event forwarding. const pubsub = await Effect.runPromise(PubSub.unbounded()) - const status: ScanStatus = { queued: 0, scanning: null, backfillRemaining: 0, backfillTotal: 0, manualBurstInFlight: false, currentProfile: 'regex@4' } + const status: ScanStatus = { + queued: 0, + scanning: null, + backfillRemaining: 0, + backfillTotal: 0, + manualBurstInFlight: false, + currentProfile: 'regex@4', + } const worker: ScanWorker = { - enqueue: (id) => Effect.sync(() => { workerCalls.enqueue.push(id) }), - rescanAll: () => Effect.sync(() => { workerCalls.rescanAll++; return 1 }), - backfill: () => Effect.sync(() => { workerCalls.backfill++; return 0 }), + enqueue: (id) => + Effect.sync(() => { + workerCalls.enqueue.push(id) + }), + rescanAll: () => + Effect.sync(() => { + workerCalls.rescanAll++ + return 1 + }), + backfill: () => + Effect.sync(() => { + workerCalls.backfill++ + return 0 + }), changes: Stream.fromPubSub(pubsub), - getStatus: Effect.sync(() => { workerCalls.getStatus++; return status }), + getStatus: Effect.sync(() => { + workerCalls.getStatus++ + return status + }), } const fakeWindow = { @@ -133,15 +156,21 @@ async function setupFixture(): Promise { const { dispose } = registerSecurityIpc({ db, worker, - runPromise: (eff: Effect.Effect) => Effect.runPromise(eff as unknown as Effect.Effect), + runPromise: (eff: Effect.Effect) => + Effect.runPromise(eff as unknown as Effect.Effect), getMainWindow: () => fakeWindow, - onSearchContentChanged: () => { searchInvalidations.count += 1 }, + onSearchContentChanged: () => { + searchInvalidations.count += 1 + }, }) return { db, worker, - push: (change) => Effect.runPromise(PubSub.publish(pubsub, change)).then(() => { /* void */ }), + push: (change) => + Effect.runPromise(PubSub.publish(pubsub, change)).then(() => { + /* void */ + }), dispose, workerCalls, searchInvalidations, @@ -194,16 +223,49 @@ describe('registerSecurityIpc', () => { describe('queries', () => { it('LIST_FINDINGS returns rows matching the filter', async () => { insertFindings(fixture.db, [ - { sessionId: 1, messageId: 10, kind: 'api-key', valueHash: 'h1', confidence: 0.95, provider: 'regex', startOffset: 0, endOffset: 20, state: 'active' }, - { sessionId: 1, messageId: 10, kind: 'email', valueHash: 'h2', confidence: 0.8, provider: 'regex', startOffset: 30, endOffset: 40, state: 'dismissed' }, + { + sessionId: 1, + messageId: 10, + kind: 'api-key', + valueHash: 'h1', + confidence: 0.95, + provider: 'regex', + startOffset: 0, + endOffset: 20, + state: 'active', + }, + { + sessionId: 1, + messageId: 10, + kind: 'email', + valueHash: 'h2', + confidence: 0.8, + provider: 'regex', + startOffset: 30, + endOffset: 40, + state: 'dismissed', + }, ]) - const rows = await invoke(SECURITY_IPC_CHANNELS.LIST_FINDINGS, { sessionId: 1, state: 'active' }) + const rows = await invoke(SECURITY_IPC_CHANNELS.LIST_FINDINGS, { + sessionId: 1, + state: 'active', + }) expect(rows).toHaveLength(1) }) it('LIST_SESSIONS_WITH_FINDINGS aggregates per-session counts', async () => { insertFindings(fixture.db, [ - { sessionId: 1, messageId: 10, kind: 'api-key', valueHash: 'h1', confidence: 0.95, provider: 'regex', startOffset: 0, endOffset: 20, state: 'active' }, + { + sessionId: 1, + messageId: 10, + kind: 'api-key', + valueHash: 'h1', + confidence: 0.95, + provider: 'regex', + startOffset: 0, + endOffset: 20, + state: 'active', + }, ]) updateSessionCounts(fixture.db, 1) const rows = await invoke>( @@ -216,25 +278,58 @@ describe('registerSecurityIpc', () => { it('OCCURRENCES_BY_VALUE_HASH aggregates the leak across sessions', async () => { // Add a second session in the same project sharing one value. - fixture.db.prepare( - `INSERT INTO sessions (id, project_id, source_id, session_uuid, file_path, title, started_at, ended_at, message_count) + fixture.db + .prepare( + `INSERT INTO sessions (id, project_id, source_id, session_uuid, file_path, title, started_at, ended_at, message_count) VALUES (2, 1, 1, 's-2', '/p/s-2', 'Session 2', '2026-01-02', '2026-01-02', 1)`, - ).run() - fixture.db.prepare( - `INSERT INTO messages (id, session_id, source_id, role, content_text, timestamp, seq) + ) + .run() + fixture.db + .prepare( + `INSERT INTO messages (id, session_id, source_id, role, content_text, timestamp, seq) VALUES (20, 2, 1, 'user', 'same leak AKIAIOSFODNN7EXAMPLE', '2026-01-02', 0)`, - ).run() + ) + .run() insertFindings(fixture.db, [ - { sessionId: 1, messageId: 10, kind: 'api-key', valueHash: 'shared', confidence: 0.95, provider: 'regex', startOffset: 0, endOffset: 5, state: 'active' }, - { sessionId: 1, messageId: 10, kind: 'api-key', valueHash: 'shared', confidence: 0.95, provider: 'regex', startOffset: 6, endOffset: 11, state: 'active' }, - { sessionId: 2, messageId: 20, kind: 'api-key', valueHash: 'shared', confidence: 0.95, provider: 'regex', startOffset: 0, endOffset: 5, state: 'active' }, + { + sessionId: 1, + messageId: 10, + kind: 'api-key', + valueHash: 'shared', + confidence: 0.95, + provider: 'regex', + startOffset: 0, + endOffset: 5, + state: 'active', + }, + { + sessionId: 1, + messageId: 10, + kind: 'api-key', + valueHash: 'shared', + confidence: 0.95, + provider: 'regex', + startOffset: 6, + endOffset: 11, + state: 'active', + }, + { + sessionId: 2, + messageId: 20, + kind: 'api-key', + valueHash: 'shared', + confidence: 0.95, + provider: 'regex', + startOffset: 0, + endOffset: 5, + state: 'active', + }, ]) - const rows = await invoke>( - SECURITY_IPC_CHANNELS.OCCURRENCES_BY_VALUE_HASH, - { kind: 'api-key', valueHash: 'shared' }, - ) + const rows = await invoke< + Array<{ sessionId: number; count: number; project: string | null }> + >(SECURITY_IPC_CHANNELS.OCCURRENCES_BY_VALUE_HASH, { kind: 'api-key', valueHash: 'shared' }) expect(rows).toHaveLength(2) - const bySession = new Map(rows.map(r => [r.sessionId, r])) + const bySession = new Map(rows.map((r) => [r.sessionId, r])) expect(bySession.get(1)!.count).toBe(2) expect(bySession.get(2)!.count).toBe(1) expect(bySession.get(1)!.project).toBe('p') @@ -242,11 +337,21 @@ describe('registerSecurityIpc', () => { it('RISK_BY_CATEGORY groups by kind with severity + sessions', async () => { insertFindings(fixture.db, [ - { sessionId: 1, messageId: 10, kind: 'api-key', valueHash: 'h1', confidence: 0.95, provider: 'regex', startOffset: 0, endOffset: 20, state: 'active' }, + { + sessionId: 1, + messageId: 10, + kind: 'api-key', + valueHash: 'h1', + confidence: 0.95, + provider: 'regex', + startOffset: 0, + endOffset: 20, + state: 'active', + }, ]) - const rows = await invoke>( - SECURITY_IPC_CHANNELS.RISK_BY_CATEGORY, - ) + const rows = await invoke< + Array<{ kind: string; severity: string; count: number; sessions: number }> + >(SECURITY_IPC_CHANNELS.RISK_BY_CATEGORY) expect(rows[0]).toMatchObject({ kind: 'api-key', severity: 'high', count: 1, sessions: 1 }) }) @@ -260,10 +365,23 @@ describe('registerSecurityIpc', () => { it('GET_FINDING_VALUE reads the live message text', async () => { insertFindings(fixture.db, [ - { sessionId: 1, messageId: 10, kind: 'api-key', valueHash: 'h1', confidence: 0.95, provider: 'regex', startOffset: 5, endOffset: 25, state: 'active' }, + { + sessionId: 1, + messageId: 10, + kind: 'api-key', + valueHash: 'h1', + confidence: 0.95, + provider: 'regex', + startOffset: 5, + endOffset: 25, + state: 'active', + }, ]) const findings = listFindings(fixture.db, { sessionId: 1 }) - const v = await invoke(SECURITY_IPC_CHANNELS.GET_FINDING_VALUE, findings[0]!.id) + const v = await invoke( + SECURITY_IPC_CHANNELS.GET_FINDING_VALUE, + findings[0]!.id, + ) // start=5, end=25 against 'leak AKIAIOSFODNN7EXAMPLE plz'. expect(v).toBe('AKIAIOSFODNN7EXAMPLE') }) @@ -279,16 +397,26 @@ describe('registerSecurityIpc', () => { let findingId: number beforeEach(() => { insertFindings(fixture.db, [ - { sessionId: 1, messageId: 10, kind: 'api-key', valueHash: 'h1', confidence: 0.95, provider: 'regex', startOffset: 5, endOffset: 25, state: 'active' }, + { + sessionId: 1, + messageId: 10, + kind: 'api-key', + valueHash: 'h1', + confidence: 0.95, + provider: 'regex', + startOffset: 5, + endOffset: 25, + state: 'active', + }, ]) findingId = listFindings(fixture.db, { sessionId: 1 })[0]!.id }) it('DISMISS_FINDING flips state and returns { ok: true }', async () => { - const result = await invoke<{ ok: boolean }>( - SECURITY_IPC_CHANNELS.DISMISS_FINDING, - { findingId, scope: 'session' }, - ) + const result = await invoke<{ ok: boolean }>(SECURITY_IPC_CHANNELS.DISMISS_FINDING, { + findingId, + scope: 'session', + }) expect(result).toEqual({ ok: true }) const after = listFindings(fixture.db, { sessionId: 1, state: 'any' })[0]! expect(after.state).toBe('dismissed') @@ -296,31 +424,52 @@ describe('registerSecurityIpc', () => { it('DISMISS_FINDINGS dismisses many in one call + emits one event per session', async () => { insertFindings(fixture.db, [ - { sessionId: 1, messageId: 10, kind: 'email', valueHash: 'h2', confidence: 0.8, provider: 'regex', startOffset: 30, endOffset: 40, state: 'active' }, - { sessionId: 1, messageId: 10, kind: 'phone', valueHash: 'h3', confidence: 0.8, provider: 'regex', startOffset: 45, endOffset: 55, state: 'active' }, + { + sessionId: 1, + messageId: 10, + kind: 'email', + valueHash: 'h2', + confidence: 0.8, + provider: 'regex', + startOffset: 30, + endOffset: 40, + state: 'active', + }, + { + sessionId: 1, + messageId: 10, + kind: 'phone', + valueHash: 'h3', + confidence: 0.8, + provider: 'regex', + startOffset: 45, + endOffset: 55, + state: 'active', + }, ]) const ids = listFindings(fixture.db, { sessionId: 1, state: 'active' }).map((r) => r.id) expect(ids.length).toBe(3) sentEvents.length = 0 - const result = await invoke<{ ok: boolean }>( - SECURITY_IPC_CHANNELS.DISMISS_FINDINGS, - { findingIds: ids, scope: 'session' }, - ) + const result = await invoke<{ ok: boolean }>(SECURITY_IPC_CHANNELS.DISMISS_FINDINGS, { + findingIds: ids, + scope: 'session', + }) expect(result).toEqual({ ok: true }) const after = listFindings(fixture.db, { sessionId: 1, state: 'any' }) expect(after.every((r) => r.state === 'dismissed')).toBe(true) // One consolidated change event for the single affected session. - const changes = sentEvents.filter((e) => e.channel === SECURITY_IPC_CHANNELS.EVT_FINDINGS_CHANGED) + const changes = sentEvents.filter( + (e) => e.channel === SECURITY_IPC_CHANNELS.EVT_FINDINGS_CHANGED, + ) expect(changes).toHaveLength(1) expect((changes[0]!.payload as { sessionId: number }).sessionId).toBe(1) }) it('UNDISMISS_FINDING flips state back to active', async () => { await invoke(SECURITY_IPC_CHANNELS.DISMISS_FINDING, { findingId, scope: 'session' }) - const result = await invoke<{ ok: boolean }>( - SECURITY_IPC_CHANNELS.UNDISMISS_FINDING, - { findingId }, - ) + const result = await invoke<{ ok: boolean }>(SECURITY_IPC_CHANNELS.UNDISMISS_FINDING, { + findingId, + }) expect(result).toEqual({ ok: true }) const after = listFindings(fixture.db, { sessionId: 1, state: 'any' })[0]! expect(after.state).toBe('active') @@ -334,8 +483,9 @@ describe('registerSecurityIpc', () => { expect(result.findingId).toBe(findingId) expect(result.sessionId).toBe(1) expect(fixture.searchInvalidations.count).toBe(1) - const msg = fixture.db.prepare('SELECT content_text FROM messages WHERE id = 10') - .get() as { content_text: string } + const msg = fixture.db.prepare('SELECT content_text FROM messages WHERE id = 10').get() as { + content_text: string + } expect(msg.content_text.includes('AKIAIOSFODNN7EXAMPLE')).toBe(false) }) @@ -346,17 +496,41 @@ describe('registerSecurityIpc', () => { it('PURGE_EVERYWHERE scrubs the value across sessions + returns touched sessions', async () => { // Same value (one hash) in two sessions. - fixture.db.prepare( - `INSERT INTO sessions (id, project_id, source_id, session_uuid, file_path, title, started_at, ended_at, message_count) + fixture.db + .prepare( + `INSERT INTO sessions (id, project_id, source_id, session_uuid, file_path, title, started_at, ended_at, message_count) VALUES (2, 1, 1, 's-2', '/p/s-2', 'Session 2', '2026-01-02', '2026-01-02', 1)`, - ).run() - fixture.db.prepare( - `INSERT INTO messages (id, session_id, source_id, role, content_text, timestamp, seq) + ) + .run() + fixture.db + .prepare( + `INSERT INTO messages (id, session_id, source_id, role, content_text, timestamp, seq) VALUES (20, 2, 1, 'user', 'dup AKIAIOSFODNN7EXAMPLE', '2026-01-02', 0)`, - ).run() + ) + .run() insertFindings(fixture.db, [ - { sessionId: 1, messageId: 10, kind: 'api-key', valueHash: 'shared', confidence: 0.95, provider: 'regex', startOffset: 5, endOffset: 25, state: 'active' }, - { sessionId: 2, messageId: 20, kind: 'api-key', valueHash: 'shared', confidence: 0.95, provider: 'regex', startOffset: 4, endOffset: 24, state: 'active' }, + { + sessionId: 1, + messageId: 10, + kind: 'api-key', + valueHash: 'shared', + confidence: 0.95, + provider: 'regex', + startOffset: 5, + endOffset: 25, + state: 'active', + }, + { + sessionId: 2, + messageId: 20, + kind: 'api-key', + valueHash: 'shared', + confidence: 0.95, + provider: 'regex', + startOffset: 4, + endOffset: 24, + state: 'active', + }, ]) const out = await invoke<{ count: number; sessionIds: number[] }>( SECURITY_IPC_CHANNELS.PURGE_EVERYWHERE, @@ -365,7 +539,9 @@ describe('registerSecurityIpc', () => { expect(out.count).toBe(2) expect([...out.sessionIds].sort()).toEqual([1, 2]) for (const id of [10, 20]) { - const msg = fixture.db.prepare('SELECT content_text FROM messages WHERE id = ?').get(id) as { content_text: string } + const msg = fixture.db + .prepare('SELECT content_text FROM messages WHERE id = ?') + .get(id) as { content_text: string } expect(msg.content_text.includes('AKIAIOSFODNN7EXAMPLE')).toBe(false) } }) @@ -411,10 +587,9 @@ describe('registerSecurityIpc', () => { }) it('SET_PREFS broadcasts EVT_PREFS_CHANGED with the saved value', async () => { - const saved = await invoke<{ kindAllowlist: string[] }>( - SECURITY_IPC_CHANNELS.SET_PREFS, - { kindAllowlist: ['email'] }, - ) + const saved = await invoke<{ kindAllowlist: string[] }>(SECURITY_IPC_CHANNELS.SET_PREFS, { + kindAllowlist: ['email'], + }) expect(saved.kindAllowlist).toEqual(['email']) const evt = sentEvents.find((e) => e.channel === SECURITY_IPC_CHANNELS.EVT_PREFS_CHANGED) expect(evt).toBeDefined() @@ -423,12 +598,22 @@ describe('registerSecurityIpc', () => { it('LIST_ALLOWLIST_ENTRIES + REMOVE_ALLOWLIST_ENTRY round-trip', async () => { // Dismiss a finding (which writes to allowlist_session). - const fid = listFindings(fixture.db, { sessionId: 1 }).at(0)?.id - ?? (() => { - insertFindings(fixture.db, [{ - sessionId: 1, messageId: 10, kind: 'api-key', valueHash: 'hX', - confidence: 0.95, provider: 'regex', startOffset: 0, endOffset: 5, state: 'active', - }]) + const fid = + listFindings(fixture.db, { sessionId: 1 }).at(0)?.id ?? + (() => { + insertFindings(fixture.db, [ + { + sessionId: 1, + messageId: 10, + kind: 'api-key', + valueHash: 'hX', + confidence: 0.95, + provider: 'regex', + startOffset: 0, + endOffset: 5, + state: 'active', + }, + ]) return listFindings(fixture.db, { sessionId: 1 })[0]!.id })() await invoke(SECURITY_IPC_CHANNELS.DISMISS_FINDING, { findingId: fid, scope: 'global' }) @@ -445,25 +630,40 @@ describe('registerSecurityIpc', () => { valueHash: target.valueHash, }) - const afterRemove = await invoke>(SECURITY_IPC_CHANNELS.LIST_ALLOWLIST_ENTRIES) + const afterRemove = await invoke>( + SECURITY_IPC_CHANNELS.LIST_ALLOWLIST_ENTRIES, + ) expect(afterRemove.find((e) => e.scope === 'global')).toBeUndefined() }) it('COUNT_ALLOWLIST_ENTRIES reflects dismiss + remove', async () => { const before = await invoke(SECURITY_IPC_CHANNELS.COUNT_ALLOWLIST_ENTRIES) - insertFindings(fixture.db, [{ - sessionId: 1, messageId: 10, kind: 'api-key', valueHash: 'hCount', - confidence: 0.95, provider: 'regex', startOffset: 0, endOffset: 5, state: 'active', - }]) - const fid = listFindings(fixture.db, { sessionId: 1 }).find((f) => f.valueHash === 'hCount')!.id + insertFindings(fixture.db, [ + { + sessionId: 1, + messageId: 10, + kind: 'api-key', + valueHash: 'hCount', + confidence: 0.95, + provider: 'regex', + startOffset: 0, + endOffset: 5, + state: 'active', + }, + ]) + const fid = listFindings(fixture.db, { sessionId: 1 }).find( + (f) => f.valueHash === 'hCount', + )!.id await invoke(SECURITY_IPC_CHANNELS.DISMISS_FINDING, { findingId: fid, scope: 'global' }) const after = await invoke(SECURITY_IPC_CHANNELS.COUNT_ALLOWLIST_ENTRIES) expect(after).toBe(before + 1) await invoke(SECURITY_IPC_CHANNELS.REMOVE_ALLOWLIST_ENTRY, { - scope: 'global', kind: 'api-key', valueHash: 'hCount', + scope: 'global', + kind: 'api-key', + valueHash: 'hCount', }) const afterRemove = await invoke(SECURITY_IPC_CHANNELS.COUNT_ALLOWLIST_ENTRIES) expect(afterRemove).toBe(before) @@ -479,7 +679,8 @@ describe('registerSecurityIpc', () => { await fixture.push({ type: 'session-rescanned', sessionId: 7 }) await new Promise((r) => setTimeout(r, 50)) const forwarded = sentEvents.find( - (e) => e.channel === SECURITY_IPC_CHANNELS.EVT_FINDINGS_CHANGED && + (e) => + e.channel === SECURITY_IPC_CHANNELS.EVT_FINDINGS_CHANGED && (e.payload as { type: string }).type === 'session-rescanned', ) expect(forwarded).toBeDefined() @@ -520,7 +721,14 @@ describe('registerSecurityIpc with mutationWorker', () => { handlers.clear() sentEvents.length = 0 const db = setupDb() - const status: ScanStatus = { queued: 0, scanning: null, backfillRemaining: 0, backfillTotal: 0, manualBurstInFlight: false, currentProfile: 'regex@4' } + const status: ScanStatus = { + queued: 0, + scanning: null, + backfillRemaining: 0, + backfillTotal: 0, + manualBurstInFlight: false, + currentProfile: 'regex@4', + } const worker: ScanWorker = { enqueue: () => Effect.void, rescanAll: () => Effect.sync(() => 0), @@ -535,26 +743,53 @@ describe('registerSecurityIpc with mutationWorker', () => { const calls: Array<{ method: string; args: unknown[] }> = [] let searchInvalidations = 0 const fakeProxy: MutationWorkerProxy = { - purgeFinding: async (id) => { calls.push({ method: 'purgeFinding', args: [id] }); return { findingId: id, sessionId: 1, maskUsed: '[redacted]', purgedAt: 'now' } }, - purgeFindings: async (ids) => { calls.push({ method: 'purgeFindings', args: [ids] }); return [] }, - purgeEverywhere: async (kind, hash) => { calls.push({ method: 'purgeEverywhere', args: [kind, hash] }); return { results: [], sessionIds: [] } }, - dismissFinding: async (id, scope) => { calls.push({ method: 'dismissFinding', args: [id, scope] }); return null }, - dismissFindings: async (ids, scope) => { calls.push({ method: 'dismissFindings', args: [ids, scope] }); return [] }, - undismissFinding: async (id) => { calls.push({ method: 'undismissFinding', args: [id] }); return null }, + purgeFinding: async (id) => { + calls.push({ method: 'purgeFinding', args: [id] }) + return { findingId: id, sessionId: 1, maskUsed: '[redacted]', purgedAt: 'now' } + }, + purgeFindings: async (ids) => { + calls.push({ method: 'purgeFindings', args: [ids] }) + return [] + }, + purgeEverywhere: async (kind, hash) => { + calls.push({ method: 'purgeEverywhere', args: [kind, hash] }) + return { results: [], sessionIds: [] } + }, + dismissFinding: async (id, scope) => { + calls.push({ method: 'dismissFinding', args: [id, scope] }) + return null + }, + dismissFindings: async (ids, scope) => { + calls.push({ method: 'dismissFindings', args: [ids, scope] }) + return [] + }, + undismissFinding: async (id) => { + calls.push({ method: 'undismissFinding', args: [id] }) + return null + }, changes: Stream.empty, - shutdown: async () => { /* no-op */ }, + shutdown: async () => { + /* no-op */ + }, } const fakeWindow = { - webContents: { send: (channel: string, payload: unknown) => { sentEvents.push({ channel, payload }) } }, + webContents: { + send: (channel: string, payload: unknown) => { + sentEvents.push({ channel, payload }) + }, + }, } as unknown as import('electron').BrowserWindow const { dispose, attachMutationWorker } = registerSecurityIpc({ db, worker, - runPromise: (eff: Effect.Effect) => Effect.runPromise(eff as unknown as Effect.Effect), + runPromise: (eff: Effect.Effect) => + Effect.runPromise(eff as unknown as Effect.Effect), getMainWindow: () => fakeWindow, - onSearchContentChanged: () => { searchInvalidations += 1 }, + onSearchContentChanged: () => { + searchInvalidations += 1 + }, }) // Late-attach the fake proxy — mirrors how production wires the // mutation worker in after the IPC layer is already live. @@ -595,7 +830,14 @@ describe('registerSecurityIpc with mutationWorker', () => { handlers.clear() sentEvents.length = 0 const db = setupDb() - const status: ScanStatus = { queued: 0, scanning: null, backfillRemaining: 0, backfillTotal: 0, manualBurstInFlight: false, currentProfile: 'regex@4' } + const status: ScanStatus = { + queued: 0, + scanning: null, + backfillRemaining: 0, + backfillTotal: 0, + manualBurstInFlight: false, + currentProfile: 'regex@4', + } const worker: ScanWorker = { enqueue: () => Effect.void, rescanAll: () => Effect.sync(() => 0), @@ -607,24 +849,33 @@ describe('registerSecurityIpc with mutationWorker', () => { const mutationPubsub = await Effect.runPromise(PubSub.unbounded()) const fakeProxy: MutationWorkerProxy = { - purgeFinding: async () => { throw new Error('not used') }, + purgeFinding: async () => { + throw new Error('not used') + }, purgeFindings: async () => [], purgeEverywhere: async () => ({ results: [], sessionIds: [] }), dismissFinding: async () => null, dismissFindings: async () => [], undismissFinding: async () => null, changes: Stream.fromPubSub(mutationPubsub), - shutdown: async () => { /* no-op */ }, + shutdown: async () => { + /* no-op */ + }, } const fakeWindow = { - webContents: { send: (channel: string, payload: unknown) => { sentEvents.push({ channel, payload }) } }, + webContents: { + send: (channel: string, payload: unknown) => { + sentEvents.push({ channel, payload }) + }, + }, } as unknown as import('electron').BrowserWindow const { dispose, attachMutationWorker } = registerSecurityIpc({ db, worker, - runPromise: (eff: Effect.Effect) => Effect.runPromise(eff as unknown as Effect.Effect), + runPromise: (eff: Effect.Effect) => + Effect.runPromise(eff as unknown as Effect.Effect), getMainWindow: () => fakeWindow, }) attachMutationWorker(fakeProxy) @@ -638,9 +889,7 @@ describe('registerSecurityIpc with mutationWorker', () => { const change: FindingsChange = { type: 'state-changed', sessionId: 5, state: 'purged' } await Effect.runPromise(PubSub.publish(mutationPubsub, change)) await new Promise((r) => setTimeout(r, 50)) - expect(sentEvents).toEqual([ - { channel: 'security:evt-findings-changed', payload: change }, - ]) + expect(sentEvents).toEqual([{ channel: 'security:evt-findings-changed', payload: change }]) } finally { dispose() db.close() diff --git a/apps/app/src/main/ipc/security.ts b/apps/app/src/main/ipc/security.ts index 27667a92..cb8b5eca 100644 --- a/apps/app/src/main/ipc/security.ts +++ b/apps/app/src/main/ipc/security.ts @@ -5,8 +5,6 @@ // Effect types leak past this file, matching the rest of Spool's // IPC. -import { ipcMain, type BrowserWindow } from 'electron' -import { Effect, Fiber, Stream } from 'effect' import { listFindings, listFindingsPage, @@ -39,75 +37,78 @@ import { } from '@spool-lab/core' import type { SensitiveKind } from '@spool-lab/redact' import type Database from 'better-sqlite3' +import { Effect, Fiber, Stream } from 'effect' +import { ipcMain, type BrowserWindow } from 'electron' + +import type { MutationWorkerProxy } from '../mutation-worker-proxy.js' +import type { PfCoordinator } from '../security/pf-coordinator.js' +import type { PfRuntime } from '../security/pf-runtime.js' import { loadSecurityPreferences, saveSecurityPreferences, type SecurityPreferences, } from '../securityPreferences.js' -import type { PfCoordinator } from '../security/pf-coordinator.js' -import type { PfRuntime } from '../security/pf-runtime.js' -import type { MutationWorkerProxy } from '../mutation-worker-proxy.js' /** Channel name table. Shared via type only with the renderer adapter * (no runtime import — preload uses the strings literally). */ export const SECURITY_IPC_CHANNELS = { // queries - LIST_FINDINGS: 'security:list-findings', - LIST_FINDINGS_PAGE: 'security:list-findings-page', + LIST_FINDINGS: 'security:list-findings', + LIST_FINDINGS_PAGE: 'security:list-findings-page', LIST_SESSIONS_WITH_FINDINGS: 'security:list-sessions-with-findings', LIST_SESSIONS_WITH_FINDINGS_PAGE: 'security:list-sessions-with-findings-page', COUNT_SESSIONS_WITH_FINDINGS: 'security:count-sessions-with-findings', - OCCURRENCES_BY_VALUE_HASH: 'security:occurrences-by-value-hash', - RISK_BY_CATEGORY: 'security:risk-by-category', - LAST_SCAN_COMPLETED_AT: 'security:last-scan-completed-at', - GET_FINDING_VALUE: 'security:get-finding-value', - GET_FINDING_VALUES: 'security:get-finding-values', - GET_SCAN_STATUS: 'security:get-scan-status', + OCCURRENCES_BY_VALUE_HASH: 'security:occurrences-by-value-hash', + RISK_BY_CATEGORY: 'security:risk-by-category', + LAST_SCAN_COMPLETED_AT: 'security:last-scan-completed-at', + GET_FINDING_VALUE: 'security:get-finding-value', + GET_FINDING_VALUES: 'security:get-finding-values', + GET_SCAN_STATUS: 'security:get-scan-status', // mutations - DISMISS_FINDING: 'security:dismiss-finding', - DISMISS_FINDINGS: 'security:dismiss-findings', - UNDISMISS_FINDING: 'security:undismiss-finding', - PURGE_FINDING: 'security:purge-finding', - PURGE_FINDINGS: 'security:purge-findings', - PURGE_EVERYWHERE: 'security:purge-everywhere', - RESCAN_ALL: 'security:rescan-all', - RESCAN_SESSION: 'security:rescan-session', + DISMISS_FINDING: 'security:dismiss-finding', + DISMISS_FINDINGS: 'security:dismiss-findings', + UNDISMISS_FINDING: 'security:undismiss-finding', + PURGE_FINDING: 'security:purge-finding', + PURGE_FINDINGS: 'security:purge-findings', + PURGE_EVERYWHERE: 'security:purge-everywhere', + RESCAN_ALL: 'security:rescan-all', + RESCAN_SESSION: 'security:rescan-session', // preferences - GET_PREFS: 'security:get-prefs', - SET_PREFS: 'security:set-prefs', + GET_PREFS: 'security:get-prefs', + SET_PREFS: 'security:set-prefs', // allowlist management - LIST_ALLOWLIST_ENTRIES: 'security:list-allowlist-entries', - COUNT_ALLOWLIST_ENTRIES: 'security:count-allowlist-entries', - REMOVE_ALLOWLIST_ENTRY: 'security:remove-allowlist-entry', + LIST_ALLOWLIST_ENTRIES: 'security:list-allowlist-entries', + COUNT_ALLOWLIST_ENTRIES: 'security:count-allowlist-entries', + REMOVE_ALLOWLIST_ENTRY: 'security:remove-allowlist-entry', // maintenance - LIST_BACKUPS: 'security:list-backups', - DELETE_BACKUPS: 'security:delete-backups', + LIST_BACKUPS: 'security:list-backups', + DELETE_BACKUPS: 'security:delete-backups', // Privacy Filter ML download lifecycle - PF_GET_STATE: 'security:pf-get-state', - PF_DOWNLOAD_START: 'security:pf-download-start', - PF_DOWNLOAD_CANCEL: 'security:pf-download-cancel', + PF_GET_STATE: 'security:pf-get-state', + PF_DOWNLOAD_START: 'security:pf-download-start', + PF_DOWNLOAD_CANCEL: 'security:pf-download-cancel', /** Returns the inference runtime info — null when the ModelHost * isn't running. Polled by PfDownloadCard for the runtime badge. */ - PF_GET_RUNTIME_INFO: 'security:pf-get-runtime-info', + PF_GET_RUNTIME_INFO: 'security:pf-get-runtime-info', // readiness — eagerly registered so the renderer can wait for the // scan worker without bumping into "No handler registered" errors // when Settings → Security opens during boot, and so a worker-boot // failure surfaces as a "Scanner unavailable" banner instead of a // dead UI. - GET_READINESS: 'security:get-readiness', + GET_READINESS: 'security:get-readiness', // events (push: main → renderer via webContents.send) - EVT_FINDINGS_CHANGED: 'security:evt-findings-changed', - EVT_SCAN_STATUS: 'security:evt-scan-status', - EVT_PREFS_CHANGED: 'security:evt-prefs-changed', - EVT_PF_STATE: 'security:evt-pf-state', - EVT_READINESS_CHANGED: 'security:evt-readiness-changed', + EVT_FINDINGS_CHANGED: 'security:evt-findings-changed', + EVT_SCAN_STATUS: 'security:evt-scan-status', + EVT_PREFS_CHANGED: 'security:evt-prefs-changed', + EVT_PF_STATE: 'security:evt-pf-state', + EVT_READINESS_CHANGED: 'security:evt-readiness-changed', } as const export type SecurityReadiness = @@ -123,9 +124,7 @@ export type SecurityReadiness = * the millisecond the window appears no longer races the worker * boot, which used to produce "No handler registered for * security:list-backups" errors. */ -export function registerSecurityReadinessIpc( - getMainWindow: () => BrowserWindow | null, -): { +export function registerSecurityReadinessIpc(getMainWindow: () => BrowserWindow | null): { setReadiness: (next: SecurityReadiness) => void dispose: () => void } { @@ -135,16 +134,15 @@ export function registerSecurityReadinessIpc( setReadiness: (next) => { // Same-value transitions are a no-op so the renderer doesn't // re-render its skeleton on every boot retry. - if (next.ready === current.ready && - (next.ready || next.reason === (current as { reason: string }).reason)) { + if ( + next.ready === current.ready && + (next.ready || next.reason === (current as { reason: string }).reason) + ) { return } current = next try { - getMainWindow()?.webContents.send( - SECURITY_IPC_CHANNELS.EVT_READINESS_CHANGED, - next, - ) + getMainWindow()?.webContents.send(SECURITY_IPC_CHANNELS.EVT_READINESS_CHANGED, next) } catch (err) { console.error('[security] readiness broadcast failed:', err) } @@ -209,7 +207,16 @@ export interface SecurityIpcHandle { * worker is ready so the IPC layer becomes live before that boot * completes. */ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { - const { db, worker, runPromise, getMainWindow, pfCoordinator, pfRuntime: hostRuntime, onPfEnabledChanged, onSearchContentChanged } = deps + const { + db, + worker, + runPromise, + getMainWindow, + pfCoordinator, + pfRuntime: hostRuntime, + onPfEnabledChanged, + onSearchContentChanged, + } = deps // Closure-local ref read by every mutation handler at call time — // lets `attachMutationWorker` swap the worker in after IPC has // already started taking calls. Until it's set the handlers fall @@ -222,35 +229,32 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { ipcMain.handle(SECURITY_IPC_CHANNELS.LIST_FINDINGS_PAGE, (_e, filter: FindingFilter) => listFindingsPage(db, filter), ) - ipcMain.handle(SECURITY_IPC_CHANNELS.LIST_SESSIONS_WITH_FINDINGS, (_e, filter: SessionFindingFilter) => - listSessionsWithFindings(db, filter), + ipcMain.handle( + SECURITY_IPC_CHANNELS.LIST_SESSIONS_WITH_FINDINGS, + (_e, filter: SessionFindingFilter) => listSessionsWithFindings(db, filter), ) - ipcMain.handle(SECURITY_IPC_CHANNELS.LIST_SESSIONS_WITH_FINDINGS_PAGE, (_e, filter: SessionFindingFilter) => - listSessionsWithFindingsPage(db, filter), + ipcMain.handle( + SECURITY_IPC_CHANNELS.LIST_SESSIONS_WITH_FINDINGS_PAGE, + (_e, filter: SessionFindingFilter) => listSessionsWithFindingsPage(db, filter), ) - ipcMain.handle(SECURITY_IPC_CHANNELS.COUNT_SESSIONS_WITH_FINDINGS, (_e, filter: SessionFindingFilter) => - countSessionsWithFindings(db, filter), + ipcMain.handle( + SECURITY_IPC_CHANNELS.COUNT_SESSIONS_WITH_FINDINGS, + (_e, filter: SessionFindingFilter) => countSessionsWithFindings(db, filter), ) ipcMain.handle( SECURITY_IPC_CHANNELS.OCCURRENCES_BY_VALUE_HASH, (_e, args: { kind: SensitiveKind; valueHash: string }) => occurrencesByValueHash(db, args.kind, args.valueHash), ) - ipcMain.handle(SECURITY_IPC_CHANNELS.RISK_BY_CATEGORY, () => - riskByCategory(db), - ) - ipcMain.handle(SECURITY_IPC_CHANNELS.LAST_SCAN_COMPLETED_AT, () => - lastScanCompletedAt(db), - ) + ipcMain.handle(SECURITY_IPC_CHANNELS.RISK_BY_CATEGORY, () => riskByCategory(db)) + ipcMain.handle(SECURITY_IPC_CHANNELS.LAST_SCAN_COMPLETED_AT, () => lastScanCompletedAt(db)) ipcMain.handle(SECURITY_IPC_CHANNELS.GET_FINDING_VALUE, (_e, findingId: number) => getFindingValue(db, findingId), ) ipcMain.handle(SECURITY_IPC_CHANNELS.GET_FINDING_VALUES, (_e, ids: number[]) => getFindingValues(db, ids), ) - ipcMain.handle(SECURITY_IPC_CHANNELS.GET_SCAN_STATUS, () => - runPromise(worker.getStatus), - ) + ipcMain.handle(SECURITY_IPC_CHANNELS.GET_SCAN_STATUS, () => runPromise(worker.getStatus)) // All write paths route through `currentMutationWorker` when present so // the main event loop stays free during multi-second bulk @@ -271,7 +275,10 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { const sessionId = dismissFinding(db, args.findingId, args.scope, true) if (sessionId != null) { getMainWindow()?.webContents.send(SECURITY_IPC_CHANNELS.EVT_FINDINGS_CHANGED, { - type: 'state-changed', sessionId, findingId: args.findingId, state: 'dismissed', + type: 'state-changed', + sessionId, + findingId: args.findingId, + state: 'dismissed', }) } return { ok: true } @@ -287,7 +294,9 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { const sessionIds = dismissFindings(db, args.findingIds, args.scope) for (const sessionId of sessionIds) { getMainWindow()?.webContents.send(SECURITY_IPC_CHANNELS.EVT_FINDINGS_CHANGED, { - type: 'state-changed', sessionId, state: 'dismissed', + type: 'state-changed', + sessionId, + state: 'dismissed', }) } return { ok: true } @@ -303,7 +312,10 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { const sessionId = undismissFinding(db, args.findingId) if (sessionId != null) { getMainWindow()?.webContents.send(SECURITY_IPC_CHANNELS.EVT_FINDINGS_CHANGED, { - type: 'state-changed', sessionId, findingId: args.findingId, state: 'active', + type: 'state-changed', + sessionId, + findingId: args.findingId, + state: 'active', }) } return { ok: true } @@ -315,11 +327,17 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { onSearchContentChanged?.() return result } - const publish = (change: Parameters infer R ? R : never>['webContents']['send']>[1]) => + const publish = ( + change: Parameters< + NonNullable infer R ? R : never>['webContents']['send'] + >[1], + ) => Effect.sync(() => { getMainWindow()?.webContents.send(SECURITY_IPC_CHANNELS.EVT_FINDINGS_CHANGED, change) - }) - const result = await runPromise(purgeFinding(findingId, { db, publish: publish as never })) as PurgeResult + }) + const result = (await runPromise( + purgeFinding(findingId, { db, publish: publish as never }), + )) as PurgeResult onSearchContentChanged?.() return result }) @@ -332,8 +350,10 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { const publish = (change: unknown) => Effect.sync(() => { getMainWindow()?.webContents.send(SECURITY_IPC_CHANNELS.EVT_FINDINGS_CHANGED, change) - }) - const results = await runPromise(purgeFindings(findingIds, { db, publish: publish as never })) as PurgeResult[] + }) + const results = (await runPromise( + purgeFindings(findingIds, { db, publish: publish as never }), + )) as PurgeResult[] onSearchContentChanged?.() return results }) @@ -349,9 +369,9 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { Effect.sync(() => { getMainWindow()?.webContents.send(SECURITY_IPC_CHANNELS.EVT_FINDINGS_CHANGED, change) }) - const out = await runPromise( + const out = (await runPromise( purgeEverywhere(args.kind, args.valueHash, { db, publish: publish as never }), - ) as { results: PurgeResult[]; sessionIds: number[] } + )) as { results: PurgeResult[]; sessionIds: number[] } onSearchContentChanged?.() return { count: out.results.length, sessionIds: out.sessionIds } }, @@ -379,9 +399,10 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { // sessions table. const prevKinds = new Set(prev.kindAllowlist) const nextKinds = new Set(saved.kindAllowlist) - const changed = prevKinds.size !== nextKinds.size || - [...prevKinds].some(k => !nextKinds.has(k)) || - [...nextKinds].some(k => !prevKinds.has(k)) + const changed = + prevKinds.size !== nextKinds.size || + [...prevKinds].some((k) => !nextKinds.has(k)) || + [...nextKinds].some((k) => !prevKinds.has(k)) if (changed) { // User clicked a kind-mute toggle — propagate the // user-initiated marker so the renderer's busy→idle edge @@ -390,18 +411,18 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { await runPromise(worker.backfill({ userInitiated: true })) } if (prev.pfEnabled !== saved.pfEnabled) { - try { onPfEnabledChanged?.(saved.pfEnabled) } - catch (err) { console.error('[security] onPfEnabledChanged threw:', err) } + try { + onPfEnabledChanged?.(saved.pfEnabled) + } catch (err) { + console.error('[security] onPfEnabledChanged threw:', err) + } } getMainWindow()?.webContents.send(SECURITY_IPC_CHANNELS.EVT_PREFS_CHANGED, saved) return saved }, ) - ipcMain.handle( - SECURITY_IPC_CHANNELS.LIST_BACKUPS, - (): BackupFileInfo[] => listBackups(db), - ) + ipcMain.handle(SECURITY_IPC_CHANNELS.LIST_BACKUPS, (): BackupFileInfo[] => listBackups(db)) ipcMain.handle( SECURITY_IPC_CHANNELS.DELETE_BACKUPS, (_e, args: { names: string[] }): DeleteBackupsResult => deleteBackups(db, args.names), @@ -410,8 +431,10 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { // Privacy Filter ML. When pfCoordinator is null (5b production), the // channels return a static not-installed snapshot so the renderer // renders its Download affordance instead of crashing. - ipcMain.handle(SECURITY_IPC_CHANNELS.PF_GET_STATE, () => - pfCoordinator?.getState() ?? { phase: 'not-installed', bytesDownloaded: 0, bytesTotal: 0 }, + ipcMain.handle( + SECURITY_IPC_CHANNELS.PF_GET_STATE, + () => + pfCoordinator?.getState() ?? { phase: 'not-installed', bytesDownloaded: 0, bytesTotal: 0 }, ) ipcMain.handle(SECURITY_IPC_CHANNELS.PF_DOWNLOAD_START, async () => { if (!pfCoordinator) return { ok: false, reason: 'unavailable' as const } @@ -434,11 +457,20 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { ipcMain.handle(SECURITY_IPC_CHANNELS.COUNT_ALLOWLIST_ENTRIES, () => countAllowlistEntries(db)) ipcMain.handle( SECURITY_IPC_CHANNELS.REMOVE_ALLOWLIST_ENTRY, - (_e, args: { scope: 'session' | 'global'; kind: SensitiveKind; valueHash: string; sessionUuid?: string }) => { + ( + _e, + args: { + scope: 'session' | 'global' + kind: SensitiveKind + valueHash: string + sessionUuid?: string + }, + ) => { if (args.scope === 'global') { removeAllowlistGlobal(db, args.kind, args.valueHash) } else if (args.sessionUuid) { - const row = db.prepare('SELECT id FROM sessions WHERE session_uuid = ?') + const row = db + .prepare('SELECT id FROM sessions WHERE session_uuid = ?') .get(args.sessionUuid) as { id: number } | undefined if (row) removeAllowlistSession(db, row.id, args.kind, args.valueHash) } @@ -505,7 +537,9 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { ), ) }), - ).catch(() => { /* fork rejected; ignore (cleanup path) */ }) + ).catch(() => { + /* fork rejected; ignore (cleanup path) */ + }) // Mutation-worker change forwarder is forked when `attachMutationWorker` // fires, not at registration time, so the IPC layer is live even @@ -544,7 +578,9 @@ export function registerSecurityIpc(deps: SecurityIpcDeps): SecurityIpcHandle { ), ) }), - ).catch(() => { /* fork rejected; ignore */ }) + ).catch(() => { + /* fork rejected; ignore */ + }) } return { dispose, attachMutationWorker } diff --git a/apps/app/src/main/ipc/share-auth.test.ts b/apps/app/src/main/ipc/share-auth.test.ts index f075fba4..d21f2d2e 100644 --- a/apps/app/src/main/ipc/share-auth.test.ts +++ b/apps/app/src/main/ipc/share-auth.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vite-plus/test' import { performSignIn, type SignInDeps } from './share-auth.js' diff --git a/apps/app/src/main/ipc/share-auth.ts b/apps/app/src/main/ipc/share-auth.ts index c1ef1c53..fd66a1e6 100644 --- a/apps/app/src/main/ipc/share-auth.ts +++ b/apps/app/src/main/ipc/share-auth.ts @@ -1,6 +1,7 @@ import { ipcMain } from 'electron' -import { signInWithWorkos, type SignInResult } from '../auth/workos-auth.js' + import { saveToken, loadToken, clearToken, isAvailable } from '../auth/session-store.js' +import { signInWithWorkos, type SignInResult } from '../auth/workos-auth.js' import { authedFetch } from '../share/api-client.js' export interface SignInDeps { @@ -38,9 +39,7 @@ export async function performSignIn(deps: SignInDeps): Promise Promise -export function registerShareAuthIpc( - signInImpl: SignInImpl = signInWithWorkos, -): void { +export function registerShareAuthIpc(signInImpl: SignInImpl = signInWithWorkos): void { ipcMain.handle('share-auth:available', () => isAvailable()) ipcMain.handle('share-auth:signin', async () => { diff --git a/apps/app/src/main/ipc/share-profile.ts b/apps/app/src/main/ipc/share-profile.ts index 2cc20ecb..a96a2ad1 100644 --- a/apps/app/src/main/ipc/share-profile.ts +++ b/apps/app/src/main/ipc/share-profile.ts @@ -76,9 +76,7 @@ export function registerShareProfileIpc(): void { }) if (!r.ok) { const detail = await readErrorDetail(r) - console.error( - `[share-profile] upload-avatar failed: status=${r.status} detail=${detail}`, - ) + console.error(`[share-profile] upload-avatar failed: status=${r.status} detail=${detail}`) throw new Error(detail) } return (await r.json()) as AvatarUploadResponse diff --git a/apps/app/src/main/ipc/share-publish.test.ts b/apps/app/src/main/ipc/share-publish.test.ts index bfaa4ea7..fbde502b 100644 --- a/apps/app/src/main/ipc/share-publish.test.ts +++ b/apps/app/src/main/ipc/share-publish.test.ts @@ -1,10 +1,9 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { - MAX_PUBLISH_BODY_BYTES, - type PublishRequestBody, -} from '../../shared/share-publish.js' + +import { describe, it, expect, beforeEach, vi } from 'vite-plus/test' + +import { MAX_PUBLISH_BODY_BYTES, type PublishRequestBody } from '../../shared/share-publish.js' type Handler = (e: unknown, ...args: unknown[]) => unknown diff --git a/apps/app/src/main/ipc/share-publish.ts b/apps/app/src/main/ipc/share-publish.ts index 6b98da2f..139eb65c 100644 --- a/apps/app/src/main/ipc/share-publish.ts +++ b/apps/app/src/main/ipc/share-publish.ts @@ -1,4 +1,3 @@ -import { ipcMain } from 'electron' import { getDB, getByDraftId as getCachedByDraftId, @@ -8,8 +7,8 @@ import { upsertMany as upsertCachedPublished, type PublishedShareCacheItem, } from '@spool-lab/core' -import { authedFetch } from '../share/api-client.js' -import { clearToken } from '../auth/session-store.js' +import { ipcMain } from 'electron' + import { MAX_PUBLISH_BODY_BYTES, type PublishRequestBody, @@ -22,6 +21,8 @@ import { type SetVisibilityResult, type Visibility, } from '../../shared/share-publish.js' +import { clearToken } from '../auth/session-store.js' +import { authedFetch } from '../share/api-client.js' async function readBody(res: Response): Promise> { try { @@ -32,68 +33,71 @@ async function readBody(res: Response): Promise> { } export function registerSharePublishIpc(): void { - ipcMain.handle('share-publish:publish', async (_e, body: PublishRequestBody): Promise => { - const payload = JSON.stringify(body) - // Fail fast on oversized payloads rather than buffering the whole - // body up to the server just to get a 422 back. The renderer maps - // this 413 to a localized "too large" message. Measured in UTF-8 - // bytes (what actually transits and what the server caps), not - // string length — those differ by up to 3x for CJK content. - if (Buffer.byteLength(payload, 'utf8') > MAX_PUBLISH_BODY_BYTES) { - return { ok: false, status: 413, error: { error: 'PAYLOAD_TOO_LARGE' } } - } - const r = await authedFetch('/api/publish', { - method: 'POST', - body: payload, - }) - const json = await readBody(r) - if (!r.ok) { - if (r.status === 401) clearToken() - const error: PublishErrorBody = { - error: typeof json['error'] === 'string' ? (json['error'] as string) : `HTTP_${r.status}`, + ipcMain.handle( + 'share-publish:publish', + async (_e, body: PublishRequestBody): Promise => { + const payload = JSON.stringify(body) + // Fail fast on oversized payloads rather than buffering the whole + // body up to the server just to get a 422 back. The renderer maps + // this 413 to a localized "too large" message. Measured in UTF-8 + // bytes (what actually transits and what the server caps), not + // string length — those differ by up to 3x for CJK content. + if (Buffer.byteLength(payload, 'utf8') > MAX_PUBLISH_BODY_BYTES) { + return { ok: false, status: 413, error: { error: 'PAYLOAD_TOO_LARGE' } } } - if (typeof json['detail'] === 'string') error.detail = json['detail'] as string - if (json['issues'] !== undefined) error.issues = json['issues'] - return { ok: false, status: r.status, error } - } - const id = String(json['id'] ?? '') - const url = String(json['url'] ?? '') - const version = Number(json['version'] ?? 1) + const r = await authedFetch('/api/publish', { + method: 'POST', + body: payload, + }) + const json = await readBody(r) + if (!r.ok) { + if (r.status === 401) clearToken() + const error: PublishErrorBody = { + error: typeof json['error'] === 'string' ? (json['error'] as string) : `HTTP_${r.status}`, + } + if (typeof json['detail'] === 'string') error.detail = json['detail'] as string + if (json['issues'] !== undefined) error.issues = json['issues'] + return { ok: false, status: r.status, error } + } + const id = String(json['id'] ?? '') + const url = String(json['url'] ?? '') + const version = Number(json['version'] ?? 1) - // Write the new (or refreshed) row into the local cache so the - // editor's on-mount draft lookup and the Shares list reflect this - // publish without waiting for the next /api/me/shares poll. - // visibility comes straight from the request, title from the - // snapshot — the backend echoes neither in the publish response. - const now = Date.now() - const item: PublishedShareCacheItem = { - id, - title: body.snapshot.conversation.title, - visibility: body.visibility, - version, - published_at: now, - revoked_at: null, - draft_id: body.draft_id, - client_request_id: body.idempotency_key, - updated_at: now, - } - try { - upsertCachedPublished(getDB(), [item]) - } catch (err) { - // Cache write failures are non-fatal — the next myShares poll - // will reconcile. Log so we notice systemic issues. - console.warn('[share-publish] cache upsert after publish failed:', err) - } + // Write the new (or refreshed) row into the local cache so the + // editor's on-mount draft lookup and the Shares list reflect this + // publish without waiting for the next /api/me/shares poll. + // visibility comes straight from the request, title from the + // snapshot — the backend echoes neither in the publish response. + const now = Date.now() + const item: PublishedShareCacheItem = { + id, + title: body.snapshot.conversation.title, + visibility: body.visibility, + version, + published_at: now, + revoked_at: null, + draft_id: body.draft_id, + client_request_id: body.idempotency_key, + updated_at: now, + } + try { + upsertCachedPublished(getDB(), [item]) + } catch (err) { + // Cache write failures are non-fatal — the next myShares poll + // will reconcile. Log so we notice systemic issues. + console.warn('[share-publish] cache upsert after publish failed:', err) + } - // Cast: PublishedShareCacheItem stores `visibility` as a plain - // string for forward-compat with future visibility tokens we - // haven't taught core about yet, while the IPC wire `PublishedRow` - // enforces the Visibility enum. The body.visibility we just - // composed `item` from IS a Visibility, so the narrowing is safe; - // we add the cast at the boundary rather than re-typing the cache - // module to keep core import-free of the share-publish wire. - return { ok: true, data: { id, url, version }, row: { ...item, visibility: body.visibility } } - }) + // Cast: PublishedShareCacheItem stores `visibility` as a plain + // string for forward-compat with future visibility tokens we + // haven't taught core about yet, while the IPC wire `PublishedRow` + // enforces the Visibility enum. The body.visibility we just + // composed `item` from IS a Visibility, so the narrowing is safe; + // we add the cast at the boundary rather than re-typing the cache + // module to keep core import-free of the share-publish wire. + return { ok: true, data: { id, url, version }, row: { ...item, visibility: body.visibility } } + }, + ) ipcMain.handle('share-publish:revoke', async (_e, id: string): Promise<{ ok: true }> => { const r = await authedFetch(`/api/revoke/${encodeURIComponent(id)}`, { method: 'POST' }) @@ -151,28 +155,34 @@ export function registerSharePublishIpc(): void { return (await r.json()) as MySharesResponse }) - ipcMain.handle('share-publish:claim-handle', async (_e, handle: string): Promise => { - const r = await authedFetch('/api/handles/claim', { - method: 'POST', - body: JSON.stringify({ handle }), - }) - if (!r.ok) { - if (r.status === 401) clearToken() - const body = await readBody(r) - const detail = typeof body['detail'] === 'string' ? body['detail'] : `claim ${r.status}` - throw new Error(detail) - } - return (await r.json()) as HandleClaimResponse - }) + ipcMain.handle( + 'share-publish:claim-handle', + async (_e, handle: string): Promise => { + const r = await authedFetch('/api/handles/claim', { + method: 'POST', + body: JSON.stringify({ handle }), + }) + if (!r.ok) { + if (r.status === 401) clearToken() + const body = await readBody(r) + const detail = typeof body['detail'] === 'string' ? body['detail'] : `claim ${r.status}` + throw new Error(detail) + } + return (await r.json()) as HandleClaimResponse + }, + ) - ipcMain.handle('share-publish:check-handle', async (_e, handle: string): Promise => { - const r = await authedFetch(`/api/handles/check?h=${encodeURIComponent(handle)}`) - if (!r.ok) { - if (r.status === 401) clearToken() - throw new Error(`check ${r.status}`) - } - return (await r.json()) as HandleCheckResponse - }) + ipcMain.handle( + 'share-publish:check-handle', + async (_e, handle: string): Promise => { + const r = await authedFetch(`/api/handles/check?h=${encodeURIComponent(handle)}`) + if (!r.ok) { + if (r.status === 401) clearToken() + throw new Error(`check ${r.status}`) + } + return (await r.json()) as HandleCheckResponse + }, + ) ipcMain.handle('share-publish:cached-published', (): PublishedShareCacheItem[] => { return listCachedPublished(getDB()) diff --git a/apps/app/src/main/mutation-worker-proxy.ts b/apps/app/src/main/mutation-worker-proxy.ts index 76f287f8..1b586c5a 100644 --- a/apps/app/src/main/mutation-worker-proxy.ts +++ b/apps/app/src/main/mutation-worker-proxy.ts @@ -9,9 +9,11 @@ // See mutation-worker-thread.ts for the worker side of the protocol. import { Worker } from 'node:worker_threads' -import { PubSub, Effect, Stream } from 'effect' + import type { FindingsChange, PurgeResult } from '@spool-lab/core' import type { SensitiveKind } from '@spool-lab/redact' +import { PubSub, Effect, Stream } from 'effect' + import type { FromWorker, MutationCommand, @@ -50,7 +52,11 @@ export class MutationWorkerError extends Error { public readonly reason: PurgeErrorWire['reason'] | 'unknown' public readonly findingId: number | undefined constructor(error: PurgeErrorWire | { reason: 'unknown'; message: string }) { - super('message' in error && typeof error.message === 'string' ? error.message : `mutation worker error: ${error.reason}`) + super( + 'message' in error && typeof error.message === 'string' + ? error.message + : `mutation worker error: ${error.reason}`, + ) this.name = 'MutationWorkerError' this.reason = error.reason this.findingId = 'findingId' in error ? error.findingId : undefined @@ -67,7 +73,11 @@ export async function spawnMutationWorker(workerPath: string): Promise()) function postSafe(msg: ToWorker): void { - try { worker.postMessage(msg) } catch { /* worker gone */ } + try { + worker.postMessage(msg) + } catch { + /* worker gone */ + } } function send(payload: MutationCommand): Promise { @@ -89,7 +99,9 @@ export async function spawnMutationWorker(workerPath: string): Promise { worker.off('message', onMessage) worker.off('error', onError) - worker.terminate().catch(() => { /* nothing to do */ }) + worker.terminate().catch(() => { + /* nothing to do */ + }) reject(new Error(`mutation worker did not report ready within ${BOOT_TIMEOUT_MS}ms`)) }, BOOT_TIMEOUT_MS) function clear(): void { @@ -196,7 +208,9 @@ export async function spawnMutationWorker(workerPath: string): Promise((resolve) => { worker.once('exit', () => resolve()) setTimeout(() => { - worker.terminate().catch(() => { /* nothing to do */ }) + worker.terminate().catch(() => { + /* nothing to do */ + }) resolve() }, 3000) }) diff --git a/apps/app/src/main/mutation-worker-thread.ts b/apps/app/src/main/mutation-worker-thread.ts index c4f77d95..96954028 100644 --- a/apps/app/src/main/mutation-worker-thread.ts +++ b/apps/app/src/main/mutation-worker-thread.ts @@ -25,7 +25,7 @@ // thread" in the IPC layer. import { parentPort, threadId } from 'node:worker_threads' -import { Effect } from 'effect' + import { getDB, purgeFinding as purgeFindingEff, @@ -38,6 +38,8 @@ import { type PurgeResult, } from '@spool-lab/core' import type { SensitiveKind } from '@spool-lab/redact' +import { Effect } from 'effect' + import { exitToWireResult, flattenPurgeError, @@ -83,7 +85,9 @@ function reportFatal(err: unknown): void { const error = err instanceof Error ? (err.stack ?? err.message) : String(err) try { port.postMessage({ type: 'fatal', error } satisfies FromWorker) - } catch { /* parent gone */ } + } catch { + /* parent gone */ + } process.exit(1) } @@ -101,7 +105,11 @@ console.log('[security] mutation worker thread booted; threadId =', threadId) const db = getDB({ runMigrations: false }) function postSafe(msg: FromWorker): void { - try { port.postMessage(msg) } catch { /* parent gone */ } + try { + port.postMessage(msg) + } catch { + /* parent gone */ + } } /** Forward a `FindingsChange` to main as it happens. The core purge @@ -127,10 +135,17 @@ async function handle(cmd: MutationCommand): Promise { : { ok: false, error: wire.error } } case 'purgeEverywhere': { - const exit = await Effect.runPromiseExit(purgeEverywhereEff(cmd.kind, cmd.valueHash, { db, publish })) + const exit = await Effect.runPromiseExit( + purgeEverywhereEff(cmd.kind, cmd.valueHash, { db, publish }), + ) const wire = exitToWireResult(exit, (value) => value) return wire.ok - ? { ok: true, cmd: 'purgeEverywhere', results: wire.success.results, sessionIds: wire.success.sessionIds } + ? { + ok: true, + cmd: 'purgeEverywhere', + results: wire.success.results, + sessionIds: wire.success.sessionIds, + } : { ok: false, error: wire.error } } case 'dismissFinding': { @@ -138,7 +153,12 @@ async function handle(cmd: MutationCommand): Promise { if (sessionId != null) { postSafe({ type: 'event-change', - change: { type: 'state-changed', sessionId, findingId: cmd.findingId, state: 'dismissed' }, + change: { + type: 'state-changed', + sessionId, + findingId: cmd.findingId, + state: 'dismissed', + }, }) } return { ok: true, cmd: 'dismissFinding', sessionId } @@ -168,7 +188,11 @@ async function handle(cmd: MutationCommand): Promise { port.on('message', (msg: ToWorker) => { if (msg.type === 'shutdown') { - try { db.close() } catch { /* best effort */ } + try { + db.close() + } catch { + /* best effort */ + } process.exit(0) } if (msg.type !== 'cmd') return @@ -181,11 +205,12 @@ port.on('message', (msg: ToWorker) => { // here just hands the next request to better-sqlite3, which queues. void handle(msg.payload).then( (result) => postSafe({ type: 'cmd-result', reqId: msg.reqId, result }), - (err) => postSafe({ - type: 'cmd-result', - reqId: msg.reqId, - result: { ok: false, error: flattenPurgeError(err) }, - }), + (err) => + postSafe({ + type: 'cmd-result', + reqId: msg.reqId, + result: { ok: false, error: flattenPurgeError(err) }, + }), ) }) diff --git a/apps/app/src/main/net/robust-fetch.test.ts b/apps/app/src/main/net/robust-fetch.test.ts index 78bc437e..711d7eb8 100644 --- a/apps/app/src/main/net/robust-fetch.test.ts +++ b/apps/app/src/main/net/robust-fetch.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi } from 'vite-plus/test' const directSessionFetch = vi.fn() @@ -26,9 +26,10 @@ function transport(label: string, impl: () => Promise): Transport { return { label, fetch: vi.fn(impl) as unknown as Transport['fetch'] } } -const netErr = () => Object.assign(new Error('fetch failed'), { - cause: new Error('connect ETIMEDOUT 142.250.0.1:443'), -}) +const netErr = () => + Object.assign(new Error('fetch failed'), { + cause: new Error('connect ETIMEDOUT 142.250.0.1:443'), + }) describe('robustFetch', () => { it('returns the first transport result without touching the rest', async () => { @@ -42,7 +43,9 @@ describe('robustFetch', () => { it('falls through to the next transport on a network-level failure', async () => { const ok = new Response('hi', { status: 200 }) - const first = transport('a', async () => { throw netErr() }) + const first = transport('a', async () => { + throw netErr() + }) const second = transport('b', async () => ok) const res = await robustFetch('https://x.test/', {}, [first, second]) expect(res).toBe(ok) @@ -61,38 +64,50 @@ describe('robustFetch', () => { }) it('rethrows non-network errors immediately', async () => { - const first = transport('a', async () => { throw new TypeError('body used already') }) + const first = transport('a', async () => { + throw new TypeError('body used already') + }) const second = transport('b', async () => new Response('never')) - await expect(robustFetch('https://x.test/', {}, [first, second])) - .rejects.toThrow('body used already') + await expect(robustFetch('https://x.test/', {}, [first, second])).rejects.toThrow( + 'body used already', + ) expect(second.fetch).not.toHaveBeenCalled() }) it('throws the last network error when every transport fails', async () => { - const first = transport('a', async () => { throw netErr() }) - const last = Object.assign(new Error('fetch failed'), { cause: new Error('net::ERR_CONNECTION_REFUSED') }) - const second = transport('b', async () => { throw last }) - await expect(robustFetch('https://x.test/', {}, [first, second])) - .rejects.toBe(last) + const first = transport('a', async () => { + throw netErr() + }) + const last = Object.assign(new Error('fetch failed'), { + cause: new Error('net::ERR_CONNECTION_REFUSED'), + }) + const second = transport('b', async () => { + throw last + }) + await expect(robustFetch('https://x.test/', {}, [first, second])).rejects.toBe(last) }) }) describe('fetchOnce', () => { it('probes with a GET, then sends the real request exactly once on the winner', async () => { const calls: { url: string; method?: string }[] = [] - const dead = transport('dead', async () => { throw netErr() }) + const dead = transport('dead', async () => { + throw netErr() + }) const alive: Transport = { label: 'alive', fetch: vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { - calls.push({ url: String(url), ...(init?.method !== undefined && { method: init.method }) }) + calls.push({ + url: String(url), + ...(init?.method !== undefined && { method: init.method }), + }) return new Response('ok', { status: init?.method === 'POST' ? 200 : 404 }) }) as unknown as Transport['fetch'], } - const res = await fetchOnce( - 'https://api.test/token', - { method: 'POST', body: 'code=once' }, - [dead, alive], - ) + const res = await fetchOnce('https://api.test/token', { method: 'POST', body: 'code=once' }, [ + dead, + alive, + ]) expect(res.status).toBe(200) // dead transport saw only the probe; the POST went out once, on alive. expect(dead.fetch).toHaveBeenCalledTimes(1) @@ -113,19 +128,27 @@ describe('fetchOnce', () => { const flaky: Transport = { label: 'flaky', fetch: vi.fn(async () => { - if (!probed) { probed = true; return new Response('probe ok') } + if (!probed) { + probed = true + return new Response('probe ok') + } throw netErr() // the committed POST dies mid-flight }) as unknown as Transport['fetch'], } const second = transport('never', async () => new Response('never')) - await expect(fetchOnce('https://api.test/token', { method: 'POST' }, [flaky, second])) - .rejects.toThrow('fetch failed') + await expect( + fetchOnce('https://api.test/token', { method: 'POST' }, [flaky, second]), + ).rejects.toThrow('fetch failed') expect(second.fetch).not.toHaveBeenCalled() }) it('throws when every probe fails', async () => { - const a = transport('a', async () => { throw netErr() }) - const b = transport('b', async () => { throw netErr() }) + const a = transport('a', async () => { + throw netErr() + }) + const b = transport('b', async () => { + throw netErr() + }) await expect(fetchOnce('https://api.test/x', {}, [a, b])).rejects.toThrow('fetch failed') }) }) @@ -155,14 +178,18 @@ describe('proxyRulesFromEnv', () => { }) it('maps a socks proxy to socks5 rules', () => { - expect(proxyRulesFromEnv({ all_proxy: 'socks5://127.0.0.1:7897' })).toBe('socks5://127.0.0.1:7897') + expect(proxyRulesFromEnv({ all_proxy: 'socks5://127.0.0.1:7897' })).toBe( + 'socks5://127.0.0.1:7897', + ) }) it('prefers https_proxy over all_proxy and uppercase variants', () => { - expect(proxyRulesFromEnv({ - ALL_PROXY: 'socks5://1.1.1.1:1080', - https_proxy: 'http://2.2.2.2:8080', - })).toBe('2.2.2.2:8080') + expect( + proxyRulesFromEnv({ + ALL_PROXY: 'socks5://1.1.1.1:1080', + https_proxy: 'http://2.2.2.2:8080', + }), + ).toBe('2.2.2.2:8080') }) it('returns null when unset or unparseable', () => { @@ -176,7 +203,12 @@ describe('isNetworkError', () => { it.each([ ['undici fetch failed with cause', netErr()], ['chromium net error', new Error('net::ERR_PROXY_CONNECTION_FAILED')], - ['timeout abort', Object.assign(new Error('The operation was aborted due to timeout'), { name: 'TimeoutError' })], + [ + 'timeout abort', + Object.assign(new Error('The operation was aborted due to timeout'), { + name: 'TimeoutError', + }), + ], ])('%s → true', (_name, err) => { expect(isNetworkError(err)).toBe(true) }) diff --git a/apps/app/src/main/scan-worker-proxy.ts b/apps/app/src/main/scan-worker-proxy.ts index 66d79182..073b7223 100644 --- a/apps/app/src/main/scan-worker-proxy.ts +++ b/apps/app/src/main/scan-worker-proxy.ts @@ -8,8 +8,10 @@ // See scan-worker-thread.ts for the worker side of the protocol. import { Worker } from 'node:worker_threads' -import { Effect, PubSub, Stream } from 'effect' + import type { FindingsChange, ScanStatus, ScanWorker } from '@spool-lab/core' +import { Effect, PubSub, Stream } from 'effect' + import type { FromWorker, ScanCommand, ToWorker } from './scan-worker-thread.js' export interface ScanWorkerProxy extends ScanWorker { @@ -25,7 +27,9 @@ export interface PfAnalyzeBridge { /** Run the inference round-trip for one chunk of text. Returning [] * is fine — the worker will treat it as "no PF matches". Errors get * bubbled back to the worker as pf-analyze-res ok:false. */ - analyze: (text: string) => Promise> + analyze: ( + text: string, + ) => Promise> } interface PendingCommand { @@ -68,7 +72,11 @@ export async function spawnScanWorker( // postMessage throws synchronously when the worker's MessagePort has // closed. Swallow it — caller is post-shutdown territory. function postSafe(msg: ToWorker): void { - try { worker.postMessage(msg) } catch { /* worker gone */ } + try { + worker.postMessage(msg) + } catch { + /* worker gone */ + } } function send(payload: ScanCommand): Promise { @@ -95,7 +103,9 @@ export async function spawnScanWorker( worker.off('message', onMessage) worker.off('error', onError) // Best-effort terminate; if it's already dead this is a no-op. - worker.terminate().catch(() => { /* nothing to do */ }) + worker.terminate().catch(() => { + /* nothing to do */ + }) reject(new Error(`scan worker did not report ready within ${BOOT_TIMEOUT_MS}ms`)) }, BOOT_TIMEOUT_MS) function clear(): void { @@ -162,7 +172,8 @@ export async function spawnScanWorker( postSafe({ type: 'pf-analyze-res', reqId, ok: true, matches: [] }) return } - pfBridge.analyze(text) + pfBridge + .analyze(text) .then((matches) => postSafe({ type: 'pf-analyze-res', reqId, ok: true, matches })) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) @@ -192,7 +203,10 @@ export async function spawnScanWorker( return { enqueue: (sessionId) => Effect.promise(() => send({ cmd: 'enqueue', sessionId })), rescanAll: () => Effect.promise(() => send({ cmd: 'rescanAll' })), - backfill: (opts) => Effect.promise(() => send({ cmd: 'backfill', userInitiated: opts?.userInitiated === true })), + backfill: (opts) => + Effect.promise(() => + send({ cmd: 'backfill', userInitiated: opts?.userInitiated === true }), + ), // getStatus uses the last pushed status when available — saves a // round-trip on every renderer mount. Falls back to a real call // before the first push lands (boolean sentinel because an empty diff --git a/apps/app/src/main/scan-worker-thread.ts b/apps/app/src/main/scan-worker-thread.ts index ff2a6cad..dc895fb4 100644 --- a/apps/app/src/main/scan-worker-thread.ts +++ b/apps/app/src/main/scan-worker-thread.ts @@ -23,9 +23,9 @@ // // Protocol — see `ToWorker` / `FromWorker` unions below. -import { parentPort, threadId } from 'node:worker_threads' import { join } from 'node:path' -import { Effect, Exit, Fiber, Scope, Stream } from 'effect' +import { parentPort, threadId } from 'node:worker_threads' + import { currentProfileString, getDB, @@ -36,14 +36,16 @@ import { type ScanStatus, } from '@spool-lab/core' import { regexProvider, type RedactProvider, type SensitiveMatch } from '@spool-lab/redact' -import { loadSecurityPreferences } from './securityPreferences.js' -import { mapPfMatches, setUnknownLabelSink, type PfRawMatch } from './security/class-mapping.js' import { detectWithRegex } from '@spool-lab/redact' +import { Effect, Exit, Fiber, Scope, Stream } from 'effect' + +import { mapPfMatches, setUnknownLabelSink, type PfRawMatch } from './security/class-mapping.js' // Import directly from pf-version (no `electron` import). Going through // model-paths.ts pulls `import { app } from 'electron'` into a shared // Rollup chunk and crashes the worker at load with // "Cannot find module 'electron'". import { PF_PROFILE_VERSION } from './security/pf-version.js' +import { loadSecurityPreferences } from './securityPreferences.js' if (!parentPort) { throw new Error('scan-worker-thread.ts is only meant to run as a worker_thread child') @@ -90,7 +92,9 @@ function reportFatal(err: unknown): void { const error = err instanceof Error ? (err.stack ?? err.message) : String(err) try { port.postMessage({ type: 'fatal', error } satisfies FromWorker) - } catch { /* parent gone */ } + } catch { + /* parent gone */ + } process.exit(1) } @@ -139,7 +143,10 @@ void (async () => { // anything before deciding whether to call this provider. let pfOnline = false let pfNextReqId = 1 - const pfPending = new Map void; reject: (e: Error) => void }>() + const pfPending = new Map< + number, + { resolve: (m: PfRawMatchWire[]) => void; reject: (e: Error) => void } + >() const pfProvider: RedactProvider = { name: 'pf', @@ -176,11 +183,12 @@ void (async () => { makeScanWorker({ db, providers: [regexProvider, pfProvider], - currentProfile: () => currentProfileString({ - kindAllowlist: loadSecurityPreferences().kindAllowlist, - pfEnabled: loadSecurityPreferences().pfEnabled && pfOnline, - pfVersion: PF_PROFILE_VERSION, - }), + currentProfile: () => + currentProfileString({ + kindAllowlist: loadSecurityPreferences().kindAllowlist, + pfEnabled: loadSecurityPreferences().pfEnabled && pfOnline, + pfVersion: PF_PROFILE_VERSION, + }), providerNames: ['regex', 'pf'], getKindAllowlist: () => new Set(loadSecurityPreferences().kindAllowlist), }), @@ -200,15 +208,17 @@ void (async () => { // tears down the fiber and every subsequent event is dropped. function safePost(msg: FromWorker): Effect.Effect { return Effect.sync(() => { - try { port.postMessage(msg) } catch { /* parent gone */ } + try { + port.postMessage(msg) + } catch { + /* parent gone */ + } }) } const changesFiber = await runEff( Effect.forkDaemon( - Stream.runForEach(worker.changes, (change) => - safePost({ type: 'event-change', change }), - ), + Stream.runForEach(worker.changes, (change) => safePost({ type: 'event-change', change })), ), ) const statusFiber = await runEff( @@ -235,7 +245,10 @@ void (async () => { })() return } - if (msg.type === 'pf-online') { pfOnline = true; return } + if (msg.type === 'pf-online') { + pfOnline = true + return + } if (msg.type === 'pf-offline') { pfOnline = false // Reject every pending analyze so the scan loop unblocks. @@ -264,7 +277,9 @@ void (async () => { result = await runEff(worker.rescanAll()) break case 'backfill': - result = await runEff(worker.backfill({ userInitiated: payload.userInitiated === true })) + result = await runEff( + worker.backfill({ userInitiated: payload.userInitiated === true }), + ) break case 'getStatus': result = await runEff(worker.getStatus) diff --git a/apps/app/src/main/security/class-mapping.test.ts b/apps/app/src/main/security/class-mapping.test.ts index 7061fd89..4bb5613d 100644 --- a/apps/app/src/main/security/class-mapping.test.ts +++ b/apps/app/src/main/security/class-mapping.test.ts @@ -1,14 +1,15 @@ -import { describe, it, expect, afterEach, vi } from 'vitest' import type { SensitiveMatch } from '@spool-lab/redact' -import { - mapPfMatch, - mapPfMatches, - setUnknownLabelSink, - type PfRawMatch, -} from './class-mapping.js' +import { describe, it, expect, afterEach, vi } from 'vite-plus/test' + +import { mapPfMatch, mapPfMatches, setUnknownLabelSink, type PfRawMatch } from './class-mapping.js' const regex = (kind: SensitiveMatch['kind'], start: number, end: number): SensitiveMatch => ({ - kind, value: '', start, end, confidence: 0.95, provider: 'regex', + kind, + value: '', + start, + end, + confidence: 0.95, + provider: 'regex', }) describe('class mapping — kept classes (high-precision on PII-only setting)', () => { @@ -60,7 +61,7 @@ describe('class mapping — suppression rules', () => { // Real-shape standalone secret — high score, ≥16 chars, high entropy. // Should land as generic-secret since regex doesn't know this prefix. it('high-confidence high-entropy ≥16 chars passes', () => { - const value = 'j82H1xK9pQrSt7VwYzA3bC5dF8gJ' // 28 random mixed chars + const value = 'j82H1xK9pQrSt7VwYzA3bC5dF8gJ' // 28 random mixed chars const pf: PfRawMatch = { class: 'secret', value, start: 0, end: value.length, score: 0.97 } expect(mapPfMatch(pf, { regexMatches: [], fullText: value })?.kind).toBe('generic-secret') }) @@ -70,11 +71,17 @@ describe('class mapping — suppression rules', () => { expect(mapPfMatch(pf, { regexMatches: [], fullText: value })).toBeNull() }) it('drops below 16 chars even at high confidence', () => { - const pf: PfRawMatch = { class: 'secret', value: 'shortpw1234', start: 0, end: 11, score: 0.99 } + const pf: PfRawMatch = { + class: 'secret', + value: 'shortpw1234', + start: 0, + end: 11, + score: 0.99, + } expect(mapPfMatch(pf, { regexMatches: [], fullText: 'shortpw1234' })).toBeNull() }) it('drops low-entropy strings (placeholder passwords)', () => { - const value = 'changemechangeme' // 16 chars but repetitive — low entropy + const value = 'changemechangeme' // 16 chars but repetitive — low entropy const pf: PfRawMatch = { class: 'secret', value, start: 0, end: value.length, score: 0.99 } expect(mapPfMatch(pf, { regexMatches: [], fullText: value })).toBeNull() }) @@ -82,7 +89,9 @@ describe('class mapping — suppression rules', () => { it('date alone is suppressed', () => { const pf: PfRawMatch = { class: 'date', value: '1990-05-15', start: 0, end: 10, score: 0.85 } - expect(mapPfMatch(pf, { regexMatches: [], fullText: 'random text 1990-05-15 nothing here' })).toBeNull() + expect( + mapPfMatch(pf, { regexMatches: [], fullText: 'random text 1990-05-15 nothing here' }), + ).toBeNull() }) it('date with DOB context → date-of-birth', () => { @@ -120,7 +129,13 @@ describe('class mapping — unknown-label observability', () => { it('invokes the sink with the unknown label (still drops the match)', () => { const sink = vi.fn() setUnknownLabelSink(sink) - const pf: PfRawMatch = { class: 'new_model_class', value: 'secret-ish', start: 0, end: 10, score: 0.99 } + const pf: PfRawMatch = { + class: 'new_model_class', + value: 'secret-ish', + start: 0, + end: 10, + score: 0.99, + } expect(mapPfMatch(pf, { regexMatches: [], fullText: 'secret-ish' })).toBeNull() expect(sink).toHaveBeenCalledTimes(1) expect(sink).toHaveBeenCalledWith('new_model_class') @@ -129,7 +144,13 @@ describe('class mapping — unknown-label observability', () => { it('passes only the label, never the matched value', () => { const sink = vi.fn() setUnknownLabelSink(sink) - const pf: PfRawMatch = { class: 'mystery', value: 'sensitive-user-content', start: 0, end: 22, score: 0.9 } + const pf: PfRawMatch = { + class: 'mystery', + value: 'sensitive-user-content', + start: 0, + end: 22, + score: 0.9, + } mapPfMatch(pf, { regexMatches: [], fullText: 'sensitive-user-content' }) expect(sink).toHaveBeenCalledWith('mystery') expect(sink.mock.calls[0]).not.toContain('sensitive-user-content') @@ -139,9 +160,15 @@ describe('class mapping — unknown-label observability', () => { const sink = vi.fn() setUnknownLabelSink(sink) // known class - mapPfMatch({ class: 'email', value: 'a@b.c', start: 0, end: 5, score: 0.99 }, { regexMatches: [], fullText: 'a@b.c' }) + mapPfMatch( + { class: 'email', value: 'a@b.c', start: 0, end: 5, score: 0.99 }, + { regexMatches: [], fullText: 'a@b.c' }, + ) // unknown but below the 0.85 floor — dropped before mapClass() - mapPfMatch({ class: 'mystery', value: 'x', start: 0, end: 1, score: 0.5 }, { regexMatches: [], fullText: 'x' }) + mapPfMatch( + { class: 'mystery', value: 'x', start: 0, end: 1, score: 0.5 }, + { regexMatches: [], fullText: 'x' }, + ) expect(sink).not.toHaveBeenCalled() }) @@ -171,11 +198,11 @@ describe('class mapping — unknown-label observability', () => { describe('mapPfMatches', () => { it('drops suppressed entries while keeping the rest', () => { const pf: PfRawMatch[] = [ - { class: 'person', value: 'Maya', start: 0, end: 4, score: 0.9 }, // disabled class + { class: 'person', value: 'Maya', start: 0, end: 4, score: 0.9 }, // disabled class { class: 'account_number', value: '0123456', start: 10, end: 17, score: 0.95 }, // disabled class - { class: 'email', value: 'a@b.c', start: 20, end: 25, score: 0.9 }, // kept + { class: 'email', value: 'a@b.c', start: 20, end: 25, score: 0.9 }, // kept ] const out = mapPfMatches(pf, { regexMatches: [], fullText: 'Maya 0123456 a@b.c' }) - expect(out.map(m => m.kind)).toEqual(['email']) + expect(out.map((m) => m.kind)).toEqual(['email']) }) }) diff --git a/apps/app/src/main/security/class-mapping.ts b/apps/app/src/main/security/class-mapping.ts index 937be96d..2b9a3b78 100644 --- a/apps/app/src/main/security/class-mapping.ts +++ b/apps/app/src/main/security/class-mapping.ts @@ -82,10 +82,7 @@ const DOB_CONTEXT_RX = /\b(dob|date\s+of\s+birth|born\s+on|birthday|d\.?o\.?b\.? * IDs + hashes) is well out of distribution. */ const PF_MIN_CONFIDENCE = 0.85 -export function mapPfMatch( - pf: PfRawMatch, - ctx: MappingContext, -): SensitiveMatch | null { +export function mapPfMatch(pf: PfRawMatch, ctx: MappingContext): SensitiveMatch | null { // Universal confidence floor — applied before any per-class logic // so noise gets dropped regardless of suppression rules. if (pf.score < PF_MIN_CONFIDENCE) return null diff --git a/apps/app/src/main/security/csp.test.ts b/apps/app/src/main/security/csp.test.ts index 1d444589..0c13c465 100644 --- a/apps/app/src/main/security/csp.test.ts +++ b/apps/app/src/main/security/csp.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vite-plus/test' import { __cspFixtures, buildCsp, buildPfInferenceCsp, isPfInferenceDocument } from './csp.js' @@ -161,7 +161,8 @@ describe('Renderer CSP policy', () => { it('prod keeps the canonical spool.pro family alongside the override', () => { // Published-share URLs and avatar links keep pointing at // spool.pro even when the API origin is overridden. - const connect = directives(buildCsp({ dev: false, backendOrigin: origin })).get('connect-src') ?? [] + const connect = + directives(buildCsp({ dev: false, backendOrigin: origin })).get('connect-src') ?? [] expect(connect).toContain('https://spool.pro') expect(connect).toContain('https://*.spool.pro') }) @@ -191,9 +192,17 @@ describe('Renderer CSP policy', () => { describe('Privacy Filter inference document', () => { it('selects only the dedicated inference HTML entry', () => { - expect(isPfInferenceDocument('file:///Applications/Spool.app/Contents/Resources/app.asar/out/renderer/pf-inference.html')).toBe(true) + expect( + isPfInferenceDocument( + 'file:///Applications/Spool.app/Contents/Resources/app.asar/out/renderer/pf-inference.html', + ), + ).toBe(true) expect(isPfInferenceDocument('http://localhost:5173/pf-inference.html')).toBe(true) - expect(isPfInferenceDocument('file:///Applications/Spool.app/Contents/Resources/app.asar/out/renderer/index.html')).toBe(false) + expect( + isPfInferenceDocument( + 'file:///Applications/Spool.app/Contents/Resources/app.asar/out/renderer/index.html', + ), + ).toBe(false) }) it('allows the local model protocol and WASM execution', () => { @@ -208,7 +217,9 @@ describe('Renderer CSP policy', () => { it('keeps production inference offline except for local schemes', () => { const connect = directives(buildPfInferenceCsp({ dev: false })).get('connect-src') ?? [] expect(connect).toEqual(["'self'", 'pf-model:', 'blob:']) - expect(connect.every(source => !source.startsWith('http') && !source.startsWith('ws'))).toBe(true) + expect( + connect.every((source) => !source.startsWith('http') && !source.startsWith('ws')), + ).toBe(true) }) }) }) diff --git a/apps/app/src/main/security/csp.ts b/apps/app/src/main/security/csp.ts index 4b9ec706..720f335f 100644 --- a/apps/app/src/main/security/csp.ts +++ b/apps/app/src/main/security/csp.ts @@ -39,8 +39,7 @@ import { backendUrl, DEFAULT_BACKEND } from '../share/backend-url.js' // local wrangler at :8788; in prod it's spool.pro. Without these // origins in img-src Chromium silently drops the request — the // network tab shows nothing and the renders as broken. -const IMG_ALLOW_DEV = - "'self' data: blob: https://lh3.googleusercontent.com http://localhost:8788" +const IMG_ALLOW_DEV = "'self' data: blob: https://lh3.googleusercontent.com http://localhost:8788" const IMG_ALLOW_PROD = "'self' data: blob: https://lh3.googleusercontent.com https://spool.pro https://*.spool.pro" @@ -166,42 +165,40 @@ export function installRendererCsp(opts: { dev: boolean }): void { if (process.env['SPOOL_DISABLE_CSP'] === '1') return const policy = buildCsp({ dev: opts.dev, backendOrigin: overrideBackendOrigin() }) const pfPolicy = opts.dev ? PF_DEV_CSP : PF_PROD_CSP - electronSession.defaultSession.webRequest.onHeadersReceived( - (details, callback) => { - // Only inject CSP into responses for the documents we actually - // ship. Chromium's built-in PDF viewer extension serves its UI - // off `chrome-extension://`; if we replace that response's CSP - // with ours the viewer can't load its own internal scripts / - // styles / embeds and the iframe paints blank grey. Extensions - // and Chromium-internal pages have their own appropriate CSPs; - // we should not impose ours on top of them. `data:` URLs are - // omitted deliberately — they're untrusted inline content, not - // documents we author, and Electron doesn't fire - // onHeadersReceived for them at the document level anyway. - const url = details.url || '' - const isAppDoc = - url.startsWith('http://') || - url.startsWith('https://') || - url.startsWith('file://') || - url.startsWith('blob:') - if (!isAppDoc) { - // No header changes — let Electron pass the response through. - callback({}) - return + electronSession.defaultSession.webRequest.onHeadersReceived((details, callback) => { + // Only inject CSP into responses for the documents we actually + // ship. Chromium's built-in PDF viewer extension serves its UI + // off `chrome-extension://`; if we replace that response's CSP + // with ours the viewer can't load its own internal scripts / + // styles / embeds and the iframe paints blank grey. Extensions + // and Chromium-internal pages have their own appropriate CSPs; + // we should not impose ours on top of them. `data:` URLs are + // omitted deliberately — they're untrusted inline content, not + // documents we author, and Electron doesn't fire + // onHeadersReceived for them at the document level anyway. + const url = details.url || '' + const isAppDoc = + url.startsWith('http://') || + url.startsWith('https://') || + url.startsWith('file://') || + url.startsWith('blob:') + if (!isAppDoc) { + // No header changes — let Electron pass the response through. + callback({}) + return + } + const responseHeaders = { ...details.responseHeaders } + // Strip any inbound CSP — Vite dev server in particular sends a + // permissive header and Electron will pick whichever is most + // restrictive; replacing avoids any union surprises. + for (const key of Object.keys(responseHeaders)) { + if (key.toLowerCase() === 'content-security-policy') { + delete responseHeaders[key] } - const responseHeaders = { ...details.responseHeaders } - // Strip any inbound CSP — Vite dev server in particular sends a - // permissive header and Electron will pick whichever is most - // restrictive; replacing avoids any union surprises. - for (const key of Object.keys(responseHeaders)) { - if (key.toLowerCase() === 'content-security-policy') { - delete responseHeaders[key] - } - } - responseHeaders['Content-Security-Policy'] = [isPfInferenceDocument(url) ? pfPolicy : policy] - callback({ responseHeaders }) - }, - ) + } + responseHeaders['Content-Security-Policy'] = [isPfInferenceDocument(url) ? pfPolicy : policy] + callback({ responseHeaders }) + }) } // Exported for snapshot tests so policy drift gets caught in CI without diff --git a/apps/app/src/main/security/model-download.test.ts b/apps/app/src/main/security/model-download.test.ts index 78c9ad95..78b959ad 100644 --- a/apps/app/src/main/security/model-download.test.ts +++ b/apps/app/src/main/security/model-download.test.ts @@ -1,8 +1,10 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { createHash } from 'node:crypto' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vite-plus/test' + import { downloadModel, DownloadError } from './model-download.js' import type { ModelManifest } from './model-manifest.js' @@ -20,7 +22,9 @@ function makeManifest(files: { path: string; body: Buffer }[]): ModelManifest { } } -function makeFetch(responses: Record): typeof globalThis.fetch { +function makeFetch( + responses: Record, +): typeof globalThis.fetch { return (async (input: RequestInfo | URL, init?: RequestInit) => { const url = typeof input === 'string' ? input : input.toString() const r = responses[url] @@ -37,21 +41,30 @@ function makeFetch(responses: Record) } let tmp: string -beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'spool-pf-dl-')) }) -afterEach(() => { rmSync(tmp, { recursive: true, force: true }) }) +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'spool-pf-dl-')) +}) +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) +}) describe('downloadModel', () => { it('downloads every manifest file, verifying SHA-256', async () => { const a = Buffer.from('aaaaaaaa') const b = Buffer.from('bbbbbbbbbb') - const manifest = makeManifest([{ path: 'a.bin', body: a }, { path: 'b.bin', body: b }]) + const manifest = makeManifest([ + { path: 'a.bin', body: a }, + { path: 'b.bin', body: b }, + ]) const fetchFn = makeFetch({ [`https://huggingface.co/${manifest.hfRepo}/resolve/${manifest.hfCommit}/a.bin`]: { body: a }, [`https://huggingface.co/${manifest.hfRepo}/resolve/${manifest.hfCommit}/b.bin`]: { body: b }, }) const progress: number[] = [] await downloadModel({ - modelDir: tmp, manifest, fetch: fetchFn, + modelDir: tmp, + manifest, + fetch: fetchFn, onProgress: (p) => progress.push(p.bytesDownloaded), }) expect(readFileSync(join(tmp, 'a.bin'))).toEqual(a) @@ -71,7 +84,7 @@ describe('downloadModel', () => { it('resumes from a partial .part using Range requests', async () => { const body = Buffer.from('0123456789ABCDEF') const manifest = makeManifest([{ path: 'big.bin', body }]) - writeFileSync(join(tmp, 'big.bin.part'), body.subarray(0, 6)) // 6 of 16 bytes already fetched + writeFileSync(join(tmp, 'big.bin.part'), body.subarray(0, 6)) // 6 of 16 bytes already fetched const url = `https://huggingface.co/${manifest.hfRepo}/resolve/${manifest.hfCommit}/big.bin` const rangeRequests: (string | undefined)[] = [] const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { @@ -92,20 +105,24 @@ describe('downloadModel', () => { const declared = Buffer.from('expected') const served = Buffer.from('tampered') const manifest: ModelManifest = { - hfRepo: 'r', hfCommit: 'c'.repeat(40), + hfRepo: 'r', + hfCommit: 'c'.repeat(40), files: [{ path: 'x.bin', sha256: sha256(declared), sizeBytes: declared.length }], } const url = `https://huggingface.co/r/resolve/${'c'.repeat(40)}/x.bin` const fetchFn = makeFetch({ [url]: { body: served } }) - await expect(downloadModel({ modelDir: tmp, manifest, fetch: fetchFn })) - .rejects.toBeInstanceOf(DownloadError) + await expect(downloadModel({ modelDir: tmp, manifest, fetch: fetchFn })).rejects.toBeInstanceOf( + DownloadError, + ) }) it('throws DownloadError on a non-2xx response', async () => { const manifest = makeManifest([{ path: 'x.bin', body: Buffer.from('x') }]) - const fetchFn = (async () => new Response('not found', { status: 404 })) as typeof globalThis.fetch - await expect(downloadModel({ modelDir: tmp, manifest, fetch: fetchFn })) - .rejects.toBeInstanceOf(DownloadError) + const fetchFn = (async () => + new Response('not found', { status: 404 })) as typeof globalThis.fetch + await expect(downloadModel({ modelDir: tmp, manifest, fetch: fetchFn })).rejects.toBeInstanceOf( + DownloadError, + ) }) it('cancels mid-stream when the AbortSignal fires', async () => { @@ -121,9 +138,14 @@ describe('downloadModel', () => { sig?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))) }) }) as typeof globalThis.fetch - await expect(downloadModel({ - modelDir: tmp, manifest, fetch: fetchFn, signal: controller.signal, - })).rejects.toBeTruthy() + await expect( + downloadModel({ + modelDir: tmp, + manifest, + fetch: fetchFn, + signal: controller.signal, + }), + ).rejects.toBeTruthy() // No final renamed file should appear. expect(existsSync(join(tmp, 'x.bin'))).toBe(false) }) @@ -131,7 +153,7 @@ describe('downloadModel', () => { it('discards a corrupt resume that is larger than expected', async () => { const body = Buffer.from('abcd') const manifest = makeManifest([{ path: 'x.bin', body }]) - writeFileSync(join(tmp, 'x.bin.part'), Buffer.from('zzzzzzzzzzzzz')) // larger than expectedSize + writeFileSync(join(tmp, 'x.bin.part'), Buffer.from('zzzzzzzzzzzzz')) // larger than expectedSize const url = `https://huggingface.co/${manifest.hfRepo}/resolve/${manifest.hfCommit}/x.bin` const fetchFn = makeFetch({ [url]: { body } }) await downloadModel({ modelDir: tmp, manifest, fetch: fetchFn }) diff --git a/apps/app/src/main/security/model-download.ts b/apps/app/src/main/security/model-download.ts index ac061c3f..3c268402 100644 --- a/apps/app/src/main/security/model-download.ts +++ b/apps/app/src/main/security/model-download.ts @@ -4,10 +4,19 @@ // is injectable so tests don't touch the network. import { createHash } from 'node:crypto' -import { createWriteStream, statSync, renameSync, existsSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs' +import { + createWriteStream, + statSync, + renameSync, + existsSync, + mkdirSync, + readFileSync, + unlinkSync, +} from 'node:fs' import { dirname, join } from 'node:path' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' + import type { ManifestFile, ModelManifest } from './model-manifest.js' import { manifestFileUrl, manifestTotalBytes } from './model-manifest.js' @@ -113,10 +122,10 @@ export async function downloadModel(opts: DownloadOptions): Promise { }) if (!fileMatches(partPath, file)) { - throw new DownloadError( - `SHA-256 mismatch for ${file.path}`, - { file: file.path, stage: 'hash' }, - ) + throw new DownloadError(`SHA-256 mismatch for ${file.path}`, { + file: file.path, + stage: 'hash', + }) } renameSync(partPath, finalPath) @@ -152,23 +161,23 @@ async function fetchAndAppend(args: { // useful message instead of a bare "fetch failed". const host = safeHost(args.url) const detail = cause instanceof Error ? cause.message : String(cause) - throw new DownloadError( - `Couldn't reach ${host} (${detail}). Check network / proxy.`, - { file: args.partPath, stage: 'http' }, - ) + throw new DownloadError(`Couldn't reach ${host} (${detail}). Check network / proxy.`, { + file: args.partPath, + stage: 'http', + }) } if (args.startOffset > 0 && res.status !== 206 && res.status !== 200) { - throw new DownloadError( - `Unexpected resume status ${res.status} for ${args.url}`, - { file: args.partPath, stage: 'http' }, - ) + throw new DownloadError(`Unexpected resume status ${res.status} for ${args.url}`, { + file: args.partPath, + stage: 'http', + }) } if (args.startOffset === 0 && !res.ok) { - throw new DownloadError( - `HTTP ${res.status} for ${args.url}`, - { file: args.partPath, stage: 'http' }, - ) + throw new DownloadError(`HTTP ${res.status} for ${args.url}`, { + file: args.partPath, + stage: 'http', + }) } if (!res.body) { throw new DownloadError('Response missing body', { file: args.partPath, stage: 'http' }) @@ -196,5 +205,9 @@ function fileMatches(path: string, file: ManifestFile): boolean { } function safeHost(url: string): string { - try { return new URL(url).host } catch { return url } + try { + return new URL(url).host + } catch { + return url + } } diff --git a/apps/app/src/main/security/model-host.test.ts b/apps/app/src/main/security/model-host.test.ts index 3cec40df..35b45686 100644 --- a/apps/app/src/main/security/model-host.test.ts +++ b/apps/app/src/main/security/model-host.test.ts @@ -1,9 +1,11 @@ -import { describe, it, expect, vi } from 'vitest' import { EventEmitter } from 'node:events' + import { Effect } from 'effect' import type { BrowserWindow, IpcMainEvent } from 'electron' -import { makeModelHost, type ModelHostDeps } from './model-host.js' +import { describe, it, expect, vi } from 'vite-plus/test' + import { PF_IPC, type PfReadyMessage } from '../../renderer/inference/types.js' +import { makeModelHost, type ModelHostDeps } from './model-host.js' type FakeWindow = BrowserWindow & { destroyed: boolean; crash: () => void } @@ -17,9 +19,19 @@ function fakeBrowserWindow(id: number): FakeWindow { const w: Partial = { destroyed: false, webContents: wc, - destroy() { (w as FakeWindow).destroyed = true }, - isDestroyed() { return (w as FakeWindow).destroyed }, - crash() { (wc as unknown as EventEmitter).emit('render-process-gone', {}, { reason: 'crashed', exitCode: 139 }) }, + destroy() { + ;(w as FakeWindow).destroyed = true + }, + isDestroyed() { + return (w as FakeWindow).destroyed + }, + crash() { + ;(wc as unknown as EventEmitter).emit( + 'render-process-gone', + {}, + { reason: 'crashed', exitCode: 139 }, + ) + }, } return w as FakeWindow } @@ -28,8 +40,14 @@ function fakeIpc() { const bus = new EventEmitter() bus.setMaxListeners(50) const ipc = { - on: (ch: string, fn: (...args: unknown[]) => void) => { bus.on(ch, fn); return ipc }, - removeListener: (ch: string, fn: (...args: unknown[]) => void) => { bus.removeListener(ch, fn); return ipc }, + on: (ch: string, fn: (...args: unknown[]) => void) => { + bus.on(ch, fn) + return ipc + }, + removeListener: (ch: string, fn: (...args: unknown[]) => void) => { + bus.removeListener(ch, fn) + return ipc + }, } return { ipc, bus } } @@ -38,7 +56,10 @@ const senderEvent = (id: number) => ({ sender: { id } }) as IpcMainEvent /** Build deps that spawn a fake window with the given id and immediately * schedule a ready (or failed) emission from that sender. */ -function depsEmittingReady(id: number, payload: PfReadyMessage): { deps: ModelHostDeps; win: FakeWindow } { +function depsEmittingReady( + id: number, + payload: PfReadyMessage, +): { deps: ModelHostDeps; win: FakeWindow } { const win = fakeBrowserWindow(id) const { ipc, bus } = fakeIpc() const deps: ModelHostDeps = { @@ -54,12 +75,18 @@ function depsEmittingReady(id: number, payload: PfReadyMessage): { deps: ModelHo describe('makeModelHost', () => { it('reports ready + runtime once the inference window fires pf:ready', async () => { const { deps } = depsEmittingReady(11, { - runtime: 'webgpu', adapterLabel: 'Apple M2 Pro', detectionMs: 42, + runtime: 'webgpu', + adapterLabel: 'Apple M2 Pro', + detectionMs: 42, }) - const out = await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const host = yield* makeModelHost(deps) - return { state: yield* host.getState, isReady: yield* host.ready } - }))) + const out = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* makeModelHost(deps) + return { state: yield* host.getState, isReady: yield* host.ready } + }), + ), + ) expect(out.isReady).toBe(true) expect(out.state.status).toBe('ready') expect(out.state.runtime).toBe('webgpu') @@ -76,18 +103,27 @@ describe('makeModelHost', () => { it('transitions to failed and fires onCrash on a post-handshake render-process-gone', async () => { const { deps, win } = depsEmittingReady(14, { runtime: 'wasm', detectionMs: 3 }) let crashes = 0 - const out = await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const host = yield* makeModelHost({ ...deps, onCrash: () => { crashes++ } }) - const readyBefore = yield* host.ready - // Renderer dies after a successful handshake. Pre-fix the host - // kept reporting `ready`, so scans silently went regex-only while - // the profile string still claimed pf@... coverage. - win.crash() - yield* Effect.sleep('1 millis') - const stateAfter = yield* host.getState - const readyAfter = yield* host.ready - return { readyBefore, stateAfter, readyAfter } - }))) + const out = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* makeModelHost({ + ...deps, + onCrash: () => { + crashes++ + }, + }) + const readyBefore = yield* host.ready + // Renderer dies after a successful handshake. Pre-fix the host + // kept reporting `ready`, so scans silently went regex-only while + // the profile string still claimed pf@... coverage. + win.crash() + yield* Effect.sleep('1 millis') + const stateAfter = yield* host.getState + const readyAfter = yield* host.ready + return { readyBefore, stateAfter, readyAfter } + }), + ), + ) expect(out.readyBefore).toBe(true) expect(out.readyAfter).toBe(false) expect(out.stateAfter.status).toBe('failed') @@ -97,12 +133,16 @@ describe('makeModelHost', () => { it('does not double-destroy if the window is already gone', async () => { const { deps, win } = depsEmittingReady(13, { runtime: 'wasm', detectionMs: 5 }) const destroy = vi.spyOn(win, 'destroy') - await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - yield* makeModelHost(deps) - // Simulate the window getting destroyed mid-scope (e.g. user - // explicitly killed it). Release path must NOT throw. - win.destroyed = true - }))) + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + yield* makeModelHost(deps) + // Simulate the window getting destroyed mid-scope (e.g. user + // explicitly killed it). Release path must NOT throw. + win.destroyed = true + }), + ), + ) expect(destroy).not.toHaveBeenCalled() }) @@ -112,22 +152,36 @@ describe('makeModelHost', () => { const deps: ModelHostDeps = { spawnWindow: async () => { // Wrong sender id arrives first — should be ignored. - setTimeout(() => bus.emit(PF_IPC.READY, senderEvent(99), { - runtime: 'webgpu', detectionMs: 1, - }), 0) + setTimeout( + () => + bus.emit(PF_IPC.READY, senderEvent(99), { + runtime: 'webgpu', + detectionMs: 1, + }), + 0, + ) // Real sender follows. - setTimeout(() => bus.emit(PF_IPC.READY, senderEvent(20), { - runtime: 'wasm', detectionMs: 3, - }), 5) + setTimeout( + () => + bus.emit(PF_IPC.READY, senderEvent(20), { + runtime: 'wasm', + detectionMs: 3, + }), + 5, + ) return win }, ipc, readyTimeoutMs: 200, } - const out = await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const host = yield* makeModelHost(deps) - return yield* host.getState - }))) + const out = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* makeModelHost(deps) + return yield* host.getState + }), + ), + ) expect(out.runtime).toBe('wasm') }) @@ -139,10 +193,14 @@ describe('makeModelHost', () => { ipc, readyTimeoutMs: 25, } - const out = await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const host = yield* makeModelHost(deps) - return { state: yield* host.getState, isReady: yield* host.ready } - }))) + const out = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* makeModelHost(deps) + return { state: yield* host.getState, isReady: yield* host.ready } + }), + ), + ) expect(out.isReady).toBe(false) expect(out.state.status).toBe('failed') expect(out.state.error).toMatch(/timed out/) @@ -154,18 +212,22 @@ describe('makeModelHost', () => { const { ipc, bus } = fakeIpc() const deps: ModelHostDeps = { spawnWindow: async () => { - setTimeout(() => - bus.emit(PF_IPC.FAILED, senderEvent(22), { message: 'adapter request threw' }), + setTimeout( + () => bus.emit(PF_IPC.FAILED, senderEvent(22), { message: 'adapter request threw' }), 0, ) return win }, ipc, } - const out = await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const host = yield* makeModelHost(deps) - return yield* host.getState - }))) + const out = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* makeModelHost(deps) + return yield* host.getState + }), + ), + ) expect(out.status).toBe('failed') expect(out.error).toBe('adapter request threw') }) @@ -173,13 +235,19 @@ describe('makeModelHost', () => { it('lands in failed state when spawnWindow rejects', async () => { const { ipc } = fakeIpc() const deps: ModelHostDeps = { - spawnWindow: async () => { throw new Error('window creation blew up') }, + spawnWindow: async () => { + throw new Error('window creation blew up') + }, ipc, } - const out = await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const host = yield* makeModelHost(deps) - return yield* host.getState - }))) + const out = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* makeModelHost(deps) + return yield* host.getState + }), + ), + ) expect(out.status).toBe('failed') expect(out.error).toMatch(/blew up/) }) @@ -187,14 +255,18 @@ describe('makeModelHost', () => { it('analyze fails fast when the host is not ready', async () => { const win = fakeBrowserWindow(30) const { ipc } = fakeIpc() - const result = await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const host = yield* makeModelHost({ - spawnWindow: async () => win, - ipc, - readyTimeoutMs: 20, - }) - return yield* Effect.either(host.analyze('hello')) - }))) + const result = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* makeModelHost({ + spawnWindow: async () => win, + ipc, + readyTimeoutMs: 20, + }) + return yield* Effect.either(host.analyze('hello')) + }), + ), + ) expect(result._tag).toBe('Left') }) @@ -203,9 +275,14 @@ describe('makeModelHost', () => { const { ipc, bus } = fakeIpc() const deps: ModelHostDeps = { spawnWindow: async () => { - setTimeout(() => bus.emit(PF_IPC.READY, senderEvent(31), { - runtime: 'webgpu', detectionMs: 1, - }), 0) + setTimeout( + () => + bus.emit(PF_IPC.READY, senderEvent(31), { + runtime: 'webgpu', + detectionMs: 1, + }), + 0, + ) return win }, ipc, @@ -215,15 +292,26 @@ describe('makeModelHost', () => { win.webContents.sendImpl = (channel: string, payload: unknown) => { if (channel !== PF_IPC.ANALYZE_REQUEST) return const req = payload as { reqId: number; text: string } - setTimeout(() => bus.emit(PF_IPC.ANALYZE_RESULT, senderEvent(31), { - reqId: req.reqId, ok: true, - matches: [{ class: 'email', value: req.text, start: 0, end: req.text.length, score: 0.9 }], - }), 0) + setTimeout( + () => + bus.emit(PF_IPC.ANALYZE_RESULT, senderEvent(31), { + reqId: req.reqId, + ok: true, + matches: [ + { class: 'email', value: req.text, start: 0, end: req.text.length, score: 0.9 }, + ], + }), + 0, + ) } - const matches = await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const host = yield* makeModelHost(deps) - return yield* host.analyze('a@b.c') - }))) + const matches = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* makeModelHost(deps) + return yield* host.analyze('a@b.c') + }), + ), + ) expect(matches).toEqual([{ class: 'email', value: 'a@b.c', start: 0, end: 5, score: 0.9 }]) }) @@ -232,9 +320,14 @@ describe('makeModelHost', () => { const { ipc, bus } = fakeIpc() const deps: ModelHostDeps = { spawnWindow: async () => { - setTimeout(() => bus.emit(PF_IPC.READY, senderEvent(32), { - runtime: 'wasm', detectionMs: 1, - }), 0) + setTimeout( + () => + bus.emit(PF_IPC.READY, senderEvent(32), { + runtime: 'wasm', + detectionMs: 1, + }), + 0, + ) return win }, ipc, @@ -244,14 +337,24 @@ describe('makeModelHost', () => { if (channel !== PF_IPC.ANALYZE_REQUEST) return const req = payload as { reqId: number; text: string } // Wrong sender — should be ignored, request times out. - setTimeout(() => bus.emit(PF_IPC.ANALYZE_RESULT, senderEvent(999), { - reqId: req.reqId, ok: true, matches: [], - }), 0) + setTimeout( + () => + bus.emit(PF_IPC.ANALYZE_RESULT, senderEvent(999), { + reqId: req.reqId, + ok: true, + matches: [], + }), + 0, + ) } - const out = await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const host = yield* makeModelHost(deps) - return yield* Effect.either(host.analyze('x')) - }))) + const out = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* makeModelHost(deps) + return yield* Effect.either(host.analyze('x')) + }), + ), + ) expect(out._tag).toBe('Left') }) @@ -260,19 +363,30 @@ describe('makeModelHost', () => { const { ipc, bus } = fakeIpc() const deps: ModelHostDeps = { spawnWindow: async () => { - setTimeout(() => bus.emit(PF_IPC.READY, senderEvent(33), { - runtime: 'wasm', detectionMs: 1, - }), 0) + setTimeout( + () => + bus.emit(PF_IPC.READY, senderEvent(33), { + runtime: 'wasm', + detectionMs: 1, + }), + 0, + ) return win }, ipc, analyzeTimeoutMs: 30, } - win.webContents.sendImpl = () => { /* swallow */ } - const out = await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const host = yield* makeModelHost(deps) - return yield* Effect.either(host.analyze('x')) - }))) + win.webContents.sendImpl = () => { + /* swallow */ + } + const out = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* makeModelHost(deps) + return yield* Effect.either(host.analyze('x')) + }), + ), + ) expect(out._tag).toBe('Left') if (out._tag === 'Left') { expect(String(out.left.cause)).toMatch(/timed out/) @@ -284,9 +398,14 @@ describe('makeModelHost', () => { const { ipc, bus } = fakeIpc() const deps: ModelHostDeps = { spawnWindow: async () => { - setTimeout(() => bus.emit(PF_IPC.READY, senderEvent(34), { - runtime: 'wasm', detectionMs: 1, - }), 0) + setTimeout( + () => + bus.emit(PF_IPC.READY, senderEvent(34), { + runtime: 'wasm', + detectionMs: 1, + }), + 0, + ) return win }, ipc, @@ -294,14 +413,24 @@ describe('makeModelHost', () => { win.webContents.sendImpl = (channel: string, payload: unknown) => { if (channel !== PF_IPC.ANALYZE_REQUEST) return const req = payload as { reqId: number } - setTimeout(() => bus.emit(PF_IPC.ANALYZE_RESULT, senderEvent(34), { - reqId: req.reqId, ok: false, message: 'model crashed', - }), 0) + setTimeout( + () => + bus.emit(PF_IPC.ANALYZE_RESULT, senderEvent(34), { + reqId: req.reqId, + ok: false, + message: 'model crashed', + }), + 0, + ) } - const out = await Effect.runPromise(Effect.scoped(Effect.gen(function* () { - const host = yield* makeModelHost(deps) - return yield* Effect.either(host.analyze('x')) - }))) + const out = await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const host = yield* makeModelHost(deps) + return yield* Effect.either(host.analyze('x')) + }), + ), + ) expect(out._tag).toBe('Left') if (out._tag === 'Left') { expect(String(out.left.cause)).toBe('model crashed') @@ -322,7 +451,11 @@ function fakeBrowserWindowWithSender(id: number): FakeWindowWithSender { // `render-process-gone` listener attaches, then layer on send. const w = fakeBrowserWindow(id) as unknown as FakeWindowWithSender const wc = w.webContents as FakeWindowWithSender['webContents'] - ;(wc as unknown as { send: (channel: string, payload: unknown) => void }).send = - (channel: string, payload: unknown) => { wc.sendImpl?.(channel, payload) } + ;(wc as unknown as { send: (channel: string, payload: unknown) => void }).send = ( + channel: string, + payload: unknown, + ) => { + wc.sendImpl?.(channel, payload) + } return w } diff --git a/apps/app/src/main/security/model-host.ts b/apps/app/src/main/security/model-host.ts index 3f4f590f..6352036d 100644 --- a/apps/app/src/main/security/model-host.ts +++ b/apps/app/src/main/security/model-host.ts @@ -8,10 +8,14 @@ import { Effect, Ref, Data, Scope } from 'effect' import { ipcMain, type BrowserWindow, type IpcMainEvent } from 'electron' + import { PF_IPC, - type PfReadyMessage, type PfFailedMessage, type PfRuntime, - type PfAnalyzeRequest, type PfAnalyzeResult, + type PfReadyMessage, + type PfFailedMessage, + type PfRuntime, + type PfAnalyzeRequest, + type PfAnalyzeResult, } from '../../renderer/inference/types.js' export type { PfRuntime } @@ -77,9 +81,7 @@ export interface ModelHostDeps { * `failed` and `ready` stays false. Callers check `getState` before * enabling pf in the UI, so a failed spawn doesn't tear the whole * app down. */ -export function makeModelHost( - deps: ModelHostDeps, -): Effect.Effect { +export function makeModelHost(deps: ModelHostDeps): Effect.Effect { return Effect.gen(function* () { const ipc = deps.ipc ?? ipcMain // 90 s default for pf:ready — cold WASM model load can take close @@ -87,11 +89,14 @@ export function makeModelHost( const readyTimeoutMs = deps.readyTimeoutMs ?? 90_000 const analyzeTimeoutMs = deps.analyzeTimeoutMs ?? 30_000 const stateRef = yield* Ref.make({ status: 'loading', runtime: null }) - const pending = new Map void - reject: (err: ModelHostError) => void - timer: ReturnType - }>() + const pending = new Map< + number, + { + resolve: (matches: PfMatchResult[]) => void + reject: (err: ModelHostError) => void + timer: ReturnType + } + >() let nextReqId = 1 const failWith = (err: unknown) => @@ -129,7 +134,9 @@ export function makeModelHost( status: 'ready', runtime: handshake.runtime, detectionMs: handshake.detectionMs, - ...(handshake.adapterLabel !== undefined ? { adapterLabel: handshake.adapterLabel } : {}), + ...(handshake.adapterLabel !== undefined + ? { adapterLabel: handshake.adapterLabel } + : {}), }) } else if (handshake?.kind === 'failed') { yield* Ref.set(stateRef, { status: 'failed', runtime: null, error: handshake.message }) @@ -158,12 +165,19 @@ export function makeModelHost( const wc = win.webContents const onRenderGone = () => { rejectAllPending('render process gone') - void Effect.runPromise(failWith(new ModelHostError({ stage: 'analyze', cause: 'render process gone' }))) + void Effect.runPromise( + failWith(new ModelHostError({ stage: 'analyze', cause: 'render process gone' })), + ) deps.onCrash?.() } yield* Effect.acquireRelease( - Effect.sync(() => { wc.on('render-process-gone', onRenderGone) }), - () => Effect.sync(() => { wc.removeListener('render-process-gone', onRenderGone) }), + Effect.sync(() => { + wc.on('render-process-gone', onRenderGone) + }), + () => + Effect.sync(() => { + wc.removeListener('render-process-gone', onRenderGone) + }), ) } @@ -185,8 +199,13 @@ export function makeModelHost( } } yield* Effect.acquireRelease( - Effect.sync(() => { ipc.on(PF_IPC.ANALYZE_RESULT, onAnalyzeResult) }), - () => Effect.sync(() => { ipc.removeListener(PF_IPC.ANALYZE_RESULT, onAnalyzeResult) }), + Effect.sync(() => { + ipc.on(PF_IPC.ANALYZE_RESULT, onAnalyzeResult) + }), + () => + Effect.sync(() => { + ipc.removeListener(PF_IPC.ANALYZE_RESULT, onAnalyzeResult) + }), ) function analyze(text: string): Effect.Effect { @@ -199,10 +218,14 @@ export function makeModelHost( const result = yield* Effect.async((resume) => { const timer = setTimeout(() => { pending.delete(reqId) - resume(Effect.fail(new ModelHostError({ - stage: 'analyze', - cause: `analyze timed out after ${analyzeTimeoutMs}ms`, - }))) + resume( + Effect.fail( + new ModelHostError({ + stage: 'analyze', + cause: `analyze timed out after ${analyzeTimeoutMs}ms`, + }), + ), + ) }, analyzeTimeoutMs) pending.set(reqId, { resolve: (matches) => resume(Effect.succeed(matches)), @@ -267,7 +290,8 @@ function awaitReady( if (event.sender.id === targetId) settle({ kind: 'failed', message: payload.message }) } const timer = setTimeout( - () => settle({ kind: 'failed', message: `pf:ready handshake timed out after ${timeoutMs}ms` }), + () => + settle({ kind: 'failed', message: `pf:ready handshake timed out after ${timeoutMs}ms` }), timeoutMs, ) ipc.on(PF_IPC.READY, onReady) diff --git a/apps/app/src/main/security/model-manifest.ts b/apps/app/src/main/security/model-manifest.ts index 3f513da0..718eaec5 100644 --- a/apps/app/src/main/security/model-manifest.ts +++ b/apps/app/src/main/security/model-manifest.ts @@ -42,15 +42,39 @@ export const MODEL_MANIFEST: ModelManifest = { hfRepo: PF_HF_REPO, hfCommit: '7ffa9a043d54d1be65afb281eddf0ffbe629385b', files: [ - { path: 'config.json', sha256: 'b2b26a4a4a000639ad30b0c264adbefe365bdb567fbd7bb27303b8c438375bd1', sizeBytes: 3039 }, - { path: 'tokenizer.json', sha256: '0614fe83cadab421296e664e1f48f4261fa8fef6e03e63bb75c20f38e37d07d3', sizeBytes: 27868174 }, - { path: 'tokenizer_config.json', sha256: '6c14af9ce1a284d3c3c5146b26efe4cd589c68e1dd4e9d94455606ec911ba774', sizeBytes: 234 }, - { path: 'viterbi_calibration.json', sha256: 'bbc8611ef08a55ed72d64856cbbbb9a91db8dfa881f0a92e2afbad6e4bbc775a', sizeBytes: 372 }, - { path: 'onnx/model_q4.onnx', sha256: '8f7dee8b46d096f052b359375dfba5d983cc4d18c44a783bf548615c472f8dea', sizeBytes: 160219 }, + { + path: 'config.json', + sha256: 'b2b26a4a4a000639ad30b0c264adbefe365bdb567fbd7bb27303b8c438375bd1', + sizeBytes: 3039, + }, + { + path: 'tokenizer.json', + sha256: '0614fe83cadab421296e664e1f48f4261fa8fef6e03e63bb75c20f38e37d07d3', + sizeBytes: 27868174, + }, + { + path: 'tokenizer_config.json', + sha256: '6c14af9ce1a284d3c3c5146b26efe4cd589c68e1dd4e9d94455606ec911ba774', + sizeBytes: 234, + }, + { + path: 'viterbi_calibration.json', + sha256: 'bbc8611ef08a55ed72d64856cbbbb9a91db8dfa881f0a92e2afbad6e4bbc775a', + sizeBytes: 372, + }, + { + path: 'onnx/model_q4.onnx', + sha256: '8f7dee8b46d096f052b359375dfba5d983cc4d18c44a783bf548615c472f8dea', + sizeBytes: 160219, + }, // Weights spill into an external `.onnx_data` file when the ONNX // graph exceeds protobuf's 2 GB limit. transformers.js loads it // automatically as long as it sits next to the .onnx graph. - { path: 'onnx/model_q4.onnx_data', sha256: 'f30998e28c71c5374cc7e8b7de8f0f83e981592c0c2d652d2ad4928454dbb496', sizeBytes: 917120144 }, + { + path: 'onnx/model_q4.onnx_data', + sha256: 'f30998e28c71c5374cc7e8b7de8f0f83e981592c0c2d652d2ad4928454dbb496', + sizeBytes: 917120144, + }, ], } diff --git a/apps/app/src/main/security/model-paths.ts b/apps/app/src/main/security/model-paths.ts index c0ce0b81..3fbde086 100644 --- a/apps/app/src/main/security/model-paths.ts +++ b/apps/app/src/main/security/model-paths.ts @@ -6,8 +6,10 @@ // can read them without dragging this file's `electron` import into the // worker chunk. -import { app } from 'electron' import { join } from 'node:path' + +import { app } from 'electron' + import { PF_MODEL_ID } from './pf-version.js' export { PF_MODEL_ID, PF_MODEL_VERSION, PF_HF_REPO, PF_PROFILE_VERSION } from './pf-version.js' diff --git a/apps/app/src/main/security/model-state.test.ts b/apps/app/src/main/security/model-state.test.ts index 0b108c50..fc664b48 100644 --- a/apps/app/src/main/security/model-state.test.ts +++ b/apps/app/src/main/security/model-state.test.ts @@ -1,23 +1,30 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { createHash } from 'node:crypto' import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { pfInstallStatus } from './model-state.js' + +import { describe, it, expect, beforeEach, afterEach } from 'vite-plus/test' + import type { ModelManifest } from './model-manifest.js' +import { pfInstallStatus } from './model-state.js' const sha256 = (buf: Buffer) => createHash('sha256').update(buf).digest('hex') function manifest(files: { path: string; body: Buffer }[]): ModelManifest { return { - hfRepo: 'r', hfCommit: 'c'.repeat(40), + hfRepo: 'r', + hfCommit: 'c'.repeat(40), files: files.map(({ path, body }) => ({ path, sha256: sha256(body), sizeBytes: body.length })), } } let tmp: string -beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'spool-pf-state-')) }) -afterEach(() => { rmSync(tmp, { recursive: true, force: true }) }) +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'spool-pf-state-')) +}) +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }) +}) describe('pfInstallStatus', () => { it('not-installed when no files are on disk', () => { @@ -30,7 +37,10 @@ describe('pfInstallStatus', () => { const b = Buffer.from('bbbb') writeFileSync(join(tmp, 'a.bin'), a) writeFileSync(join(tmp, 'b.bin'), b) - const m = manifest([{ path: 'a.bin', body: a }, { path: 'b.bin', body: b }]) + const m = manifest([ + { path: 'a.bin', body: a }, + { path: 'b.bin', body: b }, + ]) expect(pfInstallStatus(tmp, m)).toEqual({ status: 'installed' }) }) @@ -39,7 +49,10 @@ describe('pfInstallStatus', () => { const b = Buffer.from('bbbb') writeFileSync(join(tmp, 'a.bin'), a) writeFileSync(join(tmp, 'b.bin.part'), b.subarray(0, 2)) - const m = manifest([{ path: 'a.bin', body: a }, { path: 'b.bin', body: b }]) + const m = manifest([ + { path: 'a.bin', body: a }, + { path: 'b.bin', body: b }, + ]) const status = pfInstallStatus(tmp, m) expect(status.status).toBe('partial') if (status.status === 'partial') { diff --git a/apps/app/src/main/security/model-state.ts b/apps/app/src/main/security/model-state.ts index 90dfd6f8..56bdba2f 100644 --- a/apps/app/src/main/security/model-state.ts +++ b/apps/app/src/main/security/model-state.ts @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto' import { readFileSync, statSync } from 'node:fs' import { join } from 'node:path' + import type { ModelManifest } from './model-manifest.js' export type PfInstallStatus = @@ -27,7 +28,9 @@ export function pfInstallStatus(modelDir: string, manifest: ModelManifest): PfIn // Count partial bytes so the progress dial survives a restart. try { bytesPresent += statSync(part).size - } catch { /* no .part */ } + } catch { + /* no .part */ + } } if (allInstalled) return { status: 'installed' } if (bytesPresent === 0) return { status: 'not-installed' } diff --git a/apps/app/src/main/security/mutation-error-wire.test.ts b/apps/app/src/main/security/mutation-error-wire.test.ts index abde25bc..0ce751d6 100644 --- a/apps/app/src/main/security/mutation-error-wire.test.ts +++ b/apps/app/src/main/security/mutation-error-wire.test.ts @@ -15,8 +15,9 @@ // `reason: 'unknown'` envelope so the renderer falls back to its // generic error toast instead of branching on the wrong reason. -import { describe, it, expect } from 'vitest' import { Data, Effect } from 'effect' +import { describe, it, expect } from 'vite-plus/test' + import { exitToWireResult, flattenPurgeError, @@ -58,7 +59,9 @@ describe('unwrapEffectFailure', () => { it('returns null for a thrown synchronous exception inside a generator', async () => { const exit = await Effect.runPromiseExit( Effect.gen(function* () { - yield* Effect.sync(() => { throw new Error('oops') }) + yield* Effect.sync(() => { + throw new Error('oops') + }) }), ) expect(unwrapEffectFailure(exit)).toBeNull() @@ -66,20 +69,21 @@ describe('unwrapEffectFailure', () => { }) describe('flattenPurgeError', () => { - it.each([ - 'not-found', - 'already-purged', - 'message-missing', - 'db-failed', - ] as const)('preserves reason=%s and findingId from a typed PurgeError-shaped object', (reason) => { - expect(flattenPurgeError({ reason, findingId: 5 })).toEqual({ reason, findingId: 5 }) - }) + it.each(['not-found', 'already-purged', 'message-missing', 'db-failed'] as const)( + 'preserves reason=%s and findingId from a typed PurgeError-shaped object', + (reason) => { + expect(flattenPurgeError({ reason, findingId: 5 })).toEqual({ reason, findingId: 5 }) + }, + ) it('attaches cause.message when present, omits when not', () => { - expect(flattenPurgeError({ reason: 'db-failed', findingId: 9, cause: new Error('disk full') })) - .toEqual({ reason: 'db-failed', findingId: 9, message: 'disk full' }) - expect(flattenPurgeError({ reason: 'db-failed', findingId: 9 })) - .toEqual({ reason: 'db-failed', findingId: 9 }) + expect( + flattenPurgeError({ reason: 'db-failed', findingId: 9, cause: new Error('disk full') }), + ).toEqual({ reason: 'db-failed', findingId: 9, message: 'disk full' }) + expect(flattenPurgeError({ reason: 'db-failed', findingId: 9 })).toEqual({ + reason: 'db-failed', + findingId: 9, + }) }) it('downgrades a plain Error to reason=unknown with its message', () => { diff --git a/apps/app/src/main/security/mutation-error-wire.ts b/apps/app/src/main/security/mutation-error-wire.ts index 8d1d6658..865e193c 100644 --- a/apps/app/src/main/security/mutation-error-wire.ts +++ b/apps/app/src/main/security/mutation-error-wire.ts @@ -45,7 +45,10 @@ export function unwrapEffectFailure(exit: Exit.Exit): E | null { * us something exotic. */ export function flattenPurgeError(err: unknown): WireError { if ( - err && typeof err === 'object' && 'reason' in err && 'findingId' in err && + err && + typeof err === 'object' && + 'reason' in err && + 'findingId' in err && typeof (err as { reason: unknown }).reason === 'string' && typeof (err as { findingId: unknown }).findingId === 'number' ) { diff --git a/apps/app/src/main/security/pf-activation.test.ts b/apps/app/src/main/security/pf-activation.test.ts index 8b8e69a3..3ec274ca 100644 --- a/apps/app/src/main/security/pf-activation.test.ts +++ b/apps/app/src/main/security/pf-activation.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect } from 'vite-plus/test' + import { shouldAutoActivatePf } from './pf-activation.js' const base = { diff --git a/apps/app/src/main/security/pf-coordinator.test.ts b/apps/app/src/main/security/pf-coordinator.test.ts index 61d03ded..549f2165 100644 --- a/apps/app/src/main/security/pf-coordinator.test.ts +++ b/apps/app/src/main/security/pf-coordinator.test.ts @@ -1,10 +1,12 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { createHash } from 'node:crypto' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import { makePfCoordinator, type PfDownloadState } from './pf-coordinator.js' + +import { describe, it, expect, beforeEach, afterEach } from 'vite-plus/test' + import { MODEL_MANIFEST } from './model-manifest.js' +import { makePfCoordinator, type PfDownloadState } from './pf-coordinator.js' const sha256 = (buf: Buffer) => createHash('sha256').update(buf).digest('hex') @@ -28,7 +30,9 @@ function patchManifest(body: Buffer) { let tmp: string let restore: () => void -beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'spool-pf-coord-')) }) +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'spool-pf-coord-')) +}) afterEach(() => { rmSync(tmp, { recursive: true, force: true }) restore?.() @@ -52,7 +56,7 @@ describe('PfCoordinator', () => { c.subscribe((s) => events.push({ ...s })) await c.startDownload() expect(c.getState().phase).toBe('installed') - expect(events.some(e => e.phase === 'downloading')).toBe(true) + expect(events.some((e) => e.phase === 'downloading')).toBe(true) expect(events.at(-1)?.phase).toBe('installed') }) @@ -76,12 +80,14 @@ describe('PfCoordinator', () => { modelDir: tmp, fetch: ((_url: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))) + init?.signal?.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ) })) as typeof globalThis.fetch, }) const pending = c.startDownload() // Let the start phase publish first. - await new Promise(r => setTimeout(r, 5)) + await new Promise((r) => setTimeout(r, 5)) c.cancelDownload() await pending expect(c.getState().phase).toBe('not-installed') @@ -101,12 +107,12 @@ describe('PfCoordinator', () => { modelDir: tmp, fetch: (async () => { calls++ - await new Promise(r => setTimeout(r, 20)) + await new Promise((r) => setTimeout(r, 20)) return new Response(body, { status: 200 }) }) as typeof globalThis.fetch, }) const p1 = c.startDownload() - const p2 = c.startDownload() // should be a no-op + const p2 = c.startDownload() // should be a no-op await Promise.all([p1, p2]) expect(c.getState().phase).toBe('installed') // Only the first download hit fetch once per manifest file. diff --git a/apps/app/src/main/security/pf-coordinator.ts b/apps/app/src/main/security/pf-coordinator.ts index e44c0771..dae4507d 100644 --- a/apps/app/src/main/security/pf-coordinator.ts +++ b/apps/app/src/main/security/pf-coordinator.ts @@ -4,15 +4,12 @@ // can act on. ModelHost spawn/unload lands in PR 5c. import { Effect } from 'effect' + import { downloadModel, type DownloadProgress } from './model-download.js' import { MODEL_MANIFEST, manifestTotalBytes } from './model-manifest.js' import { pfInstallStatus } from './model-state.js' -export type PfPhase = - | 'not-installed' - | 'downloading' - | 'installed' - | 'failed' +export type PfPhase = 'not-installed' | 'downloading' | 'installed' | 'failed' export interface PfDownloadState { phase: PfPhase @@ -48,7 +45,11 @@ export function makePfCoordinator(deps: PfCoordinatorDeps): PfCoordinator { function publish(): void { for (const fn of subscribers) { - try { fn(state) } catch (err) { console.error('[pf] subscriber threw:', err) } + try { + fn(state) + } catch (err) { + console.error('[pf] subscriber threw:', err) + } } } @@ -63,46 +64,59 @@ export function makePfCoordinator(deps: PfCoordinatorDeps): PfCoordinator { publish() await run( Effect.tryPromise({ - try: () => downloadModel({ - modelDir: deps.modelDir, - manifest: MODEL_MANIFEST, - ...(deps.fetch ? { fetch: deps.fetch } : {}), - signal: abortController!.signal, - onProgress: (p: DownloadProgress) => { - state = { - phase: 'downloading', - bytesDownloaded: p.bytesDownloaded, - bytesTotal: p.bytesTotal, - } - publish() - }, - }), + try: () => + downloadModel({ + modelDir: deps.modelDir, + manifest: MODEL_MANIFEST, + ...(deps.fetch ? { fetch: deps.fetch } : {}), + signal: abortController!.signal, + onProgress: (p: DownloadProgress) => { + state = { + phase: 'downloading', + bytesDownloaded: p.bytesDownloaded, + bytesTotal: p.bytesTotal, + } + publish() + }, + }), catch: (err) => err, }).pipe( - Effect.tap(() => Effect.sync(() => { - state = { phase: 'installed', bytesDownloaded: totalBytes, bytesTotal: totalBytes } - })), + Effect.tap(() => + Effect.sync(() => { + state = { phase: 'installed', bytesDownloaded: totalBytes, bytesTotal: totalBytes } + }), + ), Effect.tap(() => Effect.annotateCurrentSpan('pf.download.outcome', 'installed')), - Effect.catchAll((err) => Effect.sync(() => { - const aborted = (err as { name?: string } | undefined)?.name === 'AbortError' - if (aborted) { - state = initialState(deps.modelDir) - } else { - state = { - phase: 'failed', - bytesDownloaded: state.bytesDownloaded, - bytesTotal: state.bytesTotal, - error: err instanceof Error ? err.message : String(err), + Effect.catchAll((err) => + Effect.sync(() => { + const aborted = (err as { name?: string } | undefined)?.name === 'AbortError' + if (aborted) { + state = initialState(deps.modelDir) + } else { + state = { + phase: 'failed', + bytesDownloaded: state.bytesDownloaded, + bytesTotal: state.bytesTotal, + error: err instanceof Error ? err.message : String(err), + } } - } - }).pipe(Effect.tap(() => Effect.annotateCurrentSpan( - 'pf.download.outcome', - (err as { name?: string } | undefined)?.name === 'AbortError' ? 'cancelled' : 'failed', - )))), - Effect.ensuring(Effect.sync(() => { - abortController = null - publish() - })), + }).pipe( + Effect.tap(() => + Effect.annotateCurrentSpan( + 'pf.download.outcome', + (err as { name?: string } | undefined)?.name === 'AbortError' + ? 'cancelled' + : 'failed', + ), + ), + ), + ), + Effect.ensuring( + Effect.sync(() => { + abortController = null + publish() + }), + ), Effect.withSpan('pf.coordinator.download', { attributes: { 'pf.download.total_bytes': totalBytes, @@ -142,7 +156,11 @@ function initialState(modelDir: string): PfDownloadState { case 'installed': return { phase: 'installed', bytesDownloaded: total, bytesTotal: total } case 'partial': - return { phase: 'not-installed', bytesDownloaded: installed.bytesPresent, bytesTotal: installed.bytesTotal } + return { + phase: 'not-installed', + bytesDownloaded: installed.bytesPresent, + bytesTotal: installed.bytesTotal, + } case 'not-installed': return { phase: 'not-installed', bytesDownloaded: 0, bytesTotal: total } } diff --git a/apps/app/src/main/security/pf-model-protocol.ts b/apps/app/src/main/security/pf-model-protocol.ts index 69ac1d26..b372b848 100644 --- a/apps/app/src/main/security/pf-model-protocol.ts +++ b/apps/app/src/main/security/pf-model-protocol.ts @@ -10,12 +10,14 @@ // so we treat any pf-model:// path as a relative join against the // model directory and refuse to escape it. -import { protocol } from 'electron' import { createReadStream } from 'node:fs' import { stat } from 'node:fs/promises' import { createRequire } from 'node:module' import { dirname, resolve, sep } from 'node:path' import { Readable } from 'node:stream' + +import { protocol } from 'electron' + import { pfModelsRoot } from './model-paths.js' /** ORT WASM/JS files are loaded by the inference renderer at runtime @@ -126,21 +128,23 @@ export function registerPfModelProtocol(): void { * renderer treats `pf-model://` as an opaque origin and CSP/fetch * reject it silently. */ export function registerPfModelScheme(): void { - protocol.registerSchemesAsPrivileged([{ - scheme: 'pf-model', - privileges: { - standard: true, - secure: true, - supportFetchAPI: true, - // The inference renderer is loaded from http://localhost in dev - // (Vite) and file:// in prod; either way the document origin - // differs from pf-model:// so Chromium's CORS layer would block - // the fetch without explicit allow. Our handler only serves files - // inside pfModelsRoot() (path-traversal refused with 403), no - // sensitive surface to widen. - corsEnabled: true, - bypassCSP: false, - stream: true, + protocol.registerSchemesAsPrivileged([ + { + scheme: 'pf-model', + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + // The inference renderer is loaded from http://localhost in dev + // (Vite) and file:// in prod; either way the document origin + // differs from pf-model:// so Chromium's CORS layer would block + // the fetch without explicit allow. Our handler only serves files + // inside pfModelsRoot() (path-traversal refused with 403), no + // sensitive surface to widen. + corsEnabled: true, + bypassCSP: false, + stream: true, + }, }, - }]) + ]) } diff --git a/apps/app/src/main/security/pf-runtime.test.ts b/apps/app/src/main/security/pf-runtime.test.ts index a53a83b3..adbc5e59 100644 --- a/apps/app/src/main/security/pf-runtime.test.ts +++ b/apps/app/src/main/security/pf-runtime.test.ts @@ -1,8 +1,10 @@ -import { describe, it, expect } from 'vitest' import { EventEmitter } from 'node:events' + import type { BrowserWindow } from 'electron' -import { makePfRuntime } from './pf-runtime.js' +import { describe, it, expect } from 'vite-plus/test' + import { PF_IPC } from '../../renderer/inference/types.js' +import { makePfRuntime } from './pf-runtime.js' type FakeWindow = BrowserWindow & { destroyed: boolean; crash: () => void } @@ -11,13 +13,25 @@ function fakeWindow(id: number): FakeWindow { // `render-process-gone` listener; `crash()` drives it in tests. const wc = new EventEmitter() as unknown as BrowserWindow['webContents'] ;(wc as unknown as { id: number }).id = id - ;(wc as unknown as { send: () => void }).send = () => { /* no-op */ } + ;(wc as unknown as { send: () => void }).send = () => { + /* no-op */ + } const w: Partial = { destroyed: false, webContents: wc, - destroy() { (w as FakeWindow).destroyed = true }, - isDestroyed() { return (w as FakeWindow).destroyed }, - crash() { (wc as unknown as EventEmitter).emit('render-process-gone', {}, { reason: 'crashed', exitCode: 139 }) }, + destroy() { + ;(w as FakeWindow).destroyed = true + }, + isDestroyed() { + return (w as FakeWindow).destroyed + }, + crash() { + ;(wc as unknown as EventEmitter).emit( + 'render-process-gone', + {}, + { reason: 'crashed', exitCode: 139 }, + ) + }, } return w as FakeWindow } @@ -26,8 +40,14 @@ function fakeIpc() { const bus = new EventEmitter() bus.setMaxListeners(50) const ipc = { - on: (ch: string, fn: (...args: unknown[]) => void) => { bus.on(ch, fn); return ipc }, - removeListener: (ch: string, fn: (...args: unknown[]) => void) => { bus.removeListener(ch, fn); return ipc }, + on: (ch: string, fn: (...args: unknown[]) => void) => { + bus.on(ch, fn) + return ipc + }, + removeListener: (ch: string, fn: (...args: unknown[]) => void) => { + bus.removeListener(ch, fn) + return ipc + }, } return { ipc, bus } } @@ -39,9 +59,18 @@ describe('makePfRuntime', () => { const rt = makePfRuntime({ ipc, spawnWindow: async () => { - setTimeout(() => bus.emit(PF_IPC.READY, { sender: { id: 50 } }, { - runtime: 'wasm', detectionMs: 1, - }), 0) + setTimeout( + () => + bus.emit( + PF_IPC.READY, + { sender: { id: 50 } }, + { + runtime: 'wasm', + detectionMs: 1, + }, + ), + 0, + ) return win }, }) @@ -59,11 +88,22 @@ describe('makePfRuntime', () => { let crashes = 0 const rt = makePfRuntime({ ipc, - onCrash: () => { crashes++ }, + onCrash: () => { + crashes++ + }, spawnWindow: async () => { - setTimeout(() => bus.emit(PF_IPC.READY, { sender: { id: 55 } }, { - runtime: 'wasm', detectionMs: 1, - }), 0) + setTimeout( + () => + bus.emit( + PF_IPC.READY, + { sender: { id: 55 } }, + { + runtime: 'wasm', + detectionMs: 1, + }, + ), + 0, + ) return win }, }) @@ -90,9 +130,18 @@ describe('makePfRuntime', () => { spawnWindow: async () => { spawned++ const w = fakeWindow(60 + spawned) - setTimeout(() => bus.emit(PF_IPC.READY, { sender: { id: w.webContents.id } }, { - runtime: 'wasm', detectionMs: 1, - }), 0) + setTimeout( + () => + bus.emit( + PF_IPC.READY, + { sender: { id: w.webContents.id } }, + { + runtime: 'wasm', + detectionMs: 1, + }, + ), + 0, + ) return w }, }) @@ -102,17 +151,29 @@ describe('makePfRuntime', () => { }) it('analyze returns [] when not active', async () => { - const rt = makePfRuntime({ spawnWindow: async () => { throw new Error('not used') } }) + const rt = makePfRuntime({ + spawnWindow: async () => { + throw new Error('not used') + }, + }) expect(await rt.analyze('hi')).toEqual([]) }) it('getState returns null when not active', async () => { - const rt = makePfRuntime({ spawnWindow: async () => { throw new Error('not used') } }) + const rt = makePfRuntime({ + spawnWindow: async () => { + throw new Error('not used') + }, + }) expect(await rt.getState()).toBeNull() }) it('stop is idempotent — calling it twice without start is fine', async () => { - const rt = makePfRuntime({ spawnWindow: async () => { throw new Error('not used') } }) + const rt = makePfRuntime({ + spawnWindow: async () => { + throw new Error('not used') + }, + }) await rt.stop() await rt.stop() expect(rt.isActive()).toBe(false) diff --git a/apps/app/src/main/security/pf-runtime.ts b/apps/app/src/main/security/pf-runtime.ts index f82d5cb7..c7f1028a 100644 --- a/apps/app/src/main/security/pf-runtime.ts +++ b/apps/app/src/main/security/pf-runtime.ts @@ -7,9 +7,11 @@ // against model installation — no point spawning the window if the // ONNX weights aren't on disk yet. +import { join } from 'node:path' + import { Effect, Exit, Scope } from 'effect' import { BrowserWindow, app, ipcMain } from 'electron' -import { join } from 'node:path' + import { makeModelHost, type ModelHost, type ModelHostDeps, type PfState } from './model-host.js' import { MODEL_MANIFEST } from './model-manifest.js' import { pfInstallStatus } from './model-state.js' @@ -79,10 +81,13 @@ export function makePfRuntime(deps: PfRuntimeDeps = {}): PfRuntime { const settled = yield* builtHost.getState yield* Effect.annotateCurrentSpan('pf.status', settled.status) if (settled.runtime) yield* Effect.annotateCurrentSpan('pf.runtime', settled.runtime) - if (settled.adapterLabel) yield* Effect.annotateCurrentSpan('pf.adapter', settled.adapterLabel) + if (settled.adapterLabel) + yield* Effect.annotateCurrentSpan('pf.adapter', settled.adapterLabel) if (settled.error) yield* Effect.annotateCurrentSpan('pf.error', settled.error) }).pipe(Effect.withSpan('pf.runtime.start')), - ).finally(() => { starting = null }) + ).finally(() => { + starting = null + }) await starting } @@ -104,12 +109,10 @@ export function makePfRuntime(deps: PfRuntimeDeps = {}): PfRuntime { if (!host) return [] return run( host.analyze(text).pipe( - Effect.tap((matches) => - Effect.annotateCurrentSpan('pf.matches', matches.length)), + Effect.tap((matches) => Effect.annotateCurrentSpan('pf.matches', matches.length)), Effect.catchAll((err) => - Effect.annotateCurrentSpan('pf.error', String(err.cause)).pipe( - Effect.as([]), - )), + Effect.annotateCurrentSpan('pf.error', String(err.cause)).pipe(Effect.as([])), + ), Effect.withSpan('pf.analyze', { attributes: { text_len: text.length } }), ), ) @@ -188,9 +191,9 @@ async function defaultSpawnWindow(): Promise { if (title !== INFERENCE_DOC_TITLE) { throw new Error( `[pf-runtime] dev loadURL returned wrong document (title=${JSON.stringify(title)}; ` + - `expected ${JSON.stringify(INFERENCE_DOC_TITLE)}). The Vite dev server probably hit ` + - `its SPA fallback — check src/renderer/pf-inference.html exists and that the load URL ` + - `points at it.`, + `expected ${JSON.stringify(INFERENCE_DOC_TITLE)}). The Vite dev server probably hit ` + + `its SPA fallback — check src/renderer/pf-inference.html exists and that the load URL ` + + `points at it.`, ) } } else { diff --git a/apps/app/src/main/securityPreferences.test.ts b/apps/app/src/main/securityPreferences.test.ts index 72bd428a..358a3ad9 100644 --- a/apps/app/src/main/securityPreferences.test.ts +++ b/apps/app/src/main/securityPreferences.test.ts @@ -6,11 +6,12 @@ // before importing the module so each suite gets its own filesystem // without touching the user's real ~/.spool/. -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest' import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync, chmodSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vite-plus/test' + let tmpDir: string let configPath: string let mod: typeof import('./securityPreferences.js') @@ -66,14 +67,17 @@ describe('loadSecurityPreferences', () => { }) it('honours stored values', () => { - writeFileSync(configPath, JSON.stringify({ - kindAllowlist: ['email', 'phone'], - infoDefaultVisible: true, - rescanAfterSync: 'manual', - securityPageValuesBlurred: true, - findingsStripValuesBlurred: false, - pfEnabled: true, - })) + writeFileSync( + configPath, + JSON.stringify({ + kindAllowlist: ['email', 'phone'], + infoDefaultVisible: true, + rescanAfterSync: 'manual', + securityPageValuesBlurred: true, + findingsStripValuesBlurred: false, + pfEnabled: true, + }), + ) const prefs = mod.loadSecurityPreferences() expect(prefs.kindAllowlist).toEqual(['email', 'phone']) expect(prefs.infoDefaultVisible).toBe(true) @@ -87,12 +91,15 @@ describe('loadSecurityPreferences', () => { // The reader is strict-`=== true`; "true", 1, etc. should not // promote to true. This prevents a corrupted/edited file from // silently flipping screen-share mode etc. - writeFileSync(configPath, JSON.stringify({ - infoDefaultVisible: 'true', - securityPageValuesBlurred: 1, - findingsStripValuesBlurred: 'yes', - pfEnabled: 'yes', - })) + writeFileSync( + configPath, + JSON.stringify({ + infoDefaultVisible: 'true', + securityPageValuesBlurred: 1, + findingsStripValuesBlurred: 'yes', + pfEnabled: 'yes', + }), + ) const prefs = mod.loadSecurityPreferences() expect(prefs.infoDefaultVisible).toBe(false) expect(prefs.securityPageValuesBlurred).toBe(false) @@ -122,10 +129,13 @@ describe('loadSecurityPreferences', () => { it('ignores the legacy flag once either new field is present', () => { // The user has already migrated and explicitly turned the page // blur off — the stale legacy flag must NOT override. - writeFileSync(configPath, JSON.stringify({ - revealValuesOnHoverOnly: true, - securityPageValuesBlurred: false, - })) + writeFileSync( + configPath, + JSON.stringify({ + revealValuesOnHoverOnly: true, + securityPageValuesBlurred: false, + }), + ) const prefs = mod.loadSecurityPreferences() expect(prefs.securityPageValuesBlurred).toBe(false) // Strip field absent → still treated as false because the new- @@ -135,9 +145,12 @@ describe('loadSecurityPreferences', () => { }) it('filters non-string entries out of kindAllowlist', () => { - writeFileSync(configPath, JSON.stringify({ - kindAllowlist: ['email', 42, null, undefined, 'phone', { kind: 'bad' }], - })) + writeFileSync( + configPath, + JSON.stringify({ + kindAllowlist: ['email', 42, null, undefined, 'phone', { kind: 'bad' }], + }), + ) expect(mod.loadSecurityPreferences().kindAllowlist).toEqual(['email', 'phone']) }) @@ -179,14 +192,17 @@ describe('loadSecurityPreferences', () => { describe('saveSecurityPreferences', () => { it('writes a partial update without dropping unrelated keys on disk', () => { // Seed with all fields set. - writeFileSync(configPath, JSON.stringify({ - kindAllowlist: ['email'], - infoDefaultVisible: true, - rescanAfterSync: 'manual', - securityPageValuesBlurred: true, - findingsStripValuesBlurred: true, - pfEnabled: true, - })) + writeFileSync( + configPath, + JSON.stringify({ + kindAllowlist: ['email'], + infoDefaultVisible: true, + rescanAfterSync: 'manual', + securityPageValuesBlurred: true, + findingsStripValuesBlurred: true, + pfEnabled: true, + }), + ) // Update only one field. mod.saveSecurityPreferences({ kindAllowlist: ['phone'] }) const onDisk = JSON.parse(readFileSync(configPath, 'utf8')) as Record @@ -221,10 +237,13 @@ describe('saveSecurityPreferences', () => { }) it('preserves unknown forward-compat keys', () => { - writeFileSync(configPath, JSON.stringify({ - kindAllowlist: ['email'], - experimentalThing: { nested: 1 }, - })) + writeFileSync( + configPath, + JSON.stringify({ + kindAllowlist: ['email'], + experimentalThing: { nested: 1 }, + }), + ) mod.saveSecurityPreferences({ kindAllowlist: ['phone'] }) const onDisk = JSON.parse(readFileSync(configPath, 'utf8')) as Record expect(onDisk['experimentalThing']).toEqual({ nested: 1 }) @@ -277,7 +296,7 @@ describe('saveSecurityPreferences', () => { }) it('explicit pfCalloutDismissed:false can re-arm the callout', () => { - mod.saveSecurityPreferences({ pfEnabled: true }) // also flips dismissed to true + mod.saveSecurityPreferences({ pfEnabled: true }) // also flips dismissed to true expect(mod.loadSecurityPreferences().pfCalloutDismissed).toBe(true) mod.saveSecurityPreferences({ pfEnabled: false, pfCalloutDismissed: false }) expect(mod.loadSecurityPreferences().pfCalloutDismissed).toBe(false) @@ -292,12 +311,20 @@ describe('saveSecurityPreferences', () => { chmodSync(configPath, 0o000) // If the platform still lets us read, skip the assertion. let denied = false - try { readFileSync(configPath, 'utf8') } catch { denied = true } + try { + readFileSync(configPath, 'utf8') + } catch { + denied = true + } if (!denied) return const prefs = mod.loadSecurityPreferences() expect(prefs.kindAllowlist).toEqual([]) } finally { - try { chmodSync(configPath, 0o600) } catch { /* ignore */ } + try { + chmodSync(configPath, 0o600) + } catch { + /* ignore */ + } } }) }) diff --git a/apps/app/src/main/securityPreferences.ts b/apps/app/src/main/securityPreferences.ts index 30f72933..5989cf93 100644 --- a/apps/app/src/main/securityPreferences.ts +++ b/apps/app/src/main/securityPreferences.ts @@ -11,6 +11,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' + import { SPOOL_DIR } from '@spool-lab/core' import type { SensitiveKind } from '@spool-lab/redact' @@ -127,8 +128,7 @@ export function loadSecurityPreferences(): SecurityPreferences { // opted into hover-blur keeps that posture after upgrading. Once // either new field has been written, the legacy value is ignored. const hasNewBlurFields = - c.securityPageValuesBlurred !== undefined - || c.findingsStripValuesBlurred !== undefined + c.securityPageValuesBlurred !== undefined || c.findingsStripValuesBlurred !== undefined const legacyBlurred = c.revealValuesOnHoverOnly === true return { kindAllowlist: normalizeKinds(c.kindAllowlist), @@ -155,10 +155,14 @@ export function saveSecurityPreferences(next: Partial): Sec // Validate the new values, falling back to defaults on bad input. const merged: SecurityConfigFile = { ...current } if (next.kindAllowlist !== undefined) merged.kindAllowlist = normalizeKinds(next.kindAllowlist) - if (next.infoDefaultVisible !== undefined) merged.infoDefaultVisible = next.infoDefaultVisible === true - if (next.rescanAfterSync !== undefined) merged.rescanAfterSync = normalizeRescan(next.rescanAfterSync) - if (next.securityPageValuesBlurred !== undefined) merged.securityPageValuesBlurred = next.securityPageValuesBlurred === true - if (next.findingsStripValuesBlurred !== undefined) merged.findingsStripValuesBlurred = next.findingsStripValuesBlurred === true + if (next.infoDefaultVisible !== undefined) + merged.infoDefaultVisible = next.infoDefaultVisible === true + if (next.rescanAfterSync !== undefined) + merged.rescanAfterSync = normalizeRescan(next.rescanAfterSync) + if (next.securityPageValuesBlurred !== undefined) + merged.securityPageValuesBlurred = next.securityPageValuesBlurred === true + if (next.findingsStripValuesBlurred !== undefined) + merged.findingsStripValuesBlurred = next.findingsStripValuesBlurred === true if (next.pfEnabled !== undefined) { merged.pfEnabled = next.pfEnabled === true // Enabling pf supersedes the in-page discovery nudge — once a user @@ -168,8 +172,10 @@ export function saveSecurityPreferences(next: Partial): Sec // separate signals gated by pfActivationPending, not by this flag. if (next.pfEnabled === true) merged.pfCalloutDismissed = true } - if (next.pfCalloutDismissed !== undefined) merged.pfCalloutDismissed = next.pfCalloutDismissed === true - if (next.pfActivationPending !== undefined) merged.pfActivationPending = next.pfActivationPending === true + if (next.pfCalloutDismissed !== undefined) + merged.pfCalloutDismissed = next.pfCalloutDismissed === true + if (next.pfActivationPending !== undefined) + merged.pfActivationPending = next.pfActivationPending === true if (next.sessionRowRiskIconVisible !== undefined) { // Symmetric with the load-side read: only a literal `false` writes // false; anything else (including a buggy `'false'` string) writes diff --git a/apps/app/src/main/sessionResume.test.ts b/apps/app/src/main/sessionResume.test.ts index 8b5119aa..db081013 100644 --- a/apps/app/src/main/sessionResume.test.ts +++ b/apps/app/src/main/sessionResume.test.ts @@ -1,7 +1,9 @@ -import { describe, expect, it } from 'vitest' import { mkdtempSync, mkdirSync, rmSync } from 'node:fs' -import { join } from 'node:path' import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'vite-plus/test' + import { resolveResumeWorkingDirectory } from './sessionResume.js' function makeTempDir(prefix: string): string { @@ -12,12 +14,14 @@ describe('resolveResumeWorkingDirectory', () => { it('uses the stored cwd when it points to an existing directory', () => { const cwd = makeTempDir('spool-resume-cwd-') - expect(resolveResumeWorkingDirectory({ - source: 'claude', - cwd, - projectDisplayPath: '/unused/project', - filePath: '/Users/test/.claude/projects/-Users-test-project/session.jsonl', - })).toBe(cwd) + expect( + resolveResumeWorkingDirectory({ + source: 'claude', + cwd, + projectDisplayPath: '/unused/project', + filePath: '/Users/test/.claude/projects/-Users-test-project/session.jsonl', + }), + ).toBe(cwd) rmSync(cwd, { recursive: true, force: true }) }) @@ -30,12 +34,14 @@ describe('resolveResumeWorkingDirectory', () => { const filePath = join(root, '.claude', 'projects', slug, 'session.jsonl') mkdirSync(join(root, '.claude', 'projects', slug), { recursive: true }) - expect(resolveResumeWorkingDirectory({ - source: 'claude', - cwd: '', - projectDisplayPath: 'workspace', - filePath, - })).toBe(projectDir) + expect( + resolveResumeWorkingDirectory({ + source: 'claude', + cwd: '', + projectDisplayPath: 'workspace', + filePath, + }), + ).toBe(projectDir) rmSync(root, { recursive: true, force: true }) }) @@ -43,34 +49,40 @@ describe('resolveResumeWorkingDirectory', () => { it('falls back to projectDisplayPath when cwd is unusable', () => { const projectDir = makeTempDir('spool-resume-project-') - expect(resolveResumeWorkingDirectory({ - source: 'codex', - cwd: '/path/that/does/not/exist', - projectDisplayPath: projectDir, - filePath: '/Users/test/.codex/sessions/2026/04/05/rollout.jsonl', - })).toBe(projectDir) + expect( + resolveResumeWorkingDirectory({ + source: 'codex', + cwd: '/path/that/does/not/exist', + projectDisplayPath: projectDir, + filePath: '/Users/test/.codex/sessions/2026/04/05/rollout.jsonl', + }), + ).toBe(projectDir) rmSync(projectDir, { recursive: true, force: true }) }) it('returns undefined when no usable working directory is available', () => { - expect(resolveResumeWorkingDirectory({ - source: 'codex', - cwd: '/path/that/does/not/exist', - projectDisplayPath: '/another/missing/path', - filePath: '/Users/test/.codex/sessions/2026/04/05/rollout.jsonl', - })).toBeUndefined() + expect( + resolveResumeWorkingDirectory({ + source: 'codex', + cwd: '/path/that/does/not/exist', + projectDisplayPath: '/another/missing/path', + filePath: '/Users/test/.codex/sessions/2026/04/05/rollout.jsonl', + }), + ).toBeUndefined() }) it('uses the Gemini project display path when cwd is unavailable', () => { const projectDir = makeTempDir('spool-resume-gemini-') - expect(resolveResumeWorkingDirectory({ - source: 'gemini', - cwd: '', - projectDisplayPath: projectDir, - filePath: '/Users/test/.gemini/tmp/workspace/chats/session-2026-04-08T00-00-deadbeef.json', - })).toBe(projectDir) + expect( + resolveResumeWorkingDirectory({ + source: 'gemini', + cwd: '', + projectDisplayPath: projectDir, + filePath: '/Users/test/.gemini/tmp/workspace/chats/session-2026-04-08T00-00-deadbeef.json', + }), + ).toBe(projectDir) rmSync(projectDir, { recursive: true, force: true }) }) diff --git a/apps/app/src/main/sessionResume.ts b/apps/app/src/main/sessionResume.ts index 825ccdd1..0dcbadc3 100644 --- a/apps/app/src/main/sessionResume.ts +++ b/apps/app/src/main/sessionResume.ts @@ -1,6 +1,7 @@ import { existsSync, statSync } from 'node:fs' -import { basename, dirname } from 'node:path' import { homedir } from 'node:os' +import { basename, dirname } from 'node:path' + import { decodeProjectSlug, type Session } from '@spool-lab/core' type ResumeSessionContext = Pick diff --git a/apps/app/src/main/share/api-client.ts b/apps/app/src/main/share/api-client.ts index 838117bb..3fda82c7 100644 --- a/apps/app/src/main/share/api-client.ts +++ b/apps/app/src/main/share/api-client.ts @@ -1,7 +1,6 @@ import { net } from 'electron' import { loadToken } from '../auth/session-store.js' - import { backendUrl } from './backend-url.js' // Electron's `net.fetch` honours macOS system proxy and the OS trust diff --git a/apps/app/src/main/sync-worker.test.ts b/apps/app/src/main/sync-worker.test.ts index 05501d54..ca95660e 100644 --- a/apps/app/src/main/sync-worker.test.ts +++ b/apps/app/src/main/sync-worker.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' import { Worker } from 'node:worker_threads' +import { describe, expect, it } from 'vite-plus/test' + /** * Regression test for the SIGTRAP startup crash reported on macOS 26.4.1 * (incident keys B3D455CA…/A41A7FF1 and 5C249A38 on Spool 0.3.8 + 0.4.11): diff --git a/apps/app/src/main/sync-worker.ts b/apps/app/src/main/sync-worker.ts index c3eaa714..9f88f8d6 100644 --- a/apps/app/src/main/sync-worker.ts +++ b/apps/app/src/main/sync-worker.ts @@ -1,4 +1,5 @@ import { parentPort } from 'node:worker_threads' + import { getDB, Syncer } from '@spool-lab/core' import type { SyncProgressEvent } from '@spool-lab/core' import type { SyncResult } from '@spool-lab/core' @@ -14,7 +15,9 @@ function reportAndExit(err: unknown): void { type: 'error', error: err instanceof Error ? (err.stack ?? err.message) : String(err), } satisfies SyncWorkerMessage) - } catch { /* parent gone — nothing we can do */ } + } catch { + /* parent gone — nothing we can do */ + } process.exit(1) } diff --git a/apps/app/src/main/terminal.test.ts b/apps/app/src/main/terminal.test.ts index 5f75c538..1b1a5ab7 100644 --- a/apps/app/src/main/terminal.test.ts +++ b/apps/app/src/main/terminal.test.ts @@ -3,9 +3,10 @@ // can be exercised without opening real terminal windows, then assert // on the exact command each runner emits. -import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest' import { execFileSync } from 'node:child_process' +import { describe, it, expect, vi, beforeEach, afterAll } from 'vite-plus/test' + const execSync = vi.fn() const spawn = vi.fn(() => ({ unref: () => {} })) diff --git a/apps/app/src/main/terminal.ts b/apps/app/src/main/terminal.ts index 009aa04c..a1e5bc43 100644 --- a/apps/app/src/main/terminal.ts +++ b/apps/app/src/main/terminal.ts @@ -25,8 +25,9 @@ // eslint-disable-next-line no-restricted-imports import { execSync, spawn } from 'node:child_process' import { existsSync, writeFileSync, mkdirSync, unlinkSync, readdirSync } from 'node:fs' -import { join } from 'node:path' import { homedir } from 'node:os' +import { join } from 'node:path' + import { shell } from 'electron' const IS_LINUX = process.platform === 'linux' @@ -35,13 +36,28 @@ const IS_LINUX = process.platform === 'linux' * Terminal identifiers. These double as the display names shown in settings * and the keys used in the runners map. */ -export const SUPPORTED_TERMINALS = ['Terminal', 'iTerm2', 'Warp', 'Ghostty', 'kitty', 'Alacritty', 'WezTerm'] as const +export const SUPPORTED_TERMINALS = [ + 'Terminal', + 'iTerm2', + 'Warp', + 'Ghostty', + 'kitty', + 'Alacritty', + 'WezTerm', +] as const export type SupportedTerminal = (typeof SUPPORTED_TERMINALS)[number] // Third-party terminals to probe, in order of popularity. New entries are // appended at the end so adding a terminal never changes which one an // existing user (with several running and no explicit preference) auto-detects. -const THIRD_PARTY: SupportedTerminal[] = ['iTerm2', 'Warp', 'kitty', 'Alacritty', 'WezTerm', 'Ghostty'] +const THIRD_PARTY: SupportedTerminal[] = [ + 'iTerm2', + 'Warp', + 'kitty', + 'Alacritty', + 'WezTerm', + 'Ghostty', +] let autoDetectedTerminal: SupportedTerminal | undefined @@ -55,15 +71,16 @@ function autoDetect(): SupportedTerminal { for (const name of THIRD_PARTY) { try { - const running = execSync( - `osascript -e 'application "${name}" is running'`, - { timeout: 2000 }, - ).toString().trim() + const running = execSync(`osascript -e 'application "${name}" is running'`, { timeout: 2000 }) + .toString() + .trim() if (running === 'true') { autoDetectedTerminal = name return autoDetectedTerminal } - } catch { /* app not installed or osascript failed — skip */ } + } catch { + /* app not installed or osascript failed — skip */ + } } autoDetectedTerminal = 'Terminal' @@ -95,12 +112,12 @@ import { shellQuote } from '../shared/resumeCommand.js' */ const runners: Record void> = { // Terminal.app — AppleScript `do script` - 'Terminal': (cmd, cwd) => { + Terminal: (cmd, cwd) => { execSync(`osascript -e 'tell application "Terminal" to do script "${withCwd(cmd, cwd)}"'`) }, // iTerm2 — AppleScript `create window with default profile command` - 'iTerm2': (cmd, cwd) => { + iTerm2: (cmd, cwd) => { const full = withCwd(cmd, cwd) const script = `tell application "iTerm2" activate @@ -112,7 +129,7 @@ const runners: Record void> = // Warp — uses Launch Configurations (official API). We write a fixed-name YAML // config to ~/.warp/launch_configurations/ and open it via warp:// URI scheme. // Docs: https://docs.warp.dev/terminal/sessions/launch-configurations - 'Warp': (cmd, cwd) => { + Warp: (cmd, cwd) => { const configDir = join(homedir(), '.warp', 'launch_configurations') const configName = `spool-resume-${Date.now()}` const configPath = join(configDir, `${configName}.yaml`) @@ -122,11 +139,15 @@ const runners: Record void> = // Clean up stale spool-resume-* configs from previous runs for (const f of readdirSync(configDir)) { if (f.startsWith('spool-resume-')) { - try { unlinkSync(join(configDir, f)) } catch {} + try { + unlinkSync(join(configDir, f)) + } catch {} } } - writeFileSync(configPath, `--- + writeFileSync( + configPath, + `--- name: ${configName} windows: - tabs: @@ -135,7 +156,8 @@ windows: cwd: "${cwd || homedir()}" commands: - exec: ${cmd} -`) +`, + ) void shell.openExternal(`warp://launch/${configName}`).catch((error) => { console.error('[terminal] failed to open Warp launch configuration:', error) }) @@ -143,35 +165,35 @@ windows: // Ghostty — macOS has no direct CLI, so pass args through `open --args`. // `-e` runs the command; `exec $SHELL` keeps the window alive afterwards. - 'Ghostty': (cmd, cwd) => { + Ghostty: (cmd, cwd) => { execSync(`open -a Ghostty --args -e sh -c ${keepAliveArg(cmd, cwd)}`) }, // Kitty — `open --args`; `exec $SHELL` keeps the window alive - 'kitty': (cmd, cwd) => { + kitty: (cmd, cwd) => { execSync(`open -a kitty --args sh -c ${keepAliveArg(cmd, cwd)}`) }, // Alacritty — uses `-e` flag for command execution - 'Alacritty': (cmd, cwd) => { + Alacritty: (cmd, cwd) => { execSync(`open -a Alacritty --args -e sh -c ${keepAliveArg(cmd, cwd)}`) }, // WezTerm — `start --` separates wezterm args from the spawned command - 'WezTerm': (cmd, cwd) => { + WezTerm: (cmd, cwd) => { execSync(`open -a WezTerm --args start -- sh -c ${keepAliveArg(cmd, cwd)}`) }, } /** App bundle paths for installation checks. Terminal.app is always present. */ const APP_PATHS: Record = { - 'Terminal': '/System/Applications/Utilities/Terminal.app', - 'iTerm2': '/Applications/iTerm.app', - 'Warp': '/Applications/Warp.app', - 'Ghostty': '/Applications/Ghostty.app', - 'kitty': '/Applications/kitty.app', - 'Alacritty': '/Applications/Alacritty.app', - 'WezTerm': '/Applications/WezTerm.app', + Terminal: '/System/Applications/Utilities/Terminal.app', + iTerm2: '/Applications/iTerm.app', + Warp: '/Applications/Warp.app', + Ghostty: '/Applications/Ghostty.app', + kitty: '/Applications/kitty.app', + Alacritty: '/Applications/Alacritty.app', + WezTerm: '/Applications/WezTerm.app', } function isInstalled(terminal: SupportedTerminal): boolean { diff --git a/apps/app/src/main/tray.ts b/apps/app/src/main/tray.ts index 05a4f146..170cf167 100644 --- a/apps/app/src/main/tray.ts +++ b/apps/app/src/main/tray.ts @@ -1,12 +1,10 @@ -import { Tray, Menu, nativeImage, app } from 'electron' import { join } from 'node:path' +import { Tray, Menu, nativeImage, app } from 'electron' + let tray: Tray | null = null -export function setupTray( - onShow: () => void, - onSync: () => void, -): void { +export function setupTray(onShow: () => void, onSync: () => void): void { // Template image — macOS auto-handles light/dark tint. // File named *Template* so Electron marks it as template automatically. const iconPath = join(__dirname, '../../resources/tray-iconTemplate.png') diff --git a/apps/app/src/main/uiPreferences.ts b/apps/app/src/main/uiPreferences.ts index b08e3d75..271a3a9b 100644 --- a/apps/app/src/main/uiPreferences.ts +++ b/apps/app/src/main/uiPreferences.ts @@ -1,6 +1,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' + import { SPOOL_DIR } from '@spool-lab/core' + import { normalizeThemeEditorState, type ThemeEditorStateV1, diff --git a/apps/app/src/preload/index.ts b/apps/app/src/preload/index.ts index 2eccb743..5e78610a 100644 --- a/apps/app/src/preload/index.ts +++ b/apps/app/src/preload/index.ts @@ -1,17 +1,36 @@ -import { contextBridge, ipcRenderer } from 'electron' import type { - FragmentResult, Session, Message, StatusInfo, SyncResult, SearchResult, ProjectGroup, - ListSessionsByIdentityOptions, ProjectSessionSortOrder, SessionsCursor, SessionsPage, DirectoryCount, - ShareDraftRow, ShareDraftListItem, UpsertShareDraftInput, + FragmentResult, + Session, + Message, + StatusInfo, + SyncResult, + SearchResult, + ProjectGroup, + ListSessionsByIdentityOptions, + ProjectSessionSortOrder, + SessionsCursor, + SessionsPage, + DirectoryCount, + ShareDraftRow, + ShareDraftListItem, + UpsertShareDraftInput, PublishedShareCacheItem, SessionSource, - FindingRow, SessionWithFindingCounts, RiskByCategoryRow, OccurrenceBySession, - FindingsChange, ScanStatus, FindingFilter, SessionFindingFilter, + FindingRow, + SessionWithFindingCounts, + RiskByCategoryRow, + OccurrenceBySession, + FindingsChange, + ScanStatus, + FindingFilter, + SessionFindingFilter, AllowlistEntryRow, Page, - BackupFileInfo, DeleteBackupsResult, + BackupFileInfo, + DeleteBackupsResult, } from '@spool-lab/core' import type { SensitiveKind } from '@spool-lab/redact' +import { contextBridge, ipcRenderer } from 'electron' export interface SecurityPreferences { kindAllowlist: SensitiveKind[] @@ -25,11 +44,7 @@ export interface SecurityPreferences { sessionRowRiskIconVisible: boolean } -export type PfPhase = - | 'not-installed' - | 'downloading' - | 'installed' - | 'failed' +export type PfPhase = 'not-installed' | 'downloading' | 'installed' | 'failed' export interface PfDownloadState { phase: PfPhase @@ -49,10 +64,10 @@ export interface PfRuntimeInfo { export type SecurityReadiness = | { ready: true } | { ready: false; reason: 'booting' | 'scanner-unavailable' } -import type { SearchSortOrder } from '../shared/searchSort.js' -import type { SidebarSortOrder } from '../shared/sidebarSort.js' -import type { PinnedSortOrder } from '../shared/pinnedSort.js' import type { ThemeEditorStateV1 } from '../renderer/theme/editorTypes.js' +import type { HubSharePrepareResult, HubSharePublishResult } from '../shared/hub-share.js' +import type { PinnedSortOrder } from '../shared/pinnedSort.js' +import type { SearchSortOrder } from '../shared/searchSort.js' import type { PublishRequestBody, PublishResult, @@ -63,10 +78,7 @@ import type { SetVisibilityResult, Visibility, } from '../shared/share-publish.js' -import type { - HubSharePrepareResult, - HubSharePublishResult, -} from '../shared/hub-share.js' +import type { SidebarSortOrder } from '../shared/sidebarSort.js' export interface AgentInfo { id: string @@ -96,14 +108,17 @@ export interface AgentsConfig { projectSortOrder?: ProjectSessionSortOrder /** UI language; 'system' follows OS preference (default). */ language?: LanguagePreference - customAgents?: Record + customAgents?: Record< + string, + { + name?: string + bin: string + acpMode: 'extension' | 'native' | 'websocket' + acpArgs?: string[] + wsEndpoint?: string + healthCheck?: string + } + > } export type SpoolAPI = typeof api @@ -111,7 +126,13 @@ export type SpoolAPI = typeof api const api = { platform: process.platform as RuntimePlatform, - search: (query: string, limit?: number, source?: string, onlyPinned?: boolean, identityKey?: string): Promise => + search: ( + query: string, + limit?: number, + source?: string, + onlyPinned?: boolean, + identityKey?: string, + ): Promise => ipcRenderer.invoke('spool:search', { query, limit, source, onlyPinned, identityKey }), searchPreview: (query: string, limit?: number, source?: string): Promise => @@ -120,20 +141,24 @@ const api = { listSessions: (options?: { limit?: number; cursor?: SessionsCursor }): Promise => ipcRenderer.invoke('spool:list-sessions', options ?? {}), - listProjectGroups: (): Promise => - ipcRenderer.invoke('spool:list-project-groups'), + listProjectGroups: (): Promise => ipcRenderer.invoke('spool:list-project-groups'), - listSessionsByIdentity: (identityKey: string, options?: ListSessionsByIdentityOptions): Promise => + listSessionsByIdentity: ( + identityKey: string, + options?: ListSessionsByIdentityOptions, + ): Promise => ipcRenderer.invoke('spool:list-sessions-by-identity', { identityKey, options }), - listProjectDirectoryCounts: (identityKey: string, sources?: SessionSource[]): Promise => + listProjectDirectoryCounts: ( + identityKey: string, + sources?: SessionSource[], + ): Promise => ipcRenderer.invoke('spool:list-project-directory-counts', { identityKey, sources }), getSession: (sessionUuid: string): Promise<{ session: Session; messages: Message[] } | null> => ipcRenderer.invoke('spool:get-session', { sessionUuid }), - getStatus: (): Promise => - ipcRenderer.invoke('spool:get-status'), + getStatus: (): Promise => ipcRenderer.invoke('spool:get-status'), pinSession: (uuid: string): Promise<{ ok: boolean }> => ipcRenderer.invoke('spool:pin-session', { uuid }), @@ -141,11 +166,9 @@ const api = { unpinSession: (uuid: string): Promise<{ ok: boolean }> => ipcRenderer.invoke('spool:unpin-session', { uuid }), - getPinnedUuids: (): Promise => - ipcRenderer.invoke('spool:get-pinned-uuids'), + getPinnedUuids: (): Promise => ipcRenderer.invoke('spool:get-pinned-uuids'), - listPinnedSessions: (): Promise => - ipcRenderer.invoke('spool:list-pinned-sessions'), + listPinnedSessions: (): Promise => ipcRenderer.invoke('spool:list-pinned-sessions'), listPinnedSessionsByIdentity: (identityKey: string): Promise => ipcRenderer.invoke('spool:list-pinned-sessions-by-identity', { identityKey }), @@ -157,23 +180,25 @@ const api = { getSystemLocale: (): Promise<'en' | 'zh-CN' | 'zh-TW' | 'ja' | 'ko' | 'de' | 'fr'> => ipcRenderer.invoke('spool:get-system-locale'), - syncNow: (): Promise => - ipcRenderer.invoke('spool:sync-now'), + syncNow: (): Promise => ipcRenderer.invoke('spool:sync-now'), - forceResyncSession: (sessionUuid: string): Promise< - { ok: true; result: 'added' | 'updated' | 'skipped' } - | { ok: false; error: string } - > => - ipcRenderer.invoke('spool:force-resync-session', { sessionUuid }), + forceResyncSession: ( + sessionUuid: string, + ): Promise< + { ok: true; result: 'added' | 'updated' | 'skipped' } | { ok: false; error: string } + > => ipcRenderer.invoke('spool:force-resync-session', { sessionUuid }), - resumeCLI: (sessionUuid: string, source: string, cwd?: string): Promise<{ ok: boolean; error?: string }> => + resumeCLI: ( + sessionUuid: string, + source: string, + cwd?: string, + ): Promise<{ ok: boolean; error?: string }> => ipcRenderer.invoke('spool:resume-cli', { sessionUuid, source, cwd }), copyFragment: (text: string): Promise<{ ok: boolean }> => ipcRenderer.invoke('spool:copy-fragment', { text }), - getSidebarCollapsed: (): Promise => - ipcRenderer.invoke('spool:get-sidebar-collapsed'), + getSidebarCollapsed: (): Promise => ipcRenderer.invoke('spool:get-sidebar-collapsed'), setSidebarCollapsed: (collapsed: boolean): Promise<{ ok: boolean }> => ipcRenderer.invoke('spool:set-sidebar-collapsed', { collapsed }), @@ -192,19 +217,21 @@ const api = { }, // AI / ACP - getAiAgents: (): Promise => - ipcRenderer.invoke('spool:ai-agents'), + getAiAgents: (): Promise => ipcRenderer.invoke('spool:ai-agents'), getBuiltinAgents: (): Promise> => ipcRenderer.invoke('spool:ai-builtin-agents'), - getAgentsConfig: (): Promise => - ipcRenderer.invoke('spool:ai-get-config'), + getAgentsConfig: (): Promise => ipcRenderer.invoke('spool:ai-get-config'), setAgentsConfig: (config: AgentsConfig): Promise<{ ok: boolean }> => ipcRenderer.invoke('spool:ai-set-config', { config }), - aiSearch: (query: string, agentId: string, context: FragmentResult[]): Promise<{ ok: boolean; fullText?: string; error?: string }> => + aiSearch: ( + query: string, + agentId: string, + context: FragmentResult[], + ): Promise<{ ok: boolean; fullText?: string; error?: string }> => ipcRenderer.invoke('spool:ai-search', { query, agentId, context }), aiCancel: (agentId: string): Promise<{ ok: boolean }> => @@ -217,25 +244,33 @@ const api = { }, onAiDone: (cb: (data: { fullText: string; error?: string }) => void) => { - const handler = (_: Electron.IpcRendererEvent, data: unknown) => cb(data as { fullText: string; error?: string }) + const handler = (_: Electron.IpcRendererEvent, data: unknown) => + cb(data as { fullText: string; error?: string }) ipcRenderer.on('spool:ai-done', handler) return () => ipcRenderer.removeListener('spool:ai-done', handler) }, - onAiToolCall: (cb: (data: { toolCallId: string; title: string; status: string; kind?: string }) => void) => { - const handler = (_: Electron.IpcRendererEvent, data: unknown) => cb(data as { toolCallId: string; title: string; status: string; kind?: string }) + onAiToolCall: ( + cb: (data: { toolCallId: string; title: string; status: string; kind?: string }) => void, + ) => { + const handler = (_: Electron.IpcRendererEvent, data: unknown) => + cb(data as { toolCallId: string; title: string; status: string; kind?: string }) ipcRenderer.on('spool:ai-tool-call', handler) return () => ipcRenderer.removeListener('spool:ai-tool-call', handler) }, - onAiSessionStarted: (cb: (data: { sessionUuid: string; source: string; cwd: string }) => void) => { - const handler = (_: Electron.IpcRendererEvent, data: unknown) => cb(data as { sessionUuid: string; source: string; cwd: string }) + onAiSessionStarted: ( + cb: (data: { sessionUuid: string; source: string; cwd: string }) => void, + ) => { + const handler = (_: Electron.IpcRendererEvent, data: unknown) => + cb(data as { sessionUuid: string; source: string; cwd: string }) ipcRenderer.on('spool:ai-session-started', handler) return () => ipcRenderer.removeListener('spool:ai-session-started', handler) }, onSyncProgress: (cb: (e: { phase: string; count: number; total: number }) => void) => { - const handler = (_: Electron.IpcRendererEvent, data: unknown) => cb(data as { phase: string; count: number; total: number }) + const handler = (_: Electron.IpcRendererEvent, data: unknown) => + cb(data as { phase: string; count: number; total: number }) ipcRenderer.on('spool:sync-progress', handler) return () => ipcRenderer.removeListener('spool:sync-progress', handler) }, @@ -246,8 +281,7 @@ const api = { return () => ipcRenderer.removeListener('spool:new-sessions', handler) }, - getTheme: (): Promise<'system' | 'light' | 'dark'> => - ipcRenderer.invoke('spool:get-theme'), + getTheme: (): Promise<'system' | 'light' | 'dark'> => ipcRenderer.invoke('spool:get-theme'), setTheme: (theme: 'system' | 'light' | 'dark'): Promise<{ ok: boolean }> => ipcRenderer.invoke('spool:set-theme', { theme }), @@ -259,17 +293,28 @@ const api = { ipcRenderer.invoke('spool:set-theme-editor-state', { state }), // Auto-update - onUpdateStatus: (cb: (data: { status: 'available' | 'downloading' | 'ready' | 'error'; version?: string; percent?: number }) => void) => { - const handler = (_: Electron.IpcRendererEvent, data: unknown) => cb(data as { status: 'available' | 'downloading' | 'ready' | 'error'; version?: string; percent?: number }) + onUpdateStatus: ( + cb: (data: { + status: 'available' | 'downloading' | 'ready' | 'error' + version?: string + percent?: number + }) => void, + ) => { + const handler = (_: Electron.IpcRendererEvent, data: unknown) => + cb( + data as { + status: 'available' | 'downloading' | 'ready' | 'error' + version?: string + percent?: number + }, + ) ipcRenderer.on('spool:update-status', handler) return () => ipcRenderer.removeListener('spool:update-status', handler) }, - downloadUpdate: (): Promise => - ipcRenderer.invoke('spool:download-update'), + downloadUpdate: (): Promise => ipcRenderer.invoke('spool:download-update'), - installUpdate: (): Promise => - ipcRenderer.invoke('spool:install-update'), + installUpdate: (): Promise => ipcRenderer.invoke('spool:install-update'), printToPdf: (html: string, widthPx: number, heightPx: number): Promise => ipcRenderer.invoke('spool:print-to-pdf', { html, widthPx, heightPx }), @@ -282,11 +327,16 @@ const api = { ipcRenderer.invoke('security:list-findings-page', filter), listSessionsWithFindings: (filter: SessionFindingFilter): Promise => ipcRenderer.invoke('security:list-sessions-with-findings', filter), - listSessionsWithFindingsPage: (filter: SessionFindingFilter): Promise> => + listSessionsWithFindingsPage: ( + filter: SessionFindingFilter, + ): Promise> => ipcRenderer.invoke('security:list-sessions-with-findings-page', filter), countSessionsWithFindings: (filter: SessionFindingFilter): Promise => ipcRenderer.invoke('security:count-sessions-with-findings', filter), - occurrencesByValueHash: (kind: SensitiveKind, valueHash: string): Promise => + occurrencesByValueHash: ( + kind: SensitiveKind, + valueHash: string, + ): Promise => ipcRenderer.invoke('security:occurrences-by-value-hash', { kind, valueHash }), riskByCategory: (): Promise => ipcRenderer.invoke('security:risk-by-category'), @@ -296,22 +346,31 @@ const api = { ipcRenderer.invoke('security:get-finding-value', findingId), getFindingValues: (ids: number[]): Promise> => ipcRenderer.invoke('security:get-finding-values', ids), - getScanStatus: (): Promise => - ipcRenderer.invoke('security:get-scan-status'), + getScanStatus: (): Promise => ipcRenderer.invoke('security:get-scan-status'), dismissFinding: (findingId: number, scope: 'session' | 'global'): Promise<{ ok: boolean }> => ipcRenderer.invoke('security:dismiss-finding', { findingId, scope }), - dismissFindings: (findingIds: number[], scope: 'session' | 'global'): Promise<{ ok: boolean }> => + dismissFindings: ( + findingIds: number[], + scope: 'session' | 'global', + ): Promise<{ ok: boolean }> => ipcRenderer.invoke('security:dismiss-findings', { findingIds, scope }), undismissFinding: (findingId: number): Promise<{ ok: boolean }> => ipcRenderer.invoke('security:undismiss-finding', { findingId }), - purgeFinding: (findingId: number): Promise<{ findingId: number; sessionId: number; maskUsed: string; purgedAt: string }> => + purgeFinding: ( + findingId: number, + ): Promise<{ findingId: number; sessionId: number; maskUsed: string; purgedAt: string }> => ipcRenderer.invoke('security:purge-finding', findingId), - purgeFindings: (findingIds: number[]): Promise> => - ipcRenderer.invoke('security:purge-findings', findingIds), - purgeEverywhere: (kind: SensitiveKind, valueHash: string): Promise<{ count: number; sessionIds: number[] }> => + purgeFindings: ( + findingIds: number[], + ): Promise< + Array<{ findingId: number; sessionId: number; maskUsed: string; purgedAt: string }> + > => ipcRenderer.invoke('security:purge-findings', findingIds), + purgeEverywhere: ( + kind: SensitiveKind, + valueHash: string, + ): Promise<{ count: number; sessionIds: number[] }> => ipcRenderer.invoke('security:purge-everywhere', { kind, valueHash }), - rescanAll: (): Promise<{ count: number }> => - ipcRenderer.invoke('security:rescan-all'), + rescanAll: (): Promise<{ count: number }> => ipcRenderer.invoke('security:rescan-all'), rescanSession: (sessionId: number): Promise<{ ok: boolean }> => ipcRenderer.invoke('security:rescan-session', sessionId), onFindingsChanged: (cb: (change: FindingsChange) => void) => { @@ -325,12 +384,12 @@ const api = { return () => ipcRenderer.removeListener('security:evt-scan-status', handler) }, - getPrefs: (): Promise => - ipcRenderer.invoke('security:get-prefs'), + getPrefs: (): Promise => ipcRenderer.invoke('security:get-prefs'), setPrefs: (next: Partial): Promise => ipcRenderer.invoke('security:set-prefs', next), onPrefsChanged: (cb: (prefs: SecurityPreferences) => void) => { - const handler = (_: Electron.IpcRendererEvent, data: unknown) => cb(data as SecurityPreferences) + const handler = (_: Electron.IpcRendererEvent, data: unknown) => + cb(data as SecurityPreferences) ipcRenderer.on('security:evt-prefs-changed', handler) return () => ipcRenderer.removeListener('security:evt-prefs-changed', handler) }, @@ -344,16 +403,13 @@ const api = { kind: SensitiveKind valueHash: string sessionUuid?: string - }): Promise<{ ok: boolean }> => - ipcRenderer.invoke('security:remove-allowlist-entry', args), + }): Promise<{ ok: boolean }> => ipcRenderer.invoke('security:remove-allowlist-entry', args), - listBackups: (): Promise => - ipcRenderer.invoke('security:list-backups'), + listBackups: (): Promise => ipcRenderer.invoke('security:list-backups'), deleteBackups: (args: { names: string[] }): Promise => ipcRenderer.invoke('security:delete-backups', args), - pfGetState: (): Promise => - ipcRenderer.invoke('security:pf-get-state'), + pfGetState: (): Promise => ipcRenderer.invoke('security:pf-get-state'), pfDownloadStart: (): Promise<{ ok: boolean; reason?: string }> => ipcRenderer.invoke('security:pf-download-start'), pfDownloadCancel: (): Promise<{ ok: boolean }> => @@ -366,8 +422,7 @@ const api = { return () => ipcRenderer.removeListener('security:evt-pf-state', handler) }, - getReadiness: (): Promise => - ipcRenderer.invoke('security:get-readiness'), + getReadiness: (): Promise => ipcRenderer.invoke('security:get-readiness'), onReadinessChanged: (cb: (state: SecurityReadiness) => void) => { const handler = (_: Electron.IpcRendererEvent, data: unknown) => cb(data as SecurityReadiness) ipcRenderer.on('security:evt-readiness-changed', handler) @@ -387,26 +442,20 @@ export interface ShareAuthUser { } const spoolShare = { - authAvailable: (): Promise => - ipcRenderer.invoke('share-auth:available'), + authAvailable: (): Promise => ipcRenderer.invoke('share-auth:available'), // Single sign-in path: WorkOS AuthKit via the system browser (PKCE + // spool:// callback). Method choice (Google, email, ...) happens on // the hosted AuthKit page, not here. - signIn: (): Promise => - ipcRenderer.invoke('share-auth:signin'), - me: (): Promise => - ipcRenderer.invoke('share-auth:me'), - signOut: (): Promise<{ ok: boolean }> => - ipcRenderer.invoke('share-auth:signout'), + signIn: (): Promise => ipcRenderer.invoke('share-auth:signin'), + me: (): Promise => ipcRenderer.invoke('share-auth:me'), + signOut: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('share-auth:signout'), publish: (body: PublishRequestBody): Promise => ipcRenderer.invoke('share-publish:publish', body), - revoke: (id: string): Promise<{ ok: true }> => - ipcRenderer.invoke('share-publish:revoke', id), + revoke: (id: string): Promise<{ ok: true }> => ipcRenderer.invoke('share-publish:revoke', id), setVisibility: (id: string, visibility: Visibility): Promise => ipcRenderer.invoke('share-publish:set-visibility', id, visibility), - myShares: (): Promise => - ipcRenderer.invoke('share-publish:my-shares'), + myShares: (): Promise => ipcRenderer.invoke('share-publish:my-shares'), claimHandle: (handle: string): Promise => ipcRenderer.invoke('share-publish:claim-handle', handle), checkHandle: (handle: string): Promise => @@ -421,8 +470,7 @@ const spoolShare = { scheduleDelete: (): Promise => ipcRenderer.invoke('share-publish:schedule-delete'), - cancelDelete: (): Promise<{ ok: true }> => - ipcRenderer.invoke('share-publish:cancel-delete'), + cancelDelete: (): Promise<{ ok: true }> => ipcRenderer.invoke('share-publish:cancel-delete'), updateDisplayName: (value: string | null): Promise<{ ok: true; changed: number }> => ipcRenderer.invoke('share-profile:update-display-name', value), @@ -430,8 +478,7 @@ const spoolShare = { ipcRenderer.invoke('share-profile:set-avatar-visible', visible), uploadAvatar: (bytes: ArrayBuffer, mime: string): Promise<{ avatar_id: string; url: string }> => ipcRenderer.invoke('share-profile:upload-avatar', bytes, mime), - deleteAvatar: (): Promise<{ ok: true }> => - ipcRenderer.invoke('share-profile:delete-avatar'), + deleteAvatar: (): Promise<{ ok: true }> => ipcRenderer.invoke('share-profile:delete-avatar'), // v2 hub share (records, not styled snapshots) — one-click publish. hubSharePrepare: (sessionUuid: string): Promise => diff --git a/apps/app/src/preload/inference.ts b/apps/app/src/preload/inference.ts index 9b8dcc4f..6cec4c4b 100644 --- a/apps/app/src/preload/inference.ts +++ b/apps/app/src/preload/inference.ts @@ -3,6 +3,7 @@ // needs. import { contextBridge, ipcRenderer } from 'electron' + import { PF_IPC, type PfReadyMessage, diff --git a/apps/app/src/renderer/App.tsx b/apps/app/src/renderer/App.tsx index 5b1ba71e..f55fab00 100644 --- a/apps/app/src/renderer/App.tsx +++ b/apps/app/src/renderer/App.tsx @@ -1,40 +1,58 @@ -import { useEffect, useState, useCallback, useRef, memo, startTransition, useDeferredValue } from 'react' +import type { FragmentResult, SearchResult, StatusInfo } from '@spool-lab/core' +import type { Message, Session, ShareDraftListItem, ShareDraftSourceKind } from '@spool-lab/core' +import type { ProjectSessionSortOrder } from '@spool-lab/core' +import { + buildPreviewDocument, + buildSpoolDocument, + DEFAULT_OPTS, + normalizeOpts, + readSpoolFile, + type Conversation, + type EditorOpts, + type SpoolDocument, +} from '@spool/share-kit' +import { + useEffect, + useState, + useCallback, + useRef, + memo, + startTransition, + useDeferredValue, +} from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' -import type { FragmentResult, SearchResult, StatusInfo } from '@spool-lab/core' -import { type SearchMode } from './components/SearchBar.js' -import FragmentResults from './components/FragmentResults.js' -import SessionDetail from './components/SessionDetail.js' + +import type { LanguagePreference } from '../preload/index.js' +import { DEFAULT_PINNED_SORT_ORDER, type PinnedSortOrder } from '../shared/pinnedSort.js' +import { DEFAULT_PROJECT_SORT_ORDER } from '../shared/projectView.js' +import { getSessionResumeCommandPrefix } from '../shared/resumeCommand.js' +import { DEFAULT_SEARCH_SORT_ORDER, type SearchSortOrder } from '../shared/searchSort.js' +import { DEFAULT_SIDEBAR_SORT_ORDER, type SidebarSortOrder } from '../shared/sidebarSort.js' +import { primeSecurityPrefsCache } from './api/securityPrefsCache.js' import AiAnswerCard from './components/AiAnswerCard.js' -import SettingsPanel from './components/SettingsPanel.js' -import Sidebar from './components/Sidebar.js' -import ProjectView from './components/ProjectView.js' +import AppToaster from './components/AppToaster.js' +import AppTopBar from './components/AppTopBar.js' +import FragmentResults from './components/FragmentResults.js' import LibraryLanding from './components/LibraryLanding.js' -import SearchOverlay from './components/SearchOverlay.js' +import ProjectView from './components/ProjectView.js' import type { ScopeValue } from './components/ScopeSelector.js' -import AppTopBar from './components/AppTopBar.js' -import SidebarRail from './components/SidebarRail.js' -import AppToaster from './components/AppToaster.js' -import SharesPage from './components/SharesPage.js' +import { type SearchMode } from './components/SearchBar.js' +import SearchOverlay from './components/SearchOverlay.js' import SecurityPage from './components/SecurityPage.js' +import SessionDetail from './components/SessionDetail.js' +import SettingsPanel from './components/SettingsPanel.js' import ShareEditorPage from './components/ShareEditorPage.js' +import SharesPage from './components/SharesPage.js' +import Sidebar from './components/Sidebar.js' +import SidebarRail from './components/SidebarRail.js' +import { useHotkeys } from './hooks/useHotkeys.js' +import { useLanguageBootstrap } from './i18n/useLanguageBootstrap.js' import { composeFromSession, sessionDraftId } from './lib/compose-from-session.js' import { draftIdForImport } from './lib/import-spool.js' -import { buildPreviewDocument, buildSpoolDocument, DEFAULT_OPTS, normalizeOpts, readSpoolFile, type Conversation, type EditorOpts, type SpoolDocument } from '@spool/share-kit' -import type { Message, Session, ShareDraftListItem, ShareDraftSourceKind } from '@spool-lab/core' -import { getSessionResumeCommandPrefix } from '../shared/resumeCommand.js' -import { DEFAULT_SEARCH_SORT_ORDER, type SearchSortOrder } from '../shared/searchSort.js' -import { DEFAULT_SIDEBAR_SORT_ORDER, type SidebarSortOrder } from '../shared/sidebarSort.js' -import { DEFAULT_PINNED_SORT_ORDER, type PinnedSortOrder } from '../shared/pinnedSort.js' -import { DEFAULT_PROJECT_SORT_ORDER } from '../shared/projectView.js' -import type { ProjectSessionSortOrder } from '@spool-lab/core' -import { defaultThemeEditorState, type ThemeEditorStateV1 } from './theme/editorTypes.js' import { applyEditorTheme } from './theme/applyEditorTheme.js' +import { defaultThemeEditorState, type ThemeEditorStateV1 } from './theme/editorTypes.js' import { loadThemeEditorState, saveThemeEditorState } from './theme/persist.js' -import { useHotkeys } from './hooks/useHotkeys.js' -import { useLanguageBootstrap } from './i18n/useLanguageBootstrap.js' -import type { LanguagePreference } from '../preload/index.js' -import { primeSecurityPrefsCache } from './api/securityPrefsCache.js' type View = 'search' | 'session' | 'shares' | 'share-editor' | 'security' type SettingsTab = 'general' | 'appearance' | 'shortcuts' | 'sources' | 'agent' | 'security' @@ -65,7 +83,11 @@ export default function App() { const [view, setView] = useState('search') const [homeMode, setHomeMode] = useState(true) const [isSearching, setIsSearching] = useState(false) - const [syncStatus, setSyncStatus] = useState<{ phase: string; count: number; total: number } | null>(null) + const [syncStatus, setSyncStatus] = useState<{ + phase: string + count: number + total: number + } | null>(null) const [status, setStatus] = useState(null) const [runtimeInfo, setRuntimeInfo] = useState(null) const searchTimer = useRef | null>(null) @@ -80,20 +102,33 @@ export default function App() { const [aiError, setAiError] = useState(null) const [aiAgent, setAiAgent] = useState('claude') const [availableAgents, setAvailableAgents] = useState([]) - const [aiToolCalls, setAiToolCalls] = useState>(new Map()) - const [aiSession, setAiSession] = useState<{ sessionUuid: string; source: string; cwd: string } | null>(null) + const [aiToolCalls, setAiToolCalls] = useState< + Map + >(new Map()) + const [aiSession, setAiSession] = useState<{ + sessionUuid: string + source: string + cwd: string + } | null>(null) const aiAnswerRef = useRef('') // Settings & modals const [showSettings, setShowSettings] = useState(false) const [settingsTab, setSettingsTab] = useState('general') - const [defaultSearchSort, setDefaultSearchSort] = useState(DEFAULT_SEARCH_SORT_ORDER) + const [defaultSearchSort, setDefaultSearchSort] = + useState(DEFAULT_SEARCH_SORT_ORDER) const [sidebarShowSourceDots, setSidebarShowSourceDots] = useState(true) const [sidebarShowSessionCount, setSidebarShowSessionCount] = useState(true) - const [sidebarSortOrder, setSidebarSortOrder] = useState(DEFAULT_SIDEBAR_SORT_ORDER) + const [sidebarSortOrder, setSidebarSortOrder] = useState( + DEFAULT_SIDEBAR_SORT_ORDER, + ) const [pinnedSortOrder, setPinnedSortOrder] = useState(DEFAULT_PINNED_SORT_ORDER) - const [projectSortOrder, setProjectSortOrder] = useState(DEFAULT_PROJECT_SORT_ORDER) - const [themeEditor, setThemeEditor] = useState(() => defaultThemeEditorState()) + const [projectSortOrder, setProjectSortOrder] = useState( + DEFAULT_PROJECT_SORT_ORDER, + ) + const [themeEditor, setThemeEditor] = useState(() => + defaultThemeEditorState(), + ) // undefined until AgentsConfig has loaded — useLanguageBootstrap skips // until then so the cached locale (set by the last applyLanguage call) // isn't transiently overwritten by the system fallback while config IPC @@ -146,147 +181,178 @@ export default function App() { } }, []) - const openShareEditor = useCallback((bundle: ShareEditorBundle, returnView: View) => { - enterShareEditor(sidebarCollapsed) - setShareEditor(bundle) - setShareEditorReturnView(returnView) - setView('share-editor') - }, [enterShareEditor, sidebarCollapsed]) - - const handleStartShareFromSession = useCallback(async (session: Session, messages: Message[], returnView: View = 'session') => { - const draftId = sessionDraftId(session.sessionUuid) - // If the user has shared this session before, reopen their saved - // draft (their edits to template / paper / typeface / etc. are in - // the snapshot). Only when no draft exists do we build a fresh - // one with DEFAULT_OPTS and persist it. - let conversation: Conversation - let opts: EditorOpts - let isNewDraft = false - try { - const existing = await window.spool?.shareDraft?.get(draftId) - if (existing) { - const doc = JSON.parse(existing.snapshot_json) as SpoolDocument - conversation = doc.conversation - // Merge with DEFAULT_OPTS so a snapshot saved before a new - // EditorOpts field landed (e.g. colorway, density) doesn't - // leave that field undefined and crash TEMPLATE_RATIO lookups - // / PreviewPane's TemplateRender downstream. - opts = normalizeOpts(doc.opts) - } else { + const openShareEditor = useCallback( + (bundle: ShareEditorBundle, returnView: View) => { + enterShareEditor(sidebarCollapsed) + setShareEditor(bundle) + setShareEditorReturnView(returnView) + setView('share-editor') + }, + [enterShareEditor, sidebarCollapsed], + ) + + const handleStartShareFromSession = useCallback( + async (session: Session, messages: Message[], returnView: View = 'session') => { + const draftId = sessionDraftId(session.sessionUuid) + // If the user has shared this session before, reopen their saved + // draft (their edits to template / paper / typeface / etc. are in + // the snapshot). Only when no draft exists do we build a fresh + // one with DEFAULT_OPTS and persist it. + let conversation: Conversation + let opts: EditorOpts + let isNewDraft = false + try { + const existing = await window.spool?.shareDraft?.get(draftId) + if (existing) { + const doc = JSON.parse(existing.snapshot_json) as SpoolDocument + conversation = doc.conversation + // Merge with DEFAULT_OPTS so a snapshot saved before a new + // EditorOpts field landed (e.g. colorway, density) doesn't + // leave that field undefined and crash TEMPLATE_RATIO lookups + // / PreviewPane's TemplateRender downstream. + opts = normalizeOpts(doc.opts) + } else { + conversation = composeFromSession(session, messages) + opts = DEFAULT_OPTS + isNewDraft = true + } + } catch (err) { + console.error( + 'Failed to load or persist share draft, falling back to a fresh compose:', + err, + ) conversation = composeFromSession(session, messages) opts = DEFAULT_OPTS - isNewDraft = true } - } catch (err) { - console.error('Failed to load or persist share draft, falling back to a fresh compose:', err) - conversation = composeFromSession(session, messages) - opts = DEFAULT_OPTS - } - openShareEditor({ - draftId, - sourceKind: 'spool-session', - sourceOrigin: session.sessionUuid, - conversation, - opts, - }, returnView) - if (isNewDraft) { - // Persist the freshly composed draft AFTER the view switch so a - // large session doesn't stall the click on two megabyte-scale - // JSON.stringify calls plus an IPC round-trip. Double-rAF, not - // setTimeout(0): a zero timer fires before the compositor - // produces the first frame, which would block the very paint - // this defers past. Failure is non-fatal: the editor already - // holds the conversation, and the first edit's autosave - // (ShareEditorPage) writes the same payload. - const persistDraft = () => { - try { - const doc = buildSpoolDocument(conversation, opts) - void window.spool?.shareDraft?.upsert({ - draft_id: draftId, - source_kind: 'spool-session', - source_origin: session.sessionUuid, - title: conversation.title, - snapshot_json: JSON.stringify(doc), - preview_json: JSON.stringify(buildPreviewDocument(doc)), - }).catch((err) => console.error('Persist new share draft failed:', err)) - } catch (err) { - console.error('Persist new share draft failed:', err) + openShareEditor( + { + draftId, + sourceKind: 'spool-session', + sourceOrigin: session.sessionUuid, + conversation, + opts, + }, + returnView, + ) + if (isNewDraft) { + // Persist the freshly composed draft AFTER the view switch so a + // large session doesn't stall the click on two megabyte-scale + // JSON.stringify calls plus an IPC round-trip. Double-rAF, not + // setTimeout(0): a zero timer fires before the compositor + // produces the first frame, which would block the very paint + // this defers past. Failure is non-fatal: the editor already + // holds the conversation, and the first edit's autosave + // (ShareEditorPage) writes the same payload. + const persistDraft = () => { + try { + const doc = buildSpoolDocument(conversation, opts) + void window.spool?.shareDraft + ?.upsert({ + draft_id: draftId, + source_kind: 'spool-session', + source_origin: session.sessionUuid, + title: conversation.title, + snapshot_json: JSON.stringify(doc), + preview_json: JSON.stringify(buildPreviewDocument(doc)), + }) + .catch((err) => console.error('Persist new share draft failed:', err)) + } catch (err) { + console.error('Persist new share draft failed:', err) + } } + requestAnimationFrame(() => requestAnimationFrame(persistDraft)) } - requestAnimationFrame(() => requestAnimationFrame(persistDraft)) - } - }, [openShareEditor]) + }, + [openShareEditor], + ) - const handleStartShareFromUuid = useCallback(async (sessionUuid: string) => { - try { - const result = await window.spool?.getSession(sessionUuid) - if (!result) { - console.error('Cannot share — session not found:', sessionUuid) - return + const handleStartShareFromUuid = useCallback( + async (sessionUuid: string) => { + try { + const result = await window.spool?.getSession(sessionUuid) + if (!result) { + console.error('Cannot share — session not found:', sessionUuid) + return + } + await handleStartShareFromSession(result.session, result.messages, view) + } catch (err) { + console.error('Failed to load session for share:', err) } - await handleStartShareFromSession(result.session, result.messages, view) - } catch (err) { - console.error('Failed to load session for share:', err) - } - }, [handleStartShareFromSession, view]) - - const handleImportSpoolFile = useCallback(async (file: File) => { - let doc: SpoolDocument - try { - doc = await readSpoolFile(file) - } catch (err) { - const message = err instanceof Error ? err.message : t('toast.importFailed_readError') - toast.error(t('toast.importFailed_title', { name: file.name }), { description: message }) - return - } - try { - const opts: EditorOpts = normalizeOpts(doc.opts) - const normalized: SpoolDocument = { ...doc, opts } - const normalizedJson = JSON.stringify(normalized) - const draftId = await draftIdForImport(normalizedJson) - await window.spool?.shareDraft?.upsert({ - draft_id: draftId, - source_kind: 'imported-file', - source_origin: file.name, - title: normalized.conversation.title, - snapshot_json: normalizedJson, - preview_json: JSON.stringify(buildPreviewDocument(normalized)), - }) - openShareEditor({ - draftId, - sourceKind: 'imported-file', - sourceOrigin: file.name, - conversation: normalized.conversation, - opts, - }, 'shares') - } catch (err) { - console.error('Failed to persist imported .spool draft:', err) - toast.error(t('toast.importFailed_title', { name: file.name }), { description: t('toast.importFailed_saveError') }) - } - }, [openShareEditor]) + }, + [handleStartShareFromSession, view], + ) - const handleOpenDraft = useCallback(async (draft: ShareDraftListItem) => { - // The list query intentionally omits snapshot_json — fetch the - // full row before parsing so the editor gets the complete - // conversation rather than the truncated preview blob. - try { - const full = await window.spool?.shareDraft?.get(draft.draft_id) - if (!full) { - console.error('Draft vanished between list and open:', draft.draft_id) + const handleImportSpoolFile = useCallback( + async (file: File) => { + let doc: SpoolDocument + try { + doc = await readSpoolFile(file) + } catch (err) { + const message = err instanceof Error ? err.message : t('toast.importFailed_readError') + toast.error(t('toast.importFailed_title', { name: file.name }), { description: message }) return } - const doc = JSON.parse(full.snapshot_json) as SpoolDocument - openShareEditor({ - draftId: draft.draft_id, - sourceKind: draft.source_kind, - sourceOrigin: draft.source_origin, - conversation: doc.conversation, - opts: normalizeOpts(doc.opts), - }, 'shares') - } catch (err) { - console.error('Failed to parse draft snapshot:', err) - } - }, [openShareEditor]) + try { + const opts: EditorOpts = normalizeOpts(doc.opts) + const normalized: SpoolDocument = { ...doc, opts } + const normalizedJson = JSON.stringify(normalized) + const draftId = await draftIdForImport(normalizedJson) + await window.spool?.shareDraft?.upsert({ + draft_id: draftId, + source_kind: 'imported-file', + source_origin: file.name, + title: normalized.conversation.title, + snapshot_json: normalizedJson, + preview_json: JSON.stringify(buildPreviewDocument(normalized)), + }) + openShareEditor( + { + draftId, + sourceKind: 'imported-file', + sourceOrigin: file.name, + conversation: normalized.conversation, + opts, + }, + 'shares', + ) + } catch (err) { + console.error('Failed to persist imported .spool draft:', err) + toast.error(t('toast.importFailed_title', { name: file.name }), { + description: t('toast.importFailed_saveError'), + }) + } + }, + [openShareEditor], + ) + + const handleOpenDraft = useCallback( + async (draft: ShareDraftListItem) => { + // The list query intentionally omits snapshot_json — fetch the + // full row before parsing so the editor gets the complete + // conversation rather than the truncated preview blob. + try { + const full = await window.spool?.shareDraft?.get(draft.draft_id) + if (!full) { + console.error('Draft vanished between list and open:', draft.draft_id) + return + } + const doc = JSON.parse(full.snapshot_json) as SpoolDocument + openShareEditor( + { + draftId: draft.draft_id, + sourceKind: draft.source_kind, + sourceOrigin: draft.source_origin, + conversation: doc.conversation, + opts: normalizeOpts(doc.opts), + }, + 'shares', + ) + } catch (err) { + console.error('Failed to parse draft snapshot:', err) + } + }, + [openShareEditor], + ) // Open a draft by id alone — the Published tab's revoked rows carry // only the `draft_id` the backend stored at publish time, not a @@ -294,26 +360,32 @@ export default function App() { // the drafts list, where a missing row is a freak race), the draft // behind a revoked share may well have been deleted since — surface // that as a toast instead of failing silently. - const handleOpenDraftById = useCallback(async (draftId: string) => { - try { - const full = await window.spool?.shareDraft?.get(draftId) - if (!full) { + const handleOpenDraftById = useCallback( + async (draftId: string) => { + try { + const full = await window.spool?.shareDraft?.get(draftId) + if (!full) { + toast.error(t('shares.publishedTab.draftMissing')) + return + } + const doc = JSON.parse(full.snapshot_json) as SpoolDocument + openShareEditor( + { + draftId: full.draft_id, + sourceKind: full.source_kind, + sourceOrigin: full.source_origin, + conversation: doc.conversation, + opts: normalizeOpts(doc.opts), + }, + 'shares', + ) + } catch (err) { + console.error('Failed to open draft for revoked share:', err) toast.error(t('shares.publishedTab.draftMissing')) - return } - const doc = JSON.parse(full.snapshot_json) as SpoolDocument - openShareEditor({ - draftId: full.draft_id, - sourceKind: full.source_kind, - sourceOrigin: full.source_origin, - conversation: doc.conversation, - opts: normalizeOpts(doc.opts), - }, 'shares') - } catch (err) { - console.error('Failed to open draft for revoked share:', err) - toast.error(t('shares.publishedTab.draftMissing')) - } - }, [openShareEditor, t]) + }, + [openShareEditor, t], + ) const handleCloseShareEditor = useCallback(() => { setShareEditor(null) @@ -322,9 +394,7 @@ export default function App() { useEffect(() => { if (!window.spool?.getSidebarCollapsed) return - window.spool.getSidebarCollapsed() - .then(setSidebarCollapsed) - .catch(console.error) + window.spool.getSidebarCollapsed().then(setSidebarCollapsed).catch(console.error) }, []) // Warm the SecurityPreferences cache at app boot so every Toggle in @@ -356,9 +426,11 @@ export default function App() { }) }, []) - const showProjectView = activeProjectKey !== null && view === 'search' && !selectedSession && !query.trim() + const showProjectView = + activeProjectKey !== null && view === 'search' && !selectedSession && !query.trim() const showSearchResults = view === 'search' && !selectedSession && !!query.trim() - const isHomeMode = homeMode && view === 'search' && !selectedSession && !showProjectView && !showSearchResults + const isHomeMode = + homeMode && view === 'search' && !selectedSession && !showProjectView && !showSearchResults const isSharesView = view === 'shares' const isSecurityView = view === 'security' const isShareEditorView = view === 'share-editor' @@ -396,28 +468,30 @@ export default function App() { // Load agents + config, apply configured default const refreshAgents = useCallback(() => { if (!window.spool?.getAiAgents) return - Promise.all([ - window.spool.getAiAgents(), - window.spool.getAgentsConfig(), - ]).then(([agents, config]) => { - const ready = agents.filter(a => a.status === 'ready') - setAvailableAgents(ready) - setDefaultSearchSort(config.defaultSearchSort ?? DEFAULT_SEARCH_SORT_ORDER) - setSidebarShowSourceDots(config.sidebarShowSourceDots ?? true) - setSidebarShowSessionCount(config.sidebarShowSessionCount ?? true) - setSidebarSortOrder(config.sidebarSortOrder ?? DEFAULT_SIDEBAR_SORT_ORDER) - setPinnedSortOrder(config.pinnedSortOrder ?? DEFAULT_PINNED_SORT_ORDER) - setProjectSortOrder(config.projectSortOrder ?? DEFAULT_PROJECT_SORT_ORDER) - setLanguage(config.language ?? 'system') - const defaultId = config.defaultAgent && ready.find(a => a.id === config.defaultAgent) - ? config.defaultAgent - : ready[0]?.id - if (defaultId) setAiAgent(defaultId) - }).catch(console.error) + Promise.all([window.spool.getAiAgents(), window.spool.getAgentsConfig()]) + .then(([agents, config]) => { + const ready = agents.filter((a) => a.status === 'ready') + setAvailableAgents(ready) + setDefaultSearchSort(config.defaultSearchSort ?? DEFAULT_SEARCH_SORT_ORDER) + setSidebarShowSourceDots(config.sidebarShowSourceDots ?? true) + setSidebarShowSessionCount(config.sidebarShowSessionCount ?? true) + setSidebarSortOrder(config.sidebarSortOrder ?? DEFAULT_SIDEBAR_SORT_ORDER) + setPinnedSortOrder(config.pinnedSortOrder ?? DEFAULT_PINNED_SORT_ORDER) + setProjectSortOrder(config.projectSortOrder ?? DEFAULT_PROJECT_SORT_ORDER) + setLanguage(config.language ?? 'system') + const defaultId = + config.defaultAgent && ready.find((a) => a.id === config.defaultAgent) + ? config.defaultAgent + : ready[0]?.id + if (defaultId) setAiAgent(defaultId) + }) + .catch(console.error) }, []) // Detect available ACP agents on mount - useEffect(() => { refreshAgents() }, []) + useEffect(() => { + refreshAgents() + }, []) const handleSidebarSortChange = useCallback(async (next: SidebarSortOrder) => { setSidebarSortOrder(next) @@ -482,7 +556,7 @@ export default function App() { if (error) setAiError(error) }) const offToolCall = window.spool.onAiToolCall?.((tc) => { - setAiToolCalls(prev => { + setAiToolCalls((prev) => { const next = new Map(prev) const previous = prev.get(tc.toolCallId) next.set(tc.toolCallId, { @@ -496,7 +570,12 @@ export default function App() { const offSession = window.spool.onAiSessionStarted?.((info) => { setAiSession(info) }) - return () => { offChunk(); offDone(); offToolCall?.(); offSession?.() } + return () => { + offChunk() + offDone() + offToolCall?.() + offSession?.() + } }, []) useEffect(() => { @@ -538,28 +617,40 @@ export default function App() { window.spool.getStatus().then(setStatus).catch(console.error) scheduleSearchRefresh() }) - return () => { offProgress(); offNew() } + return () => { + offProgress() + offNew() + } }, [query, searchMode]) - const doSearch = useCallback(async (q: string) => { - if (!q.trim()) { setResults([]); setIsSearching(false); return } - const requestId = ++searchRequestSeq.current - setIsSearching(true) - const scopedKey = searchScopeProject?.identityKey - try { - const res = window.spool ? await window.spool.search(q, 20, undefined, false, scopedKey) : [] - if (requestId !== searchRequestSeq.current) return - startTransition(() => { - setResults(res) - }) - } catch (error) { - console.error('[search] failed', error) - } finally { - if (requestId === searchRequestSeq.current) { + const doSearch = useCallback( + async (q: string) => { + if (!q.trim()) { + setResults([]) setIsSearching(false) + return } - } - }, [searchScopeProject]) + const requestId = ++searchRequestSeq.current + setIsSearching(true) + const scopedKey = searchScopeProject?.identityKey + try { + const res = window.spool + ? await window.spool.search(q, 20, undefined, false, scopedKey) + : [] + if (requestId !== searchRequestSeq.current) return + startTransition(() => { + setResults(res) + }) + } catch (error) { + console.error('[search] failed', error) + } finally { + if (requestId === searchRequestSeq.current) { + setIsSearching(false) + } + } + }, + [searchScopeProject], + ) const doPreviewSearch = useCallback(async (q: string) => { if (!q.trim() || !window.spool?.searchPreview) { @@ -577,51 +668,61 @@ export default function App() { setLastCompletedPreviewQuery(q) }, []) - const doAiSearch = useCallback(async (overrideQuery?: string) => { - const q = (overrideQuery ?? query).trim() - if (!q || !window.spool?.aiSearch) return + const doAiSearch = useCallback( + async (overrideQuery?: string) => { + const q = (overrideQuery ?? query).trim() + if (!q || !window.spool?.aiSearch) return - const scopedKey = searchScopeProject?.identityKey - let ftsResults = results - if (ftsResults.length === 0 && window.spool) { - try { - ftsResults = await window.spool.search(q, 20, undefined, false, scopedKey) - } catch (error) { - setAiError(String(error)) - setAiStreaming(false) - return + const scopedKey = searchScopeProject?.identityKey + let ftsResults = results + if (ftsResults.length === 0 && window.spool) { + try { + ftsResults = await window.spool.search(q, 20, undefined, false, scopedKey) + } catch (error) { + setAiError(String(error)) + setAiStreaming(false) + return + } } - } - if (ftsResults.length > 0 && results.length === 0) setResults(ftsResults) - const fragmentContext = ftsResults.filter((result): result is FragmentResult & { kind: 'fragment' } => result.kind === 'fragment') - - aiAnswerRef.current = '' - setAiAnswer('') - setAiError(null) - setAiStreaming(true) - setAiToolCalls(new Map()) - setAiSession(null) - - window.spool.aiSearch(q, aiAgent, fragmentContext).catch((err) => { - setAiError(String(err)) - setAiStreaming(false) - }) - }, [query, aiAgent, results, searchScopeProject]) + if (ftsResults.length > 0 && results.length === 0) setResults(ftsResults) + const fragmentContext = ftsResults.filter( + (result): result is FragmentResult & { kind: 'fragment' } => result.kind === 'fragment', + ) - const handleQueryChange = useCallback((q: string) => { - setQuery(q) - if (!q.trim()) setHomeMode(true) - if (searchMode === 'fast') { - if (searchTimer.current) clearTimeout(searchTimer.current) - searchTimer.current = setTimeout(() => { void doSearch(q) }, 120) - void doPreviewSearch(q) - } - if (aiAnswer || aiError) { + aiAnswerRef.current = '' setAiAnswer('') setAiError(null) - aiAnswerRef.current = '' - } - }, [doPreviewSearch, doSearch, searchMode, aiAnswer, aiError]) + setAiStreaming(true) + setAiToolCalls(new Map()) + setAiSession(null) + + window.spool.aiSearch(q, aiAgent, fragmentContext).catch((err) => { + setAiError(String(err)) + setAiStreaming(false) + }) + }, + [query, aiAgent, results, searchScopeProject], + ) + + const handleQueryChange = useCallback( + (q: string) => { + setQuery(q) + if (!q.trim()) setHomeMode(true) + if (searchMode === 'fast') { + if (searchTimer.current) clearTimeout(searchTimer.current) + searchTimer.current = setTimeout(() => { + void doSearch(q) + }, 120) + void doPreviewSearch(q) + } + if (aiAnswer || aiError) { + setAiAnswer('') + setAiError(null) + aiAnswerRef.current = '' + } + }, + [doPreviewSearch, doSearch, searchMode, aiAnswer, aiError], + ) const handleSubmit = useCallback(() => { if (!query.trim()) return @@ -636,26 +737,29 @@ export default function App() { } }, [searchMode, doAiSearch, doSearch, query]) - const handleModeChange = useCallback((mode: SearchMode) => { - setSearchMode(mode) - if (mode === 'fast') { - setAiAnswer('') - setAiError(null) - setAiStreaming(false) - setAiToolCalls(new Map()) - aiAnswerRef.current = '' - if (query.trim()) { - setHomeMode(false) - setSelectedSession(null) - setTargetMessageId(null) - setView('search') - void doSearch(query) + const handleModeChange = useCallback( + (mode: SearchMode) => { + setSearchMode(mode) + if (mode === 'fast') { + setAiAnswer('') + setAiError(null) + setAiStreaming(false) + setAiToolCalls(new Map()) + aiAnswerRef.current = '' + if (query.trim()) { + setHomeMode(false) + setSelectedSession(null) + setTargetMessageId(null) + setView('search') + void doSearch(query) + } + } else { + setResults([]) + setIsSearching(false) } - } else { - setResults([]) - setIsSearching(false) - } - }, [query, doSearch]) + }, + [query, doSearch], + ) const handleSelectSuggestion = useCallback((uuid: string, messageId?: number) => { setHomeMode(false) @@ -670,12 +774,15 @@ export default function App() { // search-results state. const [sessionReturnView, setSessionReturnView] = useState('search') - const handleOpenSession = useCallback((uuid: string, messageId?: number) => { - setSessionReturnView(view === 'session' ? sessionReturnView : view) - setSelectedSession(uuid) - setTargetMessageId(messageId ?? null) - setView('session') - }, [view, sessionReturnView]) + const handleOpenSession = useCallback( + (uuid: string, messageId?: number) => { + setSessionReturnView(view === 'session' ? sessionReturnView : view) + setSelectedSession(uuid) + setTargetMessageId(messageId ?? null) + setView('session') + }, + [view, sessionReturnView], + ) const handleBack = useCallback(() => { setView(sessionReturnView) @@ -699,7 +806,7 @@ export default function App() { // ⌘K opens overlay (suppressed when a modal layer is on top, e.g. Settings) // ⌘B toggles the sidebar — macOS convention shared with Mail, Notes, Xcode. useHotkeys({ - 'mod+k': () => setSearchOverlayOpen(open => !open), + 'mod+k': () => setSearchOverlayOpen((open) => !open), 'mod+b': () => toggleSidebar(), }) @@ -720,66 +827,84 @@ export default function App() { return } let cancelled = false - window.spool.listProjectGroups() - .then(groups => { + window.spool + .listProjectGroups() + .then((groups) => { if (cancelled) return - const match = groups.find(g => g.identityKey === activeProjectKey) + const match = groups.find((g) => g.identityKey === activeProjectKey) setActiveProjectName(match?.displayName ?? null) }) - .catch(() => { if (!cancelled) setActiveProjectName(null) }) - return () => { cancelled = true } + .catch(() => { + if (!cancelled) setActiveProjectName(null) + }) + return () => { + cancelled = true + } }, [activeProjectKey]) const handleSearchOpen = useCallback(() => setSearchOverlayOpen(true), []) const handleSearchClose = useCallback(() => setSearchOverlayOpen(false), []) - const handleSearchCommit = useCallback((q: string) => { - setSearchOverlayOpen(false) - setQuery(q) - setHomeMode(false) - setSelectedSession(null) - setTargetMessageId(null) - setView('search') - if (searchTimer.current) clearTimeout(searchTimer.current) - if (searchMode === 'ai') { - void doAiSearch(q) - } else { - void doSearch(q) - } - }, [doSearch, doAiSearch, searchMode]) - - const handleOpenResultFromOverlay = useCallback((uuid: string, messageId: number | undefined, q: string) => { - setSearchOverlayOpen(false) - setSelectedSession(uuid) - setTargetMessageId(messageId ?? null) - setView('session') - if (q.trim() && q !== query) { + const handleSearchCommit = useCallback( + (q: string) => { + setSearchOverlayOpen(false) setQuery(q) setHomeMode(false) - void doSearch(q) - } - }, [query, doSearch]) - - const handleCopySessionId = useCallback((source: FragmentResult['source']) => { - const command = getSessionResumeCommandPrefix(source) - if (!command) return - toast.success(t('toast.copiedSessionId'), { - id: 'resume-command', - description: ( - - {t('toast.copiedSessionId_description')}{' '} - - {command} - - - ), - }) - }, [t]) + setSelectedSession(null) + setTargetMessageId(null) + setView('search') + if (searchTimer.current) clearTimeout(searchTimer.current) + if (searchMode === 'ai') { + void doAiSearch(q) + } else { + void doSearch(q) + } + }, + [doSearch, doAiSearch, searchMode], + ) + + const handleOpenResultFromOverlay = useCallback( + (uuid: string, messageId: number | undefined, q: string) => { + setSearchOverlayOpen(false) + setSelectedSession(uuid) + setTargetMessageId(messageId ?? null) + setView('session') + if (q.trim() && q !== query) { + setQuery(q) + setHomeMode(false) + void doSearch(q) + } + }, + [query, doSearch], + ) - const activeAgentInfo = availableAgents.find(a => a.id === aiAgent) ?? availableAgents[0] + const handleCopySessionId = useCallback( + (source: FragmentResult['source']) => { + const command = getSessionResumeCommandPrefix(source) + if (!command) return + toast.success(t('toast.copiedSessionId'), { + id: 'resume-command', + description: ( + + {t('toast.copiedSessionId_description')}{' '} + + {command} + + + ), + }) + }, + [t], + ) + + const activeAgentInfo = availableAgents.find((a) => a.id === aiAgent) ?? availableAgents[0] const activeAgentName = activeAgentInfo?.name ?? aiAgent const hasAgents = availableAgents.length > 0 - const fragmentSources = deferredResults.filter((result): result is FragmentSearchResult => result.kind === 'fragment') - const fragmentPreview = previewSuggestions.filter((result): result is FragmentSearchResult => result.kind === 'fragment') + const fragmentSources = deferredResults.filter( + (result): result is FragmentSearchResult => result.kind === 'fragment', + ) + const fragmentPreview = previewSuggestions.filter( + (result): result is FragmentSearchResult => result.kind === 'fragment', + ) const sidebarElement = ( { setSettingsTab('general'); setShowSettings(true) }} + onSettingsClick={() => { + setSettingsTab('general') + setShowSettings(true) + }} /> ) @@ -868,7 +998,10 @@ export default function App() { share editor's PageLayout. */} {showSettings && ( { setShowSettings(false); refreshAgents() }} + onClose={() => { + setShowSettings(false) + refreshAgents() + }} initialTab={settingsTab} claudeCount={status?.claudeSessions ?? null} codexCount={status?.codexSessions ?? null} @@ -884,20 +1017,24 @@ export default function App() { open={searchOverlayOpen} initialQuery={query} scope={searchScopeProject} - contextualScope={activeProjectKey && activeProjectName - ? { identityKey: activeProjectKey, displayName: activeProjectName } - : null} + contextualScope={ + activeProjectKey && activeProjectName + ? { identityKey: activeProjectKey, displayName: activeProjectName } + : null + } mode={searchMode} {...(hasAgents ? { onModeChange: setSearchMode } : {})} - {...(hasAgents ? { - agentSelector: ( - - ) - } : {})} + {...(hasAgents + ? { + agentSelector: ( + + ), + } + : {})} onClose={handleSearchClose} onScopeChange={setSearchScopeProject} onCommit={handleSearchCommit} @@ -908,152 +1045,202 @@ export default function App() { } return ( -
+
-
+
{sidebarElement} -
-
- {isSharesView ? ( - - ) : isSecurityView ? ( - { setSettingsTab('security'); setShowSettings(true) }} - onShareSession={handleStartShareFromUuid} - /> - ) : isHomeMode ? ( - { - setActiveProjectKey(key) - setHomeMode(false) - setSelectedSession(null) - setTargetMessageId(null) - setView('search') - setQuery('') - }} - onOpenSession={handleOpenSession} - onCopySessionId={handleCopySessionId} - onShare={handleStartShareFromUuid} - /> - ) : ( - <> - {!showProjectView && view !== 'session' && !!query.trim() && ( -
- -

- {t('search.resultsFor')} "{query}" - · - - {searchScopeProject - ? t('search.scope_project', { project: searchScopeProject.displayName }) - : t('search.scope_all')} - -

- {searchMode === 'ai' && availableAgents.length > 0 && ( - +
+
+ {isSharesView ? ( + + ) : isSecurityView ? ( + { + setSettingsTab('security') + setShowSettings(true) + }} + onShareSession={handleStartShareFromUuid} + /> + ) : isHomeMode ? ( + { + setActiveProjectKey(key) + setHomeMode(false) + setSelectedSession(null) + setTargetMessageId(null) + setView('search') + setQuery('') + }} + onOpenSession={handleOpenSession} + onCopySessionId={handleCopySessionId} + onShare={handleStartShareFromUuid} + /> + ) : ( + <> + {!showProjectView && view !== 'session' && !!query.trim() && ( +
+ +

+ {t('search.resultsFor')}{' '} + + "{query}" + + + · + + + {searchScopeProject + ? t('search.scope_project', { project: searchScopeProject.displayName }) + : t('search.scope_all')} + +

+ {searchMode === 'ai' && availableAgents.length > 0 && ( + + )} +
)} -
- )} -
- {view === 'session' && selectedSession ? ( - - ) : showProjectView && activeProjectKey ? ( - - ) : ( -
- {searchMode === 'ai' && (aiAnswer || aiStreaming || aiError) && ( - { - void window.spool.resumeCLI(aiSession.sessionUuid, aiSession.source, aiSession.cwd) - }, - } : {})} +
+ {view === 'session' && selectedSession ? ( + + ) : showProjectView && activeProjectKey ? ( + - )} - {searchMode === 'ai' && fragmentSources.length > 0 && (aiAnswer || aiStreaming) && ( -
- {t('search.sourcesUsed')} -
- )} - {searchMode === 'ai' && !aiAnswer && !aiStreaming && !aiError && fragmentSources.length === 0 && query.trim() ? ( -
- - - -

- {t('search.pressEnterToAsk', { key: 'Enter' }) - .split('Enter') - .flatMap((part, i, arr) => i < arr.length - 1 - ? [part, Enter] - : [part])} -

-
) : ( -
- +
+ {searchMode === 'ai' && (aiAnswer || aiStreaming || aiError) && ( + { + void window.spool.resumeCLI( + aiSession.sessionUuid, + aiSession.source, + aiSession.cwd, + ) + }, + } + : {})} + /> + )} + {searchMode === 'ai' && + fragmentSources.length > 0 && + (aiAnswer || aiStreaming) && ( +
+ {t('search.sourcesUsed')} +
+ )} + {searchMode === 'ai' && + !aiAnswer && + !aiStreaming && + !aiError && + fragmentSources.length === 0 && + query.trim() ? ( +
+ + + +

+ {t('search.pressEnterToAsk', { key: 'Enter' }) + .split('Enter') + .flatMap((part, i, arr) => + i < arr.length - 1 + ? [ + part, + + Enter + , + ] + : [part], + )} +

+
+ ) : ( +
+ +
+ )}
)}
- )} -
- - )} + + )}
@@ -1062,7 +1249,10 @@ export default function App() { {showSettings && ( { setShowSettings(false); refreshAgents() }} + onClose={() => { + setShowSettings(false) + refreshAgents() + }} initialTab={settingsTab} claudeCount={status?.claudeSessions ?? null} codexCount={status?.codexSessions ?? null} @@ -1079,20 +1269,24 @@ export default function App() { open={searchOverlayOpen} initialQuery={query} scope={searchScopeProject} - contextualScope={activeProjectKey && activeProjectName - ? { identityKey: activeProjectKey, displayName: activeProjectName } - : null} + contextualScope={ + activeProjectKey && activeProjectName + ? { identityKey: activeProjectKey, displayName: activeProjectName } + : null + } mode={searchMode} {...(hasAgents ? { onModeChange: setSearchMode } : {})} - {...(hasAgents ? { - agentSelector: ( - - ) - } : {})} + {...(hasAgents + ? { + agentSelector: ( + + ), + } + : {})} onClose={handleSearchClose} onScopeChange={setSearchScopeProject} onCommit={handleSearchCommit} @@ -1102,18 +1296,22 @@ export default function App() { ) } -const AgentSelector = memo(function AgentSelector({ agents, activeAgent, onSelect }: { +const AgentSelector = memo(function AgentSelector({ + agents, + activeAgent, + onSelect, +}: { agents: AgentInfo[] activeAgent: string onSelect: (id: string) => void }) { const [open, setOpen] = useState(false) if (agents.length === 0) return null - const active = agents.find(a => a.id === activeAgent) ?? agents[0]! + const active = agents.find((a) => a.id === activeAgent) ?? agents[0]! if (agents.length <= 1) { return ( - + {active.name} ) @@ -1124,18 +1322,21 @@ const AgentSelector = memo(function AgentSelector({ agents, activeAgent, onSelec {open && ( -
- {agents.map(a => ( +
+ {agents.map((a) => ( @@ -124,8 +154,17 @@ export default function AiAnswerCard({ answer, streaming, agentName, sources, er function SparklesIcon() { return ( - - + + ) } diff --git a/apps/app/src/renderer/components/AppToaster.tsx b/apps/app/src/renderer/components/AppToaster.tsx index 5f493fc0..9d54d73f 100644 --- a/apps/app/src/renderer/components/AppToaster.tsx +++ b/apps/app/src/renderer/components/AppToaster.tsx @@ -93,13 +93,7 @@ export default function AppToaster() { className="text-[color:var(--color-status-warning)] dark:text-[color:var(--color-status-warning-dark)]" /> ), - info: ( - - ), + info: , }} /> ) diff --git a/apps/app/src/renderer/components/AppTopBar.tsx b/apps/app/src/renderer/components/AppTopBar.tsx index c5914ccd..2bf349ae 100644 --- a/apps/app/src/renderer/components/AppTopBar.tsx +++ b/apps/app/src/renderer/components/AppTopBar.tsx @@ -1,6 +1,6 @@ +import { PanelLeft } from 'lucide-react' import type { CSSProperties, ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import { PanelLeft } from 'lucide-react' type Props = { sidebarCollapsed: boolean @@ -18,7 +18,12 @@ type Props = { * boundary below it, so the eye reads "left column + right column" * running top-to-bottom. */ -export default function AppTopBar({ sidebarCollapsed, onToggleSidebar, trafficLightInset = true, children }: Props) { +export default function AppTopBar({ + sidebarCollapsed, + onToggleSidebar, + trafficLightInset = true, + children, +}: Props) { const { t } = useTranslation() const dragStyle = { WebkitAppRegion: 'drag' } as CSSProperties const noDragStyle = { WebkitAppRegion: 'no-drag' } as CSSProperties @@ -31,31 +36,35 @@ export default function AppTopBar({ sidebarCollapsed, onToggleSidebar, trafficLi } return ( -
+
{/* Background: animated sidebar split + content bg flooding the rest of the bar. macOS collapses the sidebar segment to zero because its traffic-light gutter stays in the top bar; Linux keeps a narrow sidebar chrome segment so page chrome aligns with the content pane below. */} -