# ADR 028: On the desktop, a "local graph" is a new graph on This Mac's bundled server

> docs/progress/desktop-local-graph.md. Bug: B-643.

Source: https://nooklet.danielalder.cz/decisions/028-desktop-local-graphs-on-the-bundled-server

Date: 2026-10-04. Status: accepted. Extends ADR 025 (move 1, "new local-only graph"). Work record:
`docs/progress/desktop-local-graph.md`. Bug: B-643.

## Context

ADR 025's move 1 was Capacitor-only: on the phone a local-only graph is an OPFS replica with no
server. The desktop app's switcher therefore never offered it (`GraphSwitcher.tsx` checked
`platform.name === "capacitor"`; the desktop app is the web platform). The owner, on the first
real-device test: the Mac app in remote mode (window on `http://127.0.0.1:6200`) cannot add a local
graph, the phone can.

The desktop app differs from the phone in two ways that matter. Its window always shows some
server's own origin (the bundled sidecar's, or a remote one's), and it ships a server of its own
("This Mac", `~/.nooklet/default`), which ADR 025 already lets host any number of graphs.

## Decision

A new local graph on the desktop is a new graph on the bundled server: `<dataDir>/graphs/<id>/`,
named by the switcher (B-644's generated names), made by the server's own CLI
(`nooklet graph create <id> --label <name>`), opened at `http://127.0.0.1:<port>/g/<id>`.

The page cannot call the shell (Tauri gives a server origin no IPC, and in remote mode the sidecar
is not running), so the switcher navigates to a reserved address,
`http://nooklet-desktop.invalid/{new-local-graph?label=…|open-local-graph?id=…}`, which
`main.rs`'s `on_navigation` intercepts. The shell creates the graph, makes This Mac active on it
(`desktop.json` `active_local_graph`) and restarts, as every other switch already does. The shell
injects This Mac's graphs (`localGraphs`, read from each `graph.json`) so the switcher lists them
from any server, and the launcher's picker lists them too.

## Rejected

- **An OPFS replica inside the window's origin** (the phone's model). It would belong to whichever
  server the window happens to show: invisible from This Mac and every other server, lost when that
  entry is removed or its address changes, with no Markdown mirror, no MCP, no CLI. The web code
  also treats a no-address entry on a served origin inconsistently (`apiBaseUrl` falls back to the
  page's own `/g/<slug>`).
- **Only pointing at the picker's "This Mac"**: that is one existing graph, not a new one.
- **A Tauri capability granting IPC to server origins**: a remote server's page (or a note's
  content) would get the whole command set. The navigation door opens on two benign actions only.

## Costs

- Any page shown in the window can trigger the two requests: make an empty graph on This Mac, or
  switch to one there, each followed by a visible restart. Nothing is read, deleted or sent.
- A restart per switch into or out of This Mac (about two seconds for the sidecar), as before.
- When the app reuses an already-running external server instead of its own, the CLI writes to the
  app's data dir, which may not be the one that server serves.
- That the real window's navigation reaches `on_navigation` was read, not observed: wry 0.55.1's
  `wkwebview/navigation.rs` runs every navigation action through the handler and cancels on
  `false`. No GUI run was made (the owner was using the app); Chromium e2e and `cargo test` cover
  each side separately.

## Amendment (2026-10-04, B-704): a third request, "add a server graph"

The in-app switcher's "add a server graph" verified the server with a request from the page. In the
desktop window the page is some server's origin (`http://127.0.0.1:<port>`), so a server on another
origin is a cross-origin request, and that server's CORS allowlist (`capacitor://localhost`,
`https://localhost` only) refuses it: "Load failed". Same-origin graphs (another `/g/<id>` on the
server the window shows) were never affected.

**Decision.** The same door as the two local-graph requests:
`http://nooklet-desktop.invalid/add-server-graph?url=<address>`. `main.rs` remembers the address
(`remember_remote_graph`, which also treats a bare server address and its `/g/default` as one
graph, as the launcher does), makes it active and restarts — what Switch Server… → Add a server
does. No token travels: after the restart the window shows that server's own page, whose set-up
screen asks for one. The switcher hides its token field for such an address and says so. A plain
browser tab cannot hand anything to a shell; there the form says a graph on another server opens
in its own tab and links it, and sends no request.

The same two requests are also offered on the set-up screen (`views/DesktopServerSwitch.tsx`):
a window on a remote server this device has no token for shows `ConnectView`, not the app, so the
graph menu is unreachable there; "Open a different graph instead" and "Back to This Mac" are the
way out without the native menu.

**Rejected.** Widening the server's CORS allowlist to `http://127.0.0.1:*` or `tauri://localhost`:
every page on the loopback origin of every machine could then call a server with a token it holds,
and the token would still have to be pasted into a page that is not that server's own.

**Cost.** Any page shown in the window can now also make the app switch to an arbitrary server,
after a visible restart. That server's page is then what the window shows, and it gets nothing it
could not get by being opened in a browser: no token or note content passes through the request.
That the real WKWebView window delivers this navigation to `on_navigation` is, as for the other two
requests, read from wry rather than observed (`cargo test` covers parsing and the config change;
`e2e/tests/desktop-local-graph.spec.ts` "B-704…" covers the page's side in Chromium).
