# Working on LeaveSafe ## Build from source ```bash # Requires Go 1.25+. Node is NOT required — see below. git clone https://github.com/atakankizilyuce/LeaveSafe.git cd LeaveSafe go build -o leavesafe ./cmd/leavesafe # your platform make all # all five targets ``` ```bash ./leavesafe # normal mode ./leavesafe -dev # serves web assets from the filesystem ``` ## The phone interface It is a Vite + TypeScript + Preact app in `web/src`, built into `web/dist` and embedded in the binary. **`web/dist` is committed.** That is deliberate: `go build` and `go install` have to work on a machine that has never installed Node, and a Go project that silently produces a binary with no UI is a bad surprise. The cost of committing a build artifact is that it can drift from its source, so CI rebuilds it and fails if the result differs from what is checked in. If you change anything under `web/src`, rebuild and commit the output — and rebuild the binary too, since it embeds `web/dist` at compile time: ```bash cd web npm ci npm run build # writes web/dist — commit this npm run typecheck cd .. && go build -o leavesafe ./cmd/leavesafe ``` For live reload, run the binary and point Vite's dev server at it. The dev server proxies `/ws` to port 9443, so start the binary on that port — the listener picks a free one otherwise, and the proxy would have nothing to aim at: ```bash PORT=9443 go run ./cmd/leavesafe # terminal one cd web && npm run dev # terminal two ``` `./leavesafe -dev` also exists and serves `web/dist` straight from disk, so a rebuild shows up without restarting the binary. ## The session protocol lives in two repositories `internal/ws/session.go` is one half of a construction whose other half is the Flutter application's `lib/link/session.dart`. There is no shared code between them — two languages, two crypto libraries — so the only thing keeping them speaking the same protocol is a set of fixtures. The application's `test/link/session_test.dart` holds frames **this daemon really produced**, in both directions, compared byte for byte. That is the test that would catch the failure worth catching: two implementations that each agree with themselves and not with each other. Nothing written against one side alone can see it, because each side is internally consistent by construction. So a change to the derivation, the info labels, the nonce layout or the envelope is a protocol change, and it is a change to both repositories at once: 1. Make it here, and regenerate the fixtures — seal known plaintexts under a known pairing key and the two nonces the fixtures name, and print the frames. 2. Paste them into `session_test.dart` and make the same change there. 3. Bump the construction name if the two are no longer compatible. It is negotiated — the app asks for it by name and `auth_ok` names it back — so a second construction is a second constant and a client that asks for whichever it prefers, never a version number either end has to guess at. It should be hard, and that is the point. An app and a daemon of different versions must always negotiate down to something both speak rather than fail in a way that reads as a broken alarm. ## Checks Every pull request has to pass the same gate, and all of it runs locally: ```bash make fmt # gofmt make vet # go vet make lint # golangci-lint (staticcheck, gosec, revive, errcheck, ...) make web-lint # biome plus tsc, for web/src make web-verify # rebuilds web/dist and fails if the committed output drifted make vuln # govulncheck make test # unit tests make check # all of the above ``` The CI workflow adds a few things a laptop cannot cover on its own: | Job | What it does | |-----|--------------| | `format` | `gofmt` plus a check that `go.mod`/`go.sum` are tidy | | `typos` | spell check across the repo ([typos](https://github.com/crate-ci/typos), configured in `_typos.toml`) | | `lint` | `golangci-lint` once per target OS — half this codebase sits behind build tags, so a single platform never sees all of it | | `test` | unit tests on Linux, Windows and macOS, with coverage reported in the run summary | | `e2e` | starts the real binary on each OS and drives the whole user flow over a real WebSocket | | `realtrigger` | fires the hardware changes each runner genuinely permits, and records every one it cannot | | `sandbox-linux` | boots a real Linux VM under QEMU/KVM and creates real kernel-backed hardware | | `frontend` | Biome, `tsc`, a production build, and a check that the committed `web/dist` still matches `web/src` | | `build` | the full five-target release matrix | | `vulncheck` | `govulncheck` against the Go toolchain and dependencies | `ci-success` aggregates all of them, so branch protection only needs that one required check. Dependency and action updates arrive weekly through Dependabot. ## How much this proves Every run publishes a coverage matrix naming each sensor that was genuinely triggered and each one that could not be, with the reason. No test fakes hardware and reports success: where a real trigger is impossible, it is skipped and the gap is stated. In the Linux VM the charger is genuinely unplugged through the `test_power` kernel module and the real binary reads the change from a real `/sys`. On Windows real pointer activity is synthesised and the input sensor fires; real IP changes are detected on all three. Everything else skips with a measured reason rather than a fake pass — what no CI environment can reach is listed in [manual-verification.md](manual-verification.md). Run the layers locally with `make test-e2e`, `make test-realtrigger` and `make test-sandbox`; plain `make test` stays fast and touches no hardware. ## The rule the dashboard is held to The dashboard takes the terminal over: raw mode, the alternate screen, and a scrolling region pinned under its header. That is a bargain with the person who typed the command, and it has one term: > **Ctrl+C must work at every instant the dashboard is on screen.** It is not one feature among several. In raw mode nothing turns Ctrl+C into a signal on the program's behalf — the keystroke arrives as a byte, and if this program is not reading, nobody is. A user who cannot press it is left killing the process from another window, and what that leaves behind is a terminal still in raw mode with no echo and no line editing. The same is true of Ctrl+Z. Two things follow, and both have been broken once: - **The goroutine that reads the keyboard reads the keyboard and nothing else.** It does not run commands. It does not wait on the network, the disk, a hash or a sensor. Anything a console command does, it does somewhere else — see `typedLines.pump` in `cmd/leavesafe/input.go`. Reading and running were one goroutine once, and a slow command was a terminal nobody could get out of. - **Nothing on the arming path may cost the sum of its parts.** Arming is allowed to wait for an answer it needs, but it waits for the slowest of them, not for all of them added up, and it does not ask again for what is already settled — see `Manager.availabilityFor` in `internal/monitor`. Six sensors each allowed twenty seconds is two minutes. There is a wider version of the same term, and it is worth saying because it was broken in two more places on Windows: **the program does not take anything it was not given.** Not the size of the user's window, not the foreground of their desktop. It was maximizing the console it was started in and opening a helper window every couple of seconds while armed, and between them the machine read as locked — see `syspath.HideWindow`, and the absence of anything that resizes a window. And one more, which is the same rule pointed the other way: **whatever the dashboard took, it gives back, and it stops drawing the moment it has.** The alternate screen, raw mode, and on Windows two console settings that default the wrong way for a full-screen program — see `takeConsole` in `cmd/leavesafe/console_windows.go`. Handing the terminal back while still writing to it as though it were ours is how the status grid ended up painted across the user's shell on the way out; `statusBar.handBack` is what closes that. `cmd/leavesafe/console_deafness_test.go`, `TestNothingIsDrawnOnTheTerminalAfterItIsHandedBack` in `terminal_test.go`, the console-mode tests in `console_windows_test.go` and the arming tests in `internal/monitor/availability_test.go` are what keep all of it true. None of them passes against the code it was written for. ## Cutting a release [`releasing.md`](releasing.md) is the order to do it in: what to rehearse before tagging, what to watch during the run, and why merging a pull request in the tap repository — rather than pushing the tag — is the moment a version reaches Homebrew and Scoop. It also covers testing the update check against releases that already exist, without publishing anything.