From 40478a122128b16e3e43f56e01696d6fea6e3cf2 Mon Sep 17 00:00:00 2001 From: stslex Date: Tue, 23 Jun 2026 22:05:53 +0300 Subject: [PATCH 01/51] =?UTF-8?q?feat(gui-tauri):=20macOS=20self-installin?= =?UTF-8?q?g=20app=20=E2=80=94=20bundle=20+=20in-app=20privileged=20instal?= =?UTF-8?q?l/disable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship Splitway as a self-installing macOS app so a user double-clicks Splitway.app, clicks one button, authenticates once via the native password dialog, and the root split-DNS daemon is installed and running — no terminal. - Bundle: an ad-hoc/unsigned `Splitway.app` (`.app` only — no signing, notarization, .dmg/.pkg, or SMAppService) embedding the splitway-daemon + splitway helpers, a GUI LaunchDaemon plist (carrying --socket-group splitway), and bootstrap.sh. The bundle path is additive: the Tauri bundler is invoked only by scripts/build-macos-app.sh via a tauri.bundle.macos.json overlay applied with --config, so `cargo build` / `nix build` / `nix flake check` never read it and the Linux build is untouched. - Commands (bridge.rs): install_service / disable_service escalate via `osascript ... with administrator privileges` (one prompt) to run the inert, idempotent bootstrap.sh as root; host_platform lets the frontend branch copy. They keep the truth contract — do the work, fire refresh-now, never touch the VM; real health flows back through view-model-changed. No capability change (custom commands are not ACL-gated). The escalated command is fixed apart from the bundle-derived resource path, which is escape-safe + `quoted form of`'d (unit-tested via the pure build_admin_applescript). - Frontend: the NotRunning blocker offers an Install button on macOS (vs the Linux systemctl line); PermissionDenied guides toward sign-out (not usermod); a discreet footer link disables via a two-click arm (WKWebView suppresses window.confirm). Platform-branched, no view-model shape change. - bootstrap.sh hardening: pins a system PATH; installs the root-run daemon binary to a root:wheel 0755 /usr/local/bin and refuses if the dir cannot be made root-owned (closes the launchd unsafe-binary-location escalation on the Homebrew-on-Intel layout); settles + retries the bootout->bootstrap relaunch so a re-install never intermittently leaves the daemon stopped. Verified end-to-end on macOS against a live VPN: install (socket 0660 root:splitway, no quarantine), GUI Connected, interface/domain mutations through the group socket, idempotent re-install, and disable (reverts /etc/resolver, removes the plist). The re-login group gotcha did not manifest here — a freshly launched app sees the new membership immediately (documented). Homebrew packaging (installing the same .app, no competing service block) is the next phase. See docs/design/macos-self-install.md. Co-Authored-By: Claude Opus 4.8 --- README.md | 23 +++ ROADMAP.md | 15 ++ docs/design/macos-self-install.md | 146 ++++++++++++++ .../launchd/com.splitway.daemon.gui.plist | 67 ++++++ splitway-gui-tauri/.gitignore | 6 + splitway-gui-tauri/scripts/bootstrap.sh | 189 +++++++++++++++++ splitway-gui-tauri/scripts/build-macos-app.sh | 103 ++++++++++ splitway-gui-tauri/src/bridge.rs | 190 +++++++++++++++++- splitway-gui-tauri/src/lib.rs | 16 ++ splitway-gui-tauri/tauri.bundle.macos.json | 26 +++ splitway-gui-tauri/ui/src/api.ts | 25 +++ splitway-gui-tauri/ui/src/app.ts | 133 +++++++++++- splitway-gui-tauri/ui/src/contract-check.ts | 2 +- splitway-gui-tauri/ui/src/lifecycle.ts | 20 +- splitway-gui-tauri/ui/src/render.ts | 90 +++++++-- splitway-gui-tauri/ui/src/styles.css | 79 ++++++++ splitway-gui-tauri/ui/test/stage.test.ts | 54 ++++- 17 files changed, 1146 insertions(+), 38 deletions(-) create mode 100644 docs/design/macos-self-install.md create mode 100644 packaging/launchd/com.splitway.daemon.gui.plist create mode 100755 splitway-gui-tauri/scripts/bootstrap.sh create mode 100755 splitway-gui-tauri/scripts/build-macos-app.sh create mode 100644 splitway-gui-tauri/tauri.bundle.macos.json diff --git a/README.md b/README.md index 1db294b..ce800d2 100755 --- a/README.md +++ b/README.md @@ -345,6 +345,29 @@ equivalently, add the group via your own `users.users..extraGroups`. See the security note under [Using it under niri](#using-it-under-niri-wayland), then that section for binding it to a key. +#### macOS (`Splitway.app`) + +On macOS the GUI ships as a self-installing app — **no Terminal, no Homebrew, no +code signing**. Build it locally (it is ad-hoc/unsigned, `.app` only): + +```sh +bash splitway-gui-tauri/scripts/build-macos-app.sh +cp -R target/release/bundle/macos/Splitway.app /Applications/ +``` + +The wrapper pulls its off-PATH toolchain (`cargo-tauri`, `node`, `esbuild`, …) +from nixpkgs via `nix shell`, so it just runs. A locally-built `.app` carries no +quarantine flag, so it launches from `/Applications` with no Gatekeeper prompt. + +Open it and click **Install & start the Splitway service**: one native password +prompt installs the `splitway-daemon`/`splitway` helpers to `/usr/local/bin`, +creates the `splitway` group and adds you to it, and bootstraps the root +LaunchDaemon with a group-reachable socket — the app then shows **Connected** (no +re-login needed if you launch it after the install; an app left open across the +install reconnects after a relaunch). A discreet **Stop the Splitway service** +footer link reverses it (the daemon reverts `/etc/resolver` and won't relaunch at +boot). See [docs/design/macos-self-install.md](docs/design/macos-self-install.md). + ### Using it under niri (Wayland) niri is a tiling Wayland compositor with **no system tray**, so Splitway is a diff --git a/ROADMAP.md b/ROADMAP.md index bb9d17d..7584c4c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -224,6 +224,21 @@ reimplemented per frontend: `packages..splitway-gui`, bundling the IBM Plex OFL `woff2`, and the README GUI-install section. Split from 7d because its real proof — the *built* binary rendering for a fresh in-group niri user — is machine-bound. +- **7d-3 — macOS self-install**: the macOS counterpart of 7d-2's Linux bundling. + An ad-hoc/unsigned `Splitway.app` (`.app` only — no signing, notarization, + `.dmg`/`.pkg`, or `SMAppService`) that bundles the `splitway-daemon` + `splitway` + helpers, a GUI LaunchDaemon plist (carrying `--socket-group splitway`), and a + `bootstrap.sh`. Two health-keyed Tauri commands escalate via `osascript … with + administrator privileges` (one native password prompt) to install/start + (`NotRunning` → Install button) and disable (footer link) the root daemon — no + terminal. The bundle path is additive (the Tauri bundler is invoked only by the + build wrapper; `cargo build` / `nix build` never read `bundle`), and the commands + keep the truth contract (do the work → refresh-now → never touch the VM). Split + from 7d-2 because its real proof — the built `.app` driving the live install on + macOS — is machine-bound. See + [`docs/design/macos-self-install.md`](design/macos-self-install.md). (Homebrew — + installing the same `.app` + binaries, with no competing `service` block — is the + next phase.) ### Phase 8 — feature freeze + hardening diff --git a/docs/design/macos-self-install.md b/docs/design/macos-self-install.md new file mode 100644 index 0000000..7abd858 --- /dev/null +++ b/docs/design/macos-self-install.md @@ -0,0 +1,146 @@ +# macOS self-install — one-click privileged daemon bootstrap from Splitway.app + +The macOS daemon must run as **root** (it writes `/etc/resolver/` and +flushes the DNS cache). Before this, bringing it up meant a terminal ritual: +`sudo launchctl load …` plus a hand-written config. This feature makes the user +double-click `Splitway.app`, click one button, authenticate once via the native +macOS password dialog, and have the root daemon installed + running — no terminal. + +## The agreement + +`Splitway.app` is built locally from source and ships **unsigned** (ad-hoc +identity `-`): no Apple Developer account, no notarization, `.app` only (no +`.dmg`/`.pkg`). A locally-built `.app` carries no `com.apple.quarantine`, so it +launches from `/Applications` without Gatekeeper friction — which is exactly what +makes the no-signing path viable. + +The app installs the daemon itself through two Tauri commands keyed to the +existing health states: + +- **`install_service`** — offered when `Health == NotRunning` (no socket). It + escalates via `osascript`'s `do shell script … with administrator privileges` + (one native password prompt) to run the bundled `bootstrap.sh install` as root. + The script, idempotently: installs `splitway-daemon` + `splitway` from the app + bundle to `/usr/local/bin` (`755`, quarantine stripped); ensures a `splitway` + group and adds the console user; installs the GUI LaunchDaemon plist (carrying + `--socket-group splitway`) to `/Library/LaunchDaemons`; and + `launchctl bootout` → `bootstrap` → `enable`s it. +- **`disable_service`** — a discreet footer link once connected. Runs + `bootstrap.sh disable`: `launchctl bootout` (SIGTERM → the daemon reverts + `/etc/resolver` before exit) and removes the plist so it will not relaunch. + +Both keep the **truth contract** ([architecture.md](../architecture.md) §2): the +command does the privileged work, fires refresh-now, and returns a +`Result<(), String>` — it never touches the view-model. The real health +(`NotRunning` → `PermissionDenied`/`Connected`, or back to `NotRunning`) flows +back only through the next `view-model-changed`, exactly as for the mutation +commands. No optimistic flips. + +A third command, **`host_platform`**, lets the frontend branch the remediation +copy: macOS gets the Install button / sign-out guidance; Linux keeps its +`systemctl` / `usermod` copy-paste commands. This is frontend presentation only — +no view-model field is added, so the bindings contract is untouched. + +## Why this shape + +- **`osascript` admin escalation, not `SMAppService`.** `SMAppService` needs code + signing + notarization + a user-approved Login Item — all rejected here. + `osascript … with administrator privileges` is the supported, signing-free way + to get one password prompt and run a fixed root command. +- **Not the deprecated `AuthorizationExecuteWithPrivileges`**, and not a + `brew services` wrapper (a Finder-launched `.app` has a minimal `PATH`, and the + brew prefix differs Intel vs ARM — Homebrew is a later phase anyway). +- **The escalated command is inert.** It is a fixed `/bin/bash