Compare commits

..
Author SHA1 Message Date
Ian bff70e4035 Describe the restart without naming one variable 2026-10-21 18:07:15 -04:00
Ian 452f801921 Offer a restart once setup finishes 2026-10-21 17:25:56 -04:00
ian-at-strix 7c128de056 Give the app the Strix logo as its icon (#34) 2026-10-21 16:45:38 -04:00
ian-at-strix 64e938bf7a Guide the user to the extension approval switch (#33)
* Guide the user to the extension approval switch

* Allow uninstalling while the extension waits for approval

* Show the approval instruction in larger orange text

* Leave opening System Settings to the macOS alert

* Shrink the approval instruction slightly

* Hide the setup log

* Add a button that opens System Settings while approval is pending
2026-10-21 16:44:27 -04:00
ian-at-strix b212bb92ee Uninstall through the bundled CLI (#24)
* Remove the CA from the app, not the extension

* Uninstall through the bundled CLI

* Log uninstall only when something was removed

* Rename the Installer app to StrixFirewall (#28)

* Rename the Installer app to Firewall

* Name the app Strix Firewall

* Name the app StrixFirewall

* Show why the extension failed to start (#31)

* Delete the CA secret without reading it

* Treat a missing extension as already deactivated

* Leave installing the app to the package
2026-10-21 16:01:25 -04:00
14 changed files with 156 additions and 66 deletions

No files matched your search

+2 -4
View File
@@ -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) {
@@ -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
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+3
View File
@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}
+19 -30
View File
@@ -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
}
+62 -23
View File
@@ -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 {
@@ -111,14 +114,34 @@ final class FirewallModel: NSObject, ObservableObject {
completed = true
log("Setup complete")
offerRestart()
}
/// Apps opened before setup, terminals included, only get the environment the env agent sets once reopened.
private func offerRestart() {
let alert = NSAlert()
alert.messageText = "Restart to finish setting up"
alert.informativeText = "Apps that were already open, such as terminals, can't install packages until they're reopened. Restarting reopens them all."
alert.addButton(withTitle: "Restart")
alert.addButton(withTitle: "Later")
NSApp.activate(ignoringOtherApps: true)
guard alert.runModal() == .alertFirstButtonReturn else { return }
let osascript = Process()
osascript.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
osascript.arguments = ["-e", "tell application \"loginwindow\" to «event aevtrest»"]
try? osascript.run()
}
/// 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 +174,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 +191,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 +243,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 +299,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 +426,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 +439,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 +456,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 -9
View File
@@ -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"
+1
View File
@@ -97,6 +97,7 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
info:
path: App/Info.plist
properties: