Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cfc1cbafc |
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String> {
|
||||
[
|
||||
"-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<String> {
|
||||
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<String> {
|
||||
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<Item = String>) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn check_args(plan: &BroadcastRulesPlan) -> Vec<String> {
|
||||
fn check_args(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
|
||||
locked(
|
||||
["-C".to_string(), CHAIN.to_string()]
|
||||
.into_iter()
|
||||
.chain(rule_spec(plan)),
|
||||
.chain(rule_spec(plan, comment)),
|
||||
)
|
||||
}
|
||||
|
||||
fn insert_args(plan: &BroadcastRulesPlan) -> Vec<String> {
|
||||
fn insert_args(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
|
||||
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<String> {
|
||||
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<String> = ["-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<Vec<String>> {
|
||||
listing
|
||||
.lines()
|
||||
@@ -162,7 +226,8 @@ fn tagged_deletions(listing: &str, tag: &str) -> Vec<Vec<String>> {
|
||||
}
|
||||
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<Vec<String>>,
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
}
|
||||
|
||||
/// The three rules of one offered range.
|
||||
fn offer_rules(interface: &str, range: &Ipv4Range) -> Vec<Rule> {
|
||||
///
|
||||
/// 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<Rule> {
|
||||
let range_text = range.to_string();
|
||||
let tag = exit_tag(interface, range);
|
||||
let comment = ["-m", "comment", "--comment", tag.as_str()];
|
||||
let comment: Vec<String> = if comment {
|
||||
owned(&["-m", "comment", "--comment", tag.as_str()])
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let spec = |head: &[&str], tail: &[&str]| -> Vec<String> {
|
||||
owned(head)
|
||||
.into_iter()
|
||||
.chain(owned(&comment))
|
||||
.chain(comment.iter().cloned())
|
||||
.chain(owned(tail))
|
||||
.collect()
|
||||
};
|
||||
@@ -142,9 +154,16 @@ struct Found {
|
||||
rest: Vec<String>,
|
||||
}
|
||||
|
||||
/// Every rule in an `iptables -S <chain>` listing carrying a tag that starts
|
||||
/// with `prefix`. Anything else in the chain is left alone.
|
||||
/// Every rule in an `iptables -S <chain>` 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<Found> {
|
||||
// `tsunagi-exit:<interface>:`
|
||||
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<Found> {
|
||||
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<Found> {
|
||||
.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<String> {
|
||||
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<String> {
|
||||
locked(
|
||||
owned(&["-t", table, "-D", chain])
|
||||
@@ -236,6 +272,7 @@ fn apply_offer(
|
||||
};
|
||||
let prefix = exit_tag_prefix(interface);
|
||||
let wanted: Vec<String> = 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<Found>)> = 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<Vec<(String, String)>>,
|
||||
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<String> = 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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<u8> {
|
||||
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> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
// 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()
|
||||
|
||||
@@ -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<Built, String> {
|
||||
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<Built, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Shows a state on the icon, if it is a different one than is showing.
|
||||
fn show(icon: &TrayIcon, shown: &mut Option<Health>, 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<Window>) {
|
||||
let (open, quit) = (built.open.clone(), built.quit.clone());
|
||||
@@ -128,8 +141,22 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
struct Pump {
|
||||
window: Arc<Window>,
|
||||
icon: Option<TrayIcon>,
|
||||
shown: Option<Health>,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Pump {
|
||||
impl ApplicationHandler<Health> 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<dyn std::error::Error>> {
|
||||
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
|
||||
}
|
||||
|
||||
let mut builder = EventLoop::builder();
|
||||
let mut builder = EventLoop::<Health>::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<dyn std::error::Error>> {
|
||||
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<Icon, String> {
|
||||
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())
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<NetworkReport>) -> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -9,3 +9,4 @@ Terminal=false
|
||||
Categories=Network;Utility;
|
||||
Keywords=mesh;vpn;overlay;network;
|
||||
StartupNotify=false
|
||||
StartupWMClass=tsunagi-tray
|
||||
Vendored
+14
@@ -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
|
||||
|
||||
+63
@@ -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 <tray_binary> <version> <output.app>
|
||||
# <tray_binary> the built tsunagi-tray executable
|
||||
# <version> the release version (the tag without its leading v)
|
||||
# <output.app> 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" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleName</key>
|
||||
<string>Tsunagi</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Tsunagi</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>cy.hexor.tsunagi.tray</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>tsunagi-tray</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${short}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${short}</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>11.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
if command -v codesign >/dev/null 2>&1; then
|
||||
codesign --force --deep --sign - "$app"
|
||||
fi
|
||||
|
||||
echo "built $app"
|
||||
Vendored
+9
@@ -36,6 +36,15 @@
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>10</integer>
|
||||
|
||||
<!-- The control socket belongs to the admin group, so the CLI and the tray
|
||||
work without sudo for any administrator. A plain account is not in it
|
||||
and cannot manage the agent. -->
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>TSUNAGI_CONTROL_GROUP</key>
|
||||
<string>admin</string>
|
||||
</dict>
|
||||
|
||||
<!-- Logs. launchd creates these as root; rotate them with newsyslog if you
|
||||
keep the daemon around. -->
|
||||
<key>StandardOutPath</key>
|
||||
|
||||
Reference in new issue
Block a user