From 9009404536027140bbdd165676e7e8a56cc47849 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 15 Aug 2026 21:38:16 +0800 Subject: [PATCH] Wire breadcrumbs into the bread event fabric (app id crumbs) The watch daemon publishes bread.crumbs.profile.changed and bread.crumbs.health.changed on real transitions (not every poll tick) and honors bread.command.crumbs.set_profile via the existing state::set_profile path. BreadClient is fail-silent: if breadd is down, breadcrumbs behaves exactly as before. Document the contract in EVENTS.md. CLAUDE.md now points at CONTRIBUTING (single-trunk, no three-branch model), bakery, and EVENTS.md. --- .gitignore | 3 - CLAUDE.md | 50 ++++++++++++ Cargo.lock | 189 +++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + EVENTS.md | 78 ++++++++++++++++++ src/app.rs | 15 +--- src/bread_events.rs | 97 +++++++++++++++++++++++ src/lib.rs | 1 + src/state.rs | 21 ++++- src/watch.rs | 49 +++++++++++- tests/flow_watch.rs | 103 ++++++++++++++++++++++++ 11 files changed, 588 insertions(+), 19 deletions(-) create mode 100644 CLAUDE.md create mode 100644 EVENTS.md create mode 100644 src/bread_events.rs diff --git a/.gitignore b/.gitignore index 1cfb491..3308c88 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,3 @@ desktop.ini # Claude Code local state .claude/ - -# Local hygiene notes (not for commit) -CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..75bbc74 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,50 @@ +# CLAUDE.md — Repo hygiene + +Scope: this file covers *repo hygiene* — branching, remotes, CI, cleanup. It is not project documentation. + +This repo follows the branch/release workflow documented in `CONTRIBUTING.md` +— read and follow it for any git, branch, or release work here (the +single-trunk model, `feature/x`/`fix/x` branch naming, how RC tags work, +etc). Don't improvise a different workflow. The short version: there is one +long-lived branch, `main` — no `dev` or `beta` branch exists. `main` +auto-publishes a dev-track build on every push. "Beta" and "stable" are both +just tags, not branches: push a `vX.Y.Z-rc.N` tag to publish a beta-track +build, push a plain `vX.Y.Z` tag to cut the signed stable release. +"Freezing" for stabilization means pausing pushes to `main`, not moving a +branch. This replaced an earlier three-branch (`dev`/`beta`/`main`) model +after `main` was found to have silently rotted out of sync with `dev`/`beta` +across most repos in this ecosystem — a manual "merge beta into main +monthly" step nobody reliably did across a dozen-plus repos. Collapsing to +one branch removes the class of bug; there's nothing left that can fall out +of sync. + +## Remotes +- `origin` — Forgejo (`git.breadway.dev` via Hestia, SSH) — authoritative. +- `github` — GitHub mirror. Push both when publishing. Agents push `origin` only; the GitHub remote auto-mirrors. + +## Distribution +- Bakery-only. `bakery.toml` is the product manifest; there is no + `packaging/arch/PKGBUILD` in this repo. +- Tracks: `bakery track set {dev,beta,stable}` then `bakery update breadcrumbs` + (or `bakery update --all`). See CONTRIBUTING.md and bread-ecosystem's + `docs/release-channels.md`. + +## Events +- Bread bus contract: `EVENTS.md`. App id is `crumbs`. +- Fail-silent: breadcrumbs behaves the same whether `breadd` is running or + not. Commands (`bread.command.crumbs.*`) are only received while + `breadcrumbs watch` / the user systemd unit is up. + +## CI +- `check.yml` — clippy + `cargo test --release` on `feature/**` and `fix/**`. +- `dev-release.yml` — triggered on push to `main` (dev-track bakery publish). +- `rc-release.yml` — triggered on any `vX.Y.Z-rc.N` tag push (beta track). +- `release.yml` — triggered on any other `v*` tag push (signed stable). + +All CI runs on a self-hosted runner. No build/lint/test CI runs on ordinary +commits or PRs to `main` beyond the dev-track workflow above. + +## Don't +- Don't embed credentials in remote URLs — SSH or a credential helper only. +- Don't invent bread command verbs that have no real breadcrumbs feature + behind them. See EVENTS.md. diff --git a/Cargo.lock b/Cargo.lock index 4751a84..286ee71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,7 +38,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -49,19 +49,48 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "bread-shared" +version = "0.7.0" +source = "git+https://git.breadway.dev/Breadway/bread?tag=v0.7.0#22e34e2cf2202305d7960759dfccb54dc79f948b" +dependencies = [ + "dirs", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "bread-utils" +version = "0.3.1" +source = "git+https://git.breadway.dev/Breadway/bread-ecosystem?tag=v0.7.1#db2fa3c4b4c1e6933bc5cf62a236d05972fdc886" +dependencies = [ + "bread-shared", + "dirs", + "serde", + "serde_json", ] [[package]] name = "breadcrumbs" version = "2.0.1" dependencies = [ + "bread-utils", "clap", "serde", "serde_json", "toml", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.1" @@ -108,12 +137,44 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -148,6 +209,21 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.8.0" @@ -160,6 +236,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -178,6 +260,17 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "serde" version = "1.0.228" @@ -247,6 +340,26 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.8.23" @@ -300,12 +413,27 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -315,6 +443,63 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "winnow" version = "0.7.15" diff --git a/Cargo.toml b/Cargo.toml index 4b2ec2f..bc3371c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } toml = "0.8" serde_json = "1" +bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["bread-client"] } [profile.release] opt-level = "s" diff --git a/EVENTS.md b/EVENTS.md new file mode 100644 index 0000000..b5bf7f4 --- /dev/null +++ b/EVENTS.md @@ -0,0 +1,78 @@ +# breadcrumbs — bread event integration + +breadcrumbs is a standalone Wi-Fi state machine: it works exactly the same +with or without `breadd` running. When breadd *is* present **and** the +`breadcrumbs watch` daemon (or the systemd user service it installs) is up, +breadcrumbs publishes events into the shared bread automation fabric and +listens for a small set of commands. See the parent `bread` repo's +`Documentation.md` — specifically its "Namespaces" and "Integrating a +bread\* app" sections — for the general convention this follows. + +App id: **`crumbs`**. Transport: `bread-utils`'s `bread_client` module +(feature `bread-client`) — the watch process links it directly, since it's +the long-running piece that both emits on real transitions and holds the +command subscription open. + +One-shot CLI invocations (`breadcrumbs status`, `profile set`, `init`, …) +do **not** emit or subscribe on their own. A `breadcrumbs profile set home` +while the watcher is running is picked up on the watcher's next tick +(state is re-read every loop) and *then* published as +`bread.crumbs.profile.changed`. If the watcher is not running, the CLI +still switches the profile on disk — there is just nobody listening for +`bread.command.crumbs.*`, and nobody emitting `bread.crumbs.*`. + +## Events published (`bread.crumbs.*`) + +| Event | Data | When | +|-------|------|------| +| `bread.crumbs.profile.changed` | `{ "from": "", "to": "" }` | The watch loop observes that the persisted active profile is no longer the one it last acted on (CLI `profile set`, `detect --apply`, or `bread.command.crumbs.set_profile`). Not emitted on watcher start just because a profile is already selected. | +| `bread.crumbs.health.changed` | `{ "profile": "", "health": "", "ssid": }` | The watch loop's health classification changes — including the first observation after start, and the forced re-evaluation after a profile change. **Not** emitted on every poll tick while the classification stays the same. | +| `bread.crumbs.set_profile.done` | `{ "profile": "" }` | `bread.command.crumbs.set_profile` persisted the new profile. | +| `bread.crumbs.set_profile.failed` | `{ "error": "" }` | `bread.command.crumbs.set_profile` was received but rejected (unknown profile, missing `profile` field, config unreadable). | + +`health` is the Rust enum variant name, not a prettier label: + +| Variant | Meaning | +|---------|---------| +| `Up` | Adapter present, internet reachable, Tailscale healthy if the profile requires it. | +| `DownNoNet` | No internet. | +| `DownTailscaleManual` | Tailscale required but needs login / isn't installed — cannot auto-fix. | +| `DownTailscaleOther` | Tailscale required and unhealthy for some other (usually auto-recoverable) reason. | +| `NoAdapter` | No Wi-Fi interface. | +| `UnknownProfile` | Persisted profile name is not in the config. | + +`ssid` is the currently-associated SSID, or `null` when there isn't one +(no adapter, not associated, unknown profile). + +## Commands honored (`bread.command.crumbs.*`) + +These are only received while `breadcrumbs watch` / `breadcrumbs.service` +is running. Publishing a command with no subscriber is a silent no-op — +that is the documented bread convention, not a breadcrumbs bug. + +| Verb | Data | Effect | +|------|------|--------| +| `set_profile` | `{ "profile": "" }` | Persist `` via the same `state::set_profile` path the CLI uses. Wakes the watch loop immediately so the new profile is classified (and recovered, if down) on the next tick rather than waiting out the current poll interval. Does **not** run `flow::run` on the command thread — that would race the watch loop. Emits `bread.crumbs.set_profile.done`/`.failed`. | + +### Not implemented: extra verbs + +There is no `pin`, `select`, `scan`, `init`, or other command verb. The +CLI already covers those as synchronous one-shots (`breadcrumbs init`, +`breadcrumbs scan`, …), and breadcrumbs has no "pinned network" concept +to hang a bus verb on. If/when that changes, the corresponding +`bread.command.crumbs.*` verb should be added at the same time, not +stubbed out ahead of it. + +## Fail-safe behavior + +- If breadd isn't installed or isn't running, `emit` is a silent no-op + (`BreadClient::emit` never blocks or errors the caller) and the + command subscription simply never receives anything — breadcrumbs' + actual Wi-Fi / Tailscale / watch functionality is entirely unaffected + either way. +- If breadd restarts, the command subscription reconnects automatically + (`BreadClient::subscribe`'s background thread has its own backoff loop); + no restart of the breadcrumbs watcher is needed. +- If the breadcrumbs watcher is not running, commands are a graceful + no-op at the bus (no subscriber) and no `bread.crumbs.*` events fire. + The CLI still works. diff --git a/src/app.rs b/src/app.rs index 8830a70..5fce6f6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,9 +10,9 @@ use std::time::Duration; use clap::{Parser, Subcommand}; use crate::config::{Config, NetworkDef}; -use crate::state::State; +use crate::state::{self, State}; use crate::util::{self, command_exists, home_dir}; -use crate::{config, flow, nm, notify, watch}; +use crate::{config, flow, nm, watch}; const C_RESET: &str = "\x1b[0m"; const C_BOLD: &str = "\x1b[1m"; @@ -288,16 +288,7 @@ fn cmd_profile(cfg: &mut Config, action: Option) -> Result { - if !cfg.profiles.contains_key(&name) { - let avail: Vec<&String> = cfg.profiles.keys().collect(); - return Err(format!("unknown profile '{name}'. Available: {avail:?}")); - } - let st = State { - profile: name.clone(), - updated: crate::util::timestamp(), - }; - st.save()?; - notify::log(&format!("profile set -> {name}")); + state::set_profile(cfg, &name)?; println!("profile = {C_BOLD}{name}{C_RESET}"); if no_apply { return Ok(0); diff --git a/src/bread_events.rs b/src/bread_events.rs new file mode 100644 index 0000000..e99f603 --- /dev/null +++ b/src/bread_events.rs @@ -0,0 +1,97 @@ +//! `bread.crumbs.*` event integration — optional, non-blocking. See +//! `EVENTS.md` at the repo root for the full contract. breadcrumbs works +//! identically with or without breadd running; every call here is +//! fire-and-forget (`BreadClient::emit` never blocks or errors this +//! process) so a missing or restarting breadd never affects Wi-Fi +//! automation itself. + +use bread_utils::bread_client::{BreadClient, BreadEvent}; + +use crate::config::Config; +use crate::state; + +/// This app's id in bread's sibling-app namespace registry +/// (`bread_shared::apps::KNOWN_APPS`) — events publish as `bread.crumbs.*`, +/// commands arrive on `bread.command.crumbs.*`. +pub const APP_ID: &str = "crumbs"; + +pub fn client() -> BreadClient { + BreadClient::connect(APP_ID) +} + +pub fn emit_profile_changed(client: &BreadClient, from: &str, to: &str) { + client.emit( + "bread.crumbs.profile.changed", + serde_json::json!({ "from": from, "to": to }), + ); +} + +pub fn emit_health_changed(client: &BreadClient, profile: &str, health: &str, ssid: Option<&str>) { + client.emit( + "bread.crumbs.health.changed", + serde_json::json!({ + "profile": profile, + "health": health, + "ssid": ssid, + }), + ); +} + +/// Reacts to `bread.command.crumbs.*` verbs. Only `set_profile` maps to +/// real, existing breadcrumbs functionality today — there is no pin/select +/// (or other) verb because breadcrumbs has no such concept. Unrecognized +/// verbs are ignored, not stubbed as no-ops that pretend to succeed. +/// +/// Returns `true` when a profile was actually persisted, so the watch loop +/// can wake immediately and re-evaluate instead of waiting out the current +/// poll interval. +/// +/// Emits `bread.crumbs.set_profile.done`/`.failed` per the confirmation +/// convention in bread's Documentation.md. +pub fn handle_command(event: &BreadEvent) -> bool { + let Some(verb) = event.event.strip_prefix("bread.command.crumbs.") else { + return false; + }; + match verb { + "set_profile" => handle_set_profile(event), + other => { + crate::notify::log(&format!( + "watch: ignoring unrecognized bread.command.crumbs.{other}" + )); + false + } + } +} + +fn handle_set_profile(event: &BreadEvent) -> bool { + let Some(name) = event.data.get("profile").and_then(|v| v.as_str()) else { + emit_set_profile_failed("missing string \"profile\" in command data"); + return false; + }; + match Config::load().and_then(|cfg| state::set_profile(&cfg, name)) { + Ok(()) => { + crate::notify::log(&format!( + "watch: profile set via bread.command.crumbs.set_profile -> {name}" + )); + client().emit( + "bread.crumbs.set_profile.done", + serde_json::json!({ "profile": name }), + ); + true + } + Err(e) => { + emit_set_profile_failed(&e); + false + } + } +} + +fn emit_set_profile_failed(error: &str) { + crate::notify::log(&format!( + "watch: bread.command.crumbs.set_profile failed: {error}" + )); + client().emit( + "bread.crumbs.set_profile.failed", + serde_json::json!({ "error": error }), + ); +} diff --git a/src/lib.rs b/src/lib.rs index d8f9929..a76610e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,6 +8,7 @@ //! compiled binary. pub mod app; +pub mod bread_events; pub mod config; pub mod flow; pub mod nm; diff --git a/src/state.rs b/src/state.rs index 0366959..c55f570 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,7 +2,7 @@ use std::fs; use serde::{Deserialize, Serialize}; -use crate::config::{state_dir, state_path}; +use crate::config::{state_dir, state_path, Config}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct State { @@ -32,3 +32,22 @@ impl State { fs::write(state_path(), text).map_err(|e| format!("writing state: {e}")) } } + +/// Persist `name` as the active profile if it exists in `cfg`. Shared by the +/// CLI `profile set` path and `bread.command.crumbs.set_profile` so they +/// cannot drift. Does not run [`crate::flow::run`] — the CLI applies +/// afterwards unless `--no-apply`, and the watch daemon picks the new +/// profile up on its next tick. +pub fn set_profile(cfg: &Config, name: &str) -> Result<(), String> { + if !cfg.profiles.contains_key(name) { + let avail: Vec<&String> = cfg.profiles.keys().collect(); + return Err(format!("unknown profile '{name}'. Available: {avail:?}")); + } + State { + profile: name.to_string(), + updated: crate::util::timestamp(), + } + .save()?; + crate::notify::log(&format!("profile set -> {name}")); + Ok(()) +} diff --git a/src/watch.rs b/src/watch.rs index aacfe7b..100dc56 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -4,6 +4,9 @@ use std::sync::mpsc::{self, Receiver}; use std::thread; use std::time::{Duration, Instant}; +use bread_utils::bread_client::BreadClient; + +use crate::bread_events; use crate::config::Config; use crate::flow; use crate::notify::{log, notify, Urgency}; @@ -28,6 +31,21 @@ pub enum Health { UnknownProfile, } +impl Health { + /// Wire name used in `bread.crumbs.health.changed` — the Rust variant + /// as a string, not a prettier label. + pub fn as_str(&self) -> &'static str { + match self { + Health::Up => "Up", + Health::DownNoNet => "DownNoNet", + Health::DownTailscaleManual => "DownTailscaleManual", + Health::DownTailscaleOther => "DownTailscaleOther", + Health::NoAdapter => "NoAdapter", + Health::UnknownProfile => "UnknownProfile", + } + } +} + pub fn classify(cfg: &Config, profile: &str) -> (Health, Option) { // Checked before gather(): a profile missing from config would otherwise // silently fall back to "tailscale not required" and read as healthy off @@ -134,7 +152,22 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { log("watch: started"); let (tx, rx) = mpsc::channel::<()>(); - spawn_nm_monitor(tx); + spawn_nm_monitor(tx.clone()); + + // Long-lived, so this uses BreadClient::subscribe (a persistent + // background thread with its own reconnect/backoff loop). breadd being + // absent or restarting is transparent: the subscription just quietly + // stops delivering commands until it reconnects. A successful + // `set_profile` wakes this loop the same way `nmcli monitor` does, so + // the new profile is applied on the next tick instead of waiting out + // the current poll interval. + let bread = BreadClient::connect(bread_events::APP_ID); + let wake = tx; + let _commands = bread.subscribe("bread.command.crumbs.**", move |event| { + if bread_events::handle_command(&event) { + let _ = wake.send(()); + } + }); let mut profile = State::load(&cfg.settings.default_profile).profile; if run_initial { @@ -179,6 +212,7 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { &format!("{prev_profile} -> {profile}"), Urgency::Low, ); + bread_events::emit_profile_changed(&bread, &prev_profile, &profile); prev_profile = profile.clone(); prev_health = None; // force re-evaluation/recovery for new profile last_flow_at = None; // allow immediate recovery on profile change @@ -186,6 +220,9 @@ pub fn run(mut cfg: Config, run_initial: bool) -> i32 { let (health, ssid) = classify(&cfg, &profile); let transition = prev_health.as_ref() != Some(&health); + if transition { + bread_events::emit_health_changed(&bread, &profile, health.as_str(), ssid.as_deref()); + } match &health { Health::Up => { @@ -300,4 +337,14 @@ mod tests { let earlier = Instant::now(); assert!(debounce_ready(Some(earlier), Duration::from_millis(0))); } + + #[test] + fn health_as_str_is_the_variant_name() { + assert_eq!(Health::Up.as_str(), "Up"); + assert_eq!(Health::DownNoNet.as_str(), "DownNoNet"); + assert_eq!(Health::DownTailscaleManual.as_str(), "DownTailscaleManual"); + assert_eq!(Health::DownTailscaleOther.as_str(), "DownTailscaleOther"); + assert_eq!(Health::NoAdapter.as_str(), "NoAdapter"); + assert_eq!(Health::UnknownProfile.as_str(), "UnknownProfile"); + } } diff --git a/tests/flow_watch.rs b/tests/flow_watch.rs index 110d950..e455764 100644 --- a/tests/flow_watch.rs +++ b/tests/flow_watch.rs @@ -10,8 +10,11 @@ mod common; use std::collections::BTreeMap; +use bread_utils::bread_client::BreadEvent; +use breadcrumbs::bread_events; use breadcrumbs::config::{Config, NetworkDef, Profile, Settings}; use breadcrumbs::flow; +use breadcrumbs::state::{self, State}; use breadcrumbs::util::with_runner; use breadcrumbs::watch::{classify, Health}; @@ -470,3 +473,103 @@ fn classify_reports_up_when_tailscale_healthy() { assert_eq!(health, Health::Up); } + +// --------------------------------------------------------------------- +// bread.command.crumbs.set_profile — persists via the same path as the +// CLI, and must not depend on breadd being reachable (`emit` is +// fire-and-forget). +// --------------------------------------------------------------------- + +fn command_event(event: &str, data: serde_json::Value) -> BreadEvent { + BreadEvent { + event: event.to_string(), + timestamp: 0, + data, + } +} + +#[test] +fn set_profile_command_persists_even_with_no_daemon_reachable() { + let _env = EnvSandbox::new(); + let cfg = Config::load().expect("fresh config"); + state::set_profile(&cfg, "away").unwrap(); + assert_eq!(State::load("away").profile, "away"); + + let acted = bread_events::handle_command(&command_event( + "bread.command.crumbs.set_profile", + serde_json::json!({ "profile": "home" }), + )); + + assert!(acted, "known profile must persist"); + assert_eq!(State::load("away").profile, "home"); +} + +#[test] +fn set_profile_command_rejects_unknown_profile() { + let _env = EnvSandbox::new(); + let cfg = Config::load().expect("fresh config"); + state::set_profile(&cfg, "away").unwrap(); + + let acted = bread_events::handle_command(&command_event( + "bread.command.crumbs.set_profile", + serde_json::json!({ "profile": "bogus" }), + )); + + assert!(!acted); + assert_eq!( + State::load("away").profile, + "away", + "a rejected set_profile must not touch state" + ); +} + +#[test] +fn set_profile_command_rejects_missing_profile_field() { + let _env = EnvSandbox::new(); + let cfg = Config::load().expect("fresh config"); + state::set_profile(&cfg, "away").unwrap(); + + let acted = bread_events::handle_command(&command_event( + "bread.command.crumbs.set_profile", + serde_json::json!({}), + )); + + assert!(!acted); + assert_eq!(State::load("away").profile, "away"); +} + +#[test] +fn handle_command_ignores_unrecognized_verb() { + let _env = EnvSandbox::new(); + let cfg = Config::load().expect("fresh config"); + state::set_profile(&cfg, "away").unwrap(); + + let acted = bread_events::handle_command(&command_event( + "bread.command.crumbs.pin", + serde_json::json!({}), + )); + + assert!(!acted); + assert_eq!( + State::load("away").profile, + "away", + "an unrecognized verb must not touch state" + ); +} + +#[test] +fn handle_command_ignores_events_outside_its_own_command_namespace() { + let _env = EnvSandbox::new(); + let cfg = Config::load().expect("fresh config"); + state::set_profile(&cfg, "away").unwrap(); + + assert!(!bread_events::handle_command(&command_event( + "bread.command.clip.clear", + serde_json::json!({}), + ))); + assert!(!bread_events::handle_command(&command_event( + "bread.crumbs.profile.changed", + serde_json::json!({ "from": "away", "to": "home" }), + ))); + assert_eq!(State::load("away").profile, "away"); +}