Files
firewall/macos/App/ContentView.swift
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

155 lines
5.3 KiB
Swift

import SwiftUI
struct ContentView: View {
@ObservedObject var model: FirewallModel
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
steps
Divider()
footer
}
.frame(width: 540)
.task { await model.refresh() }
.onAppear(perform: recenterIfOffscreen)
.onChange(of: model.completed) { done in
guard done else { return }
Task {
// Let the success show before the window closes; the app stays in the menu bar.
try? await Task.sleep(nanoseconds: 800_000_000)
dismiss()
}
}
}
/// A frame saved on a display that is no longer attached leaves the window unreachable.
private func recenterIfOffscreen() {
DispatchQueue.main.async {
for window in NSApplication.shared.windows where window.title == SetupWindow.title && window.screen == nil {
window.center()
}
}
}
private var header: some View {
HStack(spacing: 14) {
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.")
.font(.callout)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(20)
}
private var steps: some View {
VStack(spacing: 18) {
StepRow(index: 1,
title: "Install the system extension",
note: "macOS will ask you to approve it in System Settings.",
status: model.extensionStep)
StepRow(index: 2,
title: "Turn on screening",
note: "macOS will ask you to allow a network configuration.",
status: model.screeningStep)
StepRow(index: 3,
title: "Trust the local certificate authority",
note: "Minted on this Mac by the extension. The private key never leaves the Secure Enclave.",
status: model.trustStep)
}
.padding(20)
}
private var footer: some View {
HStack {
if model.busy { ProgressView().controlSize(.small) }
if model.completed {
Label("Firewall is set up. It lives in the menu bar now.", systemImage: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.callout)
} else if model.removed {
Label("Firewall is uninstalled. Reboot to finish removing the extension.",
systemImage: "trash.circle.fill")
.foregroundStyle(.secondary)
.font(.callout)
}
Spacer()
if model.awaitingApproval {
Button("Open System Settings") { model.openApprovalSettings() }
}
Button("Uninstall") { Task { await model.uninstall() } }
.disabled(model.busy && !model.awaitingApproval)
Button("Install") { Task { await model.install() } }
.keyboardShortcut(.defaultAction)
.disabled(model.busy || model.completed)
}
.padding(16)
}
}
private struct StepRow: View {
let index: Int
let title: String
let note: String
let status: StepStatus
var body: some View {
HStack(alignment: .top, spacing: 12) {
indicator
.frame(width: 22, height: 22)
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.body.weight(.medium))
Text(note).font(.caption).foregroundStyle(.secondary)
Text(status.detail)
.font(font)
.foregroundStyle(tint)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
}
@ViewBuilder
private var indicator: some View {
switch status {
case .pending:
Text("\(index)")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 20, height: 20)
.overlay(Circle().strokeBorder(.secondary.opacity(0.4)))
case .running, .needsAction:
ProgressView().controlSize(.small)
case .done:
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
case .failed:
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange)
}
}
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
}
}
}