Skip to content

Tunnel Pilot v2.0.0

Latest

Choose a tag to compare

@github-actions github-actions released this 26 Jul 03:45
e26f0d8
Tunnel Pilot v2 (Rust + Tauri) (#4)

* docs: add v2 rewrite spec package (Rust + Tauri v2)

Complete AI-ready specification for rewriting Tunnel Pilot from Flutter
to Rust + Tauri v2 + Svelte, driven by RAM efficiency (idle tray target
~15-30MB vs Flutter ~100-200MB).

Package (spec/):
- 00-README, 01-PRD, 02-ARCHITECTURE, 03-TECH-SPEC, 04-DATA-MODEL
- 05-UI-UX-SPEC, design-tokens
- 06-MIGRATION-REPO, 07-ROADMAP, AGENTS.md

Locked decisions: russh/tokio; keychain+plaintext fallback; v2.0 = v1
parity + command palette + resizable window + groups/tags + UI polish;
OS code-signing skipped (open-source, unfunded) but updater minisign
signing required; window chrome custom on macOS / native on Win+Linux.

Hardened via a 7-round adversarial tech-architect review loop: 2 blockers
(russh has no ping()/is_closed(); v1 bundle-id/app-support path mismatch)
and all F1-F31 findings resolved. Connect/disconnect flow verified solid
end-to-end (guarded single-writer state machine, two-level cancellation,
cancellation-aware connect+auth, per-attempt failure hygiene, flag-based
lost-wakeup-free retry). Remaining items are M1/M6 verification spikes.

Archived last Flutter build as tag v1.4.2-flutter-final; v2 developed on
branch rewrite/tauri, cutover to master at parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: add build progress checkpoint (durable, token-exhaustion safe)

Tracks milestone + per-item status and commit hashes on rewrite/tauri so
work resumes cleanly after context summarization. Encodes ground rules
(local commits only, never push; no touching Flutter), verification spikes
(F16/F5/F15), and the M0 item checklist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(m0): gitignore Tauri/Node build artifacts + secrets

Append v2-rewrite ignores (node_modules, dist, src-tauri/target, .env,
updater private *.key, secrets file) to the existing Flutter .gitignore
without touching any Flutter entries. Per spec 06 §2 root hygiene.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(m0): frontend toolchain (pnpm + Svelte 5 + TS + Vite 6 + Tauri CLI v2)

Init package.json with @tauri-apps/cli@2 + @tauri-apps/api@2, the plugin
JS bindings (autostart, notification, updater, dialog, clipboard-manager),
and Svelte 5 / TypeScript / Vite 6 tooling. Scripts: dev, build, check,
lint, tauri. onlyBuiltDependencies (esbuild) in pnpm-workspace.yaml for
pnpm 11. cargo 1.96 + node 25 + pnpm 11 confirmed present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m0): scaffold Tauri v2 + Svelte shell in place at repo root

Runnable app that boots to a hidden window (tray-only). Frontend: Vite 6 +
Svelte 5 (index.html, src/main.ts, App.svelte, app.css), split tsconfig
(browser app + node config for vite.config). Rust: src-tauri/ with Cargo.toml
(tauri 2 + tokio + tracing + serde/thiserror), build.rs, main.rs → lib::run(),
minimal setup returning Ok. tauri.conf.json: productName "Tunnel Pilot",
identifier com.kalfian.tunnelpilot, window visible:false + resizable + min
size, macOS Overlay titlebar (transparent, macos-private-api) / native
decorations elsewhere (spec 03 §14). Info.plist LSUIElement=true (agent app),
capabilities/default.json (core:default + window show/hide), desktop icon set.
cargo build + vite build + svelte-check all clean; clippy/fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m0): Rust module tree per spec 02 §3 + real error.rs & events.rs

Create the full subsystem module tree as documented stubs (module decls +
doc comments + TODO markers referencing the milestone that fills each):
state/{tunnel_registry,settings_state,log_buffer}, ssh/{engine,client,
forward,health,reconnect,wake,stats}, storage/{config_file,migration,backup},
credentials, tray/{icon,menu}, updater, window, platform/{dock,autostart,
notify}, commands/{forwards,groups,settings,logs,backup,updater,app}.

Real content: error.rs — `AppError` (thiserror + Serialize, internally
tagged {kind,message}) crossing IPC per AGENTS.md §4, `Result<T>` alias,
From<io/serde_json>; events.rs — the exact 10 event-name constants from
spec 02 §7. Crate-level `#![allow(dead_code)]` documents the scaffold phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m0): Svelte layout per spec 02 §4 + typed IPC/events/types contract

lib/types.ts: full TS models mirroring the Rust structs 1:1 (spec 04) —
strict, no `any`. lib/ipc.ts: one typed wrapper per command in the spec 02 §6
catalog (contract source of truth, AGENTS.md §1); camelCase args map to the
snake_case Rust params. lib/events.ts: EVENTS constants mirroring events.rs +
typed listen() helpers + payload interfaces. Stores (forwards/groups/settings/
logs/updater/palette) as read-through mirrors. Placeholder components + route
views (visuals owned by design agent per 05-UI-UX-SPEC.md).

Tooling: eslint flat config (typescript-eslint + eslint-plugin-svelte,
Flutter/Rust dirs ignored), prettier + prettier-plugin-svelte, scoped lint
script. pnpm check + lint + build all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m0): register Tauri plugins as no-ops (spec 02 §8)

Add + register single-instance (first, per §8), autostart (LaunchAgent),
notification, updater (Builder, endpoints/pubkey deferred to M6), dialog, and
clipboard-manager. Grant their `:default` capability permissions to the main
window so M4 can call them; only spec-catalogued surface is exposed
(AGENTS.md §8). Callbacks are stubbed with TODO(M3) markers. Builds clean;
clippy/fmt pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m0): hidden-at-start window + minimal tray (Open/Quit)

Build a minimal system tray in setup: "Open" shows/focuses the main window,
"Quit" exits. Window is hidden at start via tauri.conf.json (visible:false).
macOS baseline: set ActivationPolicy::Accessory so dev runs also sit in the
tray dock-less (mirrors Info.plist LSUIElement; showInDock switching is M3).
Tray reuses the bundled icon. Dynamic count icon + menu rebuild deferred to
M3; hide-on-close deferred to M3 per spec.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m0): tracing + tracing→log-buffer layer stub (spec 03 §18)

Add logging.rs: init_tracing() builds a tracing_subscriber registry (env
filter defaulting to info + fmt layer + a stub LogBufferLayer) via try_init
(idempotent for tests). LogBufferLayer is a no-op for M0; M2 fleshes it out to
push LogEntry into the 500-cap ring buffer and emit log://line. Initialized
first in setup with a boot info! line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(m0): cross-platform Tauri build matrix (build-only, unsigned)

Add .github/workflows/tauri-build.yml — macOS/Windows/Linux matrix on
rewrite/tauri (push/PR/dispatch). Installs rust (fmt+clippy) + node 22 + pnpm
11, Linux webkit2gtk/gtk/appindicator system deps, runs the frontend
(check+lint) and Rust (fmt+clippy) gates, then `pnpm tauri build` and uploads
the bundle. BUILD ONLY: no OS code-signing, no updater signing (M6/M7, spec
06 §4). Additive — the Flutter release.yml is untouched until cutover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m0): add minimal plugins.updater config so the app launches

