Skip to content

feat(desktop): the Electron shell, the design system, and browsing the wiki (8.1, 8.2, 8.5, 8.10) - #13

Merged
protonspy merged 3 commits into
mainfrom
feat/desktop-shell
Aug 1, 2026
Merged

feat(desktop): the Electron shell, the design system, and browsing the wiki (8.1, 8.2, 8.5, 8.10)#13
protonspy merged 3 commits into
mainfrom
feat/desktop-shell

Conversation

@protonspy

@protonspy protonspy commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes tasks 8.1, 8.2, 8.5 and 8.10 of plans/open-wiki.md. This is the first Electron code in the repo.

What changed

The entry point is wiring and nothing else. Which project this window is, what the renderer may ask for, how a page becomes HTML, where Back goes, what a folder change means — each is a module beside it that a test calls without starting a window, because CI has no display. Same seam the recorder put under CaptureSource and the pipeline under FfmpegRunner.

The project root is bound in the main process and never crosses the bridge. The renderer names a slug, never a path — and a page is resolved by slug through the index rather than by joining a slug onto a path, so adr:0016 makes the correct implementation also the one that does no path arithmetic on untrusted input.

A launch that names no project says so and quits. Falling back to the working directory would open somebody's home folder as a wiki.

The three findings worth reading

Opening the window opened the microphone. The status poll went through the lazy session ??=, and recorder.exe opens both WASAPI devices the moment it launches — before it reads a request. Launching the app held the microphone for the window's lifetime while the chrome said nothing was being recorded: exactly the failure the persistent indicator exists to prevent, inverted.

record:start took a filesystem path from the renderer. Every other handler binds the project root; this was the exception. It now takes the occasion, and the directory comes from 4.16's id.

The renderer could navigate away with the preload attached. A click handler in the page is a convenience, not a boundary. The main process refuses navigation now, and shell.openExternal — which is ShellExecute on Windows, invoking whichever protocol handler is registered — allowlists the scheme.

Plus: both markdown extensions became markdown-it rules rather than String.replace over rendered HTML, which was breaking out of title="…" attributes and rendering every code span that quotes the wiki syntax as a live link. Several pages in this repository do exactly that.

How it was verified

  • pnpm test — 907 passing (100 new in @open-wiki/desktop)
  • pnpm --filter @open-wiki/desktop test:coverage — 93.5% lines, floor is 76%
  • pnpm run typecheck, pnpm lint, prettier --check — clean
  • scc validate — no findings
  • code-review and security-review subagents on the diff; every finding closed in the follow-up commit

What is not covered

.tsx view components are outside the coverage instrument (the shared config includes src/**/*.ts), and preload.ts is excluded because it imports electron and decides nothing. The review checked specifically whether meaningful behaviour hid there; the one rule that had — "is this change the open page?" — moved into watcher.ts where it is tested.

Electron is bumped to 38: 33 is outside its support window and its Chromium no longer receives security fixes, which is the wrong floor for an application whose threat model is untrusted content in a renderer.

🤖 Generated with Claude Code

https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK

Summary by CodeRabbit

  • New Features

    • Added a desktop wiki application for browsing project pages, metadata, sources, and Markdown content.
    • Added wikilinks, citations, page navigation, breadcrumbs, and safe external-link handling.
    • Added recording controls with live status, elapsed time, pause, resume, and stop actions.
    • Added automatic updates when wiki or source files change.
    • Added project detection and launch support.
    • Added secure handling for page links, project files, and rendered content.
  • Style

    • Added a dark desktop interface with recording indicators, error states, and accessible focus styling.

protonspy and others added 2 commits August 1, 2026 14:59
…e wiki

Plan 8.1, 8.2, 8.5 and 8.10.

The entry point is wiring and nothing else. Which project this window is, what
the renderer may ask for, how a page becomes HTML, where Back goes, what a
folder change means — each is a module beside it that a test calls without
starting a window, because CI has no display. That is the same seam the
recorder put under `CaptureSource` and the pipeline put under `FfmpegRunner`,
applied to the one part of this product that cannot be run in CI at all.

Three things about the window are deliberate rather than default.
`contextIsolation` is on, `nodeIntegration` off, `sandbox` on, and the page
carries a CSP of `default-src 'none'`: this window renders markdown an agent
wrote, and a renderer with Node in it is one prompt injection away from being
the agent's hands. The project root is bound in the main process and never
crosses the bridge, so the renderer names a slug and never a path. And a page
is resolved by slug *through the index* rather than by joining a slug onto a
path — `adr:0016` makes the index the only thing that knows where a page sits,
which means the correct implementation is also the one that does no path
arithmetic on untrusted input.

A launch that names no project says so and quits. Falling back to the working
directory would open somebody's home folder as a wiki; 8.4's launcher is what
belongs there, and saying so beats guessing.

The design system is tokens in one file. Dense and dark because of what the
window is for — a wiki read beside the harness, glanced at while a meeting
records — and neither of those wants generous whitespace. Focus is visible on
everything, always. The recording indicator is persistent and not subtle:
somebody who forgets it is running ends up with a recording of a conversation
the other people in it believe ended.

Browsing renders with `html: false`, turns a wikilink into an href the app
intercepts rather than a navigation, and shows a *broken* wikilink as broken
where the reader would have clicked it — a link to nowhere that looks like a
link is worse than obviously missing text, and it is the same thing 7.1
reports. Following a link after going back discards the forward history,
exactly as a browser does; anything else and Back stops meaning "where I came
from".

