Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1281b081c | ||
|
|
f94d3bf211 | ||
|
|
753e21dd19 | ||
|
|
7c128de056 | ||
|
|
64e938bf7a | ||
|
|
b212bb92ee |
No files matched your search
@@ -82,7 +82,7 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
|
||||
};
|
||||
|
||||
let reason = policies::screen(&url, firewall).await;
|
||||
telemetry::screened(firewall, &url, reason.is_some());
|
||||
telemetry::screened(firewall, &url, reason.as_deref());
|
||||
if let Some(reason) = reason {
|
||||
warn!(method = %req.method(), %url, %reason, "blocked");
|
||||
return Ok(blocked(&reason));
|
||||
|
||||
@@ -56,6 +56,7 @@ fn options() -> Command {
|
||||
}
|
||||
|
||||
fn serve() -> ! {
|
||||
telemetry::set_mode("proxy");
|
||||
let config = load();
|
||||
let port = config.port;
|
||||
print_setup(&config);
|
||||
@@ -83,6 +84,7 @@ fn uninstall() -> ! {
|
||||
}
|
||||
|
||||
fn run(invocation: Invocation) -> ! {
|
||||
telemetry::set_mode("wrapper");
|
||||
let command = &invocation.program;
|
||||
let port = match aliasing::spawn(0, load()) {
|
||||
Ok(port) => port,
|
||||
|
||||
@@ -37,7 +37,7 @@ where
|
||||
async fn serve(&self, req: Request) -> Result<Response, Self::Error> {
|
||||
if let Some(url) = https_url(&req) {
|
||||
let reason = policies::screen(&url, &self.firewall).await;
|
||||
telemetry::screened(&self.firewall, &url, reason.is_some());
|
||||
telemetry::screened(&self.firewall, &url, reason.as_deref());
|
||||
if let Some(reason) = reason {
|
||||
warn!(%url, "blocked by firewall: {reason}");
|
||||
return Ok(blocked(&reason));
|
||||
|
||||
@@ -98,6 +98,7 @@ impl TransparentProxyHandlerFactory for Factory {
|
||||
}
|
||||
|
||||
fn handler(exec: Executor) -> Result<Handler, BoxError> {
|
||||
telemetry::set_mode("extension");
|
||||
telemetry::heartbeat();
|
||||
let config = FirewallConfig::load().inspect_err(|_| telemetry::error("config"))?;
|
||||
let firewall = Arc::new(Firewall::new(config));
|
||||
|
||||
@@ -26,10 +26,8 @@ pub fn uninstall() -> Result<(), BoxError> {
|
||||
Err(error) if error.code() == errSecItemNotFound => false,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if system_keychain::load_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?.is_some() {
|
||||
system_keychain::delete_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
|
||||
removed = true;
|
||||
}
|
||||
// Reading a secret's data is limited to the extension, so delete without checking it exists.
|
||||
system_keychain::delete_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
|
||||
EnclaveKey::delete()?;
|
||||
for path in [CA_FILE, ENV_AGENT] {
|
||||
match std::fs::remove_file(path) {
|
||||
|
||||
@@ -72,6 +72,9 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
|
||||
Some((_, Verdict::Deny(reason))) => {
|
||||
Some(format!("{} {reason}", pkg.label()))
|
||||
}
|
||||
Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
|
||||
Some(format!("{} could not be checked by {filter} ({e})", pkg.label()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::http::{blocked, reply, text};
|
||||
use crate::policies::{self, Check, Decision};
|
||||
use crate::{Firewall, FirewallConfig};
|
||||
use crate::{telemetry, Firewall, FirewallConfig};
|
||||
use rama::http::body::util::BodyExt;
|
||||
use rama::http::client::EasyHttpWebClient;
|
||||
use rama::http::header::CONTENT_TYPE;
|
||||
@@ -19,6 +19,7 @@ pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
|
||||
bound.set_nonblocking(true)?;
|
||||
crate::runtime()?.block_on(async move {
|
||||
let firewall = Arc::new(Firewall::new(config));
|
||||
telemetry::heartbeat();
|
||||
let exec = Executor::default();
|
||||
let listener = TcpListener::try_from_std_tcp_listener(bound, exec.clone())?;
|
||||
let server = HttpServer::auto(exec).service(service_fn(move |req: Request| {
|
||||
@@ -44,6 +45,7 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
|
||||
}
|
||||
Decision::Block { eco, pkg, filter, reason } => {
|
||||
let reason = format!("{} {reason}", pkg.label());
|
||||
telemetry::screened_package(eco, &pkg, Some(&reason));
|
||||
warn!(%eco, %filter, "blocked by firewall: {reason}");
|
||||
blocked(&reason)
|
||||
}
|
||||
@@ -51,9 +53,11 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
|
||||
match &check {
|
||||
Check::NoPackage => {}
|
||||
Check::Allowed(pkg) => {
|
||||
telemetry::screened_package(target.eco, pkg, None);
|
||||
info!(eco = %target.eco, pkg = %pkg.label(), "cleared by every filter")
|
||||
}
|
||||
Check::Unverified { pkg, filter, error } => {
|
||||
telemetry::screened_package(target.eco, pkg, None);
|
||||
info!(pkg = %pkg.label(), %filter, %error, "filter could not check, allowing")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::policies::Package;
|
||||
use crate::{Firewall, FirewallConfig};
|
||||
use rama::http::client::EasyHttpWebClient;
|
||||
use rama::http::header::CONTENT_TYPE;
|
||||
use rama::http::service::client::HttpClientExt;
|
||||
use rama::rt::blocking::Runtime;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::sync::{LazyLock, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::OffsetDateTime;
|
||||
@@ -29,29 +30,40 @@ static TELEMETRY: LazyLock<Option<Telemetry>> = LazyLock::new(|| {
|
||||
})
|
||||
});
|
||||
|
||||
/// How the firewall is running: "extension", "wrapper" or "proxy".
|
||||
static MODE: OnceLock<&'static str> = OnceLock::new();
|
||||
|
||||
struct Telemetry {
|
||||
key: &'static str,
|
||||
session: String,
|
||||
events: Mutex<Vec<Value>>,
|
||||
}
|
||||
|
||||
pub fn screened(firewall: &Firewall, url: &str, blocked: bool) {
|
||||
pub fn screened(firewall: &Firewall, url: &str, reason: Option<&str>) {
|
||||
if TELEMETRY.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some((registry, rest)) = firewall.match_on(url, |r| r.upstream) else {
|
||||
return;
|
||||
};
|
||||
let Some(pkg) = firewall.resolve(registry, rest).pkg else {
|
||||
return;
|
||||
};
|
||||
let mut properties = json!({ "eco": registry.eco, "blocked": blocked });
|
||||
if blocked {
|
||||
if let Some(pkg) = firewall.resolve(registry, rest).pkg {
|
||||
screened_package(registry.eco, &pkg, reason);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn screened_package(eco: &str, pkg: &Package, reason: Option<&str>) {
|
||||
let mut properties = json!({ "eco": eco, "blocked": reason.is_some() });
|
||||
if let Some(reason) = reason {
|
||||
properties["package"] = pkg.label().into();
|
||||
properties["reason"] = reason.into();
|
||||
}
|
||||
record("package_screened", properties);
|
||||
}
|
||||
|
||||
pub fn set_mode(mode: &'static str) {
|
||||
MODE.set(mode).ok();
|
||||
}
|
||||
|
||||
pub fn error(kind: &'static str) {
|
||||
record("error", json!({ "kind": kind }));
|
||||
}
|
||||
@@ -81,6 +93,7 @@ pub fn flush() {
|
||||
fn record(event: &str, mut properties: Value) {
|
||||
let Some(telemetry) = &*TELEMETRY else { return };
|
||||
properties["distinct_id"] = telemetry.session.clone().into();
|
||||
properties["mode"] = MODE.get().copied().into();
|
||||
properties["os"] = std::env::consts::OS.into();
|
||||
properties["arch"] = std::env::consts::ARCH.into();
|
||||
properties["$process_person_profile"] = false.into();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use firewall::config::path;
|
||||
use firewall::policies::filters::consult;
|
||||
use firewall::policies::Package;
|
||||
use firewall::policies::{screen, Package};
|
||||
use firewall::{Firewall, FirewallConfig};
|
||||
|
||||
#[test]
|
||||
@@ -31,3 +31,12 @@ async fn an_allowed_version_skips_the_filters() {
|
||||
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
|
||||
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failing_closed_blocks_what_could_not_be_checked() {
|
||||
let config = r#"{"fail_open": false, "denylist": {"base": "http://127.0.0.1:1"}}"#;
|
||||
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
|
||||
let url = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz";
|
||||
let blocked = screen(url, &firewall).await.unwrap_or_default();
|
||||
assert!(blocked.contains("could not be checked"), "{blocked:?}");
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "16x16",
|
||||
"scale": "1x",
|
||||
"filename": "icon_16.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "16x16",
|
||||
"scale": "2x",
|
||||
"filename": "icon_32.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "32x32",
|
||||
"scale": "1x",
|
||||
"filename": "icon_32.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "32x32",
|
||||
"scale": "2x",
|
||||
"filename": "icon_64.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "128x128",
|
||||
"scale": "1x",
|
||||
"filename": "icon_128.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "128x128",
|
||||
"scale": "2x",
|
||||
"filename": "icon_256.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "256x256",
|
||||
"scale": "1x",
|
||||
"filename": "icon_256.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "256x256",
|
||||
"scale": "2x",
|
||||
"filename": "icon_512.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "512x512",
|
||||
"scale": "1x",
|
||||
"filename": "icon_512.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "512x512",
|
||||
"scale": "2x",
|
||||
"filename": "icon_1024.png"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -10,11 +10,9 @@ struct ContentView: View {
|
||||
Divider()
|
||||
steps
|
||||
Divider()
|
||||
transcript
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(width: 540, height: 560)
|
||||
.frame(width: 540)
|
||||
.task { await model.refresh() }
|
||||
.onAppear(perform: recenterIfOffscreen)
|
||||
.onChange(of: model.completed) { done in
|
||||
@@ -38,9 +36,11 @@ struct ContentView: View {
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "shield.lefthalf.filled")
|
||||
.font(.system(size: 34, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
Image(nsImage: NSImage(named: "logo-transparent")!)
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 40)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Firewall").font(.title2.weight(.semibold))
|
||||
Text("Screens package downloads before they reach your machine.")
|
||||
@@ -70,27 +70,6 @@ struct ContentView: View {
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private var transcript: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(model.transcript.indices, id: \.self) { line in
|
||||
Text(model.transcript[line])
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.id(line)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
.onChange(of: model.transcript.count) { count in
|
||||
proxy.scrollTo(count - 1)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
if model.busy { ProgressView().controlSize(.small) }
|
||||
@@ -105,8 +84,11 @@ struct ContentView: View {
|
||||
.font(.callout)
|
||||
}
|
||||
Spacer()
|
||||
if model.awaitingApproval {
|
||||
Button("Open System Settings") { model.openApprovalSettings() }
|
||||
}
|
||||
Button("Uninstall") { Task { await model.uninstall() } }
|
||||
.disabled(model.busy)
|
||||
.disabled(model.busy && !model.awaitingApproval)
|
||||
Button("Install") { Task { await model.install() } }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(model.busy || model.completed)
|
||||
@@ -129,9 +111,10 @@ private struct StepRow: View {
|
||||
Text(title).font(.body.weight(.medium))
|
||||
Text(note).font(.caption).foregroundStyle(.secondary)
|
||||
Text(status.detail)
|
||||
.font(.caption)
|
||||
.font(font)
|
||||
.foregroundStyle(tint)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -146,7 +129,7 @@ private struct StepRow: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(Circle().strokeBorder(.secondary.opacity(0.4)))
|
||||
case .running:
|
||||
case .running, .needsAction:
|
||||
ProgressView().controlSize(.small)
|
||||
case .done:
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
@@ -155,9 +138,15 @@ private struct StepRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var font: Font {
|
||||
if case .needsAction = status { return .system(size: 14) }
|
||||
return .caption
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch status {
|
||||
case .pending, .running: return .secondary
|
||||
case .needsAction: return .orange
|
||||
case .done: return .green
|
||||
case .failed: return .orange
|
||||
}
|
||||
|
||||
@@ -7,13 +7,15 @@ import SystemExtensions
|
||||
enum StepStatus: Equatable {
|
||||
case pending
|
||||
case running(String)
|
||||
/// Waiting on the user to do something outside the app.
|
||||
case needsAction(String)
|
||||
case done(String)
|
||||
case failed(String)
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .pending: return "Not started"
|
||||
case let .running(text), let .done(text), let .failed(text): return text
|
||||
case let .running(text), let .needsAction(text), let .done(text), let .failed(text): return text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,14 +45,15 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
@Published private(set) var extensionStep = StepStatus.pending
|
||||
@Published private(set) var screeningStep = StepStatus.pending
|
||||
@Published private(set) var trustStep = StepStatus.pending
|
||||
@Published private(set) var busy = false
|
||||
@Published private var operations = 0
|
||||
var busy: Bool { operations > 0 }
|
||||
@Published private(set) var awaitingApproval = false
|
||||
@Published private(set) var completed = false
|
||||
@Published private(set) var removed = false
|
||||
@Published private(set) var transcript: [String] = []
|
||||
@Published private(set) var screening = false
|
||||
|
||||
private let logger = Logger(subsystem: FirewallIDs.appBundleID, category: "installer")
|
||||
private var pendingRequest: CheckedContinuation<Void, Error>?
|
||||
private var pendingRequest: (request: OSSystemExtensionRequest, continuation: CheckedContinuation<Void, Error>)?
|
||||
private var statusObserver: NSObjectProtocol?
|
||||
// NE only posts status notifications for connections that are still referenced.
|
||||
@Published private var watched: NETransparentProxyManager?
|
||||
@@ -88,8 +91,8 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
func install() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
removed = false
|
||||
|
||||
guard CompetingExtensions.confirmInstall() else {
|
||||
@@ -115,10 +118,14 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
|
||||
/// After the admin gate, every stage runs even if an earlier one fails, so a partial uninstall never strands the rest.
|
||||
func uninstall() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
completed = false
|
||||
removed = false
|
||||
// Stop waiting on an activation the user never approved; the deactivation below withdraws it.
|
||||
if let pending = pendingRequest {
|
||||
finishRequest(pending.request, .failure(CocoaError(.userCancelled)))
|
||||
}
|
||||
|
||||
// Install needs admin, so uninstall does too: untrusting the CA prompts for it, and a cancel stops here.
|
||||
guard await untrustAuthorities() else {
|
||||
@@ -151,16 +158,16 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
|
||||
/// Restarting the tunnel rebuilds the provider, which is what makes it re-read the config.
|
||||
func restart() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
guard await stage(\.screeningStep, running: "Restarting screening", work: restartScreening) else { return }
|
||||
// A restart can mint a new CA when the screened hosts change.
|
||||
_ = await stage(\.trustStep, running: "Waiting for the extension's CA", work: trustCertificateAuthority)
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
if enabled {
|
||||
_ = await stage(\.screeningStep, running: "Starting screening", work: startScreening)
|
||||
} else {
|
||||
@@ -168,6 +175,14 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func openApprovalSettings() {
|
||||
var pane = "x-apple.systempreferences:com.apple.preference.security"
|
||||
if #available(macOS 15, *) {
|
||||
pane = "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension"
|
||||
}
|
||||
if let url = URL(string: pane) { NSWorkspace.shared.open(url) }
|
||||
}
|
||||
|
||||
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
|
||||
running: String,
|
||||
work: @MainActor () async throws -> String) async -> Bool {
|
||||
@@ -212,6 +227,9 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
forExtensionWithIdentifier: FirewallIDs.extensionBundleID, queue: .main))
|
||||
extensionStep = .pending
|
||||
log("System extension deactivated, macOS finishes removing it on the next reboot")
|
||||
} catch OSSystemExtensionError.extensionNotFound {
|
||||
extensionStep = .pending
|
||||
log("No system extension to deactivate")
|
||||
} catch {
|
||||
extensionStep = .failed(error.localizedDescription)
|
||||
log("Deactivation failed: \(error.localizedDescription)")
|
||||
@@ -265,14 +283,18 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
private func submit(_ request: OSSystemExtensionRequest) async throws {
|
||||
request.delegate = self
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
pendingRequest = continuation
|
||||
pendingRequest = (request, continuation)
|
||||
OSSystemExtensionManager.shared.submitRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishRequest(_ result: Result<Void, Error>) {
|
||||
pendingRequest?.resume(with: result)
|
||||
/// Callbacks for an abandoned request can still arrive, so only the pending one is finished.
|
||||
private func finishRequest(_ request: OSSystemExtensionRequest, _ result: Result<Void, Error>) {
|
||||
guard let pending = pendingRequest, pending.request === request else { return }
|
||||
pendingRequest = nil
|
||||
awaitingApproval = false
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
pending.continuation.resume(with: result)
|
||||
}
|
||||
|
||||
// MARK: - Network extension
|
||||
@@ -388,7 +410,6 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func log(_ line: String) {
|
||||
transcript.append("\(Date().formatted(date: .omitted, time: .standard)) \(line)")
|
||||
logger.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
@@ -402,12 +423,14 @@ extension FirewallModel: OSSystemExtensionRequestDelegate {
|
||||
|
||||
nonisolated func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
|
||||
Task { @MainActor in
|
||||
extensionStep = .running("Approve Firewall in System Settings › General › Login Items & Extensions")
|
||||
log("Waiting for user approval")
|
||||
// Only MDM can pre-approve a system extension, so take them to the switch.
|
||||
if let pane = URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension") {
|
||||
NSWorkspace.shared.open(pane)
|
||||
// macOS shows its own alert that opens System Settings; this says what to do there.
|
||||
var instruction = "In System Settings › Privacy & Security, click Allow for StrixFirewall"
|
||||
if #available(macOS 15, *) {
|
||||
instruction = "In System Settings › General › Login Items & Extensions, click Network Extensions and turn on StrixFirewall"
|
||||
}
|
||||
extensionStep = .needsAction(instruction)
|
||||
awaitingApproval = true
|
||||
log("Waiting for user approval")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,12 +440,12 @@ extension FirewallModel: OSSystemExtensionRequestDelegate {
|
||||
if result == .willCompleteAfterReboot {
|
||||
log("Extension will finish installing after a reboot")
|
||||
}
|
||||
finishRequest(.success(()))
|
||||
finishRequest(request, .success(()))
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {
|
||||
Task { @MainActor in finishRequest(.failure(error)) }
|
||||
Task { @MainActor in finishRequest(request, .failure(error)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Builds and signs StrixFirewall.app, installs it to /Applications, and packages it as build/Firewall.pkg.
|
||||
# Builds and signs StrixFirewall.app and packages it as build/Firewall.pkg.
|
||||
# The package is notarized with the notarytool keychain profile in NOTARY_PROFILE; `build.sh debug`
|
||||
# makes a Debug build and skips notarization.
|
||||
#
|
||||
@@ -39,7 +39,6 @@ PKG_ROOT=build/pkg-root
|
||||
main() {
|
||||
build
|
||||
sign
|
||||
install_app
|
||||
package
|
||||
[ $CONFIGURATION = Debug ] || notarize
|
||||
}
|
||||
@@ -64,13 +63,6 @@ sign() {
|
||||
codesign --verify --deep --strict "$APP"
|
||||
}
|
||||
|
||||
# The extension can only be activated from /Applications.
|
||||
install_app() {
|
||||
echo "==> Installing /Applications/StrixFirewall.app"
|
||||
rm -rf /Applications/StrixFirewall.app
|
||||
cp -R "$APP" /Applications/StrixFirewall.app
|
||||
}
|
||||
|
||||
# Marked non-relocatable so the installer never updates another copy with the same bundle id.
|
||||
package() {
|
||||
echo "==> Packaging build/Firewall.pkg"
|
||||
|
||||
@@ -97,6 +97,7 @@ targets:
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
info:
|
||||
path: App/Info.plist
|
||||
properties:
|
||||
|
||||