tauri-plugin-updater v2 is not a bare no-op: registering it panics at launch
(PluginInitialization) unless a `plugins.updater` block exists — it
deserializes to a required Config struct, so a null/missing block errors.
Ship a minimal block (pubkey:"" + placeholder GitHub Releases endpoint); the
pubkey is only parsed at verify time so empty is fine at init. M6 fills the
real minisign pubkey. Verified: release + debug binaries launch to the tray,
window hidden, tracing boot line emitted, no crash. Spec 02 §8 updated per
AGENTS.md §9.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m0): mark M0 complete in progress checkpoint

Tick all M0 items, set milestone table M0 ✅, record findings (updater config
requirement, pnpm allowBuilds, macOS Accessory baseline), append the 10-commit
M0 log, and set Next action = M1 (spike F16 russh 0.45 first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(m0): prettier-normalize vite.config.ts

Apply the prettier formatting (collapsed hmr ternary) so the committed file
matches `pnpm lint`/`prettier --check`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m1): pin russh/russh-keys 0.45 + F16 spike spec corrections

F16 liveness spike verified against russh 0.45.0 source. Confirmed:
keepalive_interval/keepalive_max fields, channel.into_stream() ->
ChannelStream (AsyncRead+AsyncWrite), channel_open_direct_tcpip
(Into<String> host args, u32 ports), channel_open_session() RTT probe,
no ping(). Keepalive is the teardown authority (session.run() returns
Err(KeepaliveTimeout) on peer miss).

Corrected 03-TECH-SPEC against 0.45 reality (AGENTS.md §9):
- is_closed() DOES exist on client::Handle (spec claimed it did not).
- Session future is a PRIVATE join handle, not awaitable; the F7
  connection-lost signal is observed by polling Handle::is_closed().
- publickey auth takes Arc<keys::key::KeyPair>, not PrivateKeyWithHashAlg
  (that type is a later russh version). auth_* return Result<bool>.
- load_secret_key is blocking -> spawn_blocking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m1): state models, stats, tunnel registry + backoff helper

- state/models.rs: ForwardConfig/ForwardInput/ForwardStatus/TunnelStats/
  ForwardRuntime/AppSettings with camelCase serde + effective-keepalive
  normalization (0->10 / 0->3, spec 03 §2) and lenient v1 defaults (F19).
- ssh/stats.rs: StatsInner atomics (byte/conn counters + supervisor-written
  latency) -> TunnelStats snapshot (F1: no ping_failures; F30: dead-channel
  counter is per-attempt, not here).
- state/tunnel_registry.rs: TunnelHandle (two-level tokens F6, stable
  JoinHandle F21, lock-guarded retry_requested flag + wakeup-only retry_notify
  F29, wake_notify NIT-1), and the SINGLE guarded set_status writer enforcing
  the transition table (F23/F28/F31) + begin_terminal_error / request_retry.
- state/mod.rs: AppState (registry + in-memory config/settings/credential
  mirrors for M1) + tunnel://status,stats emit helpers.
- ssh/reconnect.rs: pure backoff() (delay*2^attempt, clamp 1-60).

Unit tests: backoff sequence/clamp, transition table over ALL (from,to)
pairs incl. disconnecting->error dropped (F28) and connecting->disconnecting
allowed (F31).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m1): russh client, forward piping, supervisor engine + debug cmds

- ssh/client.rs: russh 0.45 Handler (accept-any host key, v1 parity),
  build_config (keepalive), connect (15s), authenticate (30s, identity
  precedence, checks auth-accepted bool, spawn_blocking key load).
- ssh/forward.rs: per-conn direct-tcpip open (10s), bidirectional
  byte-counting copy, per-attempt ForwardFailSignal (counter+notify) — 3
  consecutive failures fire the WAKE only, never the parent (F26/F27b/F30);
  success resets the streak (v1 parity); active-conn guard never negative.
- ssh/engine.rs: single long-lived supervisor owning the session in-task
  (F21); cancellation-aware bind->connect->auth via run_until_cancelled (F24);
  5x500ms EADDRINUSE bind-retry (F25); per-attempt reset (F27a/F30);
  session-lost via is_closed() poll (F7/F16-corrected); 5-arm accept loop
  (attempt-cancel, accept, liveness, fail-wake, wake-probe) + 3s RTT/stats
  probe (§6); guarded status writes; error PARK with flag-based retry (F29);
  conflict handling; connect/disconnect (awaits JoinHandle, F21/F31)/retry
  (F27c) entry points.
- async-trait dep (russh Handler is #[async_trait], not re-exported).
- commands/debug.rs + lib.rs: temporary debug commands + AppState manage to
  drive the engine (removed at M4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(m1): state-machine + in-process russh integration tests

- tunnel_registry: handle-based state-machine tests — happy-path lifecycle,
  clicks-during-disconnecting no-op, disconnecting->error dropped (F28),
  connecting->disconnecting allowed (F31), two-level token semantics (F6),
  retry only-when-parked (F27c), retry racing final failure honored (F29).
- ssh/it_tests: in-process russh 0.45 forwarding server harness (no external
  sshd) — end-to-end forward + byte counters + port release (§1/§6/F21),
  session death -> error with no ping counter (F1/F7), dead-channel 3-failure
  teardown + reconnect without cancelling parent (F26), teardown during
  connecting releases port fast + reaches disconnected (F24/F31), retry from
  error reuses the same supervisor (F23).
- Fix: guarded status writes use watch send_replace (updates value even with
  no live receiver).

All 24 tests pass; clippy --all-targets -D warnings + fmt --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m1): mark M1 complete in progress checkpoint

M1 checklist, findings/spec-corrections, commit log, milestone table, and
Next action=M2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m1): non-blocking RTT probe + atomic start reserve + token-driven attempt reset

F32 (MAJOR): the RTT/wake probe ran INLINE in the accept-loop select!, so a
wedged session (channel_open_session hanging to the 3s timeout) blocked
user-disconnect, listener.accept(), and the is_closed() liveness poll for ~3s
every 3s. Probes now run in SPAWNED tasks off the loop: the periodic stats
probe updates latency in the shared cell for the next publish (guarded by an
in-flight flag to avoid pile-up), and the wake probe pokes a `wake_dead`
Notify on failure (new select arm) → reconnect. The loop's cheap arms
(attempt_cancel/accept/is_closed) stay responsive. session.disconnect during
teardown is also bounded by DISCONNECT_TIMEOUT.

F33 (MINOR): connect_forward's check-then-insert was not atomic — two
concurrent same-id connects could both spawn a supervisor, the 2nd insert
orphaning the 1st (leaked task holding the bound port). Added
registry.try_begin_start (reserve-or-reject under lock; insert clears the
reservation) so at most one supervisor per id even under concurrent drivers.

F34 (MINOR): mint_fresh_attempt (and request_retry) now cancel the OUTGOING
attempt token before swapping, so a reconnect reaps the previous attempt's
forward children via their attempt_cancel arm (token-driven) instead of only
via session-disconnect channel errors — active_connections isn't left inflated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(m1): F35 coverage — keepalive-timeout, no-double-bind, conflict, park/retry

Harness additions: server `channel_open_session` override with a hang mode
(wedged-probe simulation), and a switchable black-hole TCP relay (silent
network death: holds sockets open, forwards nothing).

New integration tests:
- silent_drop_triggers_keepalive_timeout: black-hole the SSH TCP (no graceful
  disconnect) → russh keepalives unanswered → client session ends via
  keepalive-timeout → error within ~interval×max. Proves the REAL F1/F7
  liveness mechanism (not a received disconnect, no ping counter).
- user_disconnect_during_wedged_probe_is_fast (F32): with the RTT probe hung,
  a user disconnect still tears down in <2s (not ~3s).
- rapid_connect_disconnect_connect_no_double_bind: 4 fast cycles on one local
  port, all connect, no EADDRINUSE, port free at the end.
- same_port_conflict_disconnects_first: a 2nd config on the same local port
  disconnects the first (engine conflict path, previously untested).
- disconnect_while_parked_in_error: user disconnect from error reaches
  disconnected/removed and releases the port (F31/F23) — end-to-end.
- retry_racing_reconnect_attempts_never_lost: hammer retry through the live
  supervisor while the server is down, then recover — no lost wakeup (F29/F23).

30 tests pass (stable x4); clippy --all-targets -D warnings + fmt --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m1): record architecture code-review fixes (F32-F35) + host-key backlog

M1 findings: F32 non-blocking probe, F33 atomic start-reserve, F34 token-driven
attempt reset, F35 keepalive-timeout/coverage tests. Added a known-backlog note
for host-key verification (NIT — accept-any host key = v1 parity, MITM exposure
to revisit post-cutover). Commit log + checklist updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m2): pin keyring v3 per-target backends + tempfile dev-dep

keyring v3 ships no backend by default (F9); pin native features per OS via
[target.'cfg(...)'.dependencies]: apple-native (macOS), windows-native
(Windows), sync-secret-service + crypto-rust (Linux). Without this every
password silently hits the plaintext fallback. Add tempfile for fallback-store
unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m2): credential store — keychain-first with plaintext fallback

Implement credentials/ (backend.rs, fallback.rs, mod.rs):
- KeychainBackend trait + KeyringBackend (keyring v3) behind it so routing is
  testable without a live OS keychain. Stable KC_SERVICE = "tunnel-pilot",
  account = forward id (spec 04 §10).
- keychain_available(): boot-time sentinel round-trip probe (headless Linux →
  false), cached in CredentialStore and surfaced to the UI warning.
- CredentialStore::{set,get,delete}_password route to keychain when available,
  else a SEPARATE tunnel_pilot_secrets.json fallback file (0600 on unix, atomic
  temp+rename), kept out of the main config and backups. get falls through to
  the file on a keychain miss; delete purges both stores.
- Security (AGENTS §8): secret only ever lives in keychain/fallback; never
  logged, emitted, or serialized elsewhere — trace lines reference forward id
  only. No unwrap/expect in prod paths; all ops return Result<_, AppError>
  (reused existing AppError::Credential — no new variants needed).

Tests (inline, unconditional): fallback set→get→delete, per-forward isolation,
idempotent delete, keychain-available never writes the fallback file, secret
lands ONLY in the fallback file with exact {schemaVersion,secrets} shape,
keychain-miss fallthrough, 0600 perms. Real OS-keychain roundtrip #[ignore]d
(passes locally on macOS; gated for headless CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m2): shared 3s stats emit sampler + registry sampler slot

Single shared sampler task (ssh/health.rs) that reads each connected
tunnel's stats_cell and emits tunnel://stats on a 3s tick — holds no
session and never tears down (liveness stays owned by russh keepalive +
the session-future signal, F1/F21). Auto-starts idempotently on the
first connect via a new AtomicBool slot in TunnelRegistry
(try_start_sampler / stop_sampler / is_sampler_running) and auto-stops
when no connected tunnels remain, with a release-then-recheck to close
the auto-restart race. sample_once is extracted so the
sample-and-never-teardown invariant is unit-tested (spec 03 §2/§6).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m2): wire sampler emit + immediate wake-reconnect + F36 guards

Supervisor integration for M2 liveness wiring (ssh/engine.rs):

- Sampler ownership (spec 03 §2): the supervisor now ONLY publishes the
  fresh TunnelStats snapshot into its stats_cell and no longer emits
  tunnel://stats itself; the shared sampler is the sole emitter (removes
  the double-emit). ensure_sampler is called on each reach-connected so
  the sampler auto-starts on first connect / restarts after reconnect.

- Immediate wake-reconnect (spec 03 §4): a failed wake RTT probe now
  returns Disposition::ReconnectImmediate, routed through handle_teardown
  with immediate=true — it still enters `error` (the legal pre-connecting
  transition) and honors auto_reconnect=false (park, not a silent stall),
  but SKIPS the backoff wait and consumes no retry budget. Fixes a latent
  bug: the old wake path did `continue` without first moving Connected ->
  Error, so the illegal Connected -> Connecting transition no-op'd and the
  supervisor broke, leaving the tunnel falsely "connected".

- F36 RAII guards: InFlightGuard clears the periodic-probe in-flight flag
  on drop (a panic can no longer wedge the pile-up guard forever), and
  StartReservation releases a try_begin_start reservation on drop unless
  disarmed by insert (a panic before insert can no longer leak the id).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m2): sleep/wake watchdog task + setup registration

Monotonic-gap watchdog (ssh/wake.rs): a single app-lifetime task ticks
every 5s using tokio::time::Instant; if the observed inter-tick gap
exceeds 30s the machine likely slept, so it sweeps only the `connected`
tunnels and pokes each supervisor's existing request_wake_probe/
wake_notify to probe-and-reconnect-if-dead. Holds no session and decides
no teardown. Per F15 this heuristic is best-effort, NOT assumed reliable
across OS sleep — the russh session-future signal (F7) is the backstop;
real-OS-sleep verification stays deferred to M6. Registered in lib.rs
setup via tauri::async_runtime::spawn (setup is not an async context).
The 3s stats sampler is NOT started here — it auto-starts on connect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(m2): wake sweep, sampler lifecycle, immediate wake-reconnect

In-process russh IT coverage (spec 03 §4 acceptance):

- wake_probe_leaves_healthy_tunnel_connected: a wake nudge on a healthy
  tunnel probes and does NOT tear it down; the shared sampler auto-started
  on connect and the supervisor feeds a live snapshot into the cell.
- wake_probe_reconnects_silently_dead_tunnel: with a slow keepalive
  (3600s) so only the wake RTT probe can detect death, a black-holed
  session is detected by the probe (not keepalive), forces a reconnect,
  and recovers once the fault clears.
- session_signal_recovers_without_any_wake_nudge: with NO wake nudge at
  all, a silent death still recovers via the keepalive/session-future
  backstop (F7/F15) — proving recovery does not depend on the heuristic.

Sampler unit tests (sample_once carries latency, skips non-connected,
never mutates status; ensure_sampler idempotent + auto-stops) and the
wake gap-threshold unit test live alongside their modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m2): TunnelGroup model + persisted config store (config_file.rs)

Adds the v2 TunnelGroup model (spec 04 §2) and the ConfigStore over
tunnel_pilot_config.json in the single canonical app_config_dir (F2, spec
03 §7 / 04 §9):

- ConfigDocument = { schemaVersion, forwards, groups, settings }; every
  section #[serde(default)] so a v1/partial file still parses.
- Atomic write (tmp + fsync + rename) serialized behind an async Mutex.
- Read-merge-write: save_forwards/save_settings/save_groups each load the
  full doc, replace one section, rewrite — siblings preserved (mirrors v1).
- Corruption on load -> copy to .corrupted-<ts> sidecar, log, start fresh;
  never crash. tokio::fs throughout (no sync I/O on the async path).

AppSettings gains PartialEq/Eq for document comparison in tests. Tests:
atomic write (no tmp leftover), corruption->sidecar, sibling preservation
both directions, and no-secret-in-config sanity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m2): credential store in-memory + fallback-only constructors

Two constructors on CredentialStore for integration + testing, plus their
backends:

- InMemoryBackend / CredentialStore::in_memory() — no OS keychain, no disk;
  probe() reports available so the keychain route is exercised. Backs the
  headless AppState (engine tests) and migration tests without polluting the
  real login keychain.
- NullBackend / CredentialStore::fallback_only(path) — keychain forced
  unavailable, every secret routed to the plaintext fallback file. Real use:
  headless Linux (no Secret Service); also lets tests exercise the fallback
  route deterministically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m2): v1->v2 migration (hardcoded per-OS probe) + lenient v1-backup parse

Migration (spec 04 §12, F2/F17):
- Hardcoded per-OS v1-path probe, NOT Tauri dir resolution: macOS
  ~/Library/Application Support/com.kalfian.tunnelpilot/... (bundle id has NO
  underscore), Windows %APPDATA%\kalfian\Tunnel Pilot\..., Linux = None
  (v1 never shipped). v1_config_path_for(os, base) is pure so all three OS
  branches are unit-testable from any host.
- migrate_if_needed: skip when v2 schemaVersion>=2 (idempotent); treat a
  pre-v2 file AT the v2 path as same-dir v1; else probe the hardcoded path.
- import: lenient v1 parse (ForwardConfig serde defaults + captured
  sshPassword), move each plaintext password into the credential store
  (hasStoredPassword=true, never into the v2 file), write a .v1-backup copy,
  lay down the v2 doc atomically.

Backup (spec 04 §11, F19): BackupFile + parse_backup — reject version>2,
tolerate v1 (no groups -> [], ignore legacy sshPassword), force
hasStoredPassword=false on import; export strips the flag. Apply-to-state
(replace|merge) stays M4.

Tests: per-OS path builders + Linux=no-probe, probe finds a real v1 fixture,
end-to-end import (keychain route + fallback route, password never in the v2
file, .v1-backup written), idempotency, in-place upgrade, fresh install,
lenient v1-backup import.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m2): wire persisted config + credential store into AppState + boot migration

Replaces M1's in-memory configs/passwords stand-ins with the persisted
config file and the credential store (spec 02 §5, 03 §7/§9):

- AppState now holds: configs as an ordered Vec (array order = display order,
  spec 04 §9), groups + settings RAM mirrors, an Arc<CredentialStore>, cached
  keychain_available, and an optional Arc<ConfigStore>. new_hydrated(app,
  store, creds, doc) seeds the mirrors from the loaded document; new_headless
  uses an in-memory credential store and no persistence (engine tests).
- get_config/get_password are unchanged for the engine; set_password/
  get_password/delete_password now route to the keychain/fallback store.
  upsert_config/remove_config/set_settings mutate the RAM mirror then flush to
  disk fire-and-forget (None store = no-op in headless).
- HydrateSnapshot (forwards/groups/settings/keychainAvailable) + debug_hydrate
  command make the persisted data + keychain warning reachable over IPC; the
  real app_hydrate lands in M4.
- lib.rs boot: resolve app_config_dir, build ConfigStore + CredentialStore
  there, run migrate_if_needed then load() via block_on at the binary edge,
  fall back to defaults on error (never crash), construct new_hydrated.

it_tests use set_settings instead of the now-private settings field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m2): mark M2 complete (persistence + migration + AppState integration)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m2): create fallback secrets tmp atomically at 0600 (F39)

The plaintext fallback secrets file was written with std::fs::write (default
umask, typically 0644) and only chmod'd to 0600 afterward, leaving a brief
window where the plaintext SSH password was world-readable. Create the tmp
sibling with mode 0600 from the start via OpenOptions::mode(0o600).create_new
on unix (clearing any stale tmp from a crashed write first), then the atomic
rename preserves 0600. Non-unix behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m2): per-field serde defaults on AppSettings so partial blocks merge (NIT)

A legacy/partial settings block missing one key deserialized the whole struct
to Default, silently discarding every other configured value. Add per-field
#[serde(default)] with the correct v1 defaults (via shared free functions the
Default impl also uses, keeping them in lockstep) so a partial block merges
field-by-field with defaults instead of resetting. Adds tests for the partial
and empty-block cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m2): route persistence through a single ordered writer (F37)

persist_forwards/persist_settings were fire-and-forget detached spawns that
each snapshotted a section and raced for the config store's write lock, so an
older snapshot could be written after a newer one -> silent data loss / stale
config on next boot. Route all persistence through ONE background writer task
(spawned in new_hydrated) fed by an mpsc channel: mutations enqueue the latest
full section snapshot, the writer serializes writes in enqueue order and
coalesces bursts to the newest per section (last-write-wins is correct since
each save is a whole-section write). Mutation accessor signatures are unchanged
(callers unaffected). Error handling stays tracing::error! for now; user-facing
surfacing of persist failures is an M4 follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m2): run keychain password read off the async runtime (F38)

The keychain get ran synchronously on the async auth path (ssh/client.rs auth
-> AppState::get_password -> keyring get), and keyring does blocking OS calls
(macOS Security framework / Linux Secret Service D-Bus) that can stall a tokio
worker. Add AppState::get_password_async which wraps the read in
tokio::task::spawn_blocking (mirroring the identity-key load already on
spawn_blocking) and switch the password auth branch to it. The sync accessor is
retained for the boot/migration/sync-command paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m2): record M2 post-review hardening (F37/F38/F39/NIT) + F37 M4 follow-up

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m3): dynamic count tray icon + debounced state-driven menu

Idle/1-9-clamped count icon (macOS template); pure count->asset and
menu-model builders unit-tested. Per-tunnel rows with Retry-on-error,
conditional bulk Start/Stop All, update-notice slot. Rebuilds on
tunnel://status, debounced ~100ms; clicks routed to engine/window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m3): window hide-on-close, show/hide, single-instance re-show, quit

CloseRequested -> prevent_close + hide (app persists in tray). show/hide
apply dock visibility. Second launch focuses existing window + emits
window://focus. quit_app tears down every live tunnel before exit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m3): dock/taskbar visibility + autostart reconcile

Dock visible only when window shown AND showInDock (pure decision
unit-tested); macOS activation-policy (no objc FFI, F11), Win/Linux
skipTaskbar. Autostart reconciled to launchAtLogin on boot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m3): global bulk start_all/stop_all commands (F3)

connectAll/disconnectAll parity: start_all connects disconnected +
retries errored, skips live; stop_all silently disconnects all live.
Distinct from per-group bulk (AGENTS §1). Shared run_* helpers reused by
the tray.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m3): wire tray/window/dock/autostart + bulk commands into setup

Replace M0 minimal tray with tray::setup; register start_all/stop_all;
single-instance re-show; autostart reconcile on boot; hide-on-close
handler. Window boots hidden -> dock hidden until show_window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m3): mark M3 done — tray/window/lifecycle/autostart/dock

Milestone table + M3 checklist + findings; Next action = M4 (after
F37/F38/F39 M2-hardening lands). 89 Rust tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: record M3 review outcome + defensive follow-ups (F40/F41/F42)

M3 lifecycle review clean (0 blockers/majors). Track quit-watchdog and
double-quit-guard as pre-cutover hardening; host-key verification backlog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m4): full IPC command surface + F37 error surfacing + log buffer

Implement the complete backend command surface (spec 02 §6) with thin
handlers over the state/ssh/storage/credentials services (AGENTS §3), and
wire the tracing→log-buffer bridge (spec 03 §18):

- forwards (§6.1): list/create/update/delete/duplicate/reorder,
  connect/disconnect/retry, get_forward_runtime, copy_ssh_command
  (v1-exact `ssh -N -L … -p … [-i …] user@host`, never a password),
  set/clear password (route through CredentialStore; config holds only
  hasStoredPassword). update_forward force-disconnects a live tunnel first.
- groups (§6.2): CRUD (+ persisted `collapsed`), assign, per-group
  start/stop, list_tags (derived union).
- settings (§6.3): get/update with autostart + dock side effects.
- logs (§6.4): get/clear/text over the 500-cap newest-first ring buffer.
- backup (§6.5): export (password-stripped) + import (replace|merge,
  reject version>current) via a pure, tested plan_import.
- updater (§6.6): skip_update real; check/install deferred to M6 stubs.
- app (§6.7): app_hydrate returns the full AppSnapshot (forwards, groups,
  settings, logs, live runtimes, keychainAvailable, update).

F37 follow-up: mutation commands now return Result and await the ordered
writer's outcome. PersistMsg carries a per-message ack; the single writer
sends the real save result back to every coalesced ack — ordering
guarantee preserved, failures now surfaced to the UI as AppError::Storage.

All commands registered in the invoke_handler; debug commands removed.
New model types (LogEntry, LogLevel, UpdateStatus, AppSnapshot, GroupInput).
Tests: copy_ssh_command (bind/port/identity, no password), backup
replace-vs-merge, duplicate/reorder logic, update_forward force-disconnect,
log-buffer cap/format, F37 ack outcome. 106 Rust tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m4): record M4 Phase-1 backend surface + F37 done (milestone in-progress)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(m4): add vitest for frontend store/helper tests

Node-env vitest config (src/**/*.test.ts); no jsdom yet — component
tests arrive with the ui-ux work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m4): forwards store reconciliation + connectedCount

Reconcile the ordered list + per-tunnel status/stats/error maps from
forwards://changed, tunnel://status, tunnel://stats, and hydrate
runtimes. applyForwards seeds neutral defaults for new ids and prunes
removed ones while preserving live status on reorder/rename. Derived
connectedCount feeds the tray/UI count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m4): settings/groups/logs/updater/backup store reconcilers

settings (+keychainUnavailable), groups, logs (newest-first, cap 500),
and updater stores gain reconcile fns fed by settings://changed,
groups://changed, log://line, log://cleared, update://status/progress
and the hydrate snapshot. Adds a backup importMode UI-state store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m4): hydrateAll + event->store subscription wiring

hydrateAll() pulls app_hydrate() AppSnapshot and fans it into every
store (applyForwards before applyRuntimes). subscribeEvents() binds all
Rust->FE events to store reconcilers and re-hydrates on window://focus,
per AGENTS.md §5 (never assume the webview kept state while hidden).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m4): ForwardForm validation rules

Pure helpers: required text fields, port range 1-65535 for ssh/local/
remote ports, non-negative keep-alive fields (0 = use-default), and
password XOR identity-file exclusivity (accounts for an existing stored
password on edit). Returns a field->message map for direct form binding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m4): design token layer + global base styles

Transcribe spec/design-tokens.md verbatim into src/styles/tokens.css
(:root light + [data-theme=dark]) and add a tokened reset/focus-ring/
reduced-motion base layer. app.css imports both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m4): UI primitive library + UI plumbing helpers

Tokened, presentational primitives with full states (hover/focus-visible/
active/disabled/loading): Button, Toggle (36x20 custom), Input, Select,
SegmentedControl, StatusDot, StatChip, EmptyState, Skeleton, SidebarItem,
Dialog (focus-trap/Esc/return-focus), Menu, ToastHost, and an inlined Lucide
icon set (no emoji, no runtime dep). UI-layer helpers (theme/platform/view/
toast/format) live in src/lib/ui — distinct from the Rust-backed data stores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m4): Connections view — cards, list, form, delete confirm

Flat ordered tunnel list (ConnectionList) with drag + alt-arrow keyboard
reorder (optimistic, settles on forwards://changed). ConnectionRow renders the
five-state machine (disconnected/connecting/connected/disconnecting/error),
live mono stat chips when connected, per-row actions (copy-ssh via
clipboard-manager, edit, duplicate, delete) and an error strip with Retry/View
log. ForwardForm dialog: General/Advanced sub-tabs (state preserved), v1-parity
fields + defaults, password/identity segmented auth, validateForwardForm gating,
dirty tracking, discard-confirm; password flows only via set/clear_forward_
password. ConfirmDialog defaults focus to the safe action. Toolbar: count,
filter, duplicate/delete (selection), Add; honest empty/filter/loading states.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m4): Activity + Settings views

Activity: reverse-chron mono log stream, level + substring filters, click-row
copy / Copy all / Clear (clipboard-manager), level-colored tokens, empty state.
Settings: startup/connections/updates/appearance toggles via updateSettings
(store-driven auto-revert on failure), animated auto-reconnect sub-options,
3-icon theme segmented control, six-state update banner (renders states;
actions inert pending M6), backup export/import with mode segmented control +
confirm dialog. Version footer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m4): app shell + boot wiring (hydrate/subscribe/theme)

Sidebar-rail IA (Connections/Activity/Settings) with active-count badge and
compact icon-only rail under 640px; macOS custom transparent titlebar +
data-tauri-drag-region (native decorations elsewhere); keychain-fallback
warning banner; 150ms route crossfade (reduced-motion aware); ToastHost.
main.ts boots initTheme() + subscribeEvents() + hydrateAll().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(m4): component tests + jsdom vitest setup

Vitest component tests (jsdom per-file docblock, @testing-library/svelte,
lib/ipc mocked) for ConnectionRow (state renders + toggle/retry call the IPC
contract), ForwardForm (submit gating, invalid-submit error display, password
via the secret channel), and SettingsView backup import-mode selection.
Add jsdom + testing-library dev deps and the Svelte plugin to vitest.config.
hydrate.test.ts reformatted by prettier (whitespace only, no logic change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m4): mark M4 done — Phase-2b UI layer + commit range

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m5): emit WINDOW_FOCUS on every window show path (F44)

