The route read its own configuration from Platform.environment and $HOME, so
the only branch a test could reach was "disabled" -- the serving path, the
missing-config path and the malformed-email path were all unreachable without
mutating the process environment.
Both are now constructor parameters defaulting to the environment, so
production behaviour is unchanged and tests drive all four outcomes: 404 when
disabled, 200 with the account when enabled, 404 when ~/.config/crdt-sync/ is
incomplete, and 404 when firebase.json has no usable email. The password's
trailing newline is stripped, which the test pins.
256 tests, analyzer clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
The desktop app is a Flutter web build in a Chrome --app window, so
FlutterSecureStorage resolves to the web backend: AES-encrypted localStorage
keyed inside the browser profile. That left the sync account settable only by
typing it into the settings screen — nothing outside the page can write that
store (verified: `secret-tool` targets libsecret, which only a Linux build
would read, and a Dart CLI cannot even import flutter_secure_storage since it
is a Flutter plugin that fails to resolve on the VM).
So the account now arrives the same way the GitHub token already does: from
the wrapper, which owns the filesystem and serves the page. loadAccount()
falls back to GET /sync-account when the store is empty and persists the
result, so the route is consulted once per install rather than being
load-bearing forever.
The route reads ~/.config/crdt-sync/{firebase.json,password} — the same pair
the Python daemons already use — so there is one source of truth per machine
instead of a per-app copy.
Off unless CRDT_SYNC_SERVE_ACCOUNT is set, because it hands a credential with
database write access to anything that can reach the port. Verified both
ways against the running wrapper: 404 without the env var, 200 with it, and
the app serves normally either way. It answers 404 rather than 403 when
disabled, so a probe cannot tell the route exists.
clearAccount() now writes an opt-out marker that the fallback checks —
without it, Disconnect would delete the account and the next launch would
silently re-adopt it, making the button look broken.
252 tests, analyzer clean. Pinned to crdt_sync_dart-v0.8.0 for the shared
path/env-var constants and accountFromWrapper().
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
Launching the app died with an unhandled SocketException whenever anything
already held the wrapper port -- from `todo` and from `./run.sh` alike, since
run.sh execs the same entry point. What was holding it was a wrapper from an
earlier session that had outlived its window: /proc/<pid>/exe read
"/opt/todo/bin/todo_desktop (deleted)", i.e. install_arch.sh had replaced the
binary underneath a process that kept serving anyway.
That orphan is manufactured by the handoff branch in bin/todo_desktop.dart.
When the launched browser exits within 5s the launcher assumes it handed the
URL to an existing window and keeps serving rather than pulling the server out
from under it -- right at that moment, but nothing ever reaps the server once
that window closes. It then survives an upgrade and blocks every later launch.
The port cannot simply move: the browser keys localStorage (the GitHub token)
and IndexedDB (the notes) by origin, so a fallback port would present an empty,
logged-out app. lib/desktop/port_guard.dart therefore decides who owns 8730 and
acts: a wrapper with no window attached is stale, so it is terminated
(SIGTERM, escalating to SIGKILL) and the port rebound with the current build; a
wrapper that still has a window is joined, leaving exactly one server; anything
else is named and refused. Reaping is what makes an upgrade self-healing, so
install_arch.sh needs no restart step of its own -- one mechanism, not two.
It fails closed at every step. `ss` hides the owning pid for another user's
socket, and that is a real branch rather than an impossible one: it resolves to
"refuse", never to a blind kill or a retry loop.
Two things measurement contradicted, both of which had to change the design:
Re-running the Chrome command does not focus the existing --app window, it
opens a second one (verified on Thorium: the process returns in ~180ms and the
window count goes 1 -> 2). So the join path opens a window instead of printing
and exiting; both windows share the one server and one IndexedDB, and typing
`todo` to get nothing visible would be the worse outcome.
Chrome also rewrites its own argument vector into a single space-joined string,
so /proc/<pid>/cmdline for a browser contains no NUL separators at all. Exact
token matching therefore never fired, and the first run of the live-window case
reaped the server under an open window. Both matchers now tolerate either
layout. The direction of that error matters and is commented as such: a false
negative kills a server someone is using, a false positive merely opens a
spare window.
The decision logic lives in lib/ rather than in bin/, which is
coverage:ignore-file -- otherwise the one path that has to work would be the
only untested code in the repo. Host access sits behind an injected SystemProbe,
and the ss parser is additionally exercised against genuine `ss` output by
binding a real socket, since a fixture cannot catch a format change.
Verified by running it, not only by tests: orphaned dev wrapper, orphaned AOT
wrapper from the installed package, live window, a foreign owner (python
http.server -- named, exit 1, and confirmed still alive afterwards), ./run.sh,
and plain `todo`. 238 tests, 100% line coverage held.
CLAUDE.md picks up the port guard, and drops two stale claims: the suite is 238
tests taking 1-2min, not 191 tests in ~5s -- that drift predates this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The desktop app is a browser now, so it cannot write files. This adds the other
half: a loopback HTTP server that serves the web build and owns the two files
the browser cannot touch -- `~/todo/BACKLOG.md`, which the user's tooling and
the todo MCP server read, and a disk copy of the note log so a wiped Chrome
profile is not a total-loss event for unsynced notes.
Notable details:
- Binds to 127.0.0.1 only. The endpoints overwrite files in the user's home
with no authentication, so a routable bind would let anything on the network
rewrite the backlog.
- Serves explicit content types. CanvasKit refuses a `.wasm` labelled anything
else and the app then renders nothing at all.
- Mirroring to disk is debounced. The store persists on every keystroke, so
mirroring eagerly meant an HTTP request plus a whole-log write per character;
IndexedDB still gets every keystroke, only the durability copy waits.
Separately, this fixes a pre-existing destructive bug found while testing:
`settings_screen_test` did real file I/O and overwrote the user's canonical
`~/todo/BACKLOG.md` with a single fake note on *every* `flutter test` run. It
went unnoticed because the app rewrites the file whenever it launches. The
export destination is now injectable and the test redirects it to a temp dir;
verified that a full suite run leaves the real file byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>