From 0cfc1cbafc60d79a72e14940d4258ceeeb21f822 Mon Sep 17 00:00:00 2001 From: Aleksandr Bogomiakov Date: Sat, 3 Oct 2026 03:30:47 +0100 Subject: [PATCH] Tray state icon and window picture; firewall rules without the comment match; macOS app bundle The tray icon is dark red with no connection (or a lost exit node), blue when connected and green while an exit node is in use; the windows get the logo and an app id that matches the desktop entry, and the exit icon moves to the bottom left of the tile. Exit-node and broadcast firewall rules fall back to being recognised by their shape on kernels that have no `comment` match, as small router builds do. The macOS archive carries Tsunagi.app for the Homebrew cask, and the daemon plist opens the control socket to the admin group. Co-Authored-By: Claude --- .gitea/workflows/release.yml | 4 + .github/workflows/release.yml | 4 + .../src/overlay/hostrules/linux.rs | 175 +++++++++++++++--- .../src/overlay/hostrules/linux_exit.rs | 103 ++++++++++- crates/tsunagi-tray/src/app.rs | 4 + crates/tsunagi-tray/src/icon.rs | 135 ++++++++++++++ crates/tsunagi-tray/src/main.rs | 5 +- crates/tsunagi-tray/src/tray.rs | 72 ++++--- crates/tsunagi-tray/src/ui.rs | 47 +++-- crates/tsunagi-tray/src/watch.rs | 146 +++++++++++++++ dist/linux/tsunagi-tray.desktop | 1 + dist/macos/README.md | 14 ++ dist/macos/build-app.sh | 63 +++++++ dist/macos/cy.hexor.tsunagi.plist | 9 + 14 files changed, 692 insertions(+), 90 deletions(-) create mode 100644 crates/tsunagi-tray/src/icon.rs create mode 100644 crates/tsunagi-tray/src/watch.rs create mode 100755 dist/macos/build-app.sh diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 611208c..02bbf31 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -173,6 +173,10 @@ jobs: macOS) cp dist/macos/cy.hexor.tsunagi.plist "$stage/" cp dist/macos/README.md "$stage/INSTALL.md" + # The tray as an application bundle, for the Homebrew cask. + bash dist/macos/build-app.sh \ + "target/${{ matrix.target }}/release/tsunagi-tray" \ + "$version" "$stage/Tsunagi.app" ;; Windows) cp dist/windows/README.md "$stage/INSTALL.md" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f177017..89f17c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -175,6 +175,10 @@ jobs: macOS) cp dist/macos/cy.hexor.tsunagi.plist "$stage/" cp dist/macos/README.md "$stage/INSTALL.md" + # The tray as an application bundle, for the Homebrew cask. + bash dist/macos/build-app.sh \ + "target/${{ matrix.target }}/release/tsunagi-tray" \ + "$version" "$stage/Tsunagi.app" ;; Windows) cp dist/windows/README.md "$stage/INSTALL.md" diff --git a/crates/tsunagi-core/src/overlay/hostrules/linux.rs b/crates/tsunagi-core/src/overlay/hostrules/linux.rs index 6e98398..f2bcb3e 100644 --- a/crates/tsunagi-core/src/overlay/hostrules/linux.rs +++ b/crates/tsunagi-core/src/overlay/hostrules/linux.rs @@ -66,24 +66,68 @@ pub(super) enum Ran { pub(super) type Run<'a> = &'a dyn Fn(&[String]) -> Ran; /// The match and target of the rule, without the chain or position. -fn rule_spec(plan: &BroadcastRulesPlan) -> Vec { - [ - "-i", - plan.interface.as_str(), - "-p", - "udp", - "-s", - &plan.range.to_string(), - "-m", - "comment", - "--comment", - &plan.tag(), - "-j", - "ACCEPT", - ] - .into_iter() - .map(str::to_string) - .collect() +/// +/// `comment` false leaves the tag off, for a kernel that has no `comment` +/// match; the rule is then recognised by its shape, see [`tagged_deletions`]. +fn rule_spec(plan: &BroadcastRulesPlan, comment: bool) -> Vec { + let range = plan.range.to_string(); + let tag = plan.tag(); + let mut words = vec!["-i", plan.interface.as_str(), "-p", "udp", "-s", &range]; + if comment { + words.extend(["-m", "comment", "--comment", &tag]); + } + words.extend(["-j", "ACCEPT"]); + words.into_iter().map(str::to_string).collect() +} + +/// Whether the kernel has the `comment` match, which a rule needs to carry +/// its tag. Asked of iptables itself: checking a rule that does not exist +/// fails either way, but only a missing match says so. +pub(super) fn comment_supported(run: Run<'_>) -> bool { + let probe = locked( + [ + "-t", + "filter", + "-C", + "FORWARD", + "-m", + "comment", + "--comment", + "tsunagi-probe", + "-j", + "ACCEPT", + ] + .map(str::to_string), + ); + match run(&probe) { + Ran::Exited { + success: false, + stderr, + .. + } => !stderr.contains("No chain/target/match by that name"), + _ => true, + } +} + +/// `iptables -S` words as option clauses: an option with its value, a negated +/// one with its `!`. It prints the options in its own order, not the one a +/// rule was written in, so rules are compared by their clauses, sorted. +fn clauses(words: &[String]) -> Vec { + let mut out = Vec::new(); + let mut at = 0; + while at < words.len() { + let take = if words[at] == "!" { 3 } else { 2 }; + let end = (at + take).min(words.len()); + out.push(words[at..end].join(" ")); + at = end; + } + out.sort(); + out +} + +/// Whether two rules say the same thing, whatever order they list it in. +pub(super) fn same_rule(a: &[String], b: &[String]) -> bool { + clauses(a) == clauses(b) } /// Waits briefly for the xtables lock instead of failing when another tool @@ -96,19 +140,19 @@ pub(super) fn locked(args: impl IntoIterator) -> Vec { .collect() } -fn check_args(plan: &BroadcastRulesPlan) -> Vec { +fn check_args(plan: &BroadcastRulesPlan, comment: bool) -> Vec { locked( ["-C".to_string(), CHAIN.to_string()] .into_iter() - .chain(rule_spec(plan)), + .chain(rule_spec(plan, comment)), ) } -fn insert_args(plan: &BroadcastRulesPlan) -> Vec { +fn insert_args(plan: &BroadcastRulesPlan, comment: bool) -> Vec { locked( ["-I".to_string(), CHAIN.to_string(), "1".to_string()] .into_iter() - .chain(rule_spec(plan)), + .chain(rule_spec(plan, comment)), ) } @@ -147,8 +191,28 @@ pub(super) fn split_rule_line(line: &str) -> Vec { words } +/// Whether a rule is the one this module writes for some range of the +/// interface the tag belongs to, with no comment on it. +fn is_uncommented_rule(tag: &str, rest: &[String]) -> bool { + let Some(interface) = tag.strip_prefix("tsunagi:") else { + return false; + }; + let Some(range) = rest + .windows(2) + .find(|pair| pair[0] == "-s") + .map(|pair| pair[1].as_str()) + else { + return false; + }; + let ours: Vec = ["-i", interface, "-p", "udp", "-s", range, "-j", "ACCEPT"] + .map(str::to_string) + .to_vec(); + same_rule(&ours, rest) +} + /// The delete commands for every rule in an `iptables -S INPUT` listing that -/// carries exactly this tag. Anything else in the chain is left alone. +/// carries exactly this tag, or, with no `comment` match in the kernel, is +/// exactly the rule written for some range. Anything else is left alone. fn tagged_deletions(listing: &str, tag: &str) -> Vec> { listing .lines() @@ -162,7 +226,8 @@ fn tagged_deletions(listing: &str, tag: &str) -> Vec> { } let tagged = rest .windows(2) - .any(|pair| pair[0] == "--comment" && pair[1] == tag); + .any(|pair| pair[0] == "--comment" && pair[1] == tag) + || is_uncommented_rule(tag, rest); tagged.then(|| { locked( ["-D".to_string(), CHAIN.to_string()] @@ -197,7 +262,8 @@ fn missing(plan: &BroadcastRulesPlan) -> String { /// Makes the firewall hold exactly one rule for this interface, the planned one. fn apply_firewall(run: Run<'_>, plan: &BroadcastRulesPlan) -> RuleOutcome { - let present = match run(&check_args(plan)) { + let comment = comment_supported(run); + let present = match run(&check_args(plan, comment)) { Ran::Missing => return RuleOutcome::Failed(missing(plan)), Ran::Exited { success, .. } => success, }; @@ -227,7 +293,7 @@ fn apply_firewall(run: Run<'_>, plan: &BroadcastRulesPlan) -> RuleOutcome { return RuleOutcome::Failed(failure(&stderr)); } } - match run(&insert_args(plan)) { + match run(&insert_args(plan, comment)) { Ran::Missing => RuleOutcome::Failed(missing(plan)), Ran::Exited { success: true, .. } => RuleOutcome::Applied, Ran::Exited { stderr, .. } => RuleOutcome::Failed(failure(&stderr)), @@ -365,7 +431,7 @@ mod tests { #[test] fn the_rule_is_udp_only_from_the_overlay_range_on_this_interface() { - let spec = words(&rule_spec(&plan())); + let spec = words(&rule_spec(&plan(), true)); assert_eq!( spec, "-i tsun0 -p udp -s 10.13.37.0/24 -m comment --comment tsunagi:tsun0 -j ACCEPT" @@ -373,8 +439,8 @@ mod tests { // Nothing here may widen to other protocols or every source. assert!(spec.contains("-p udp")); assert!(!spec.contains("0.0.0.0/0")); - assert!(words(&insert_args(&plan())).starts_with("-w 5 -I INPUT 1 -i tsun0")); - assert!(words(&check_args(&plan())).starts_with("-w 5 -C INPUT -i tsun0")); + assert!(words(&insert_args(&plan(), true)).starts_with("-w 5 -I INPUT 1 -i tsun0")); + assert!(words(&check_args(&plan(), true)).starts_with("-w 5 -C INPUT -i tsun0")); } #[test] @@ -408,6 +474,49 @@ mod tests { assert!(tagged_deletions("", "tsunagi:tsun0").is_empty()); } + #[test] + fn a_kernel_without_the_comment_match_says_so_in_its_error() { + let answer = |stderr: &'static str| { + move |_: &[String]| Ran::Exited { + success: false, + stdout: String::new(), + stderr: stderr.to_string(), + } + }; + assert!(!comment_supported(&answer( + "iptables: No chain/target/match by that name." + ))); + assert!(comment_supported(&answer( + "iptables: Bad rule (does a matching rule exist in that chain?)." + ))); + assert!(comment_supported(&|_: &[String]| Ran::Missing)); + } + + #[test] + fn a_rule_written_without_a_comment_is_found_by_its_shape() { + // Listed the way iptables prints it: its own option order. + let listing = "\ +-P INPUT ACCEPT +-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -j ACCEPT +-A INPUT -s 10.99.0.0/16 -i tsun0 -p udp -j ACCEPT +-A INPUT -s 10.13.37.0/24 -i tsun1 -p udp -j ACCEPT +-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -j DROP +-A INPUT -s 10.13.37.0/24 -i tsun0 -p tcp -j ACCEPT +"; + let deletions = tagged_deletions(listing, "tsunagi:tsun0"); + assert_eq!(deletions.len(), 2, "{deletions:?}"); + assert!(words(&deletions[0]).ends_with("-s 10.13.37.0/24 -i tsun0 -p udp -j ACCEPT")); + assert!(words(&deletions[1]).contains("10.99.0.0/16")); + } + + #[test] + fn without_a_comment_the_rule_has_none() { + let with = words(&rule_spec(&plan(), true)); + let without = words(&rule_spec(&plan(), false)); + assert!(with.contains("-m comment --comment tsunagi:tsun0")); + assert_eq!(without, "-i tsun0 -p udp -s 10.13.37.0/24 -j ACCEPT"); + } + /// A scripted `iptables`: answers by the leading verb, records every call. struct Fake { calls: RefCell>, @@ -428,6 +537,14 @@ mod tests { } } fn run(&self, args: &[String]) -> Ran { + // The probe for the `comment` match: not part of what is tested. + if args.iter().any(|word| word == "tsunagi-probe") { + return Ran::Exited { + success: false, + stdout: String::new(), + stderr: "Bad rule".into(), + }; + } self.calls.borrow_mut().push(words(args)); if self.missing { return Ran::Missing; diff --git a/crates/tsunagi-core/src/overlay/hostrules/linux_exit.rs b/crates/tsunagi-core/src/overlay/hostrules/linux_exit.rs index f1b1221..9a7ba96 100644 --- a/crates/tsunagi-core/src/overlay/hostrules/linux_exit.rs +++ b/crates/tsunagi-core/src/overlay/hostrules/linux_exit.rs @@ -47,7 +47,10 @@ use crate::state::Ipv4Range; use super::RuleOutcome; use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules, exit_tag, exit_tag_prefix}; -use super::linux::{LinuxHostRules, Ran, Run, failure, locked, run_iptables, split_rule_line}; +use super::linux::{ + LinuxHostRules, Ran, Run, comment_supported, failure, locked, run_iptables, same_rule, + split_rule_line, +}; /// The chains the offering rules live in: `(table, chain)`. const CHAINS: [(&str, &str); 2] = [("nat", "POSTROUTING"), ("filter", "FORWARD")]; @@ -65,14 +68,23 @@ fn owned(words: &[&str]) -> Vec { } /// The three rules of one offered range. -fn offer_rules(interface: &str, range: &Ipv4Range) -> Vec { +/// +/// Each carries a comment naming its interface and range, which is how it is +/// found again. A kernel without the `comment` match (small router builds +/// leave it out) cannot take that, so `comment` false leaves it off and the +/// rules are recognised by their shape instead; see [`tagged_rules`]. +fn offer_rules(interface: &str, range: &Ipv4Range, comment: bool) -> Vec { let range_text = range.to_string(); let tag = exit_tag(interface, range); - let comment = ["-m", "comment", "--comment", tag.as_str()]; + let comment: Vec = if comment { + owned(&["-m", "comment", "--comment", tag.as_str()]) + } else { + Vec::new() + }; let spec = |head: &[&str], tail: &[&str]| -> Vec { owned(head) .into_iter() - .chain(owned(&comment)) + .chain(comment.iter().cloned()) .chain(owned(tail)) .collect() }; @@ -142,9 +154,16 @@ struct Found { rest: Vec, } -/// Every rule in an `iptables -S ` listing carrying a tag that starts -/// with `prefix`. Anything else in the chain is left alone. +/// Every rule in an `iptables -S ` listing that is ours: carrying a +/// tag that starts with `prefix`, or, on a kernel with no `comment` match, +/// being exactly one of the three rules this module writes for some range. +/// Anything else in the chain is left alone. fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec { + // `tsunagi-exit::` + let interface = prefix + .strip_prefix("tsunagi-exit:") + .and_then(|rest| rest.strip_suffix(':')) + .unwrap_or_default(); listing .lines() .filter_map(|line| { @@ -158,7 +177,8 @@ fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec { let tag = rest .windows(2) .find(|pair| pair[0] == "--comment" && pair[1].starts_with(prefix)) - .map(|pair| pair[1].clone())?; + .map(|pair| pair[1].clone()) + .or_else(|| uncommented_tag(interface, chain, rest))?; Some(Found { tag, rest: rest.to_vec(), @@ -167,6 +187,22 @@ fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec { .collect() } +/// The tag a rule would have carried, when it is one of ours written without +/// a comment: its words are those of the rule for the range it names. +fn uncommented_tag(interface: &str, chain: &str, rest: &[String]) -> Option { + if interface.is_empty() { + return None; + } + let range: Ipv4Range = rest + .windows(2) + .find(|pair| pair[0] == "-s" || pair[0] == "-d") + .and_then(|pair| pair[1].parse().ok())?; + offer_rules(interface, &range, false) + .iter() + .any(|rule| rule.chain == chain && same_rule(&rule.spec, rest)) + .then(|| exit_tag(interface, &range)) +} + fn delete_args(table: &str, chain: &str, found: &Found) -> Vec { locked( owned(&["-t", table, "-D", chain]) @@ -236,6 +272,7 @@ fn apply_offer( }; let prefix = exit_tag_prefix(interface); let wanted: Vec = ranges.iter().map(|r| exit_tag(interface, r)).collect(); + let comment = comment_supported(run); // What is there now, per chain. let mut present: Vec<(&str, &str, Vec)> = Vec::new(); @@ -263,7 +300,7 @@ fn apply_offer( ranges .iter() .map(|range| { - let rules = offer_rules(interface, range); + let rules = offer_rules(interface, range, comment); let tag = exit_tag(interface, range); let in_place = rules .iter() @@ -468,7 +505,7 @@ mod tests { #[test] fn the_rules_masquerade_the_range_out_of_every_other_interface_and_forward_it() { - let rules = offer_rules("tsun0", &range()); + let rules = offer_rules("tsun0", &range(), true); assert_eq!(rules.len(), 3); assert_eq!( words(&rules[0].spec), @@ -521,6 +558,8 @@ mod tests { listing: RefCell>, missing: bool, check_ok: bool, + /// A kernel with no `comment` match, as small router builds have. + no_comment: bool, } impl Fake { @@ -530,6 +569,7 @@ mod tests { listing: RefCell::new(Vec::new()), missing: false, check_ok, + no_comment: false, } } @@ -556,6 +596,13 @@ mod tests { stdout, stderr: String::new(), }; + if self.no_comment && line.contains("-m comment") { + return Ran::Exited { + success: false, + stdout: String::new(), + stderr: "iptables: No chain/target/match by that name.".to_string(), + }; + } if line.contains(" -S ") { let listing: Vec = self .listing @@ -586,6 +633,44 @@ mod tests { } } + #[test] + fn a_kernel_without_the_comment_match_gets_the_rules_without_comments() { + let mut fake = Fake::new(false); + fake.no_comment = true; + let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]); + assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]); + let inserted = fake.verbs("-I"); + assert_eq!(inserted.len(), 3, "{inserted:?}"); + assert!( + inserted.iter().all(|call| !call.contains("comment")), + "{inserted:?}" + ); + } + + #[test] + fn rules_written_without_a_comment_are_ours_and_are_removed_with_them() { + // The way iptables prints them: its own option order. + let listing = "\ +-P FORWARD ACCEPT +-A FORWARD -s 10.13.37.0/24 -i tsun0 ! -o tsun0 -j ACCEPT +-A FORWARD -d 10.13.37.0/24 -o tsun0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT +-A FORWARD -i tsun0 -j ACCEPT +-A FORWARD -s 10.13.37.0/24 -i tsun1 ! -o tsun1 -j ACCEPT +"; + let found = tagged_rules(listing, "FORWARD", &exit_tag_prefix("tsun0")); + assert_eq!(found.len(), 2, "{found:?}"); + assert!( + found + .iter() + .all(|rule| rule.tag == exit_tag("tsun0", &range())) + ); + let nat = "-A POSTROUTING -s 10.13.37.0/24 ! -o tsun0 -j MASQUERADE\n"; + assert_eq!( + tagged_rules(nat, "POSTROUTING", &exit_tag_prefix("tsun0")).len(), + 1 + ); + } + #[test] fn a_fresh_host_gets_the_three_rules() { let fake = Fake::new(false); diff --git a/crates/tsunagi-tray/src/app.rs b/crates/tsunagi-tray/src/app.rs index bf1c6e6..9d9875a 100644 --- a/crates/tsunagi-tray/src/app.rs +++ b/crates/tsunagi-tray/src/app.rs @@ -81,6 +81,8 @@ impl App { } let builder = egui::ViewportBuilder::default() .with_title("About tsunagi") + .with_app_id(crate::icon::APP_ID) + .with_icon(crate::icon::window()) .with_inner_size([560.0, 520.0]) .with_min_inner_size([380.0, 280.0]); let socket = self.agent.socket(); @@ -112,6 +114,8 @@ impl App { let viewport_id = egui::ViewportId::from_hash_of(("devices", &id)); let builder = egui::ViewportBuilder::default() .with_title(devices::title(network)) + .with_app_id(crate::icon::APP_ID) + .with_icon(crate::icon::window()) .with_inner_size([940.0, 540.0]) .with_min_inner_size([480.0, 320.0]); let agent = &self.agent; diff --git a/crates/tsunagi-tray/src/icon.rs b/crates/tsunagi-tray/src/icon.rs new file mode 100644 index 0000000..4ae59b2 --- /dev/null +++ b/crates/tsunagi-tray/src/icon.rs @@ -0,0 +1,135 @@ +//! The application's picture: the logo drawn in code, in the colour of the +//! state it stands for, so there is no asset to ship or to go missing. +//! +//! It is the same shape as `dist/linux/tsunagi.svg` (a disc, a ring, a dot). + +use crate::watch::Health; + +/// Normal operation. The colour of the logo itself. +const BLUE: [u8; 3] = [0x2f, 0x80, 0xd8]; +/// An exit node is in use. +const GREEN: [u8; 3] = [0x3c, 0xb0, 0x4a]; +/// Nothing is connected, or the exit node in use is gone. +const DARK_RED: [u8; 3] = [0x9a, 0x1f, 0x1f]; + +/// The window application id, which is also the name of the desktop entry +/// (`tsunagi-tray.desktop`). The compositor finds the icon through it. +pub(crate) const APP_ID: &str = "tsunagi-tray"; + +const SIZE: u32 = 64; +const SAMPLES: u32 = 4; + +/// What the logo looks like at a point of its 128 × 128 design, as +/// premultiplied-free RGBA in `0.0..=1.0`. +fn shade(x: f32, y: f32, color: [u8; 3]) -> [f32; 4] { + let distance = ((x - 64.0).powi(2) + (y - 64.0).powi(2)).sqrt(); + if distance > 56.0 { + return [0.0; 4]; + } + let mut pixel = [ + f32::from(color[0]) / 255.0, + f32::from(color[1]) / 255.0, + f32::from(color[2]) / 255.0, + ]; + let mut white = 0.0; + if (26.5..=33.5).contains(&distance) { + white = 0.9; + } + if distance <= 9.0 { + white = 1.0; + } + for channel in &mut pixel { + *channel = *channel * (1.0 - white) + white; + } + [pixel[0], pixel[1], pixel[2], 1.0] +} + +/// The logo as RGBA pixels, antialiased by supersampling. +fn rgba(color: [u8; 3]) -> Vec { + let scale = 128.0 / SIZE as f32; + let mut out = Vec::with_capacity((SIZE * SIZE * 4) as usize); + for py in 0..SIZE { + for px in 0..SIZE { + let mut sum = [0.0f32; 4]; + for sy in 0..SAMPLES { + for sx in 0..SAMPLES { + let x = (px as f32 + (sx as f32 + 0.5) / SAMPLES as f32) * scale; + let y = (py as f32 + (sy as f32 + 0.5) / SAMPLES as f32) * scale; + let [r, g, b, a] = shade(x, y, color); + sum[0] += r * a; + sum[1] += g * a; + sum[2] += b * a; + sum[3] += a; + } + } + let count = (SAMPLES * SAMPLES) as f32; + let alpha = sum[3] / count; + let unmultiply = if sum[3] > 0.0 { 1.0 / sum[3] } else { 0.0 }; + for channel in &sum[..3] { + out.push((channel * unmultiply * 255.0).round() as u8); + } + out.push((alpha * 255.0).round() as u8); + } + } + out +} + +fn colour_of(health: Health) -> [u8; 3] { + match health { + Health::Disconnected => DARK_RED, + Health::Connected => BLUE, + Health::ExitNode => GREEN, + } +} + +/// The tray icon for a state. +pub(crate) fn tray(health: Health) -> Option { + tray_icon::Icon::from_rgba(rgba(colour_of(health)), SIZE, SIZE).ok() +} + +/// The icon of the application's windows. +pub(crate) fn window() -> eframe::egui::IconData { + eframe::egui::IconData { + rgba: rgba(BLUE), + width: SIZE, + height: SIZE, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pixel(pixels: &[u8], x: u32, y: u32) -> [u8; 4] { + let at = ((y * SIZE + x) * 4) as usize; + [pixels[at], pixels[at + 1], pixels[at + 2], pixels[at + 3]] + } + + #[test] + fn the_logo_is_a_coloured_disc_with_a_white_centre_and_clear_corners() { + let pixels = rgba(GREEN); + assert_eq!(pixels.len(), (SIZE * SIZE * 4) as usize); + assert_eq!(pixel(&pixels, 0, 0)[3], 0, "the corner is transparent"); + let centre = pixel(&pixels, SIZE / 2, SIZE / 2); + assert_eq!( + centre, + [255, 255, 255, 255], + "the dot in the middle is white" + ); + // Halfway between the ring and the edge is the colour itself. + let body = pixel(&pixels, SIZE / 2, SIZE / 2 - (SIZE * 44 / 128)); + assert_eq!(body, [GREEN[0], GREEN[1], GREEN[2], 255]); + } + + #[test] + fn each_state_has_its_own_colour() { + let colours = [ + colour_of(Health::Disconnected), + colour_of(Health::Connected), + colour_of(Health::ExitNode), + ]; + assert_ne!(colours[0], colours[1]); + assert_ne!(colours[1], colours[2]); + assert_ne!(colours[0], colours[2]); + } +} diff --git a/crates/tsunagi-tray/src/main.rs b/crates/tsunagi-tray/src/main.rs index 73a156c..f1c6507 100644 --- a/crates/tsunagi-tray/src/main.rs +++ b/crates/tsunagi-tray/src/main.rs @@ -15,9 +15,11 @@ mod app; mod devices; mod exit; mod format; +mod icon; mod stats; mod tray; mod ui; +mod watch; use eframe::egui; @@ -53,7 +55,8 @@ fn window() -> Result<(), Box> { // A fixed, small window; the whole layout targets this size. viewport: egui::ViewportBuilder::default() .with_title("tsunagi") - .with_app_id("tsunagi") + .with_app_id(icon::APP_ID) + .with_icon(icon::window()) .with_inner_size([360.0, 600.0]) .with_resizable(false), ..Default::default() diff --git a/crates/tsunagi-tray/src/tray.rs b/crates/tsunagi-tray/src/tray.rs index 9c5f0bf..72fe170 100644 --- a/crates/tsunagi-tray/src/tray.rs +++ b/crates/tsunagi-tray/src/tray.rs @@ -8,7 +8,10 @@ use std::process::{Child, Command}; use std::sync::{Arc, Mutex}; use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem}; -use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; +use tray_icon::{TrayIcon, TrayIconBuilder}; + +use crate::icon; +use crate::watch::{self, Health}; /// The window process, when there is one. struct Window { @@ -69,9 +72,9 @@ fn build() -> Result { menu.append(&quit).map_err(|err| err.to_string())?; let icon = TrayIconBuilder::new() - .with_tooltip("tsunagi") + .with_tooltip(Health::Disconnected.label()) .with_menu(Box::new(menu)) - .with_icon(make_icon()?) + .with_icon(icon::tray(Health::Disconnected).ok_or("cannot draw the tray icon")?) .build() .map_err(|err| err.to_string())?; Ok(Built { @@ -101,6 +104,16 @@ fn build_checked() -> Result { }) } +/// Shows a state on the icon, if it is a different one than is showing. +fn show(icon: &TrayIcon, shown: &mut Option, health: Health) { + if *shown == Some(health) { + return; + } + *shown = Some(health); + let _ = icon.set_icon(icon::tray(health)); + let _ = icon.set_tooltip(Some(health.label())); +} + /// Acts on menu choices as the platform's loop delivers them. fn handle_menu(built: &Built, window: Arc) { let (open, quit) = (built.open.clone(), built.quit.clone()); @@ -128,8 +141,22 @@ pub(crate) fn run() -> Result<(), Box> { child: Mutex::new(None), }), ); + + // The icon belongs to this thread, so the answers come to it: the poller + // queues them and the loop here takes them off once a second. + let (tx, rx) = std::sync::mpsc::channel(); + watch::spawn(move |health| { + let _ = tx.send(health); + }); + let icon = built.icon; + let mut shown = None; + gtk::glib::timeout_add_seconds_local(1, move || { + while let Ok(health) = rx.try_recv() { + show(&icon, &mut shown, health); + } + gtk::glib::ControlFlow::Continue + }); gtk::main(); - drop(built.icon); Ok(()) } @@ -148,9 +175,16 @@ pub(crate) fn run() -> Result<(), Box> { struct Pump { window: Arc, icon: Option, + shown: Option, } - impl ApplicationHandler for Pump { + impl ApplicationHandler for Pump { + fn user_event(&mut self, _event_loop: &ActiveEventLoop, health: Health) { + if let Some(icon) = &self.icon { + show(icon, &mut self.shown, health); + } + } + fn resumed(&mut self, _event_loop: &ActiveEventLoop) { if self.icon.is_some() { return; @@ -170,7 +204,7 @@ pub(crate) fn run() -> Result<(), Box> { fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {} } - let mut builder = EventLoop::builder(); + let mut builder = EventLoop::::with_user_event(); // macOS: live in the menu bar with no Dock icon. #[cfg(target_os = "macos")] { @@ -178,33 +212,17 @@ pub(crate) fn run() -> Result<(), Box> { builder.with_activation_policy(ActivationPolicy::Accessory); } let event_loop = builder.build()?; + let proxy = event_loop.create_proxy(); + watch::spawn(move |health| { + let _ = proxy.send_event(health); + }); let mut pump = Pump { window: Arc::new(Window { child: Mutex::new(None), }), icon: None, + shown: None, }; event_loop.run_app(&mut pump)?; Ok(()) } - -/// A small round icon, drawn in code so there is no asset to ship yet. -fn make_icon() -> Result { - const SIZE: u32 = 32; - let centre = (SIZE as f32 - 1.0) / 2.0; - let radius = SIZE as f32 * 0.46; - let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize]; - for y in 0..SIZE { - for x in 0..SIZE { - let (dx, dy) = (x as f32 - centre, y as f32 - centre); - if dx * dx + dy * dy <= radius * radius { - let i = ((y * SIZE + x) * 4) as usize; - rgba[i] = 0x2f; - rgba[i + 1] = 0x80; - rgba[i + 2] = 0xd8; - rgba[i + 3] = 0xff; - } - } - } - Icon::from_rgba(rgba, SIZE, SIZE).map_err(|err| err.to_string()) -} diff --git a/crates/tsunagi-tray/src/ui.rs b/crates/tsunagi-tray/src/ui.rs index 88340c2..bd67dfe 100644 --- a/crates/tsunagi-tray/src/ui.rs +++ b/crates/tsunagi-tray/src/ui.rs @@ -283,32 +283,31 @@ fn draw_network( } }); ui.horizontal(|ui| { - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // In the corner while this device sends its traffic through - // somebody: green while that works, red while it does not. - if let Some(via) = &network.exit.via { - let color = if network.exit.via_online { - exit::GOOD - } else { - exit::LOUD - }; - let response = ui - .add( - egui::Label::new( - egui::RichText::new(icon::SIGN_OUT).size(18.0).color(color), - ) - .sense(egui::Sense::click()), + // In the bottom left corner while this device sends its traffic through + // somebody: green while that works, red while it does not. + if let Some(via) = &network.exit.via { + let color = if network.exit.via_online { + exit::GOOD + } else { + exit::LOUD + }; + let response = ui + .add( + egui::Label::new( + egui::RichText::new(icon::SIGN_OUT).size(18.0).color(color), ) - .on_hover_cursor(egui::CursorIcon::PointingHand) - .on_hover_text(format!( - "exit node: {} — click to stop", - exit::name_of(network, via) - )); - if response.clicked() { - state.exit_confirm = - Some(devices::ExitConfirm::stop_from_tile(network, via)); - } + .sense(egui::Sense::click()), + ) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text(format!( + "exit node: {} — click to stop", + exit::name_of(network, via) + )); + if response.clicked() { + state.exit_confirm = Some(devices::ExitConfirm::stop_from_tile(network, via)); } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button("Show devices").clicked() { state.open_devices.insert(network.network_id.clone()); } diff --git a/crates/tsunagi-tray/src/watch.rs b/crates/tsunagi-tray/src/watch.rs new file mode 100644 index 0000000..43c3e86 --- /dev/null +++ b/crates/tsunagi-tray/src/watch.rs @@ -0,0 +1,146 @@ +//! What the tray icon shows: whether the agent has anybody to talk to, and +//! whether this device sends its traffic through an exit node. +//! +//! The tray process has no window and no event loop of its own to poll from, +//! so a thread asks the agent on an interval and hands each answer to a +//! callback, which puts it where the platform wants it. + +use std::time::Duration; + +use tsunagi::ipc::{self, StatusReport}; + +use crate::agent::resolve_socket; + +/// How often the agent is asked. +const INTERVAL: Duration = Duration::from_secs(3); + +/// What the icon says. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Health { + /// Nobody is connected, or the agent cannot be reached, or the exit node + /// this device uses is gone, which blocks its traffic. + Disconnected, + /// Connected, nothing out of the ordinary. + Connected, + /// This device's traffic leaves through an exit node, and that works. + ExitNode, +} + +impl Health { + /// The state a status report amounts to; `None` is an agent that did not + /// answer. + pub(crate) fn of(report: Option<&StatusReport>) -> Self { + let Some(report) = report else { + return Self::Disconnected; + }; + if !report + .networks + .iter() + .any(|network| !network.peers.is_empty()) + { + return Self::Disconnected; + } + match report + .networks + .iter() + .find(|network| network.exit.via.is_some()) + { + Some(network) if network.exit.via_online => Self::ExitNode, + Some(_) => Self::Disconnected, + None => Self::Connected, + } + } + + /// The tooltip of the icon. + pub(crate) fn label(self) -> &'static str { + match self { + Self::Disconnected => "tsunagi: not connected", + Self::Connected => "tsunagi: connected", + Self::ExitNode => "tsunagi: using an exit node", + } + } +} + +/// Starts asking the agent, and calls `notify` with every answer. +pub(crate) fn spawn(notify: impl Fn(Health) + Send + 'static) { + let spawned = std::thread::Builder::new() + .name("tray-status".into()) + .spawn(move || { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return; + }; + loop { + let report = runtime.block_on(async { + tokio::time::timeout(INTERVAL, ipc::request_status(resolve_socket())) + .await + .ok() + .and_then(Result::ok) + }); + notify(Health::of(report.as_ref())); + std::thread::sleep(INTERVAL); + } + }); + if let Err(err) = spawned { + eprintln!("cannot watch the agent for the tray icon: {err}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tsunagi::ipc::{ExitReport, NetworkReport, PeerReport}; + + fn network(peers: usize, exit: ExitReport) -> NetworkReport { + NetworkReport { + peers: vec![PeerReport::default(); peers], + exit, + ..Default::default() + } + } + + fn report(networks: Vec) -> StatusReport { + StatusReport { + networks, + ..Default::default() + } + } + + #[test] + fn no_agent_or_nobody_connected_is_the_red_state() { + assert_eq!(Health::of(None), Health::Disconnected); + assert_eq!(Health::of(Some(&report(vec![]))), Health::Disconnected); + assert_eq!( + Health::of(Some(&report(vec![network(0, ExitReport::default())]))), + Health::Disconnected + ); + } + + #[test] + fn a_connection_in_any_network_is_the_blue_state() { + let status = report(vec![ + network(0, ExitReport::default()), + network(2, ExitReport::default()), + ]); + assert_eq!(Health::of(Some(&status)), Health::Connected); + } + + #[test] + fn an_exit_node_in_use_is_green_while_it_works_and_red_once_it_is_gone() { + let using = |online| ExitReport { + via: Some("abc".into()), + via_online: online, + ..Default::default() + }; + assert_eq!( + Health::of(Some(&report(vec![network(1, using(true))]))), + Health::ExitNode + ); + assert_eq!( + Health::of(Some(&report(vec![network(1, using(false))]))), + Health::Disconnected + ); + } +} diff --git a/dist/linux/tsunagi-tray.desktop b/dist/linux/tsunagi-tray.desktop index c22e764..b39e9a3 100644 --- a/dist/linux/tsunagi-tray.desktop +++ b/dist/linux/tsunagi-tray.desktop @@ -9,3 +9,4 @@ Terminal=false Categories=Network;Utility; Keywords=mesh;vpn;overlay;network; StartupNotify=false +StartupWMClass=tsunagi-tray diff --git a/dist/macos/README.md b/dist/macos/README.md index 02d7658..5b0c44b 100644 --- a/dist/macos/README.md +++ b/dist/macos/README.md @@ -27,6 +27,20 @@ shell started `sudo`: | disposable cache | `/var/db/tsunagi/cache` | | control socket | `/var/run/tsunagi/agent.sock` | +## Homebrew + +```sh +brew tap house-of-vanity/tap +brew install --cask tsunagi-gui # the tray app, and the agent it needs +# or just the command line agent: +brew install tsunagi +sudo brew services start tsunagi # the agent, as a root daemon at boot +``` + +The cask starts the service for you. The agent runs as root, and its control +socket is open to the `admin` group, so the CLI and the tray work without +`sudo` for an administrator. + ## Native deployment — root LaunchDaemon For an always-on agent that starts at boot, install the bundled diff --git a/dist/macos/build-app.sh b/dist/macos/build-app.sh new file mode 100755 index 0000000..09bb091 --- /dev/null +++ b/dist/macos/build-app.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Wrap the tray GUI in a macOS application bundle, so it can be installed as an +# app (a Homebrew cask, or dragging it to /Applications) instead of being a bare +# executable. +# +# Usage: build-app.sh +# the built tsunagi-tray executable +# the release version (the tag without its leading v) +# the bundle to create; replaced if it exists +# +# It is a menu-bar app: LSUIElement keeps it out of the Dock and the app +# switcher, and the tray starts the window as a second process of the same +# executable, which inherits that. The bundle is signed ad hoc where `codesign` +# exists (the macOS runner), because arm64 will not run unsigned code; it is not +# notarised, so a downloaded copy needs its quarantine flag cleared, which the +# cask does. +set -euo pipefail + +tray="$1" +version="$2" +app="$3" + +# CFBundleShortVersionString is dotted numbers only; 0.1.0-rc.22 becomes 0.1.0. +short="${version%%-*}" + +rm -rf "$app" +mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" +install -m 0755 "$tray" "$app/Contents/MacOS/tsunagi-tray" + +cat > "$app/Contents/Info.plist" < + + + + CFBundleName + Tsunagi + CFBundleDisplayName + Tsunagi + CFBundleIdentifier + cy.hexor.tsunagi.tray + CFBundleExecutable + tsunagi-tray + CFBundlePackageType + APPL + CFBundleVersion + ${short} + CFBundleShortVersionString + ${short} + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSHighResolutionCapable + + + +PLIST + +if command -v codesign >/dev/null 2>&1; then + codesign --force --deep --sign - "$app" +fi + +echo "built $app" diff --git a/dist/macos/cy.hexor.tsunagi.plist b/dist/macos/cy.hexor.tsunagi.plist index 5447078..5e2b927 100644 --- a/dist/macos/cy.hexor.tsunagi.plist +++ b/dist/macos/cy.hexor.tsunagi.plist @@ -36,6 +36,15 @@ ThrottleInterval 10 + + EnvironmentVariables + + TSUNAGI_CONTROL_GROUP + admin + + StandardOutPath