hydrateAll() only ran on window://focus, which was emitted solely by
focus_from_second_instance. Tray "Open" and the show_window IPC command
call window::show_window, which showed+focused but never emitted the
event, so the primary show paths never re-hydrated (AGENTS §5).

Move the WINDOW_FOCUS emit into window::show_window (the single show path
for tray Open, IPC, and single-instance) and drop the now-duplicate emit
in focus_from_second_instance so it fires exactly once per show.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m5): resizable main window with spec min bounds (N2)

Set the main window to the 05-UI-UX-SPEC §3 sizing: default 860×640,
min 560×480 (was 900×640 / min 720×480). The old 720 minWidth blocked
the <640 Compact breakpoint entirely; 560 lets the design agent's
responsive reflow reach all three breakpoints.

resizable was already true; window still starts hidden and the macOS
Overlay titlebar / native Win+Linux decorations from M0 are unchanged.
Last-size persistence skipped (not a trivial config flag).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m5): F45 await subscribeEvents before hydrateAll on boot

The boot ran subscribeEvents() and hydrateAll() concurrently unawaited, so a
Rust->FE event firing during app_hydrate (before listeners attached) was
dropped, leaving a store stale until the next event. Sequence them: register
all listeners, then pull the snapshot baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m5): F46 require at least one auth method

validation only enforced password/identity mutual-exclusivity, so a forward
with NEITHER could be saved and would never authenticate. Add the 'at least
one auth method' rule (v1 intent) surfaced on the auth field. Update tests to
match (the old 'allows neither' case encoded the bug).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m5): F47 distinguish password-save failure from config-save failure

On the add path, if createForward succeeded but the password store call threw,
the toast said 'Save failed' though the config was created. Split the two: a
password-store failure now reports 'created, but the password wasn't stored'
and still closes the dialog (the tunnel exists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m5): F48 stable log row keys instead of array index

Keying the Activity {#each} by array index mis-associates rows to the wrong
DOM node when a filter changes the visible subset. Derive a content key +
occurrence index (order-stable under filtering) since logs carry no id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m5): N1 command palette (⌘K) with fuzzy launcher

Raycast-grade keyboard-first launcher over tunnels + actions (spec 05 §10):
- pure DP-based fuzzy matcher (lib/fuzzy.ts) with word-boundary/consecutive
  scoring + multi-term AND; unit-tested.
- palette store (open/query/recents) + a command bus (commands.ts) so the
  palette can request the ForwardForm / delete-confirm that live inside
  ConnectionsView without reaching into its state.
- CommandPalette.svelte: context-aware connect/disconnect per tunnel, per-tunnel
  action sub-menu (→), start/stop all, per-group start/stop, jump-to-view,
  toggle theme, check updates, add tunnel, about. ↑↓/↵/⌘↵/→/←/esc; hover shares
  the active index; combobox + listbox a11y with aria-activedescendant.
- App wires ⌘K (toggle from anywhere, above dialogs) + a persistent rail search
  cue; ⌘N (add) now global via the bus. ConnectionsView consumes the bus.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m5): N3 groups/tags UI + F43 reorder-under-filter fix

Groups (spec 05 §4.2):
- collapsible GroupHeader per group (collapse persisted via update_group),
  X/Y active count, per-group Start all / Stop all, ambient accent rail when
  a group has a live tunnel; an Ungrouped section; flat list stays flat when
  no groups exist (groups never tax the simple case).
- TagFilterBar dropdown bound to activeTag (single-tag per store), removable
  active pill, auto-pruned when a tag drops to 0; tag pills on cards (max 3 +N);
  Group Select + type-to-create Tags editor in the ForwardForm.

F43: ConnectionList now owns filtering and always reorders on the FULL ordered
id list (keyboard swap stays within a group; drag across groups reassigns via
assign_forward_group). Reorder is disabled while any filter/tag is active, and
a no-move drag no longer persists. Fixes the corruption where a filtered subset
was sent to reorder_forwards and Rust appended the omitted ids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m5): N2 responsive reflow (content-area container queries)

The content area is now a CSS container (excludes the rail), so Connections
reflows to the true content width per spec §3: the list column caps at 720px
and centers only past ~1100px (lines never run absurdly wide); Compact (<640)
tightens edge padding, shrinks the filter, and collapses Duplicate/Delete into
a ⋯ overflow menu. Stat chips already wrap. Pairs with the coder's resize +
min-size enablement in tauri.conf.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m5): mark M5 done — N1-N4/N7 + F43/F45-F48, commit range

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m5): F49 stop palette keys leaking to a dialog behind (⌘K over dialog)

The palette preventDefault'd its keys but never stopPropagation, so when ⌘K is
opened over the ForwardForm, Escape/Tab bubbled to Dialog.svelte's window
handler — one Escape closed the dialog behind (and in sub-menu mode closed it
instead of stepping back one level), and Tab leaked into the dialog's focus
trap. Fix: stopPropagation on the palette's owned keys (arrows/Enter/Escape/Tab)
and trap Tab on the input so navigation stays arrow-driven and self-contained.
Tests: a window keydown listener never fires; Escape steps out of the sub-menu.
Also notes F50 (fuzzy matches UTF-16 code units — emoji queries imperfect, no
crash) as an in-code backlog item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m6): desktop notifications (unexpected-only) via tauri-plugin-notification

Implements spec 03 §15. Notify on connect + terminal error only; user-initiated
disconnects are silent (structurally: v2 unexpected drops go connected→error,
never →disconnected, which is written only by the user-disconnect handler). A
transient error that will auto-reconnect is NOT notified — only the terminal
error (retries exhausted / auto-reconnect off), matching v1
forward_provider.dart. Honors showNotifications.

- platform/notify.rs: notify_connected/notify_error/notify_update_available,
  best-effort show() (never panics/propagates). Documents macOS permission
  timing (desktop request_permission is a no-op; first real event triggers any
  prompt, not a boot race) and the F5 unsigned-macOS caveat (show() delivery is
  undetectable — plugin discards the Result — so tray/log stay authoritative).
- ssh/engine.rs: hook notify_connected on the connected transition and
  notify_error only in the terminal-error (park) branch of handle_teardown.
- state/mod.rs: expose AppState::app_handle for the notify helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m6): signed self-updater (check/install/skip) with minisign verification

Implements spec 03 §16. Replaces the M4 stubs with the real tauri-plugin-updater
flow; enforces minisign bundle-signature verification (independent of OS
code-signing, which stays deferred).

- updater/mod.rs: UpdaterState (managed) caches the pending Update + a
  once-per-version notify guard + the latest availability snapshot. run_check
  queries the GitHub Releases latest.json endpoint, honors lastSkippedVersion,
  caches the Update, emits update://status, and fires the update-available
  notification once per version (auto path only). run_install
  download_and_installs (minisign verified internally against the embedded
  pubkey; tampered/unsigned → AppError::Updater), emits update://progress, then
  relaunches. auto_check_on_startup respects autoCheckUpdates and swallows
  errors so a failed check never disrupts boot.
- commands/updater.rs: real check_update/install_update over crate::updater
  (JS arg contract unchanged — AppHandle/State auto-injected). skip_update
  unchanged.
- commands/app.rs: app_hydrate returns the cached update status so a late
  window-show still sees availability found by the boot check.
