Compare commits

..
Author SHA1 Message Date
Ian f9ed658b11 Start the tray when the install finishes
The MSI's exit dialog starts it from the unelevated UI process, so it runs
as the signed-in user.
2026-10-22 16:20:18 -04:00
Ian 219765e4ff Open the folder of dated service logs from the tray 2026-10-22 16:20:11 -04:00
Ian 8b0d734f02 Show screening status in the Windows notification area
firewall-tray.exe shows a tray icon: whether the service is screening,
paused or not running, with Pause/Resume and Open log. The MSI
starts it at every sign-in and closes it before replacing or removing it.

The icon is the Strix logo, white on a dark taskbar and black on a light
one like the macOS template image, and dimmed when not screening.
tray.png is assets/logo-transparent.png scaled and padded to 32x32:
sips -Z 32 logo-transparent.png --out a.png && sips -p 32 32 a.png --out tray.png
2026-10-22 16:20:08 -04:00
Ian 10fdd0f272 Install machine-wide screening on Windows with an MSI
The MSI installs both binaries, registers and starts the service with a
DACL that lets interactive users start, stop and pause it, restarts it on
failure, and sets NODE_EXTRA_CA_CERTS and UV_SYSTEM_CERTS. Removing it runs
`firewall-service.exe uninstall` once the service is deleted, which clears
the hosts entries and removes the CA, its TPM key and the data folder but
the config.
2026-10-22 15:23:40 -04:00
12 changed files with 48 additions and 274 deletions

No files matched your search

+5 -9
View File
@@ -89,16 +89,12 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
}
info!(method = %req.method(), %url, "forwarding");
// Only an HTTPS registry was downgraded to reach us, so only it is upgraded back and rewritten.
let tls = firewall.is_https_url(&url);
let index = tls && firewall.is_index_url(&url);
let index = firewall.is_index_url(&url);
let (mut parts, body) = req.into_parts();
if tls {
let Ok(uri) = url.parse() else {
return Ok(text(400, "firewall: expected an absolute URL\n"));
};
parts.uri = uri;
}
let Ok(uri) = url.parse() else {
return Ok(text(400, "firewall: expected an absolute URL\n"));
};
parts.uri = uri;
let client = DecompressionLayer::new().into_layer(EasyHttpWebClient::default());
let response = match client.serve(Request::from_parts(parts, body)).await {
+23 -53
View File
@@ -1,11 +1,9 @@
use crate::policies::registries::{host_of, BUILT_IN};
use crate::policies::registries::{Registry, BUILT_IN};
use directories::ProjectDirs;
use rama::net::uri::Uri;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::io::{Error, ErrorKind};
use std::num::{NonZeroU16, NonZeroU64};
use std::path::PathBuf;
pub const DEFAULT_PORT: u16 = 8080;
@@ -13,7 +11,7 @@ pub const DEFAULT_PORT: u16 = 8080;
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FirewallConfig {
pub port: NonZeroU16,
pub port: u16,
/// Allow a package when a filter could not check it.
pub fail_open: bool,
/// 0 turns the age check off.
@@ -22,8 +20,9 @@ pub struct FirewallConfig {
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
pub allow: HashMap<String, Vec<AllowedPackage>>,
pub telemetry: bool,
/// Mirrors laid out like a built-in registry, by its name: `{"npm": ["https://npm.example.com/"]}`.
pub registries: HashMap<String, Vec<String>>,
/// Not describable in the file yet, so this is always the built-in table.
#[serde(skip)]
pub registries: Vec<&'static Registry>,
}
#[derive(Debug, Deserialize)]
@@ -41,19 +40,19 @@ pub struct DenylistSource {
pub base: String,
/// Polled for a new commit, which re-fetches every list in use.
pub revision: String,
pub poll_secs: NonZeroU64,
pub poll_secs: u64,
}
impl Default for FirewallConfig {
fn default() -> Self {
FirewallConfig {
port: NonZeroU16::new(DEFAULT_PORT).unwrap(),
port: DEFAULT_PORT,
fail_open: true,
min_age_hours: 0,
denylist: DenylistSource::default(),
allow: HashMap::new(),
telemetry: true,
registries: HashMap::new(),
registries: BUILT_IN.to_vec(),
}
}
}
@@ -64,7 +63,7 @@ impl Default for DenylistSource {
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(),
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main"
.into(),
poll_secs: NonZeroU64::new(10).unwrap(),
poll_secs: 10,
}
}
}
@@ -88,8 +87,7 @@ impl FirewallConfig {
};
let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
let layer: Value = serde_json::from_str(&text).map_err(invalid)?;
let config = FirewallConfig::deserialize(&layer).map_err(invalid)?;
check(&config).map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))?;
FirewallConfig::deserialize(&layer).map_err(invalid)?;
let Value::Object(layer) = layer else {
unreachable!("a config parses only from an object");
};
@@ -99,7 +97,19 @@ impl FirewallConfig {
}
pub fn local_url(&self, path: &str) -> String {
crate::local_url(self.port.get(), path)
crate::local_url(self.port, path)
}
/// Without building a `Firewall`, which needs a runtime.
pub fn hosts(&self) -> Vec<&'static str> {
let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.registries {
let host = registry.host();
if !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
}
}
@@ -119,43 +129,3 @@ fn merge(base: &mut Map<String, Value>, top: Map<String, Value>) {
}
}
}
/// What serde's types cannot say.
fn check(config: &FirewallConfig) -> Result<(), String> {
// `Duration::hours` panics past this.
if config.min_age_hours > time::Duration::MAX.whole_hours() as u64 {
return Err(format!("{} hours is too long", config.min_age_hours));
}
for eco in config.allow.keys() {
if !BUILT_IN.iter().any(|r| r.eco == eco) {
return Err(format!("unknown ecosystem {eco:?} in allow"));
}
}
for url in [&config.denylist.base, &config.denylist.revision] {
if parse_url(url, &["http", "https"]).is_none() {
return Err(format!("{url:?} is not an http(s) URL"));
}
}
for (name, mirrors) in &config.registries {
if !BUILT_IN.iter().any(|r| r.name() == name) {
return Err(format!("no built-in registry is named {name}"));
}
if let Some(url) = mirrors.iter().find(|url| !is_mirror(url)) {
return Err(format!("{url}: a mirror is an https:// URL ending in /"));
}
}
Ok(())
}
/// No port or userinfo, as `host_of` is what gets intercepted.
fn is_mirror(url: &str) -> bool {
let Some(uri) = parse_url(url, &["https"]) else { return false };
let bare_host = uri.host_str().is_some_and(|host| host == host_of(url));
bare_host && uri.query().is_none() && uri.fragment().is_none() && url.ends_with('/')
}
fn parse_url(url: &str, schemes: &[&str]) -> Option<Uri> {
let uri = Uri::parse(url).ok()?;
let has_host = uri.host_str().is_some_and(|host| !host.is_empty());
(has_host && schemes.contains(&uri.scheme_str()?)).then_some(uri)
}
+6 -32
View File
@@ -9,7 +9,6 @@ use std::collections::HashMap;
pub struct Firewall {
pub config: FirewallConfig,
registries: Vec<&'static Registry>,
patterns: HashMap<&'static str, Vec<Regex>>,
filters: Vec<Box<dyn Filter>>,
}
@@ -22,19 +21,18 @@ impl Default for Firewall {
impl Firewall {
pub fn new(config: FirewallConfig) -> Self {
let registries = with_mirrors(&config.registries);
let mut patterns = HashMap::new();
for registry in &registries {
for registry in &config.registries {
let compiled = registry.patterns.iter().map(|p| Regex::new(p.re).expect(p.re));
patterns.insert(registry.prefix, compiled.collect());
}
let ecosystems = registries.iter().map(|r| r.osv_name);
let ecosystems = config.registries.iter().map(|r| r.osv_name);
let denylist = Denylist::new(config.denylist.clone(), ecosystems);
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
if config.min_age_hours > 0 {
filters.push(Box::new(Age::new(config.min_age_hours)));
}
Firewall { config, registries, patterns, filters }
Firewall { config, patterns, filters }
}
pub fn match_on<'a>(
@@ -42,27 +40,15 @@ impl Firewall {
url: &'a str,
key: fn(&Registry) -> &'static str,
) -> Option<(&'static Registry, &'a str)> {
self.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
self.config.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
}
pub fn hosts(&self) -> Vec<&'static str> {
let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.registries {
let host = registry.host();
if !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
}
pub fn is_https_url(&self, url: &str) -> bool {
let host = host_of(url);
self.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host)
self.config.hosts()
}
pub fn is_index_url(&self, url: &str) -> bool {
self.registries.iter().any(|r| r.index && r.host() == host_of(url))
self.config.registries.iter().any(|r| r.index && r.host() == host_of(url))
}
pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved {
@@ -73,15 +59,3 @@ impl Firewall {
&self.filters
}
}
/// The built-ins, then a copy of one per mirror. Leaked, as a `Registry` is `'static` everywhere.
fn with_mirrors(mirrors: &HashMap<String, Vec<String>>) -> Vec<&'static Registry> {
let mut registries = BUILT_IN.to_vec();
for built_in in BUILT_IN {
for upstream in mirrors.get(built_in.name()).into_iter().flatten() {
let upstream = upstream.clone().leak();
registries.push(Box::leak(Box::new(Registry { upstream, ..(*built_in).clone() })));
}
}
registries
}
+5 -33
View File
@@ -3,7 +3,6 @@
use super::*;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
use std::io::Cursor;
use std::time::Duration;
use tray_icon::{Icon, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::*;
@@ -15,7 +14,6 @@ use windows_service::service::{Service, ServiceAccess, ServiceState};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
const MENU_PAUSE: &str = "pause";
const MENU_RESTART: &str = "restart";
const MENU_LOG: &str = "log";
const MENU_QUIT: &str = "quit";
const LOGO: &[u8] = include_bytes!("tray.png");
@@ -33,7 +31,6 @@ pub fn run() -> Result<(), BoxError> {
&status,
&PredefinedMenuItem::separator(),
&pause,
&MenuItem::with_id(MENU_RESTART, "Restart", true, None),
&MenuItem::with_id(MENU_LOG, "Open logs", true, None),
&PredefinedMenuItem::separator(),
&MenuItem::with_id(MENU_QUIT, "Quit", true, None),
@@ -126,11 +123,6 @@ impl Tray {
|| Ok(open_service()?.resume().map(drop)?)
}
MENU_PAUSE => || Ok(open_service()?.start::<&str>(&[])?),
MENU_RESTART => {
// Off the UI thread, since stopping takes a while.
std::thread::spawn(|| restart().unwrap_or_else(show_error));
return;
}
MENU_LOG => {
let log = HSTRING::from(data_dir().as_path());
let none = PCWSTR::null();
@@ -139,15 +131,13 @@ impl Tray {
}
_ => return,
};
action().unwrap_or_else(show_error);
if let Err(error) = action() {
let text = HSTRING::from(error.to_string());
unsafe { MessageBoxW(None, &text, w!("Strix Firewall"), MB_ICONERROR) };
}
}
}
fn show_error(error: BoxError) {
let text = HSTRING::from(error.to_string());
unsafe { MessageBoxW(None, &text, w!("Strix Firewall"), MB_ICONERROR) };
}
/// `None` when the service is not installed.
fn service_state() -> Option<ServiceState> {
let manager =
@@ -176,28 +166,10 @@ fn status_text(state: Option<ServiceState>) -> &'static str {
fn open_service() -> Result<Service, BoxError> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let access = ServiceAccess::QUERY_STATUS
| ServiceAccess::START
| ServiceAccess::STOP
| ServiceAccess::PAUSE_CONTINUE;
let access = ServiceAccess::START | ServiceAccess::PAUSE_CONTINUE;
Ok(manager.open_service(SERVICE_NAME, access)?)
}
/// Starting again re-reads the config.
fn restart() -> Result<(), BoxError> {
let service = open_service()?;
if service.query_status()?.current_state != ServiceState::Stopped {
service.stop()?;
}
for _ in 0..60 {
if service.query_status()?.current_state == ServiceState::Stopped {
return Ok(service.start::<&str>(&[])?);
}
std::thread::sleep(Duration::from_millis(500));
}
Err("The service did not stop.".into())
}
/// The alpha channel of the RGBA logo, and its width.
fn logo_mask() -> Result<(Vec<u8>, u32), BoxError> {
let mut reader = png::Decoder::new(Cursor::new(LOGO)).read_info()?;
+1 -1
View File
@@ -143,7 +143,7 @@ pub fn describe(advisories: &[&Advisory]) -> String {
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
async fn watch(source: DenylistSource, lists: Arc<Lists>) {
let period = Duration::from_secs(source.poll_secs.get());
let period = Duration::from_secs(source.poll_secs);
let mut seen = revision(&source, None).await;
loop {
tokio::time::sleep(period).await;
+2 -7
View File
@@ -36,7 +36,7 @@ pub struct Pattern {
}
/// Requests to the local server match on `prefix`; intercepted URLs match on `upstream`.
#[derive(Debug, Clone)]
#[derive(Debug)]
pub struct Registry {
pub prefix: &'static str,
pub eco: &'static str,
@@ -52,7 +52,7 @@ pub struct Registry {
}
/// A URL to fetch and a JSON pointer to the timestamp in its reply.
#[derive(Debug, Clone)]
#[derive(Debug)]
pub struct Published {
pub url: &'static str,
pub pointer: &'static str,
@@ -63,11 +63,6 @@ impl Registry {
pub fn host(&self) -> &'static str {
host_of(self.upstream)
}
/// What the config file calls it.
pub fn name(&self) -> &'static str {
self.prefix.trim_matches('/')
}
}
/// The host of an https:// URL, with the path dropped.
+1 -1
View File
@@ -15,7 +15,7 @@ use std::sync::Arc;
use tracing::{info, info_span, warn, Instrument};
pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
let bound = std::net::TcpListener::bind(("127.0.0.1", config.port.get()))?;
let bound = std::net::TcpListener::bind(("127.0.0.1", config.port))?;
bound.set_nonblocking(true)?;
crate::runtime()?.block_on(async move {
let firewall = Arc::new(Firewall::new(config));
+1 -38
View File
@@ -7,7 +7,7 @@ use firewall::{Firewall, FirewallConfig};
fn a_file_only_has_to_set_what_it_changes() {
let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses");
assert!(!config.fail_open);
assert_eq!(config.port.get(), firewall::DEFAULT_PORT);
assert_eq!(config.port, firewall::DEFAULT_PORT);
}
#[test]
@@ -15,28 +15,6 @@ fn a_misspelled_setting_is_an_error() {
assert!(serde_json::from_str::<FirewallConfig>(r#"{"fail-open": false}"#).is_err());
}
#[test]
fn nonsense_values_are_errors() {
let file = std::env::temp_dir().join(format!("firewall-nonsense-{}.json", std::process::id()));
for bad in [
r#"{"allow": {"nmp": [{"name": "left-pad"}]}}"#,
r#"{"denylist": {"poll_secs": 0}}"#,
r#"{"denylist": {"base": "raw.githubusercontent.com/usestrix"}}"#,
r#"{"denylist": {"revision": "https://"}}"#,
r#"{"port": 0}"#,
r#"{"min_age_hours": 18446744073709551615}"#,
r#"{"registries": {"npm": ["http://npm.example.com/"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com:8443/"]}}"#,
r#"{"registries": {"npm": ["https://[email protected]/"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com/?a=/"]}}"#,
] {
std::fs::write(&file, bad).expect("write");
assert!(FirewallConfig::load_from(std::slice::from_ref(&file)).is_err(), "{bad}");
}
std::fs::remove_file(&file).ok();
}
#[test]
fn the_config_lives_under_the_config_directory() {
let path = path().expect("a home directory");
@@ -60,21 +38,6 @@ fn a_later_file_wins_and_allow_lists_add_up() {
assert_eq!(names, ["a", "b"]);
}
#[tokio::test]
async fn a_mirror_is_screened_like_the_registry_it_copies() {
let config = r#"{"registries": {"npm": ["https://npm.example.com/"]}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
assert!(firewall.hosts().contains(&"npm.example.com"));
let url = "https://npm.example.com/left-pad/-/left-pad-1.3.0.tgz";
let (registry, rest) = firewall.match_on(url, |r| r.upstream).expect("the mirror");
assert_eq!(firewall.resolve(registry, rest).pkg.map(|p| p.label()), Some("[email protected]".into()));
let unknown = std::env::temp_dir().join(format!("firewall-mirror-{}.json", std::process::id()));
std::fs::write(&unknown, r#"{"registries": {"nmp": ["https://npm.example.com/"]}}"#).expect("write");
let loaded = FirewallConfig::load_from(std::slice::from_ref(&unknown));
std::fs::remove_file(&unknown).ok();
assert!(loaded.is_err());
}
#[tokio::test]
async fn an_allowed_version_skips_the_filters() {
// An unreachable denylist would otherwise leave the package unchecked.
+1 -22
View File
@@ -1,9 +1,7 @@
use firewall::aliasing::forward::downgrade;
use firewall::http::https_url;
use firewall::{aliasing, Firewall, FirewallConfig};
use firewall::Firewall;
use rama::http::{Body, Request};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
fn request(target: &str, host: Option<&str>) -> Request {
let mut builder = Request::builder().method("GET").uri(target);
@@ -37,25 +35,6 @@ fn requests_always_go_out_over_tls() {
assert_eq!(https_url(&request("/six", None)), None);
}
#[test]
fn a_plain_http_upstream_is_not_upgraded() {
let upstream = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = upstream.local_addr().expect("address");
std::thread::spawn(move || {
let (mut stream, _) = upstream.accept().expect("accept");
let _ = stream.read(&mut [0; 4096]);
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
});
let port = aliasing::spawn(0, FirewallConfig::default()).expect("spawn");
let mut proxy = TcpStream::connect(("127.0.0.1", port)).expect("connect");
let request = format!("GET http://{addr}/x HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
proxy.write_all(request.as_bytes()).expect("write");
let mut response = String::new();
proxy.read_to_string(&mut response).expect("read");
assert!(response.starts_with("HTTP/1.1 200") && response.ends_with("ok"), "{response}");
}
#[tokio::test]
async fn downgrade_touches_known_hosts_in_text_only() {
let text = b"https://pypi.org/x https://example.com/y".to_vec();
-66
View File
@@ -1,66 +0,0 @@
# Platforms
## Requirements
| | Command prefix | Machine-wide |
|---|---|---|
| macOS | 13+, Apple silicon or Intel | Apple silicon, or an Intel Mac with a T2 chip |
| Windows | 10 or 11, x86-64 | A TPM 2.0, and an admin to install |
| Linux | glibc, x86-64 or arm64 | Not supported |
There are no musl, 32-bit or Windows on Arm builds.
`cargo install strix-firewall` installs only `firewall`. Add `--features machine-wide` for the
machine-wide binaries, which otherwise come with the macOS app and the Windows MSI.
## What is screened
npm, pnpm, bun, pip, pip3, pipenv, pdm, uv, cargo, go and gem, downloading from:
| Ecosystem | Hosts |
|---|---|
| npm | registry.npmjs.org |
| PyPI | pypi.org, files.pythonhosted.org |
| crates.io | index.crates.io, static.crates.io, crates.io |
| RubyGems | rubygems.org |
| Go | proxy.golang.org |
Machine-wide screening also covers mirrors listed in the config file, under the registry they
copy (`npm`, `pypi`, `pypi-files`, `cargo/index`, `cargo-dl`, `crates-api`, `gem` or `go`):
`"registries": {"npm": ["https://npm.example.com/"]}`. A mirror must lay out its paths like that
registry. `firewall <manager>` still sends managers to the public registries.
## How machine-wide screening works
Traffic to the hosts above is decrypted with a local CA that can only sign for those hosts. Its
private key never leaves the Secure Enclave (macOS) or the TPM (Windows).
- **macOS:** a system extension, which you approve in System Settings.
- **Windows:** a service, with a hosts-file entry pointing each registry host at it.
- Without a TPM, the service can't start, so the install fails.
- Any signed-in user can pause screening from the notification-area icon, until they resume
it or the service restarts.
- Clients that use an HTTP proxy or their own DNS skip the hosts file and aren't screened.
Nor is WSL.
- Each client has to trust the CA:
| Client | Trusts it through |
|---|---|
| npm, pnpm, bun | `NODE_EXTRA_CA_CERTS`, set to `%ProgramData%\Strix Firewall\ca.pem` |
| uv | `UV_SYSTEM_CERTS`, which uses the Windows certificate store |
| pip, cargo | The Windows certificate store |
| gem, go | Untested |
Tested on Windows 11 and Apple silicon only.
## Changing settings
Machine-wide screening reads an admin-only JSON file when it starts. After editing it, restart
screening. An invalid file stops screening from starting.
| OS | File | Restart from |
|---|---|---|
| macOS | `/Library/Application Support/Firewall/config.json` | The menu bar's Restart |
| Windows | `%ProgramData%\Strix Firewall\config.json` | The notification-area icon's Restart |
The `firewall` command reads `config.json` from the user's config folder instead.
-1
View File
@@ -47,7 +47,6 @@ build() {
echo "==> Building"
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
-destination generic/platform=macOS \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
}
+3 -11
View File
@@ -10,7 +10,7 @@ settings:
"CARGO_PROFILE[config=Debug]": debug
"LIBRARY_SEARCH_PATHS[arch=arm64]": $(SRCROOT)/../core/target/aarch64-apple-darwin/$(CARGO_PROFILE)
"LIBRARY_SEARCH_PATHS[arch=x86_64]": $(SRCROOT)/../core/target/x86_64-apple-darwin/$(CARGO_PROFILE)
ARCHS: arm64 x86_64
ARCHS: arm64
"ONLY_ACTIVE_ARCH[config=Debug]": "YES"
SWIFT_VERSION: "5.0"
DEVELOPMENT_TEAM: ${TEAM}
@@ -127,13 +127,5 @@ targets:
unset CC CXX CFLAGS CXXFLAGS
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
cd "$SRCROOT/../core"
bins=
for arch in $ARCHS; do
case $arch in
arm64|arm64e) triple=aarch64-apple-darwin ;;
x86_64) triple=x86_64-apple-darwin ;;
esac
cargo build --bin firewall-uninstall --target $triple $([ "$CONFIGURATION" = Debug ] || echo --release)
bins="$bins target/$triple/$CARGO_PROFILE/firewall-uninstall"
done
lipo -create $bins -output "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/firewall-uninstall"
cargo build --bin firewall-uninstall --target aarch64-apple-darwin $([ "$CONFIGURATION" = Debug ] || echo --release)
cp "target/aarch64-apple-darwin/$CARGO_PROFILE/firewall-uninstall" "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/"