The watcher covers `wiki/` and `raw/` and not `.state/`, which is not content.
`awaitWriteFinish` is not tuning: `fs.watch` reports a file the moment it
appears, which on a copy is halfway through being written, and a page read
halfway through has no frontmatter.

CI skips Electron's ~100 MB runtime, because nothing in CI starts a window.
The installer build of 10.1 is where it is actually needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK
**Opening the window opened the microphone.** `useRecording` polls
`recordStatus` on mount, that went through the lazy `session ??=`, and
`recorder.exe` opens both WASAPI devices the moment it launches — before it
reads a single request. So launching the application put the microphone into
Windows' in-use state and held it for the window's lifetime, while the chrome
said nothing was being recorded. That is precisely the failure the persistent
indicator exists to prevent, inverted. `ensure` and `peek` are now different
methods; only `record:start` reaches `ensure`, and a poll with no session
answers idle.

**`record:start` took a filesystem path from the renderer.** Every other
handler binds the project root rather than accepting one, and this was the
exception — a compromised renderer directing recorder output anywhere the user
can write. It now takes the *occasion*; the id comes from 4.16 and the
directory from the id, so a recording lands under `raw/` because there is
nowhere else it can go.

**The renderer could navigate away with the preload attached.** The click
handler in the page is renderer-side JavaScript, which is a convenience, not a
boundary — a drag-and-drop or any renderer bug navigates without one, and a
preload re-runs on every navigation, so a window that reached a remote origin
would hand that origin `window.ow`. The main process now refuses navigation.
And `shell.openExternal` was handed any URL: on Windows that is `ShellExecute`,
which invokes whichever protocol handler is registered, and `ms-msdt:`,
`ms-officecmd:` and `search-ms:` against a WebDAV share are documented paths
from a link in a document to code execution. The scheme is allowlisted, and
the page handles `auxclick` too — a middle click is what asks Electron to open
a window, and it never fired `onClick`.

**Both markdown extensions are markdown-it rules now, not replacements over
its output.** A `String.replace` on serialised HTML does not know what an
attribute is: a citation inside a link title landed in `title="…"`, its own
quote ended that attribute, and the rest became attribute names on somebody
else's tag. It also rewrote the inside of code spans, so every page quoting
`[[target]]` or `rec://…` — several in this repository — rendered its own
examples as live links, and text markdown-it had already escaped was escaped
twice. Links are routed by `data-ow-page` rather than an `href` scheme, because
markdown-it renders `[x](page:evil)` quite happily and a scheme proves nothing
about who minted it.

The recorder client grew the failure paths it was missing: `child` and
`child.stdin` have error listeners, so a spawn failure or an `EPIPE` after a
crash no longer takes down the Electron main process; `stderr` is drained and
bounded, so a panicked sidecar neither hangs on a full pipe nor loses its
message; a send that throws removes its queue entry rather than leaving it to
be resolved by the next response; `dispose` marks the session closed; a dead
session is replaced rather than reused forever; and the unsolicited line
`recorder.exe` writes before exiting on a device failure is kept and reported,
because it is the whole explanation.

8.2 asks for record, pause and stop, and there was no way to do any of them —
the IPC existed and nothing called it. There is a control in the chrome now.

Three tests were replaced for asserting the implementation rather than the
requirement: the markdown ones fed hand-written HTML fragments to a helper and
never ran a page through the pipeline, and the "ignores an unsolicited line"
test asserted only that nothing threw, in the one arrangement where the
behaviour it named happened to hold. The rule 8.10 actually states — is this
change the open page? — moved out of the untestable component into `watcher.ts`.

Also: Electron 33 is outside its support window and its Chromium no longer
gets security fixes, which is the wrong floor for an application whose threat
model is untrusted content in a renderer; bumped to 38. The CSP names
`base-uri` and `form-action`, which do not inherit from `default-src`. And
`docs/stack.md` records why markdown-it is here, which this repo's own rule
required and the diff had skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: fa347698-6ab2-4ae9-847a-1134a5666470

📥 Commits

Reviewing files that changed from the base of the PR and between fac3bd9 and ca19a6b.

📒 Files selected for processing (1)
  • packages/access/tests/sources-inbox.spec.ts

📝 Walkthrough

Walkthrough

The PR adds a complete Electron desktop wiki application. It includes project resolution, Markdown page access, recorder sidecar integration, live file watching, secure IPC, renderer navigation, recording controls, styling, build configuration, and comprehensive tests.

Changes

Desktop wiki application