- lib.rs: manage Arc<UpdaterState>; spawn the boot auto-check detached.
- tauri.conf.json: embed the real minisign PUBLIC key + createUpdaterArtifacts.
  The private key + password live ONLY as CI secrets, never committed
  (org policy + AGENTS §8).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m6): CI updater-bundle minisign signing + latest.json (tauri-release)

Implements spec 06 §4. New tag-triggered (v2.*) cross-platform Tauri release
workflow using tauri-apps/tauri-action:
- Updater BUNDLE signing (minisign) wired from CI secrets
  TAURI_SIGNING_PRIVATE_KEY / TAURI_SIGNING_PRIVATE_KEY_PASSWORD (sourced from
  secrets.TAURI_UPDATER_PRIVATE_KEY / TAURI_UPDATER_KEY_PASSWORD) — never
  committed. includeUpdaterJson: true emits latest.json for the updater
  endpoint.
- OS code-signing / notarization intentionally left commented (open-source /
  unfunded); a future maintainer enables it by populating secrets, no
  restructuring needed. Independent of updater signing.

Additive: the Flutter release.yml (v1) stays until the M7 cutover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m6): record F5 spike result + in-memory once-per-version dedup (§15)

AGENTS §9: keep the spec true to the impl.
- §15 F5: annotate with the verified source-level finding — desktop
  tauri-plugin-notification 2.3.3 spawns the macOS show() in a detached task and
  discards its Result (always Ok), set_application uses the unsigned bundle id
  (unregistered → likely silent) but borrows com.apple.Terminal in dev (masks
  the failure); permission APIs are Granted no-ops. Best-effort; tray/log
  authoritative; acceptance does not assume macOS notifications work.
- §15: note the once-per-version dedup is in-memory (UpdaterState), not a
  persisted AppSettings field (FE-coupled); lastSkippedVersion covers the rest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m6): wire tray update-available notice to updater state

Make the tray menu reflect the cached update status from the managed
UpdaterState instead of the M3 inert slot:

- build_menu_model now takes Option<UpdateNotice> (with version) instead
  of a hardcoded bool; add pure update_notice_from_status() that offers an
  update only when available AND not skipped (lastSkippedVersion honored).
- Menu notice item shows "Update available (vX.Y.Z) - Install"; hidden
  otherwise.
- rebuild_now reads UpdaterState::latest_status() (brief std Mutex, no
  await held across it) via a gather_update_notice() helper.
- spawn_tray_sync also listens on update://status, coalesced through the
  same ~100ms debounce as tunnel://status.
- ID_UPDATE click spawns updater::run_install (same path as the
  install_update command), consistent with how other tray items invoke
  engine/commands; errors logged, no unwrap/expect.
- Unit tests cover notice inclusion/exclusion + skipped/no-version cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(m6): wire Settings update banner to the live updater backend

The M4 banner rendered states but its actions were inert while the updater
was stubbed. M6 backend is live (check/install/skip real, emits
update://status + update://progress, app_hydrate carries the cached status),
so make the banner drive the real flow:

- Full state machine (idle/checking/available/downloading/installing/ready/
  error) derived from updateStatus + updateProgress + ephemeral local flags.
- Actions call lib/ipc.ts only (dumb component, AGENTS §5): "Install &
  restart" -> installUpdate() (backend downloads+verifies+relaunches),
  "Skip this version" -> skipUpdate(version), "Check now"/"Retry" ->
  checkUpdate(). Install/verify failure surfaces as the error state.
- skip_update only persists lastSkippedVersion + emits settings://changed,
  so `available` reconciles against settings.lastSkippedVersion — skipping
  hides the banner as soon as the settings store advances.
- ready == download-complete/relaunching (no button; install auto-restarts).
- a11y: role status/alert + aria-live, progressbar semantics, release-notes
  <details> disclosure, focus-visible ring; responsive wrap.
- add clearUpdateProgress() store helper (reset on retry/install error).

hydrate already seeds updateStatus from app_hydrate.update (covered by
hydrate.test.ts). 13 new banner tests (renders each state + buttons dispatch
the right ipc calls, ipc mocked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(m6): F51 refresh cached update status + re-emit on skip_update

Skipping a version persisted lastSkippedVersion + emitted settings://changed,
but left UpdaterState.latest_status.skipped=false and never re-emitted
update://status. The FE banner reconciles against settings.lastSkippedVersion so
it hid correctly, but the TRAY reads the cached latest_status and listens only on
update://status — so it kept offering "Update available (vX) — Install" for the
just-skipped version in-session, and clicking it installed the skipped version.

Single-source fix: UpdaterState::mark_skipped flips skipped=true on the cached
status (only when it matches the currently-cached version — stale-skip guard);
updater::apply_skip re-emits update://status. skip_update now calls it after
persisting. Both tray (available && !skipped gate, unchanged) and banner hide
the notice immediately.

Tests: skip flips cached status → update_notice_from_status returns None; a skip
of a different version leaves the current offer visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(m7): finalize bundling — explicit targets, publisher/copyright, macOS entitlements + NSIS

Per-OS bundle targets (macOS app+dmg, Windows nsis, Linux appimage+deb+rpm)
instead of "all" (keeps Windows to NSIS only, no MSI). Add publisher +
copyright, un-notarized macOS with committed unsandboxed entitlements.plist
(v1 Release.entitlements parity: network client/server, files user-selected,
allow-jit; app-sandbox=false) referenced from bundle.macOS. NSIS currentUser
install mode, unsigned. LSUIElement stays in Info.plist (merged by bundler).
Updater artifacts + endpoint + pubkey intact; version 2.0.0 consistent across
tauri.conf.json / Cargo.toml / package.json. macOS overlay titlebar + Win/Linux
native decorations unchanged. No OS code-signing (unfunded — 06 §4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m7): rewrite README for v2 (Rust+Tauri) with install workarounds + v1 upgrade path

Replace the Flutter v1 README with the v2 project readme: Rust+Tauri v2 / Svelte
stack, per-OS install table, prominent unsigned-build workarounds (macOS
Gatekeeper right-click->Open, Windows SmartScreen More info->Run anyway, Linux
chmod +x — verbatim from 06 §8), v1->v2 upgrade path per OS with the KEY
config-preservation + auto-import guarantee (06 §6), build-from-source via
rustup + pnpm + `pnpm tauri dev/build`, and the two-tier signing note (OS
code-signing skipped/unfunded, updates minisign-signed). No secrets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m7): update landing page for v2 (downloads, unsigned workarounds, v1 upgrade)

Add two styled callouts to the install section: an "Upgrading from 1.x" block
(config-preservation guarantee + per-OS steps, 06 §6/§7) and an unsigned
first-launch block (macOS right-click->Open, Windows More info->Run anyway,
Linux chmod +x — 06 §8), with the always-on signed-updates note. Switch the
Manual tab to v2 artifacts (.dmg / NSIS .exe / AppImage+deb+rpm) and the Build
from Source tab from Flutter to rustup + pnpm + `pnpm tauri dev/build`. Bump
JSON-LD softwareVersion to 2.0.0 and refresh the stack/RAM copy in the FAQ.
Styling reuses existing tokens; page stays self-contained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m7): add CUTOVER.md — remaining steps to ship v2.0.0 (DONE vs PENDING)

