Commit Graph

7 Commits

Author SHA1 Message Date
1ef60fa186 Do not open a window when provisioning hits the port-recovery path
Some checks are pending
release-apk / release (APK) (push) Waiting to run
--provision-sync-account exits before the normal windowed launch, so it
looked headless -- but only on the happy path. When port 8730 is already
held, main() detours through _recoverPort first, and AttachToExisting opens
a browser window unless --no-browser was passed. It did not know about
--provision-sync-account, so the second and later provisioning runs opened a
full --app= window on the primary monitor: exactly what the headless flag
exists to avoid.

Found by running the feature twice rather than once. The first run binds the
port and stays headless; only the second reveals it.

Verified after the fix: two consecutive provisioning runs, both exit 0, zero
windows opened.

diet-guard is unaffected -- its wrapper has no port-recovery path.

256 tests, analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 22:47:16 +02:00
59899f7eba Close the provisioning browser cleanly instead of letting it SIGKILL
--virtual-time-budget makes headless Chrome SIGKILL itself when the budget
expires: the run exits -9, which reads as a failure, and it can cut a sync
short mid-flight. Found while debugging diet-guard's provisioning; todo
carried the same flag.

The page now gets a real wall-clock window and is closed deliberately, so a
non-zero exit means something actually went wrong. --enable-logging=stderr
surfaces the page's own log() output in the terminal that started the run,
which is what makes an in-app failure visible at all.

Re-verified after the change: provisioning exits 0 and todo-sync still holds
the desktop (b58facc7) alongside the live device.

256 tests, analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 22:18:24 +02:00
042d68b237 Add --provision-sync-account: set up sync with no window at all
Some checks are pending
release-apk / release (APK) (push) Waiting to run
Provisioning still required launching the app, which opens a Chrome window --
it lands on whichever monitor i3 picks and steals focus, which is exactly
what you do not want from a one-shot setup step.

The account lives in origin-keyed localStorage, so only code running in the
page can write it; the page therefore has to run. But it does not have to be
*seen*: --headless=new loads the app, its own startup auto-sync calls
loadAccount(), the wrapper fallback provisions it, and the process exits. No
window is ever mapped.

Verified end to end, not just by exit code: after the run,
FlutterSecureStorage.firebase.account holds a real AES payload in the
profile's localStorage, and todo-sync in the RTDB gained a second device
(b58facc7) -- the desktop, now pushing to Firebase rather than the GitHub
mirror.

Usage:  CRDT_SYNC_SERVE_ACCOUNT=1 todo --provision-sync-account

Also pulls the browser-candidate list out into _findBrowser() so the headless
and windowed paths cannot drift apart.

256 tests, analyzer clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177xwzcpfpWG2qca8Mr39q9
2026-08-09 22:00:26 +02:00
e2dfc2652e Recover instead of crashing when port 8730 is already taken
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>
2026-08-03 15:13:15 +02:00
78452bf995 Package the desktop app as the web build, and delete the Linux embedder
Completes the move off Flutter's Linux embedder, which manages ~20fps at 4K
against ~144fps for the same Dart code in Chrome (measured end-to-end: the real
app, fullscreen at 4K, sustains 144fps scrolling a 300-note list).

`linux/` is gone. Its icons and .desktop installer move to `desktop/`, since
those are still needed and are not Flutter platform scaffolding. install_arch.sh
now builds the web bundle plus an AOT wrapper; run.sh does the same for
development.

Four things this took to get right, each found by actually installing and
launching the package rather than assuming:

- `dart compile exe` refuses to build the package: transitive dependencies
  (sqlite3, objective_c) have native build hooks. `dart build cli` handles them,
  and emits bin/ + lib/, so the wrapper resolves its assets at ../web.
- `options=('!strip')` is load-bearing. makepkg stripped the AOT executable
  (7.0M -> 5.7M), discarding the embedded snapshot; the result still ran but was
  the bare Dart VM printing a usage message instead of the app.
- No hard browser dependency. Declaring `depends=('chromium')` made makepkg
  install it, and this machine has a policy that uninstalls chromium three
  seconds later, which then failed dependency resolution. The wrapper discovers
  whatever browser exists (Thorium included) and TODO_BROWSER overrides.
- The server no longer dies with the browser process. Chrome exits immediately
  when handing off to an instance that owns the profile -- or when a stale
  SingletonLock is left behind -- which killed the server out from under a live
  window.

Chrome gets --class=todo so WM_CLASS matches the .desktop StartupWMClass and
the taskbar shows the app icon rather than a browser icon.

Verified: `todo` launches, serves, opens a window titled todo, and recovers the
backlog through the wrapper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:08:00 +02:00
3704d34f74 Fix the web instrument's arming flag, and let the wrapper redirect its paths
Two things found while verifying the >=100fps done-condition at 4K.

`bool.fromEnvironment` only accepts the literal `true`, so
`--dart-define=TODO_FRAME_STATS=1` silently evaluated to false and the web
instrument emitted nothing at all -- a whole measurement run produced no data
and looked like the callback was broken. It now reads the value as a string and
accepts either form.

The wrapper gains --backlog-path/--log-path. Verification needs to point the
server somewhere harmless: it starts an app whose store may be empty, and an
empty store exports over the real ~/todo/BACKLOG.md through the very endpoint
being tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 13:47:38 +02:00
c37a9ef118 Add the desktop wrapper, and stop the test suite eating BACKLOG.md
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>
2026-07-20 13:33:25 +02:00