Layer / File(s) Summary
Project resolution and wiki reads
apps/desktop/src/main/project.ts, apps/desktop/src/main/api.ts, apps/desktop/tests/shell.spec.ts
Projects require raw, wiki, and .state directories. Wiki APIs index pages, parse frontmatter, expose sources, return metadata, and reject unsafe or missing slugs.
Recorder transport and Electron shell
apps/desktop/src/main/recorder.ts, apps/desktop/src/main/ipc.ts, apps/desktop/src/main/preload.ts, apps/desktop/src/main/index.ts, apps/desktop/tests/recorder.spec.ts, apps/desktop/tests/wiring.spec.ts
The main process manages a secure window, project-scoped IPC, recorder sessions, sidecar JSON-lines transport, lifecycle operations, and cleanup.
Live project watching
apps/desktop/src/main/watcher.ts, apps/desktop/src/renderer/App.tsx, apps/desktop/tests/shell.spec.ts, apps/desktop/tests/wiring.spec.ts
Chokidar watches wiki and raw, normalizes valid changes, waits for stable writes, and refreshes the renderer while matching open pages.
Renderer content and navigation
apps/desktop/src/renderer/bridge.ts, apps/desktop/src/renderer/markdown.ts, apps/desktop/src/renderer/navigation.ts, apps/desktop/src/renderer/App.tsx, apps/desktop/src/renderer/index.html, apps/desktop/src/renderer/main.tsx, apps/desktop/tests/renderer.spec.ts
The renderer loads project data, renders Markdown with wikilinks and citations, provides browser-style history, routes internal links, and validates external URLs.
Recording UI and desktop build
apps/desktop/src/renderer/recording.ts, apps/desktop/src/renderer/RecordingIndicator.tsx, apps/desktop/src/renderer/tokens.css, apps/desktop/package.json, apps/desktop/vite.config.ts, apps/desktop/vitest.config.ts, .github/workflows/ci.yml, docs/stack.md, plans/open-wiki.md, package.json, packages/access/tests/sources-inbox.spec.ts
Recording status polling, controls, indicators, dark design tokens, desktop scripts, Vite/Vitest configuration, CI Electron settings, dependency documentation, completion notes, build configuration, and a PDF test timeout are added.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Renderer
  participant MainProcess
  participant ProjectAPI
  participant Watcher
  Renderer->>MainProcess: request project and wiki data
  MainProcess->>ProjectAPI: read project metadata and page index
  ProjectAPI-->>MainProcess: return wiki data
  MainProcess-->>Renderer: return page content
  Watcher-->>MainProcess: emit normalized project change
  MainProcess-->>Renderer: forward change notification
  Renderer->>ProjectAPI: refresh index and open page
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: the Electron shell, design system, and wiki browsing features.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/desktop-shell

Comment @coderabbitai help to get the list of available commands.

The only flaky test in the repository. It is the one test in that file that
loads pdfjs, and cold on a CI runner the dynamic import plus the first parse
run past vitest's 5 s default — so it fails on how fast the machine is rather
than on anything about the code, which is the worst kind of red because it
teaches everyone to re-run instead of read.

The new timeout is deliberately generous rather than tuned to what it takes
today: a number chosen to *just* pass is the same flake again on a slower day.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3VYWWTZtEE2NxPiksKsAK

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (8)
apps/desktop/src/main/watcher.ts (1)

99-102: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider logging swallowed watcher errors.

watcher.on("error", () => {}) discards every error with no trace. The comment explains why the UI should not surface it, but a silent catch also removes any way for a developer or support engineer to tell that the live-reload feature has stopped working (e.g., on a network share raising EPERM repeatedly). Add a minimal diagnostic log (e.g., console.error or a debug channel) without changing the user-facing behavior.