Precise cutover runbook: CI secrets to set (TAURI_UPDATER_PRIVATE_KEY /
TAURI_UPDATER_KEY_PASSWORD; private key stays at ~/tunnel-pilot-updater-keys/
outside the repo, never committed), RAM methodology + per-OS/cold-start table
to fill in (idle <=30 MB, window hidden after 60s idle), device/display-gated
verifications still owed (F5 mac-notif fallback, F15 wake, signed-update +
tamper rejection, runtime tray/dock/window/single-instance/picker/clipboard,
560px breakpoints), the v1 Flutter bridge release with in-app v2 upgrade notice,
landing-page install-script update, and the actual cutover sequence (Flutter ->
legacy/flutter/ KEEP, relocate Flutter CI, merge, tag v2.0.0 -> tauri-release).
Carries forward the F40/F41/host-key/F50 backlog. No secrets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(m7): mark M7 prep done in PROGRESS + list pending release/cutover items

M6 row -> done; M7 row -> "prep done — release/cutover pending (manual/CI/
device)". Add the M7 section: executable items done (bundling config, README,
landing page, CUTOVER.md) with commit hashes, the PENDING manual/CI/device
steps (CI secrets, RAM measurement, F5/F15/signed-update/runtime/breakpoints,
v1 bridge, cutover sequence), and the carried-forward backlog (F40/F41,
host-key verification, F50). Flag the brief's F46 mislabel. Update Next action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): offer comprehensive russh algo set to fix 'Unknown algorithm'

russh 0.45's Preferred::DEFAULT omits ssh-rsa (SHA-1 RSA host keys),
ecdsa-sha2-nistp384, and the diffie-hellman-group14 KEX, so KEX/host-key
negotiation with an OpenSSH jump host (dbjump.qiscus.io, RSA host key)
failed before auth with Error::UnknownAlgo. build_config now sets
config.preferred to a comprehensive set verified present in russh 0.45's
KEXES/CIPHERS/MACS/key registries (an unregistered name is what raises
UnknownAlgo). Adds a regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): log the exact russh error on a failed connect attempt

The connect-attempt failure branch fed handle_teardown without logging the
underlying russh error, so only the later 'disconnected' line surfaced and
the real KEX/host-key/auth cause was hidden. Add a tracing::warn with the
error at the Some(Err(e)) branch (covers connect AND auth, both surfaced
via bind_connect_auth).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): show window on normal launch, stay hidden on autostart

First launch previously always booted hidden (visible:false + no show).
Now a NORMAL launch shows + focuses the window on boot, while an AUTOSTART
(login) launch stays hidden in the tray. Autostart is detected via a
--minimized flag baked into the OS launch-agent registration (passed to
the autostart plugin init) and checked in argv at setup. Close = hide-to-
tray is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): bring app to front on macOS when showing the window

As an .accessory agent app, window.set_focus() showed the window but left
the previously frontmost app (iTerm) on top when using tray 'Open'. show_window
now flips the activation policy to Regular before showing so the app can
become active and the window comes to the front; dock::refresh then restores
the final policy per showInDock. Covers tray Open, show_window IPC, and the
second-instance focus path (all route through show_window). Needs a display
to fully confirm.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): grant clipboard write-text so Copy SSH command works

clipboard-manager:default grants nothing (permissions = []), so the FE
write_text call was denied by the ACL. Add clipboard-manager:allow-write-text
(write only; read not needed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): grant core:window:allow-start-dragging so titlebar drags

data-tauri-drag-region calls window.startDragging() which is ACL-gated;
the core:window default set omits it, so drag silently no-op'd. FE drag
region was already correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): activation policy follows window visibility so the window shows (BUG A)

The main window never appeared: show_window flipped to Regular, showed the
window, then dock::refresh flipped back to Accessory (default showInDock=false).
On macOS a Regular->Accessory transition while a window is open orders the
window out — it silently vanished.

New model: the dock/activation policy follows WINDOW VISIBILITY, not the
showInDock setting. Window shown => macOS Regular (dock icon + frontmost);
window hidden => macOS Accessory (tray-only). We never switch to Accessory
while the window is visible. showInDock still drives the Windows/Linux taskbar
(no vanish problem there); on macOS a visible window now always carries a dock
icon. Added tracing::info at show/hide so the dev log shows the transitions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): rename tray menu item "Open" to "Settings" (BUG C)

The main window is the settings/config window (v1 parity), so the tray entry
now reads "Settings". Id stays ID_OPEN and the action is unchanged (still shows
the window via window::show_window). No test asserted the label; updated the doc
references only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): bridge log->tracing so russh negotiation is capturable (DIAGNOSTIC B)

russh 0.45 and russh-keys 0.45 emit through the `log` crate, NOT tracing, so
their handshake/KEX/algorithm-negotiation records never reached our subscriber —
the exact algorithm behind "connect failed: Unknown algorithm" was invisible.

Add tracing-log and install LogTracer in init_tracing to forward every `log`
record into tracing. The EnvFilter still honors RUST_LOG; the default is now a
quiet `tunnel_pilot_lib=info` (was `info`, which spammed tao/wry/russh). Run with
RUST_LOG=tunnel_pilot_lib=debug,russh=debug,russh_keys=debug to surface russh's
negotiation and pinpoint the failing algorithm. No Preferred change (capture
first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): drop ext-info/strict-kex markers from preferred KEX

russh can otherwise negotiate the server's kex-strict-s-v00@openssh.com
signaling marker as the actual key exchange and fail with UnknownAlgo
("Unknown algorithm") against OpenSSH 8.9 strict-kex servers. Offer only
real KEX algorithms; russh manages strict-kex/ext-info signaling itself.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): real Tunnel Pilot app/dock icon

Regenerate the full Tauri icon set from the v1 Flutter app logo
(assets/icons/app_icon_1024.png, 1024x1024 full-color) via tauri icon.
Refreshes icon.icns and adds the complete Windows Square*/StoreLogo set.
bundle.icon already references the standard icons/* including icon.icns,
so the macOS dock/app icon now resolves to the real logo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(runtime): bump russh 0.45 -> 0.62 to fix strict-kex negotiation

russh 0.45 cannot complete a transport handshake with OpenSSH 8.9+ jump
hosts that use strict-kex (e.g. dbjump.qiscus.io). Root cause is in 0.45's
negotiation: `negotiation::Client::select` chooses the first entry of the
preferred `kex` list that the server also offers, WITHOUT excluding the
ext-info / strict-kex signaling markers. When the markers are present the
server's `kex-strict-s-v00@openssh.com` marker can be selected as the real
KEX, and `client/kex.rs` then does `KEXES.get(&algo.kex).ok_or(UnknownAlgo)`
- the marker isn't a runnable KEX, so it fails with `Error::UnknownAlgo`.
Dropping the markers instead disables strict-kex signaling (0.45's default
list is where the markers live), which broke negotiation the other way.

russh 0.62 fixes this: `read_kex` filters `KEX_EXTENSION_NAMES` out of both
the local and remote lists before selection, so markers can never be picked
as the KEX, while still advertising them for strict-kex signaling.

API adaptation for 0.62:
- Drop the `russh-keys` crate; keys now live under `russh::keys`.
- `client::Handler`…