🔍 Proposed diagnostic logging
-  watcher.on("error", () => {});
+  watcher.on("error", (err) => {
+    console.error("[watcher] file watch error:", err);
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/watcher.ts` around lines 99 - 102, Update the error
handler passed to watcher.on in the watcher setup to emit a minimal diagnostic
log containing the watcher error, while continuing to suppress the error from
reaching the window and preserving the existing user-facing behavior.
apps/desktop/src/renderer/markdown.ts (1)

135-168: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

provenanceRule can nest an <a> inside another <a>.

The rule rewrites every text token in block.children that matches the provenance pattern, regardless of whether that token sits inside an already-open link_open/wikilink_open pair (both render as <a> via the default token renderer, since block.children is a flat token list, not a nested tree). If a citation string like src://doc#p1 appears inside a real link's label or a wikilink label — plausible in a wiki whose whole purpose is provenance tracking — the result is a nested <a> inside another <a>: invalid markup, and ambiguous for element.closest("a, span[title]") in the click handler.

Track anchor depth while iterating block.children and skip citation-splitting for text tokens between link_open/link_close or wikilink_open/wikilink_close.

♻️ Proposed depth-tracking guard
 function provenanceRule(state: StateCore): void {
   for (const block of state.tokens) {
     if (block.type !== "inline" || !block.children) continue;
     const rebuilt: Token[] = [];
+    let anchorDepth = 0;
     for (const child of block.children) {
+      if (child.type === "link_open" || child.type === "wikilink_open") anchorDepth++;
+      if (child.type === "link_close" || child.type === "wikilink_close") anchorDepth--;
-      if (child.type !== "text") {
+      if (child.type !== "text" || anchorDepth > 0) {
         rebuilt.push(child);
         continue;
       }
       rebuilt.push(...splitProvenance(state, child));
     }
     block.children = rebuilt;
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/markdown.ts` around lines 135 - 168, Update
provenanceRule to track anchor depth while iterating each block’s flat children,
incrementing for link_open and wikilink_open and decrementing for their
corresponding close tokens. Only call splitProvenance for text tokens when the
tracked depth is zero; preserve all existing tokens unchanged while inside
either link type.
apps/desktop/src/renderer/tokens.css (1)

93-97: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Focus disappears in forced-colors mode.

The rule sets outline: none and draws the ring with box-shadow. Windows high contrast mode (forced-colors: active) does not paint box-shadow. The focus indicator is then absent, which is the exact failure the comment above this rule warns against.

Restore a real outline under forced-colors: active.

♿ Proposed addition
 :focus-visible {
   outline: none;
   box-shadow: var(--focus-ring);
   border-radius: var(--radius-sm);
 }
+
+/*
+ * Windows high contrast does not paint `box-shadow`, so the ring above is not
+ * there. An outline is, and it takes the system's own colour.
+ */
+@media (forced-colors: active) {
+  :focus-visible {
+    outline: 2px solid Highlight;
+    outline-offset: 2px;
+  }
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/tokens.css` around lines 93 - 97, Update the
:focus-visible rule to restore a visible outline when forced-colors mode is
active, overriding the outline removal with a system-compatible outline while
preserving the existing box-shadow styling for normal color modes.
apps/desktop/src/renderer/recording.ts (2)

58-68: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider separating the announced label from the ticking elapsed time.

describeRecording returns label and elapsed as one text pair. The consumer at apps/desktop/src/renderer/RecordingIndicator.tsx renders both inside one node with role="status" and aria-live="polite". elapsed changes once per second because useRecording polls at POLL_MS. A screen reader therefore announces the timer every second, which hides other output.

Consider marking the elapsed value aria-hidden="true" in the component, and keeping only the state label inside the live region. describeRecording already returns the two values separately, so no change to this file is required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/recording.ts` around lines 58 - 68, Update the
RecordingIndicator component to keep only the recording state label in the
role="status" aria-live="polite" live region and mark the separately rendered
elapsed value aria-hidden="true". Leave describeRecording unchanged, preserving
its existing label and elapsed values.

73-96: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid a re-render on every poll, and prevent overlapping ticks.

Two issues in this effect:

  1. setRecording(readStatus(status)) always passes a new object. React compares by reference, so the whole subtree that consumes useRecording re-renders once per second, including while nothing is recorded. apps/desktop/src/renderer/App.tsx calls useRecording at the top level, so this is the whole application view.
  2. setInterval does not wait for the previous tick to settle. If one recordStatus call is slow, ticks overlap. Each overlapping call appends an entry to the queue in RecorderSession.call, which has no timeout.

Return the previous state when nothing changed, and chain the next poll after the current one finishes.

♻️ Proposed change
 export function useRecording(pollMs = POLL_MS): Recording {
   const [recording, setRecording] = useState<Recording>(IDLE);
   useEffect(() => {
     if (!hasBridge()) return;
     let live = true;
+    let timer: ReturnType<typeof setTimeout> | undefined;
+    // A new object every second re-renders the whole view for no change.
+    const settle = (next: Recording): void => {
+      setRecording((previous) =>
+        previous.state === next.state && previous.recordedMs === next.recordedMs ? previous : next,
+      );
+    };
     const tick = async (): Promise<void> => {
       try {
         const status = await bridge().recordStatus();
-        if (live) setRecording(readStatus(status));
+        if (live) settle(readStatus(status));
       } catch {
         // No sidecar running is the ordinary case, not an error worth a
         // banner: it means nothing is being recorded.
-        if (live) setRecording(IDLE);
+        if (live) settle(IDLE);
+      } finally {
+        // Chained, not an interval: a slow answer must not stack another poll.
+        if (live) timer = setTimeout(() => void tick(), pollMs);
       }
     };
     void tick();
-    const timer = setInterval(() => void tick(), pollMs);
     return () => {
       live = false;
-      clearInterval(timer);
+      if (timer !== undefined) clearTimeout(timer);
     };
   }, [pollMs]);
   return recording;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/recording.ts` around lines 73 - 96, Update
useRecording so its state setter returns the previous recording object when the
newly read status is unchanged, avoiding re-renders during idle or unchanged
polling. Replace the setInterval-based scheduling with a self-chaining tick that
schedules the next poll only after the current recordStatus call settles, while
preserving cleanup and the live guard.
apps/desktop/tests/wiring.spec.ts (1)

120-135: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Make the .state exclusion test able to fail.

The test writes .state/log.json first and wiki/a.md second, then waits for seen.length > 0 and asserts that every seen path starts with wiki/. If .state were watched, its event could still arrive after the assertion ran, so the test would pass. The assertion resolves on the first event, whichever file produced it.

Wait for the wiki/a.md event specifically, then give the watcher a settling window and assert that no .state path was reported.

💚 Proposed change
       writeFileSync(join(root, ".state", "log.json"), "[]", "utf8");
       writeFileSync(join(root, "wiki", "a.md"), "x", "utf8");
-      await waitFor(() => seen.length > 0);
-      expect(seen.every((p) => p.startsWith("wiki/"))).toBe(true);
+      // The wiki write is the marker: once it is reported, the watcher has
+      // processed both writes, so a `.state` event would already be here.
+      await waitFor(() => seen.includes("wiki/a.md"));
+      await new Promise((r) => setTimeout(r, 150));
+      expect(seen.filter((p) => p.startsWith(".state"))).toEqual([]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/tests/wiring.spec.ts` around lines 120 - 135, Update the test
“does not watch .state, which is not content” to wait specifically until the
wiki/a.md change is observed rather than merely waiting for any event. After
that event, allow a settling window for delayed watcher notifications, then
assert that no reported path belongs to .state while preserving the existing
cleanup.
apps/desktop/src/main/recorder.ts (1)

178-198: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a timeout or a queue bound to call.

call never settles if the sidecar reads a request and returns no line. The transport only rejects pending entries when the child closes. If the sidecar stalls while still alive, every promise stays pending.

The renderer polls record:status every second (apps/desktop/src/renderer/recording.ts, POLL_MS = 1000). Each poll appends one entry to queue. A stalled sidecar therefore grows queue without bound and leaves one unresolved promise per second.

Consider a per-request deadline that rejects the head entry and removes it from queue, or a maximum queue depth that refuses new calls.

♻️ Sketch of a per-request deadline
 export class RecorderSession {
   private readonly queue: Pending[] = [];
   private closed = false;
+  /** A request the sidecar never answers must not leave a button spinning. */
+  private static readonly TIMEOUT_MS = 10_000;
 
   ...
 
   call(
     method: RecorderMethod,
     params: Record<string, unknown> = {},
   ): Promise<Record<string, unknown>> {
     if (this.closed) return Promise.reject(new RecorderError(this.reasonItDied()));
     return new Promise((resolvePromise, rejectPromise) => {
-      const pending: Pending = { resolve: resolvePromise, reject: rejectPromise };
+      const drop = (): void => {
+        const at = this.queue.indexOf(pending);
+        if (at >= 0) this.queue.splice(at, 1);
+      };
+      const timer = setTimeout(() => {
+        drop();
+        rejectPromise(new RecorderError(`the recorder did not answer "${method}"`));
+      }, RecorderSession.TIMEOUT_MS);
+      const pending: Pending = {
+        resolve: (payload) => {
+          clearTimeout(timer);
+          resolvePromise(payload);
+        },
+        reject: (error) => {
+          clearTimeout(timer);
+          rejectPromise(error);
+        },
+      };
       this.queue.push(pending);
       try {
         this.transport.send(JSON.stringify({ method, ...params }));
       } catch (e) {
-        const at = this.queue.indexOf(pending);
-        if (at >= 0) this.queue.splice(at, 1);
+        clearTimeout(timer);
+        drop();
         rejectPromise(e instanceof Error ? e : new RecorderError(String(e)));
       }
     });
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/recorder.ts` around lines 178 - 198, Update
Recorder.call to prevent indefinitely pending requests when the sidecar remains
alive without responding: add a per-request timeout that rejects the timed-out
request with a RecorderError and removes that same Pending entry from queue,
while preserving normal response handling and transport.send failure cleanup.
Ensure the timer is cleared when the request settles to avoid affecting
completed calls.
apps/desktop/src/main/index.ts (1)

63-78: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Move channel registration out of createWindow, or key handlers by sender.

ipcMain.handle is process-global. createWindow registers every channel, and the closed listener removes every channel. The application creates one window today, so this works. Two problems appear as soon as a second window exists:

  1. ipcMain.handle throws when a channel already has a handler. The second createWindow call fails.
  2. Closing one window calls ipcMain.removeHandler for all channels, which silently disables IPC for the window that is still open.

Register the channels once at startup and resolve the per-window api from event.sender, or use ipcMain.handle with a registry keyed by webContents.id.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/index.ts` around lines 63 - 78, Move the ipcMain.handle
registration out of createWindow into one-time startup initialization, or
maintain handlers keyed by event.sender.webContents.id so multiple windows
cannot conflict. Preserve per-window API dispatch by resolving the correct api
from the sender, and remove the closed listener’s loop that calls
ipcMain.removeHandler for every channel; only clean up the closing window’s
associated resources and registry entry.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/desktop/src/main/project.ts`:
- Around line 47-59: Update resolveProject’s --project branch to validate the
resolved target with looksLikeProject(dir) instead of isDirectory(dir), matching
the cwd fallback and returning null for existing directories without the
expected project structure.

In `@apps/desktop/src/renderer/index.html`:
- Around line 15-18: Remove the unsupported frame-ancestors directive from the
Content-Security-Policy meta tag in the renderer HTML. Preserve the remaining
CSP directives unchanged; do not add alternative framing protection unless an
existing supported header or protocol-based mechanism is already part of the
implementation.

In `@apps/desktop/src/renderer/navigation.ts`:
- Around line 109-127: Update onPageClick to call shell.openExternal for
openable external URLs before navigation is refused, using isOpenableExternally
to validate the href. Preserve internal-link navigation and ensure
refuseNavigation does not leave approved external left-click links unopened;
keep setWindowOpenHandler behavior unchanged.

In `@apps/desktop/src/renderer/tokens.css`:
- Around line 22-26: Update the --ink-3 token to a color that achieves at least
4.5:1 contrast against --surface-0, preserving the existing four-step contrast
claim and its use by .frontmatter dt.

---

Nitpick comments:
In `@apps/desktop/src/main/index.ts`:
- Around line 63-78: Move the ipcMain.handle registration out of createWindow
into one-time startup initialization, or maintain handlers keyed by
event.sender.webContents.id so multiple windows cannot conflict. Preserve
per-window API dispatch by resolving the correct api from the sender, and remove
the closed listener’s loop that calls ipcMain.removeHandler for every channel;
only clean up the closing window’s associated resources and registry entry.

In `@apps/desktop/src/main/recorder.ts`:
- Around line 178-198: Update Recorder.call to prevent indefinitely pending
requests when the sidecar remains alive without responding: add a per-request
timeout that rejects the timed-out request with a RecorderError and removes that
same Pending entry from queue, while preserving normal response handling and
transport.send failure cleanup. Ensure the timer is cleared when the request
settles to avoid affecting completed calls.

In `@apps/desktop/src/main/watcher.ts`:
- Around line 99-102: Update the error handler passed to watcher.on in the
watcher setup to emit a minimal diagnostic log containing the watcher error,
while continuing to suppress the error from reaching the window and preserving
the existing user-facing behavior.

In `@apps/desktop/src/renderer/markdown.ts`:
- Around line 135-168: Update provenanceRule to track anchor depth while
iterating each block’s flat children, incrementing for link_open and
wikilink_open and decrementing for their corresponding close tokens. Only call
splitProvenance for text tokens when the tracked depth is zero; preserve all
existing tokens unchanged while inside either link type.

In `@apps/desktop/src/renderer/recording.ts`:
- Around line 58-68: Update the RecordingIndicator component to keep only the
recording state label in the role="status" aria-live="polite" live region and
mark the separately rendered elapsed value aria-hidden="true". Leave
describeRecording unchanged, preserving its existing label and elapsed values.
- Around line 73-96: Update useRecording so its state setter returns the
previous recording object when the newly read status is unchanged, avoiding
re-renders during idle or unchanged polling. Replace the setInterval-based
scheduling with a self-chaining tick that schedules the next poll only after the
current recordStatus call settles, while preserving cleanup and the live guard.

In `@apps/desktop/src/renderer/tokens.css`:
- Around line 93-97: Update the :focus-visible rule to restore a visible outline
when forced-colors mode is active, overriding the outline removal with a
system-compatible outline while preserving the existing box-shadow styling for
normal color modes.

In `@apps/desktop/tests/wiring.spec.ts`:
- Around line 120-135: Update the test “does not watch .state, which is not
content” to wait specifically until the wiki/a.md change is observed rather than
merely waiting for any event. After that event, allow a settling window for
delayed watcher notifications, then assert that no reported path belongs to
.state while preserving the existing cleanup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 92903f61-5094-4689-a6fa-d7fbec9df794

📥 Commits

Reviewing files that changed from the base of the PR and between f097bc0 and fac3bd9.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (28)
  • .github/workflows/ci.yml
  • apps/desktop/package.json
  • apps/desktop/src/main/api.ts
  • apps/desktop/src/main/index.ts
  • apps/desktop/src/main/ipc.ts
  • apps/desktop/src/main/preload.ts
  • apps/desktop/src/main/project.ts
  • apps/desktop/src/main/recorder.ts
  • apps/desktop/src/main/watcher.ts
  • apps/desktop/src/renderer/App.tsx
  • apps/desktop/src/renderer/RecordingIndicator.tsx
  • apps/desktop/src/renderer/bridge.ts
  • apps/desktop/src/renderer/index.html
  • apps/desktop/src/renderer/main.tsx
  • apps/desktop/src/renderer/markdown.ts
  • apps/desktop/src/renderer/navigation.ts
  • apps/desktop/src/renderer/recording.ts
  • apps/desktop/src/renderer/tokens.css
  • apps/desktop/tests/recorder.spec.ts
  • apps/desktop/tests/renderer.spec.ts
  • apps/desktop/tests/shell.spec.ts
  • apps/desktop/tests/wiring.spec.ts
  • apps/desktop/tsconfig.json
  • apps/desktop/vite.config.ts
  • apps/desktop/vitest.config.ts
  • docs/stack.md
  • package.json
  • plans/open-wiki.md

Comment on lines +47 to +59
export function resolveProject(args: LaunchArgs): string | null {
const flag = args.argv.indexOf("--project");
if (flag >= 0) {
const named = args.argv[flag + 1];
if (named && !named.startsWith("--")) {
const dir = isAbsolute(named) ? named : resolve(args.cwd, named);
return isDirectory(dir) ? dir : null;
}
return null;
}
const cwd = resolve(args.cwd);
return looksLikeProject(cwd) ? cwd : null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

--project path skips the "looks like a project" check that the cwd fallback enforces.

Line 53 validates the --project target with isDirectory(dir) only. The cwd fallback at line 58 uses looksLikeProject(cwd) instead. This means --project /some/existing/dir succeeds even when dir lacks raw/, wiki/, and .state/, while the same directory reached through cwd would correctly return null.

The file's own documentation states the intent for this function: falling back to an unvalidated directory "would open the user's home folder as a wiki" (see the module doc comment, Lines 13-16). That same risk applies to --project if it is ever invoked with a stale, wrong, or manually-typed path outside the ow shim's control — the renderer would open against a directory with no wiki/raw/state structure instead of being told there is no project.

shell.spec.ts does not cover this case: the --project tests only exercise a directory that already has the three subfolders (Lines 48-56) or a path that does not exist at all (Lines 68-70).

Align the two branches by validating looksLikeProject in both.

🐛 Proposed fix
   const flag = args.argv.indexOf("--project");
   if (flag >= 0) {
     const named = args.argv[flag + 1];
     if (named && !named.startsWith("--")) {
       const dir = isAbsolute(named) ? named : resolve(args.cwd, named);
-      return isDirectory(dir) ? dir : null;
+      return looksLikeProject(dir) ? dir : null;
     }
     return null;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function resolveProject(args: LaunchArgs): string | null {
const flag = args.argv.indexOf("--project");
if (flag >= 0) {
const named = args.argv[flag + 1];
if (named && !named.startsWith("--")) {
const dir = isAbsolute(named) ? named : resolve(args.cwd, named);
return isDirectory(dir) ? dir : null;
}
return null;
}
const cwd = resolve(args.cwd);
return looksLikeProject(cwd) ? cwd : null;
}
export function resolveProject(args: LaunchArgs): string | null {
const flag = args.argv.indexOf("--project");
if (flag >= 0) {
const named = args.argv[flag + 1];
if (named && !named.startsWith("--")) {
const dir = isAbsolute(named) ? named : resolve(args.cwd, named);
return looksLikeProject(dir) ? dir : null;
}
return null;
}
const cwd = resolve(args.cwd);
return looksLikeProject(cwd) ? cwd : null;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/main/project.ts` around lines 47 - 59, Update
resolveProject’s --project branch to validate the resolved target with
looksLikeProject(dir) instead of isDirectory(dir), matching the cwd fallback and
returning null for existing directories without the expected project structure.

Comment on lines +15 to +18
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- target file ---'
cat -n apps/desktop/src/renderer/index.html | sed -n '1,45p'
printf '%s\n' '--- Electron header enforcement references ---'
rg -n --glob '!node_modules' --glob '!dist' --glob '!build' \
  "onHeadersReceived|defaultSession|Content-Security-Policy|frame-ancestors" \
  apps/desktop package.json pnpm-lock.yaml yarn.lock package-lock.json 2>/dev/null | head -200 || true

Repository: protonspy/open-wiki

Length of output: 1647


🌐 Web query:

Content Security Policy frame-ancestors meta element ignored official specification

💡 Result:

The frame-ancestors directive is officially ignored when delivered via a HTML meta element [1][2][3]. According to the Content Security Policy specification, this directive—along with others like sandbox and report-uri—must be delivered via an HTTP response header to be enforced [1][2]. This behavior exists because the frame-ancestors directive determines whether a page can be embedded at the very beginning of the page load process [4]. By the time a browser parses an HTML meta element, the decision to embed the page may have already been made, making it too late for the meta-defined policy to securely prevent framing [4]. To implement frame-ancestors, you must configure your web server, application framework, or CDN to include the Content-Security-Policy HTTP header in the response [5][4][6]. If you attempt to include frame-ancestors in a meta tag, the browser will silently discard that specific directive while continuing to process any other valid directives present in the policy [4][2].

Citations:


🌐 Web query:

Electron webRequest onHeadersReceived Content-Security-Policy file URL official documentation

💡 Result:

Electron's webRequest API, specifically the onHeadersReceived method, is intended for modifying HTTP response headers [1][2]. It is important to note that the file:// protocol is not an HTTP-based protocol and does not support or recognize HTTP headers [3]. Consequently, the webRequest.onHeadersReceived event is not triggered for file:// protocol requests, and you cannot use this method to inject a Content-Security-Policy (CSP) into files loaded via file:// [3]. For resources loaded using the file:// protocol, the official Electron documentation recommends using the HTML tag delivery mechanism instead [1]: If you must apply a CSP to local files and cannot use the tag (for example, if you need to dynamically inject or change policies for local content), developers often turn to protocol interception as an alternative [4]. By using protocol.interceptBufferProtocol or similar methods, you can intercept requests for file:// resources and modify the HTML content to include the desired CSP meta tag before it is rendered by the application [4].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- desktop files ---'
git ls-files apps/desktop | sed -n '1,240p'
printf '%s\n' '--- load/navigation code and desktop config ---'
rg -n --glob '!node_modules' --glob '!dist' --glob '!build' \
  "load(File|URL)|file://|http://|https://|BrowserWindow|webContents|protocol|vite|renderer" \
  apps/desktop | head -240
printf '%s\n' '--- package metadata ---'
fd -i 'package.json' . -t f | xargs -r -n1 sh -c 'echo "--- $0"; cat "$0"'

Repository: protonspy/open-wiki

Length of output: 12922


🌐 Web query:

site:electronjs.org/docs latest tutorial security Content-Security-Policy file protocol meta tag frame-ancestors

💡 Result:

You cannot use the frame-ancestors directive within a Content-Security-Policy (CSP) meta tag. According to web security standards, frame-ancestors is a policy directive that is ignored if it is provided via a meta tag; it must be delivered via an HTTP response header to function correctly. Additionally, the Content-Security-Policy meta tag is generally not supported for the file:// protocol in many browser and environment contexts. When working with protocols such as file://, security policies are often restricted or handled differently than over standard web protocols (http/https). For applications requiring strict CSP controls (such as Electron apps), it is standard practice to use the session.webRequest.onHeadersReceived API or equivalent mechanisms to inject the CSP as an HTTP header, rather than relying on a meta tag.

Citations:


Remove frame-ancestors from the meta CSP. Browsers ignore this directive in a <meta> element. The production renderer uses file://, so session.webRequest.onHeadersReceived cannot enforce it. Use a supported header or protocol-based approach only if framing protection is required.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/index.html` around lines 15 - 18, Remove the
unsupported frame-ancestors directive from the Content-Security-Policy meta tag
in the renderer HTML. Preserve the remaining CSP directives unchanged; do not
add alternative framing protection unless an existing supported header or
protocol-based mechanism is already part of the implementation.

Comment on lines +109 to +127
/**
* Whether the application may hand a URL to the system browser.
*
* `shell.openExternal` is `ShellExecute` on Windows, which invokes whichever
* protocol handler is registered — `ms-msdt:`, `ms-officecmd:`, `search-ms:`
* against a WebDAV share. Those are documented paths from "a link in a
* document" to code execution, and markdown-it's own link filter blocks only
* `javascript:`, `vbscript:`, `file:` and most `data:`. So the answer is an
* allowlist rather than a blocklist.
*/
const OPENABLE = new Set(["http:", "https:", "mailto:"]);

export function isOpenableExternally(href: string): boolean {
try {
return OPENABLE.has(new URL(href).protocol);
} catch {
return false;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Trace external-link handling across the main process and renderer.
set -euo pipefail

echo "--- index.ts navigation/openExternal wiring ---"
rg -n -B3 -A15 'will-navigate|refuseNavigation|shell.openExternal|isOpenableExternally' apps/desktop/src/main/index.ts

echo "--- navigation.ts isOpenableExternally usages repo-wide ---"
rg -n 'isOpenableExternally' apps/desktop/src

echo "--- App.tsx onPageClick full body (beyond the supplied snippet) ---"
rg -n -A40 'const onPageClick' apps/desktop/src/renderer/App.tsx

Repository: protonspy/open-wiki

Length of output: 5346


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
import re

index = Path("apps/desktop/src/main/index.ts").read_text()
app = Path("apps/desktop/src/renderer/App.tsx").read_text()

refuse = re.search(
    r"const refuseNavigation\s*=\s*\([^)]*\)\s*=>\s*\{(?P<body>.*?)\n\s*\};",
    index,
    re.S,
)
click = re.search(
    r"const onPageClick\s*=\s*useCallback\(\s*\(event:.*?\)\s*=>\s*\{(?P<body>.*?)\n\s*\},",
    app,
    re.S,
)

assert refuse and click
refuse_body = refuse.group("body")
click_body = click.group("body")

print("index.ts imports isOpenableExternally:",
      'import { isOpenableExternally } from "../renderer/navigation.js";' in index)
print("setWindowOpenHandler calls shell.openExternal:",
      "if (isOpenableExternally(url)) void shell.openExternal(url);" in index)
print("refuseNavigation calls shell.openExternal:",
      "shell.openExternal" in refuse_body)
print("refuseNavigation prevents disallowed URL:",
      "if (url !== allowed) event.preventDefault();" in refuse_body)
print("onPageClick prevents page targets:",
      'if (target.kind === "page")' in click_body and "event.preventDefault();" in click_body)
print("onPageClick prevents source targets:",
      'target.kind === "source"' in click_body)
print("onPageClick has external branch:",
      re.search(r"target\.kind\s*===\s*['\"]external['\"]", click_body) is not None)
print("onPageClick calls shell.openExternal:",
      "shell.openExternal" in click_body)
PY

Repository: protonspy/open-wiki

Length of output: 246


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "--- apps/desktop/src/main/index.ts ---"
sed -n '80,110p' apps/desktop/src/main/index.ts

echo "--- apps/desktop/src/renderer/App.tsx ---"
sed -n '118,140p' apps/desktop/src/renderer/App.tsx

Repository: protonspy/open-wiki

Length of output: 2931


Route left-click external links through shell.openExternal.

onPageClick allows external links to navigate, but refuseNavigation blocks that navigation without opening the system browser. setWindowOpenHandler handles only the new-window path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/navigation.ts` around lines 109 - 127, Update
onPageClick to call shell.openExternal for openable external URLs before
navigation is refused, using isOpenableExternally to validate the href. Preserve
internal-link navigation and ensure refuseNavigation does not leave approved
external left-click links unopened; keep setWindowOpenHandler behavior
unchanged.

Comment on lines +22 to +26
/* --- Ink. Four steps of contrast against surface-0, all above 4.5:1. */
--ink-0: #e8ecf2;
--ink-1: #b6bec9;
--ink-2: #808a97;
--ink-3: #5b646f;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

--ink-3 does not meet the 4.5:1 the comment claims.

The comment states that all four ink steps are above 4.5:1 against --surface-0. --ink-3 (#5b646f) against --surface-0 (#101216) computes to about 3.17:1 by the WCAG relative-luminance formula. --ink-2 (#808a97) computes to about 5.36:1, so the other steps hold.

--ink-3 is applied to .frontmatter dt at line 258, which renders at --text-xs (12px). That is normal-size body text, so WCAG AA requires 4.5:1.

Either lighten --ink-3 to reach 4.5:1, or correct the comment and restrict --ink-3 to non-text use.

🎨 A value that reaches 4.5:1
   --ink-2: `#808a97`;
-  --ink-3: `#5b646f`;
+  /* `#5b646f` was 3.17:1 against --surface-0; this is ~4.6:1. */
+  --ink-3: `#737d8a`;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src/renderer/tokens.css` around lines 22 - 26, Update the
--ink-3 token to a color that achieves at least 4.5:1 contrast against
--surface-0, preserving the existing four-step contrast claim and its use by
.frontmatter dt.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant