diff --git a/.project/commands.json b/.project/commands.json
index 05b9c5a..bd5f7f3 100644
--- a/.project/commands.json
+++ b/.project/commands.json
@@ -105,6 +105,9 @@
"dev": {
"description": "Start an interactive development loop"
},
+ "surfaces": {
+ "description": "Copy the shared Settings, menu and About code into this app"
+ },
"release": {
"description": "Create and trigger a versioned release"
},
@@ -131,6 +134,16 @@
],
"cwd": "."
}
+ ],
+ "surfaces": [
+ {
+ "argv": [
+ "python3",
+ ".project/mac_surfaces.py",
+ "sync"
+ ],
+ "cwd": "."
+ }
]
}
},
@@ -144,6 +157,14 @@
".project/tests/test_projectctl.py"
],
"cwd": "."
+ },
+ {
+ "argv": [
+ "python3",
+ ".project/mac_surfaces.py",
+ "check"
+ ],
+ "cwd": "."
}
]
}
diff --git a/.project/mac-surfaces.lock.json b/.project/mac-surfaces.lock.json
new file mode 100644
index 0000000..2a82b84
--- /dev/null
+++ b/.project/mac-surfaces.lock.json
@@ -0,0 +1,20 @@
+{
+ "schema": "mac-surfaces/v1",
+ "version": 8,
+ "destination": "privacycommand/Sources/privacycommand/MacSurfaces",
+ "tool": "d667ee5efbd0ba513ddb48397881d97f259c837269e5f45d46a839fbed56350a",
+ "files": {
+ "SurfaceAbout.swift": "8722b826c6b9e53214a2ab0171ecdddf89907d60f9b17759607f4409b1f3aebc",
+ "SurfaceActivation.swift": "48d77775caf80c48a295631bacefa07d8e774888e009f86c1ac04ebbc2439d5b",
+ "SurfaceBuild.swift": "e02a17f9bde7ef4a003c0abe0f14fdecfabe8eacbfdeb8708d300a59873edbb6",
+ "SurfaceCommands.swift": "70b89a2de563bc1f3f6f835d2c89d5b90a1bce677f24d64dc0cbea722898a9c6",
+ "SurfaceManual.swift": "84df6bbeaa06265fb1c3388ced6784920994ca86bf00b5168458610b97af5cd9",
+ "SurfaceMenuBar.swift": "02776bb94305f25da89783eb2907872816133a16e417072ffb38c861611ddb2f",
+ "SurfaceProfile.swift": "63238bbeb405002770dd72e5150f2a2c74900cbbae7c50c5592af06f74d70cf0",
+ "SurfaceRows.swift": "b01b6864f50ce89b94b562cc8b052e3de5370901ab4f7efdd609e0749f2adbed",
+ "SurfaceSettings.swift": "7503434a92f6a2a94990f110939037b6b7c948a6f3d2a8efbcda4f5ae460203b",
+ "SurfaceShortcuts.swift": "83be738da118854446dc649edb26abcddb162be07f9f6348778e0fa8ebad3c6f",
+ "SurfaceSparkle.swift": "1ab6c60a27f34e0fc0551c28b82ef0c456a82e26803aabd98043dd6890ced436",
+ "SurfaceUpdates.swift": "99fcb3c8db66cba9549697a56f5538bbf0eff46e9a27714c9628286bee6326e3"
+ }
+}
diff --git a/.project/mac_surfaces.py b/.project/mac_surfaces.py
new file mode 100644
index 0000000..296114d
--- /dev/null
+++ b/.project/mac_surfaces.py
@@ -0,0 +1,215 @@
+#!/usr/bin/env python3
+"""Copy the shared macOS surface sources into an app, or verify the copy.
+
+Run from an app as `.project/mac_surfaces.py` (through `just surfaces` and the
+build). When the standards checkout is reachable, a newer reviewed version is
+copied in and shows up as an ordinary diff to commit. When it is not, as in a
+contributor's clone, the committed copy is verified against its lock. CI and
+TestFlight or release builds only ever verify, so they never change sources.
+"""
+import argparse
+import hashlib
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import sys
+
+SCHEMA = "mac-surfaces/v1"
+LOCK = ".project/mac-surfaces.lock.json"
+TOOL = ".project/mac_surfaces.py"
+STANDARD = Path("dev") / "standards" / "mac-surfaces"
+
+
+class SurfaceError(Exception):
+ pass
+
+
+def digest(path):
+ return hashlib.sha256(Path(path).read_bytes()).hexdigest()
+
+
+def source_files(source):
+ return sorted((source / "Sources" / "MacSurfaces").glob("*.swift"))
+
+
+def build_manifest(source, version):
+ return {"schema": SCHEMA, "version": version,
+ "tool": digest(source / "sync.py"),
+ "files": {path.name: digest(path) for path in source_files(source)}}
+
+
+def read_json(path):
+ return json.loads(Path(path).read_text())
+
+
+def write_json(path, value):
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
+ Path(path).write_text(json.dumps(value, indent=2) + "\n")
+
+
+def find_source(root, explicit):
+ """The standards checkout, or None when this machine does not have one."""
+ candidates = []
+ if explicit:
+ candidates.append(Path(explicit))
+ if os.environ.get("MAC_SURFACES_SOURCE"):
+ candidates.append(Path(os.environ["MAC_SURFACES_SOURCE"]))
+ candidates.extend(main_checkout_candidates(root))
+ candidates.append(root.parent / STANDARD)
+ for candidate in candidates:
+ if (candidate / "manifest.json").is_file():
+ return candidate.resolve()
+ if explicit:
+ raise SurfaceError(f"No manifest.json under {explicit}")
+ return None
+
+
+def main_checkout_candidates(root):
+ """The standards folder beside the app's main checkout, which a worktree
+ of the app shares. Empty when the app is not a git checkout or git is not
+ installed; the folder beside the app itself is still tried after this."""
+ try:
+ common = subprocess.run(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
+ cwd=root, capture_output=True, text=True, check=True).stdout.strip()
+ except (OSError, subprocess.CalledProcessError):
+ return []
+ return [Path(common).parent.parent / STANDARD] if common else []
+
+
+def publishing():
+ """True in CI and in TestFlight or release builds, which must not change sources."""
+ channel = os.environ.get("BUILD_CHANNEL", "")
+ return channel in ("ci", "testflight", "release") or bool(os.environ.get("CI") or os.environ.get("GITHUB_ACTIONS"))
+
+
+def verify(root, lock):
+ """Problems with the committed copy, as a list of sentences."""
+ destination = root / lock["destination"]
+ problems = []
+ for name, expected in lock["files"].items():
+ path = destination / name
+ if not path.is_file():
+ problems.append(f"{name} is missing")
+ elif digest(path) != expected:
+ problems.append(f"{name} was edited in this app")
+ if destination.is_dir():
+ for path in sorted(destination.glob("*.swift")):
+ if path.name not in lock["files"]:
+ problems.append(f"{path.name} is not part of the standard")
+ return problems
+
+
+def check(root):
+ lock_path = root / LOCK
+ if not lock_path.is_file():
+ raise SurfaceError("No copy recorded; run sync with --destination first")
+ lock = read_json(lock_path)
+ problems = verify(root, lock)
+ if problems:
+ raise SurfaceError("The shared surface code has drifted: " + "; ".join(problems)
+ + ". Change the standard, then sync; do not edit the copy.")
+ print(f"Mac surfaces: version {lock['version']}, {len(lock['files'])} files verified")
+
+
+def sync(root, destination, explicit_source, force):
+ lock_path = root / LOCK
+ lock = read_json(lock_path) if lock_path.is_file() else None
+ if destination is None:
+ if lock is None:
+ raise SurfaceError("First sync needs --destination, the folder in the app that holds the copy")
+ destination = lock["destination"]
+ if lock is not None and publishing():
+ print("Mac surfaces: CI or publication build; verifying the committed copy without copying")
+ check(root)
+ return
+ source = find_source(root, explicit_source)
+ if source is None:
+ if lock is None:
+ raise SurfaceError("The standards checkout is not on this machine and no copy is recorded")
+ print("Mac surfaces: standards checkout not found; using the committed copy")
+ check(root)
+ return
+
+ manifest = read_json(source / "manifest.json")
+ actual = build_manifest(source, manifest["version"])
+ if actual != manifest:
+ raise SurfaceError("The standards checkout has changes that manifest.json does not record; "
+ "run `python3 sync.py manifest --bump` there")
+
+ if lock is not None:
+ problems = verify(root, dict(lock, destination=lock["destination"]))
+ if problems and not force:
+ raise SurfaceError("The shared surface code has drifted: " + "; ".join(problems)
+ + ". Change the standard, then sync (or pass --force to discard the edits).")
+ if manifest["version"] < lock["version"]:
+ print(f"Mac surfaces: checkout has version {manifest['version']}, older than this app's "
+ f"{lock['version']}; keeping the committed copy")
+ return
+ same = manifest["files"] == lock["files"] and destination == lock["destination"]
+ if same and not problems and manifest["tool"] == lock.get("tool"):
+ print(f"Mac surfaces: version {lock['version']}, up to date")
+ return
+ if manifest["version"] == lock["version"] and manifest["files"] != lock["files"]:
+ raise SurfaceError("The standard changed without a version bump; "
+ "run `python3 sync.py manifest --bump` in the standards checkout")
+
+ target = root / destination
+ target.mkdir(parents=True, exist_ok=True)
+ previous = set(lock["files"]) if lock else set()
+ old_target = root / lock["destination"] if lock else target
+ for name in previous - set(manifest["files"]):
+ (old_target / name).unlink(missing_ok=True)
+ if lock and old_target != target:
+ for name in previous & set(manifest["files"]):
+ (old_target / name).unlink(missing_ok=True)
+ for path in source_files(source):
+ shutil.copyfile(path, target / path.name)
+ tool = root / TOOL
+ if not tool.is_file() or digest(tool) != manifest["tool"]:
+ tool.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(source / "sync.py", tool)
+ write_json(lock_path, {"schema": SCHEMA, "version": manifest["version"], "destination": destination,
+ "tool": manifest["tool"], "files": manifest["files"]})
+ print(f"Mac surfaces: copied version {manifest['version']} ({len(manifest['files'])} files) "
+ f"into {destination}; review and commit the change")
+
+
+def write_manifest(source, bump):
+ path = source / "manifest.json"
+ version = read_json(path)["version"] if path.is_file() else 0
+ current = build_manifest(source, version)
+ if path.is_file() and current == read_json(path):
+ print(f"Mac surfaces: manifest is current at version {version}")
+ return
+ if not bump and path.is_file():
+ raise SurfaceError("Sources changed; rerun with --bump to publish them as a new version")
+ write_json(path, build_manifest(source, version + 1))
+ print(f"Mac surfaces: manifest written, version {version + 1}")
+
+
+def main(argv=None):
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("action", choices=["sync", "check", "manifest"])
+ parser.add_argument("--root", default=".", help="the app checkout (default: current directory)")
+ parser.add_argument("--destination", help="folder in the app for the copy, relative to the root")
+ parser.add_argument("--source", help="the standard's folder; found automatically when omitted")
+ parser.add_argument("--force", action="store_true", help="discard edits made to the copy")
+ parser.add_argument("--bump", action="store_true", help="manifest: publish changed sources as a new version")
+ options = parser.parse_args(argv)
+ try:
+ if options.action == "manifest":
+ write_manifest(Path(options.source or Path(__file__).parent).resolve(), options.bump)
+ elif options.action == "check":
+ check(Path(options.root).resolve())
+ else:
+ sync(Path(options.root).resolve(), options.destination, options.source, options.force)
+ except (SurfaceError, OSError, ValueError, KeyError) as error:
+ print("Mac surfaces failed: " + str(error), file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/README.md b/README.md
index 394309f..c4b4151 100644
--- a/README.md
+++ b/README.md
@@ -47,7 +47,7 @@ All analysis happens on your machine, and the app ships no analytics of its own.
## Get it
-Requires macOS 13 or later.
+Requires macOS 14 or later.
**Homebrew** — the cask lives in [`privacykey/homebrew-tap`](https://github.com/privacykey/homebrew-tap):
diff --git a/docs/PROJECT-COMMANDS.md b/docs/PROJECT-COMMANDS.md
index c0ad916..8785305 100644
--- a/docs/PROJECT-COMMANDS.md
+++ b/docs/PROJECT-COMMANDS.md
@@ -73,6 +73,7 @@ the captured identity. Credentials remain in the configured local/CI stores.
- `release-check` — Validate release eligibility
- `screenshot` — Capture reproducible screenshots
- `dev` — Start an interactive development loop
+- `surfaces` — Copy the shared Settings, menu and About code into this app
- `release` — Create and trigger a versioned release
- `release-local` — Keep the combined local release builder
diff --git a/justfile b/justfile
index 19ecd7f..7512895 100644
--- a/justfile
+++ b/justfile
@@ -111,6 +111,10 @@ screenshot value="":
dev value="":
@python3 .project/projectctl.py dev {{quote(value)}} --target {{quote(target)}} --platform {{quote(platform)}} --environment {{quote(environment)}} --channel {{quote(channel)}} --suite {{quote(suite)}} --package {{quote(package)}} --filter {{quote(filter)}} --state {{quote(state)}} --width {{quote(width)}} --height {{quote(height)}}
+# Copy the shared Settings, menu and About code into this app
+surfaces:
+ @python3 .project/projectctl.py surfaces --target {{quote(target)}} --platform {{quote(platform)}} --environment {{quote(environment)}} --channel {{quote(channel)}} --suite {{quote(suite)}} --package {{quote(package)}} --filter {{quote(filter)}} --state {{quote(state)}} --width {{quote(width)}} --height {{quote(height)}}
+
# Create and trigger a versioned release
release chosen:
@python3 .project/projectctl.py release {{quote(chosen)}} --target {{quote(target)}} --platform {{quote(platform)}} --environment {{quote(environment)}} --channel {{quote(channel)}} --suite {{quote(suite)}} --package {{quote(package)}} --filter {{quote(filter)}} --state {{quote(state)}} --width {{quote(width)}} --height {{quote(height)}}
diff --git a/privacycommand/Package.swift b/privacycommand/Package.swift
index f5bd447..db9b1f5 100644
--- a/privacycommand/Package.swift
+++ b/privacycommand/Package.swift
@@ -4,7 +4,7 @@ import PackageDescription
let package = Package(
name: "privacycommand",
platforms: [
- .macOS(.v13)
+ .macOS(.v14)
],
products: [
.library(name: "privacycommandCore", targets: ["privacycommandCore"]),
diff --git a/privacycommand/Resources/Info.plist b/privacycommand/Resources/Info.plist
index 3f37788..6ead983 100644
--- a/privacycommand/Resources/Info.plist
+++ b/privacycommand/Resources/Info.plist
@@ -27,7 +27,7 @@
LSApplicationCategoryType
public.app-category.developer-tools
LSMinimumSystemVersion
- 13.0
+ 14.0
LSUIElement
NSAppleEventsUsageDescription
diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceAbout.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceAbout.swift
new file mode 100644
index 0000000..8fa8a59
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceAbout.swift
@@ -0,0 +1,226 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+/// The app's one About window. Add it to the App body; `SurfaceCommands` and
+/// `SurfaceAboutButton` both open it.
+public struct SurfaceAboutWindow: Scene {
+ public static let id = "surface-about"
+
+ private let app: SurfaceApp
+ private let help: SurfaceHelp
+ private let build: SurfaceBuild
+
+ public init(app: SurfaceApp, help: SurfaceHelp, build: SurfaceBuild = .current) {
+ self.app = app
+ self.help = help
+ self.build = build
+ }
+
+ public var body: some Scene {
+ let title = "About \(app.name)"
+ return Window(title, id: Self.id) {
+ SurfaceAboutView(app: app, help: help, build: build)
+ .surfaceNotRestored()
+ }
+ .windowResizability(.contentSize)
+ .defaultPosition(.center)
+ .commandsRemoved()
+ }
+}
+
+/// Opens the About window. In a tab layout this is the last row of General; in
+/// a sidebar layout it is pinned under the category list.
+public struct SurfaceAboutButton: View {
+ @Environment(\.openWindow) private var openWindow
+ private let app: SurfaceApp
+
+ public init(app: SurfaceApp) {
+ self.app = app
+ }
+
+ public var body: some View {
+ Button("About \(app.name)") {
+ NSApplication.shared.activate()
+ openWindow(id: SurfaceAboutWindow.id)
+ }
+ }
+}
+
+public struct SurfaceAboutView: View {
+ @Environment(\.openWindow) private var openWindow
+ private let app: SurfaceApp
+ private let help: SurfaceHelp
+ private let build: SurfaceBuild
+
+ @State private var showsAcknowledgements = false
+ @State private var copied = false
+
+ public init(app: SurfaceApp, help: SurfaceHelp, build: SurfaceBuild = .current) {
+ self.app = app
+ self.help = help
+ self.build = build
+ }
+
+ public var body: some View {
+ VStack(spacing: 14) {
+ Image(nsImage: NSApplication.shared.applicationIconImage)
+ .resizable()
+ .frame(width: 96, height: 96)
+ .accessibilityHidden(true)
+ wordmark
+ version
+ Text(app.summary)
+ .multilineTextAlignment(.center)
+ .fixedSize(horizontal: false, vertical: true)
+ if app.showsCapabilityGrid {
+ Divider()
+ capabilities
+ }
+ Divider()
+ links
+ HStack(spacing: 18) {
+ Button("\(app.name) Help") { help.manual(openWindow) }
+ Button("Keyboard Shortcuts") { help.shortcuts(openWindow) }
+ }
+ .buttonStyle(.link)
+ footer
+ }
+ .padding(.horizontal, 28)
+ .padding(.top, 22)
+ .padding(.bottom, 18)
+ .frame(width: 440)
+ .sheet(isPresented: $showsAcknowledgements) {
+ SurfaceAcknowledgementsView(items: app.acknowledgements)
+ }
+ }
+
+ private var wordmark: some View {
+ HStack(spacing: 0) {
+ Text(app.wordmark.lead)
+ Text(app.wordmark.accent).foregroundStyle(app.accent)
+ }
+ .font(.system(size: 34, weight: .semibold))
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(app.name)
+ }
+
+ private var version: some View {
+ VStack(spacing: 4) {
+ Text(build.versionLine)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ if let provenance = build.provenanceLine {
+ Button {
+ let board = NSPasteboard.general
+ board.clearContents()
+ board.setString("\(build.versionLine)\n\(provenance)", forType: .string)
+ copied = true
+ } label: {
+ HStack(alignment: .firstTextBaseline, spacing: 5) {
+ Text(provenance)
+ .font(.caption.monospaced())
+ .multilineTextAlignment(.center)
+ .lineLimit(3)
+ .fixedSize(horizontal: false, vertical: true)
+ Image(systemName: copied ? "checkmark" : "doc.on.doc").font(.caption)
+ }
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .help("Copy build details")
+ }
+ }
+ }
+
+ private var capabilities: some View {
+ let columns = [GridItem(.flexible(), alignment: .topLeading), GridItem(.flexible(), alignment: .topLeading)]
+ return LazyVGrid(columns: columns, alignment: .leading, spacing: 10) {
+ ForEach(app.capabilities) { capability in
+ Label {
+ VStack(alignment: .leading, spacing: 1) {
+ Text(capability.title).font(.callout.weight(.semibold))
+ Text(capability.detail)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ } icon: {
+ Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var links: some View {
+ if app.links.contains(where: \.showsURL) {
+ VStack(spacing: 8) {
+ ForEach(app.links) { link in
+ VStack(spacing: 2) {
+ Link(link.title, destination: link.url)
+ if link.showsURL {
+ Text(link.url.absoluteString)
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ }
+ }
+ }
+ }
+ } else {
+ HStack(spacing: 18) {
+ ForEach(app.links) { link in
+ Link(link.title, destination: link.url)
+ }
+ }
+ }
+ }
+
+ private var footer: some View {
+ VStack(spacing: 6) {
+ if !app.acknowledgements.isEmpty {
+ Button("Acknowledgements") { showsAcknowledgements = true }
+ .buttonStyle(.link)
+ }
+ Text(app.legalLine(copyright: build.copyright,
+ year: Calendar.current.component(.year, from: .now)))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
+
+struct SurfaceAcknowledgementsView: View {
+ @Environment(\.dismiss) private var dismiss
+ let items: [SurfaceAcknowledgement]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Acknowledgements").font(.headline)
+ ScrollView {
+ VStack(alignment: .leading, spacing: 14) {
+ ForEach(items) { item in
+ VStack(alignment: .leading, spacing: 3) {
+ Text(item.name).font(.callout.weight(.semibold))
+ Text(item.licence).font(.caption).foregroundStyle(.secondary)
+ Text(item.text)
+ .font(.caption.monospaced())
+ .textSelection(.enabled)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ HStack {
+ Spacer()
+ Button("Done") { dismiss() }.keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(20)
+ .frame(width: 440, height: 420)
+ }
+}
+#endif
diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceActivation.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceActivation.swift
new file mode 100644
index 0000000..a5d7bb3
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceActivation.swift
@@ -0,0 +1,99 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+/// Keeps a menu bar utility in the Dock while any of its windows is open, so
+/// the main menu and ⌘, work, and returns it to the menu bar when the last one
+/// closes. Every titled window counts: Settings, About and the manual included.
+@MainActor
+public final class SurfaceActivation {
+ public static let shared = SurfaceActivation()
+
+ private var observers: [NSObjectProtocol] = []
+
+ private init() {}
+
+ /// Call once at launch in a menu bar utility. Does nothing when repeated.
+ /// `initialPolicy` is applied at once; an app without `LSUIElement`, as
+ /// when run from `swift run`, passes `.accessory` to start in the menu bar.
+ public func start(initialPolicy: NSApplication.ActivationPolicy? = nil) {
+ guard observers.isEmpty else { return }
+ if let initialPolicy, NSApplication.shared.activationPolicy() != initialPolicy {
+ NSApplication.shared.setActivationPolicy(initialPolicy)
+ }
+ let center = NotificationCenter.default
+ observers.append(center.addObserver(forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main) { _ in
+ MainActor.assumeIsolated { SurfaceActivation.shared.update(closing: nil) }
+ })
+ observers.append(center.addObserver(forName: NSWindow.willCloseNotification, object: nil, queue: .main) { note in
+ let closing = (note.object as? NSWindow).map(ObjectIdentifier.init)
+ MainActor.assumeIsolated { SurfaceActivation.shared.update(closing: closing) }
+ })
+ }
+
+ /// A titled window at the normal level. Popovers, menus and the status
+ /// item's own window do not count.
+ public nonisolated static func countsAsWindow(isVisible: Bool, isTitled: Bool, isPanel: Bool, isNormalLevel: Bool) -> Bool {
+ isVisible && isTitled && !isPanel && isNormalLevel
+ }
+
+ private func update(closing: ObjectIdentifier?) {
+ let hasWindow = NSApplication.shared.windows.contains { window in
+ ObjectIdentifier(window) != closing && Self.countsAsWindow(
+ isVisible: window.isVisible,
+ isTitled: window.styleMask.contains(.titled),
+ isPanel: window is NSPanel,
+ isNormalLevel: window.level == .normal
+ )
+ }
+ let wanted: NSApplication.ActivationPolicy = hasWindow ? .regular : .accessory
+ if NSApplication.shared.activationPolicy() != wanted {
+ NSApplication.shared.setActivationPolicy(wanted)
+ if hasWindow { NSApplication.shared.activate() }
+ }
+ }
+}
+
+/// Marks the hosting window as not restorable, so About, the manual and the
+/// shortcuts window do not reopen at the next launch when left open at quit.
+struct SurfaceNotRestored: NSViewRepresentable {
+ func makeNSView(context: Context) -> NSView {
+ let view = NSView(frame: .zero)
+ DispatchQueue.main.async { view.window?.isRestorable = false }
+ return view
+ }
+
+ func updateNSView(_ view: NSView, context: Context) {
+ view.window?.isRestorable = false
+ }
+}
+
+extension View {
+ /// Apply to the root view of a secondary window.
+ public func surfaceNotRestored() -> some View {
+ background(SurfaceNotRestored())
+ }
+}
+
+/// Opens the Settings scene from AppKit code: a status item's menu, an
+/// `NSMenu` action or an app delegate. SwiftUI code uses `openSettings`.
+public enum SurfaceSettingsOpener {
+ /// Sends the app menu's Settings… item (⌘,), which is what SwiftUI wires;
+ /// the `showSettingsWindow:` selector no longer opens it on current macOS.
+ @MainActor
+ public static func open() {
+ NSApplication.shared.activate()
+ if let item = settingsItem(in: NSApplication.shared.mainMenu), let action = item.action {
+ NSApplication.shared.sendAction(action, to: item.target, from: item)
+ } else {
+ NSApplication.shared.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
+ }
+ }
+
+ @MainActor
+ static func settingsItem(in menu: NSMenu?) -> NSMenuItem? {
+ guard let appMenu = menu?.items.first?.submenu else { return nil }
+ return appMenu.items.first { $0.keyEquivalent == "," && $0.keyEquivalentModifierMask == [.command] }
+ }
+}
+#endif
diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceBuild.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceBuild.swift
new file mode 100644
index 0000000..ed856b5
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceBuild.swift
@@ -0,0 +1,51 @@
+#if os(macOS)
+import Foundation
+
+/// The version line and build provenance for About, read from the Info.plist
+/// keys that the versioning standard stamps at build time.
+public struct SurfaceBuild: Sendable, Equatable {
+ public let marketingVersion: String
+ public let buildNumber: String
+ public let sha: String?
+ public let branch: String?
+ public let channel: String
+ public let isDirty: Bool
+ public let dirtyFiles: Int
+ public let isTagged: Bool
+ public let copyright: String?
+
+ public init(info: [String: Any]) {
+ func value(_ key: String) -> String? {
+ guard let text = info[key] as? String, !text.isEmpty, text != "unknown" else { return nil }
+ return text
+ }
+ marketingVersion = value("CFBundleShortVersionString") ?? "0.0.0"
+ buildNumber = value("CFBundleVersion") ?? "0"
+ sha = value("BuildSHA")
+ branch = value("BuildBranch")
+ channel = value("BuildChannel") ?? "unknown"
+ isDirty = value("BuildDirty") == "YES"
+ dirtyFiles = Int(value("BuildDirtyFiles") ?? "") ?? 0
+ isTagged = value("BuildTagged") == "YES"
+ copyright = value("NSHumanReadableCopyright")
+ }
+
+ public static let current = SurfaceBuild(info: Bundle.main.infoDictionary ?? [:])
+
+ /// A clean tree on a release tag, built for release.
+ public var isRelease: Bool { channel == "release" && isTagged && !isDirty }
+
+ public var versionLine: String { "Version \(marketingVersion) (\(buildNumber))" }
+
+ /// Commit, branch and dirty state. Nil on a release build, which shows
+ /// nothing about the repository.
+ public var provenanceLine: String? {
+ guard !isRelease else { return nil }
+ var parts = [sha, branch].compactMap { $0 }
+ if isDirty {
+ parts.append("\(dirtyFiles) uncommitted change\(dirtyFiles == 1 ? "" : "s")")
+ }
+ return parts.isEmpty ? "built from Xcode, no commit recorded" : parts.joined(separator: " · ")
+ }
+}
+#endif
diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceCommands.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceCommands.swift
new file mode 100644
index 0000000..ad604aa
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceCommands.swift
@@ -0,0 +1,45 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+/// The standard app menu and Help menu. `Settings…` with ⌘, comes from the
+/// app's Settings scene. SwiftUI always puts that item last in its own group,
+/// so Check for Updates… takes the next block down, above Services.
+public struct SurfaceCommands: Commands {
+ @Environment(\.openWindow) private var openWindow
+
+ private let app: SurfaceApp
+ private let help: SurfaceHelp
+ private let updates: SurfaceUpdates?
+
+ public init(app: SurfaceApp, help: SurfaceHelp, updates: SurfaceUpdates? = nil) {
+ self.app = app
+ self.help = help
+ self.updates = updates
+ }
+
+ public var body: some Commands {
+ CommandGroup(replacing: .appInfo) {
+ Button("About \(app.name)") {
+ NSApplication.shared.activate()
+ openWindow(id: SurfaceAboutWindow.id)
+ }
+ }
+ CommandGroup(before: .systemServices) {
+ if let updates, case .openSource = app.distribution {
+ Button("Check for Updates…") { updates.check() }
+ Divider()
+ }
+ }
+ CommandGroup(replacing: .help) {
+ Button("\(app.name) Help") { help.manual(openWindow) }
+ Button("Keyboard Shortcuts") { help.shortcuts(openWindow) }
+ .keyboardShortcut("?", modifiers: .command)
+ if let replay = help.replayWelcome {
+ Divider()
+ Button("Welcome to \(app.name)") { replay() }
+ }
+ }
+ }
+}
+#endif
diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceManual.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceManual.swift
new file mode 100644
index 0000000..a8a53b5
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceManual.swift
@@ -0,0 +1,288 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+/// A block of a manual page, parsed from Markdown.
+public enum SurfaceMarkdownBlock: Hashable, Sendable {
+ case heading(level: Int, text: String)
+ case paragraph(String)
+ case bullets([String])
+ case numbered([String])
+ case code(String)
+ case quote(String)
+ case rule
+ case image(file: String, alt: String)
+}
+
+/// The small Markdown subset manual pages use: headings, paragraphs, bullet
+/// and numbered lists, fenced code, quotes, rules, images on their own line,
+/// and inline emphasis, code and links inside text. A list item wrapped over
+/// several indented lines is one item; a blank line or unindented prose ends
+/// the list.
+public enum SurfaceMarkdown {
+ public static func parse(_ text: String) -> [SurfaceMarkdownBlock] {
+ var blocks: [SurfaceMarkdownBlock] = []
+ var paragraph: [String] = []
+ var bullets: [String] = []
+ var numbered: [String] = []
+ var code: [String]?
+
+ func flush() {
+ if !paragraph.isEmpty { blocks.append(.paragraph(paragraph.joined(separator: " "))); paragraph = [] }
+ if !bullets.isEmpty { blocks.append(.bullets(bullets)); bullets = [] }
+ if !numbered.isEmpty { blocks.append(.numbered(numbered)); numbered = [] }
+ }
+
+ for rawLine in text.components(separatedBy: .newlines) {
+ let line = rawLine.trimmingCharacters(in: .whitespaces)
+ if var open = code {
+ if line.hasPrefix("```") {
+ blocks.append(.code(open.joined(separator: "\n")))
+ code = nil
+ } else {
+ open.append(rawLine)
+ code = open
+ }
+ continue
+ }
+ if line.hasPrefix("```") { flush(); code = []; continue }
+ if line.isEmpty { flush(); continue }
+ if line == "---" || line == "***" { flush(); blocks.append(.rule); continue }
+ if line.hasPrefix("#") {
+ let level = line.prefix(while: { $0 == "#" }).count
+ let title = line.dropFirst(level).trimmingCharacters(in: .whitespaces)
+ if level <= 6, !title.isEmpty { flush(); blocks.append(.heading(level: level, text: title)); continue }
+ }
+ if line.hasPrefix("!["), let close = line.firstIndex(of: "]"), line[line.index(after: close)...].hasPrefix("("), line.hasSuffix(")") {
+ let alt = String(line[line.index(line.startIndex, offsetBy: 2).. ") || line == ">" {
+ flush()
+ let body = String(line.dropFirst(line == ">" ? 1 : 2))
+ if case let .quote(previous)? = blocks.last { blocks[blocks.count - 1] = .quote(previous + " " + body) } else { blocks.append(.quote(body)) }
+ continue
+ }
+ let indented = rawLine.hasPrefix(" ") || rawLine.hasPrefix("\t")
+ if indented, !bullets.isEmpty {
+ bullets[bullets.count - 1] += " " + line
+ continue
+ }
+ if indented, !numbered.isEmpty {
+ numbered[numbered.count - 1] += " " + line
+ continue
+ }
+ if line.hasPrefix("- ") || line.hasPrefix("* ") {
+ if !paragraph.isEmpty || !numbered.isEmpty { flush() }
+ bullets.append(String(line.dropFirst(2)))
+ continue
+ }
+ if let dot = line.firstIndex(of: "."), line[.. AttributedString {
+ (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)))
+ ?? AttributedString(text)
+ }
+}
+
+/// One page of the bundled manual: a Markdown file whose first heading is
+/// its title. Pages are ordered by file name, so number them.
+public struct SurfaceManualPage: Identifiable, Hashable, Sendable {
+ public let id: String
+ public let title: String
+ public let markdown: String
+ public let blocks: [SurfaceMarkdownBlock]
+
+ public init(id: String, markdown: String) {
+ self.id = id
+ self.markdown = markdown
+ blocks = SurfaceMarkdown.parse(markdown)
+ title = blocks.compactMap { if case let .heading(_, text) = $0 { text } else { nil } }.first ?? id
+ }
+}
+
+public enum SurfaceManual {
+ /// The app's bundled manual: a `Manual` folder of Markdown files in the
+ /// main bundle, copied with the app so it always matches the version.
+ public static var bundledFolder: URL? {
+ Bundle.main.url(forResource: "Manual", withExtension: nil)
+ }
+
+ public static func pages(in folder: URL?) -> [SurfaceManualPage] {
+ guard let folder,
+ let files = try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
+ else { return [] }
+ return files
+ .filter { $0.pathExtension.lowercased() == "md" }
+ .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }
+ .compactMap { url in
+ guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
+ return SurfaceManualPage(id: url.deletingPathExtension().lastPathComponent, markdown: text)
+ }
+ }
+}
+
+/// Renders parsed Markdown. Links to another page (`[text](02-page.md)`)
+/// select that page; other links open in the browser.
+public struct SurfaceMarkdownView: View {
+ private let blocks: [SurfaceMarkdownBlock]
+ private let folder: URL?
+
+ public init(blocks: [SurfaceMarkdownBlock], folder: URL? = nil) {
+ self.blocks = blocks
+ self.folder = folder
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
+ view(for: block)
+ }
+ }
+ .textSelection(.enabled)
+ .frame(maxWidth: 680, alignment: .leading)
+ }
+
+ @ViewBuilder
+ private func view(for block: SurfaceMarkdownBlock) -> some View {
+ switch block {
+ case let .heading(level, text):
+ Text(text)
+ .font(level == 1 ? .largeTitle.weight(.bold) : level == 2 ? .title2.weight(.semibold) : .headline)
+ .padding(.top, level == 1 ? 0 : 8)
+ case let .paragraph(text):
+ Text(SurfaceMarkdown.inline(text)).lineSpacing(3)
+ case let .bullets(items):
+ VStack(alignment: .leading, spacing: 5) {
+ ForEach(Array(items.enumerated()), id: \.offset) { _, item in
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(verbatim: "•")
+ Text(SurfaceMarkdown.inline(item))
+ }
+ }
+ }
+ case let .numbered(items):
+ VStack(alignment: .leading, spacing: 5) {
+ ForEach(Array(items.enumerated()), id: \.offset) { index, item in
+ HStack(alignment: .firstTextBaseline, spacing: 8) {
+ Text(verbatim: "\(index + 1).").monospacedDigit()
+ Text(SurfaceMarkdown.inline(item))
+ }
+ }
+ }
+ case let .code(text):
+ Text(text)
+ .font(.body.monospaced())
+ .padding(10)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.quaternary, in: RoundedRectangle(cornerRadius: 6))
+ case let .quote(text):
+ HStack(alignment: .top, spacing: 10) {
+ RoundedRectangle(cornerRadius: 1.5).fill(.tertiary).frame(width: 3)
+ Text(SurfaceMarkdown.inline(text)).foregroundStyle(.secondary)
+ }
+ case .rule:
+ Divider()
+ case let .image(file, alt):
+ if let folder, let image = NSImage(contentsOf: folder.appending(path: file)) {
+ Image(nsImage: image)
+ .resizable()
+ .scaledToFit()
+ .frame(maxWidth: 640)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ .accessibilityLabel(alt)
+ } else {
+ Text(alt).italic().foregroundStyle(.secondary)
+ }
+ }
+ }
+}
+
+/// The in-app manual: pages in a sidebar, a search field, the page beside it.
+public struct SurfaceManualView: View {
+ private let app: SurfaceApp
+ private let pages: [SurfaceManualPage]
+ private let folder: URL?
+ @State private var selection: String?
+ @State private var query = ""
+
+ public init(app: SurfaceApp, folder: URL?) {
+ self.app = app
+ self.folder = folder
+ pages = SurfaceManual.pages(in: folder)
+ _selection = State(initialValue: pages.first?.id)
+ }
+
+ private var shown: [SurfaceManualPage] {
+ let needle = query.trimmingCharacters(in: .whitespaces)
+ guard !needle.isEmpty else { return pages }
+ return pages.filter { $0.title.localizedCaseInsensitiveContains(needle) || $0.markdown.localizedCaseInsensitiveContains(needle) }
+ }
+
+ public var body: some View {
+ NavigationSplitView {
+ List(shown, selection: $selection) { page in
+ Text(page.title).tag(page.id)
+ }
+ .navigationSplitViewColumnWidth(min: 210, ideal: 240, max: 320)
+ .searchable(text: $query, placement: .sidebar)
+ } detail: {
+ if let page = pages.first(where: { $0.id == selection }) {
+ ScrollView {
+ SurfaceMarkdownView(blocks: page.blocks, folder: folder)
+ .padding(28)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .environment(\.openURL, OpenURLAction { url in
+ if url.pathExtension == "md", pages.contains(where: { $0.id == url.deletingPathExtension().lastPathComponent }) {
+ selection = url.deletingPathExtension().lastPathComponent
+ return .handled
+ }
+ return .systemAction
+ })
+ } else {
+ ContentUnavailableView("This manual has no pages yet", systemImage: "book")
+ }
+ }
+ .navigationTitle("\(app.name) Help")
+ }
+}
+
+/// The manual window. Add it to the App body beside the About window.
+public struct SurfaceManualWindow: Scene {
+ public static let id = "surface-manual"
+
+ private let app: SurfaceApp
+ private let folder: URL?
+
+ /// `folder` defaults to the `Manual` folder bundled with the app.
+ public init(app: SurfaceApp, folder: URL? = SurfaceManual.bundledFolder) {
+ self.app = app
+ self.folder = folder
+ }
+
+ public var body: some Scene {
+ let title = "\(app.name) Help"
+ return Window(title, id: Self.id) {
+ SurfaceManualView(app: app, folder: folder)
+ .surfaceNotRestored()
+ }
+ .defaultSize(width: 880, height: 620)
+ .commandsRemoved()
+ }
+}
+#endif
diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceMenuBar.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceMenuBar.swift
new file mode 100644
index 0000000..00e95f0
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceMenuBar.swift
@@ -0,0 +1,237 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+/// Whether the menu bar item is shown and which glyph it uses. Writes that do
+/// not change a value are ignored: an unguarded `MenuBarExtra(isInserted:)`
+/// binding re-renders in a loop.
+@MainActor
+public final class SurfaceMenuBarPreference: ObservableObject {
+ @Published public private(set) var isShown: Bool
+ @Published public private(set) var icon: String
+
+ private let defaults: UserDefaults
+ private let shownKey: String
+ private let iconKey: String
+
+ public init(
+ defaultIcon: String,
+ shownByDefault: Bool = true,
+ defaults: UserDefaults = .standard,
+ prefix: String = "surface.menuBar",
+ shownKey: String? = nil,
+ iconKey: String? = nil
+ ) {
+ self.defaults = defaults
+ self.shownKey = shownKey ?? prefix + ".shown"
+ self.iconKey = iconKey ?? prefix + ".icon"
+ isShown = defaults.object(forKey: self.shownKey) as? Bool ?? shownByDefault
+ icon = defaults.string(forKey: self.iconKey) ?? defaultIcon
+ }
+
+ public func setShown(_ value: Bool) {
+ guard value != isShown else { return }
+ isShown = value
+ defaults.set(value, forKey: shownKey)
+ }
+
+ public func setIcon(_ value: String) {
+ guard value != icon else { return }
+ icon = value
+ defaults.set(value, forKey: iconKey)
+ }
+
+ /// For `MenuBarExtra(isInserted:)` in an app with a Dock icon. Dragging the
+ /// item out of the menu bar writes the same setting.
+ public var insertion: Binding {
+ Binding(get: { self.isShown }, set: { self.setShown($0) })
+ }
+}
+
+/// One choice in the icon picker.
+public struct SurfaceMenuBarIcon: Identifiable {
+ public let id: String
+ public let title: String
+ public let image: Image
+
+ public init(id: String, title: String, image: Image) {
+ self.id = id
+ self.title = title
+ self.image = image
+ }
+}
+
+/// The Menu bar section of General: show or hide (Dock apps only), the icon
+/// picker, then a toggle for each optional action the app passes in.
+public struct SurfaceMenuBarSection: View {
+ private let app: SurfaceApp
+ @ObservedObject private var preference: SurfaceMenuBarPreference
+ private let icons: [SurfaceMenuBarIcon]
+ private let actions: Actions
+
+ public init(
+ app: SurfaceApp,
+ preference: SurfaceMenuBarPreference,
+ icons: [SurfaceMenuBarIcon],
+ @ViewBuilder actions: () -> Actions
+ ) {
+ self.app = app
+ self.preference = preference
+ self.icons = icons
+ self.actions = actions()
+ }
+
+ public var body: some View {
+ Section("Menu bar") {
+ if app.shape.canHideMenuBarItem {
+ Toggle("Show \(app.name) in the menu bar", isOn: preference.insertion)
+ }
+ Group {
+ if icons.count > 1 {
+ LabeledContent("Icon") {
+ HStack(spacing: 6) {
+ ForEach(icons) { icon in
+ iconTile(icon)
+ }
+ }
+ }
+ }
+ actions
+ }
+ .disabled(app.shape.canHideMenuBarItem && !preference.isShown)
+ }
+ }
+
+ private func iconTile(_ icon: SurfaceMenuBarIcon) -> some View {
+ let isSelected = icon.id == preference.icon
+ return Button {
+ preference.setIcon(icon.id)
+ } label: {
+ VStack(spacing: 3) {
+ icon.image
+ .frame(width: 30, height: 22)
+ .background(isSelected ? AnyShapeStyle(.selection) : AnyShapeStyle(.quaternary),
+ in: RoundedRectangle(cornerRadius: 6))
+ Text(icon.title).font(.caption2)
+ }
+ }
+ .buttonStyle(.plain)
+ .accessibilityAddTraits(isSelected ? .isSelected : [])
+ }
+}
+
+/// Opens Settings from a menu bar item. A utility is activated first so the
+/// window does not open behind another app.
+struct SurfaceSettingsAction {
+ let open: OpenSettingsAction
+
+ @MainActor
+ func callAsFunction() {
+ NSApplication.shared.activate()
+ open()
+ }
+}
+
+/// The top of a menu bar popover: the mark and the app name.
+public struct SurfacePopoverHeader: View {
+ private let app: SurfaceApp
+ private let mark: AnyView
+ private let trailing: Trailing
+
+ /// `mark` is drawn at 20 points; `trailing` sits at the right edge, for a
+ /// filter menu or a status glyph.
+ public init(app: SurfaceApp, mark: Image, @ViewBuilder trailing: () -> Trailing) {
+ self.app = app
+ self.mark = AnyView(mark.resizable().aspectRatio(contentMode: .fit))
+ self.trailing = trailing()
+ }
+
+ /// A drawn mark, such as a SwiftUI shape, instead of an image.
+ public init(app: SurfaceApp, @ViewBuilder mark: () -> Mark, @ViewBuilder trailing: () -> Trailing) {
+ self.app = app
+ self.mark = AnyView(mark())
+ self.trailing = trailing()
+ }
+
+ public var body: some View {
+ HStack(spacing: 8) {
+ mark
+ .frame(width: 20, height: 20)
+ .accessibilityHidden(true)
+ Text(app.name).font(.headline)
+ Spacer()
+ trailing
+ }
+ }
+}
+
+extension SurfacePopoverHeader where Trailing == EmptyView {
+ public init(app: SurfaceApp, mark: Image) {
+ self.init(app: app, mark: mark) { EmptyView() }
+ }
+}
+
+extension SurfaceMenuBarSection where Actions == EmptyView {
+ /// For an item with no optional actions: show or hide and the icon picker only.
+ public init(app: SurfaceApp, preference: SurfaceMenuBarPreference, icons: [SurfaceMenuBarIcon]) {
+ self.init(app: app, preference: preference, icons: icons) { EmptyView() }
+ }
+}
+
+/// The bottom of a menu bar popover: Open on the left, then a gear for
+/// Settings (⌘,) and Quit (⌘Q). About is reached through Settings.
+public struct SurfacePopoverFooter: View {
+ @Environment(\.openSettings) private var openSettings
+ private let app: SurfaceApp
+ private let openApp: (@MainActor () -> Void)?
+
+ /// Pass `openApp` only when the app has a main window.
+ public init(app: SurfaceApp, openApp: (@MainActor () -> Void)? = nil) {
+ self.app = app
+ self.openApp = openApp
+ }
+
+ public var body: some View {
+ HStack(spacing: 12) {
+ if let openApp {
+ Button("Open \(app.name)") { openApp() }
+ }
+ Spacer()
+ Button {
+ SurfaceSettingsAction(open: openSettings)()
+ } label: {
+ Image(systemName: "gearshape")
+ }
+ .help("Settings")
+ .accessibilityLabel("Settings")
+ .keyboardShortcut(",", modifiers: .command)
+ Button("Quit \(app.name)") { NSApplication.shared.terminate(nil) }
+ .keyboardShortcut("q", modifiers: .command)
+ }
+ .buttonStyle(.borderless)
+ }
+}
+
+/// The closing items of a native menu bar menu: Open , Settings…, Quit.
+public struct SurfaceMenuItems: View {
+ @Environment(\.openSettings) private var openSettings
+ private let app: SurfaceApp
+ private let openApp: (@MainActor () -> Void)?
+
+ public init(app: SurfaceApp, openApp: (@MainActor () -> Void)? = nil) {
+ self.app = app
+ self.openApp = openApp
+ }
+
+ public var body: some View {
+ Divider()
+ if let openApp {
+ Button("Open \(app.name)") { openApp() }
+ }
+ Button("Settings…") { SurfaceSettingsAction(open: openSettings)() }
+ .keyboardShortcut(",", modifiers: .command)
+ Button("Quit \(app.name)") { NSApplication.shared.terminate(nil) }
+ .keyboardShortcut("q", modifiers: .command)
+ }
+}
+#endif
diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceProfile.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceProfile.swift
new file mode 100644
index 0000000..400b0cb
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceProfile.swift
@@ -0,0 +1,177 @@
+#if os(macOS)
+import AppKit
+import SwiftUI
+
+/// How the app reaches its users. Decides the About links, the legal line,
+/// the capability grid and whether update controls exist.
+public enum SurfaceDistribution: Sendable, Equatable {
+ case appStore(website: URL, support: URL, creator: SurfaceLink)
+ case openSource(repository: URL, issues: URL, licence: String)
+}
+
+/// Whether the app lives in the Dock, the menu bar, or both.
+public enum SurfaceShape: Sendable, Equatable {
+ case dockApp
+ case dockAppWithMenuBarItem
+ case menuBarUtility
+
+ /// Only an app with a Dock icon may hide its menu bar item.
+ public var canHideMenuBarItem: Bool { self == .dockAppWithMenuBarItem }
+}
+
+public enum SurfaceSettingsLayout: Sendable, Equatable {
+ case tabs
+ case sidebar
+}
+
+public struct SurfaceLink: Identifiable, Sendable, Equatable {
+ public let title: String
+ public let url: URL
+ /// A source link prints its URL beneath the title.
+ public let showsURL: Bool
+
+ public var id: URL { url }
+
+ public init(_ title: String, url: URL, showsURL: Bool = false) {
+ self.title = title
+ self.url = url
+ self.showsURL = showsURL
+ }
+}
+
+public struct SurfaceCapability: Identifiable, Sendable, Equatable {
+ public let title: String
+ public let detail: String
+
+ public var id: String { title }
+
+ public init(_ title: String, detail: String) {
+ self.title = title
+ self.detail = detail
+ }
+}
+
+public struct SurfaceAcknowledgement: Identifiable, Sendable, Equatable {
+ public let name: String
+ public let licence: String
+ public let text: String
+
+ public var id: String { name }
+
+ public init(name: String, licence: String, text: String) {
+ self.name = name
+ self.licence = licence
+ self.text = text
+ }
+}
+
+/// The app name split in two; the second part takes the accent colour.
+public struct SurfaceWordmark: Sendable, Equatable {
+ public let lead: String
+ public let accent: String
+
+ public var plain: String { lead + accent }
+
+ public init(lead: String, accent: String) {
+ self.lead = lead
+ self.accent = accent
+ }
+}
+
+/// Everything the shared surfaces need to know about one app.
+public struct SurfaceApp: Sendable {
+ public var wordmark: SurfaceWordmark
+ public var accent: Color
+ public var summary: String
+ public var capabilities: [SurfaceCapability]
+ public var distribution: SurfaceDistribution
+ public var shape: SurfaceShape
+ public var acknowledgements: [SurfaceAcknowledgement]
+
+ public var name: String { wordmark.plain }
+
+ public init(
+ wordmark: SurfaceWordmark,
+ accent: Color,
+ summary: String,
+ capabilities: [SurfaceCapability] = [],
+ distribution: SurfaceDistribution,
+ shape: SurfaceShape,
+ acknowledgements: [SurfaceAcknowledgement] = []
+ ) {
+ self.wordmark = wordmark
+ self.accent = accent
+ self.summary = summary
+ self.capabilities = capabilities
+ self.distribution = distribution
+ self.shape = shape
+ self.acknowledgements = acknowledgements
+ }
+
+ public var settingsLayout: SurfaceSettingsLayout {
+ switch distribution {
+ case .appStore: .sidebar
+ case .openSource: .tabs
+ }
+ }
+
+ public var showsCapabilityGrid: Bool {
+ if case .openSource = distribution { return !capabilities.isEmpty }
+ return false
+ }
+
+ public var links: [SurfaceLink] {
+ switch distribution {
+ case let .appStore(website, support, creator):
+ [SurfaceLink(String(localized: "Website"), url: website),
+ SurfaceLink(String(localized: "Support"), url: support), creator]
+ case let .openSource(repository, issues, _):
+ [SurfaceLink(String(localized: "Source on GitHub"), url: repository, showsURL: true),
+ SurfaceLink(String(localized: "Report an Issue"), url: issues)]
+ }
+ }
+
+ /// The last line of About. `copyright` is the Info.plist value, if any.
+ public func legalLine(copyright: String?, year: Int) -> String {
+ let stated = copyright?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let mark = (stated?.isEmpty == false ? stated : nil) ?? "© \(year)"
+ switch distribution {
+ case .appStore: return mark
+ case let .openSource(_, _, licence): return "\(licence) · \(mark)"
+ }
+ }
+}
+
+/// What the three Help items do. Leave `openManual` and `openShortcuts` nil
+/// to use the shared `SurfaceManualWindow` and `SurfaceShortcutsWindow`.
+public struct SurfaceHelp {
+ public var openManual: (@MainActor () -> Void)?
+ public var openShortcuts: (@MainActor () -> Void)?
+ /// Nil in an app that has no first-run welcome; the item is then omitted.
+ public var replayWelcome: (@MainActor () -> Void)?
+
+ public init(
+ openManual: (@MainActor () -> Void)? = nil,
+ openShortcuts: (@MainActor () -> Void)? = nil,
+ replayWelcome: (@MainActor () -> Void)? = nil
+ ) {
+ self.openManual = openManual
+ self.openShortcuts = openShortcuts
+ self.replayWelcome = replayWelcome
+ }
+
+ @MainActor
+ func manual(_ openWindow: OpenWindowAction) {
+ if let openManual { openManual(); return }
+ NSApplication.shared.activate()
+ openWindow(id: SurfaceManualWindow.id)
+ }
+
+ @MainActor
+ func shortcuts(_ openWindow: OpenWindowAction) {
+ if let openShortcuts { openShortcuts(); return }
+ NSApplication.shared.activate()
+ openWindow(id: SurfaceShortcutsWindow.id)
+ }
+}
+#endif
diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceRows.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceRows.swift
new file mode 100644
index 0000000..32e9f7f
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceRows.swift
@@ -0,0 +1,196 @@
+#if os(macOS)
+import ServiceManagement
+import SwiftUI
+
+/// A row label with an ⓘ button. Explanations go in the popover; captions
+/// under a row are for live status and warnings only.
+public struct SurfaceInfoLabel: View {
+ private let title: String
+ private let info: String
+ @State private var isShowing = false
+
+ public init(_ title: String, info: String) {
+ self.title = title
+ self.info = info
+ }
+
+ public var body: some View {
+ HStack(spacing: 5) {
+ Text(title)
+ Button {
+ isShowing.toggle()
+ } label: {
+ Image(systemName: "info.circle")
+ }
+ .buttonStyle(.plain)
+ .foregroundStyle(.secondary)
+ .help(info)
+ .accessibilityLabel("About \(title)")
+ .popover(isPresented: $isShowing, arrowEdge: .trailing) {
+ Text(info)
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(14)
+ .frame(width: 280, alignment: .leading)
+ }
+ }
+ }
+}
+
+/// What macOS reports for the app's login item.
+public enum SurfaceLoginItemState: Sendable, Equatable {
+ case enabled
+ case off
+ case requiresApproval
+ case unavailable
+
+ public init(_ status: SMAppService.Status) {
+ switch status {
+ case .enabled: self = .enabled
+ case .notRegistered: self = .off
+ case .requiresApproval: self = .requiresApproval
+ case .notFound: self = .unavailable
+ @unknown default: self = .unavailable
+ }
+ }
+
+ public var isOn: Bool { self == .enabled || self == .requiresApproval }
+
+ /// Shown under the toggle. Nil when off, where the toggle says enough.
+ public var caption: String? {
+ switch self {
+ case .enabled: "Enabled"
+ case .off: nil
+ case .requiresApproval: "Waiting for approval in System Settings ▸ General ▸ Login Items"
+ case .unavailable: "Unavailable for this copy of the app"
+ }
+ }
+}
+
+/// Launch at login for menu bar utilities, with the real state as a caption.
+public struct SurfaceLaunchAtLoginRow: View {
+ private let app: SurfaceApp
+ @State private var state = SurfaceLoginItemState(SMAppService.mainApp.status)
+ @State private var failure: String?
+
+ public init(app: SurfaceApp) {
+ self.app = app
+ }
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Toggle("Open \(app.name) at login", isOn: Binding(
+ get: { state.isOn },
+ set: { set($0) }
+ ))
+ if let text = failure ?? state.caption {
+ Text(text)
+ .font(.caption)
+ .foregroundStyle(failure == nil ? AnyShapeStyle(.secondary) : AnyShapeStyle(.red))
+ }
+ }
+ .onAppear { state = SurfaceLoginItemState(SMAppService.mainApp.status) }
+ }
+
+ private func set(_ isOn: Bool) {
+ do {
+ if isOn {
+ try SMAppService.mainApp.register()
+ } else {
+ try SMAppService.mainApp.unregister()
+ }
+ failure = nil
+ } catch {
+ failure = error.localizedDescription
+ }
+ state = SurfaceLoginItemState(SMAppService.mainApp.status)
+ }
+}
+
+/// How much a destructive action asks before it runs.
+public enum SurfaceGate: Sendable, Equatable {
+ /// Settings, caches, logs: one confirmation that names what is lost.
+ case confirm
+ /// User data: the confirmation, then this word typed out.
+ case typed(String)
+
+ public func accepts(_ input: String) -> Bool {
+ switch self {
+ case .confirm: true
+ case let .typed(word): input.trimmingCharacters(in: .whitespaces) == word
+ }
+ }
+}
+
+/// A destructive button that never acts on the first click.
+public struct SurfaceDestructiveButton: View {
+ private let title: String
+ private let gate: SurfaceGate
+ private let question: String
+ private let consequence: String
+ private let confirmTitle: String
+ private let action: @MainActor () -> Void
+
+ @State private var isConfirming = false
+ @State private var isTyping = false
+ @State private var typed = ""
+
+ /// `title` ends in an ellipsis because it opens a dialog.
+ public init(
+ _ title: String,
+ gate: SurfaceGate,
+ question: String,
+ consequence: String,
+ confirmTitle: String,
+ action: @escaping @MainActor () -> Void
+ ) {
+ assert(title.hasSuffix("…"), "A button that opens a dialog ends in an ellipsis")
+ self.title = title
+ self.gate = gate
+ self.question = question
+ self.consequence = consequence
+ self.confirmTitle = confirmTitle
+ self.action = action
+ }
+
+ public var body: some View {
+ Button(title, role: .destructive) { isConfirming = true }
+ .confirmationDialog(question, isPresented: $isConfirming) {
+ Button(gate == .confirm ? confirmTitle : "Continue", role: .destructive) {
+ if gate == .confirm {
+ action()
+ } else {
+ typed = ""
+ isTyping = true
+ }
+ }
+ Button("Cancel", role: .cancel) {}
+ } message: {
+ Text(consequence)
+ }
+ .sheet(isPresented: $isTyping) { typeToConfirm }
+ }
+
+ private var typeToConfirm: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(question).font(.headline)
+ Text(consequence).fixedSize(horizontal: false, vertical: true)
+ if case let .typed(word) = gate {
+ TextField("Type \(word) to confirm", text: $typed)
+ .textFieldStyle(.roundedBorder)
+ }
+ HStack {
+ Spacer()
+ Button("Cancel", role: .cancel) { isTyping = false }
+ .keyboardShortcut(.cancelAction)
+ Button(confirmTitle, role: .destructive) {
+ isTyping = false
+ action()
+ }
+ .disabled(!gate.accepts(typed))
+ }
+ }
+ .padding(20)
+ .frame(width: 380)
+ }
+}
+#endif
diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceSettings.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceSettings.swift
new file mode 100644
index 0000000..23bb370
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceSettings.swift
@@ -0,0 +1,165 @@
+#if os(macOS)
+import SwiftUI
+
+/// One Settings pane. Supply form sections; the scaffold wraps them in a
+/// grouped form.
+public struct SurfacePane: Identifiable {
+ public let id: String
+ public let title: String
+ public let systemImage: String
+ let content: AnyView
+
+ public init(
+ _ title: String,
+ systemImage: String,
+ id: String? = nil,
+ @ViewBuilder content: () -> Content
+ ) {
+ self.id = id ?? title
+ self.title = title
+ self.systemImage = systemImage
+ self.content = AnyView(content())
+ }
+}
+
+/// Toolbar-tab Settings for the open-source apps. The first pane is General
+/// and ends with the About button. A single pane shows as a plain form.
+public struct SurfaceTabSettings: View {
+ public nonisolated static let maximumPanes = 5
+ public static let width: CGFloat = 560
+
+ /// Five panes at most; merge before adding a sixth.
+ public nonisolated static func accepts(paneCount: Int) -> Bool {
+ (1...maximumPanes).contains(paneCount)
+ }
+
+ private let app: SurfaceApp
+ private let panes: [SurfacePane]
+ @AppStorage("surface.settings.pane") private var selection = ""
+
+ public init(app: SurfaceApp, panes: [SurfacePane]) {
+ assert(Self.accepts(paneCount: panes.count), "Tab Settings take one to \(Self.maximumPanes) panes")
+ self.app = app
+ self.panes = panes
+ }
+
+ public var body: some View {
+ Group {
+ if panes.count == 1, let only = panes.first {
+ form(only)
+ } else {
+ tabs
+ }
+ }
+ .frame(width: Self.width)
+ .onAppear {
+ if !panes.contains(where: { $0.id == selection }), let first = panes.first {
+ selection = first.id
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var tabs: some View {
+ if #available(macOS 15.0, *) {
+ TabView(selection: $selection) {
+ ForEach(panes) { pane in
+ Tab(pane.title, systemImage: pane.systemImage, value: pane.id) {
+ form(pane)
+ }
+ }
+ }
+ } else {
+ TabView(selection: $selection) {
+ ForEach(panes) { pane in
+ form(pane)
+ .tabItem { Label(pane.title, systemImage: pane.systemImage) }
+ .tag(pane.id)
+ }
+ }
+ }
+ }
+
+ private func form(_ pane: SurfacePane) -> some View {
+ Form {
+ pane.content
+ if pane.id == panes.first?.id {
+ Section {
+ SurfaceAboutButton(app: app)
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+}
+
+/// Sidebar Settings for apps with many panes. A list beside the form, so no
+/// collapse button appears; the About button is pinned under the list.
+public struct SurfaceSidebarSettings: View {
+ public static let minimumSize = CGSize(width: 720, height: 560)
+ public static let sidebarWidth: CGFloat = 200
+
+ private let app: SurfaceApp
+ private let panes: [SurfacePane]
+ @State private var selection: String
+
+ public init(app: SurfaceApp, panes: [SurfacePane]) {
+ assert(!panes.isEmpty, "Sidebar Settings need at least one pane")
+ self.app = app
+ self.panes = panes
+ _selection = State(initialValue: panes.first?.id ?? "")
+ }
+
+ public var body: some View {
+ HStack(spacing: 0) {
+ List(selection: selected) {
+ ForEach(panes) { pane in
+ Label(pane.title, systemImage: pane.systemImage).tag(pane.id)
+ }
+ }
+ .listStyle(.sidebar)
+ .safeAreaInset(edge: .bottom) {
+ SurfaceAboutButton(app: app)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(12)
+ }
+ .frame(width: Self.sidebarWidth)
+ Divider()
+ Form {
+ if let pane = panes.first(where: { $0.id == selection }) {
+ pane.content
+ }
+ }
+ .formStyle(.grouped)
+ }
+ .frame(minWidth: Self.minimumSize.width, minHeight: Self.minimumSize.height)
+ }
+
+ /// One category is always selected: clearing the selection is ignored.
+ private var selected: Binding {
+ Binding(
+ get: { selection },
+ set: { if let value = $0 { selection = value } }
+ )
+ }
+}
+
+/// Picks the layout the app's profile calls for.
+public struct SurfaceSettings: View {
+ private let app: SurfaceApp
+ private let panes: [SurfacePane]
+
+ public init(app: SurfaceApp, panes: [SurfacePane]) {
+ self.app = app
+ self.panes = panes
+ }
+
+ public var body: some View {
+ switch app.settingsLayout {
+ case .tabs: SurfaceTabSettings(app: app, panes: panes)
+ case .sidebar: SurfaceSidebarSettings(app: app, panes: panes)
+ }
+ }
+}
+#endif
diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceShortcuts.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceShortcuts.swift
new file mode 100644
index 0000000..c98fd48
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceShortcuts.swift
@@ -0,0 +1,94 @@
+#if os(macOS)
+import SwiftUI
+
+/// One row of the Keyboard Shortcuts window.
+public struct SurfaceShortcut: Identifiable, Hashable, Sendable {
+ public let keys: String
+ public let title: String
+ public let detail: String?
+
+ public var id: String { keys + " " + title }
+
+ public init(_ keys: String, _ title: String, detail: String? = nil) {
+ self.keys = keys
+ self.title = title
+ self.detail = detail
+ }
+}
+
+public struct SurfaceShortcutGroup: Identifiable, Hashable, Sendable {
+ public let title: String
+ public let items: [SurfaceShortcut]
+
+ public var id: String { title }
+
+ public init(_ title: String, items: [SurfaceShortcut]) {
+ self.title = title
+ self.items = items
+ }
+
+ /// The shortcuts every app has. Put it first.
+ public static func standard(for app: SurfaceApp) -> SurfaceShortcutGroup {
+ SurfaceShortcutGroup(String(localized: "General"), items: [
+ SurfaceShortcut("⌘,", String(localized: "Settings…")),
+ SurfaceShortcut("⌘?", String(localized: "Keyboard Shortcuts")),
+ SurfaceShortcut("⌘H", String(localized: "Hide \(app.name)")),
+ SurfaceShortcut("⌘Q", String(localized: "Quit \(app.name)")),
+ ])
+ }
+}
+
+public struct SurfaceShortcutsView: View {
+ private let groups: [SurfaceShortcutGroup]
+
+ public init(groups: [SurfaceShortcutGroup]) {
+ self.groups = groups
+ }
+
+ public var body: some View {
+ Form {
+ ForEach(groups) { group in
+ Section(group.title) {
+ ForEach(group.items) { item in
+ LabeledContent {
+ Text(item.keys)
+ .font(.body.monospaced())
+ .padding(.horizontal, 7)
+ .padding(.vertical, 2)
+ .background(.quaternary, in: RoundedRectangle(cornerRadius: 5))
+ } label: {
+ Text(item.title)
+ if let detail = item.detail {
+ Text(detail).font(.caption).foregroundStyle(.secondary)
+ }
+ }
+ }
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .frame(width: 460)
+ .frame(minHeight: 160, maxHeight: 720)
+ }
+}
+
+/// The Keyboard Shortcuts window (Help ▸ Keyboard Shortcuts, ⌘?).
+public struct SurfaceShortcutsWindow: Scene {
+ public static let id = "surface-shortcuts"
+
+ private let groups: [SurfaceShortcutGroup]
+
+ public init(groups: [SurfaceShortcutGroup]) {
+ self.groups = groups
+ }
+
+ public var body: some Scene {
+ Window("Keyboard Shortcuts", id: Self.id) {
+ SurfaceShortcutsView(groups: groups)
+ .surfaceNotRestored()
+ }
+ .windowResizability(.contentSize)
+ .commandsRemoved()
+ }
+}
+#endif
diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceSparkle.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceSparkle.swift
new file mode 100644
index 0000000..4539847
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceSparkle.swift
@@ -0,0 +1,7 @@
+#if os(macOS) && canImport(Sparkle)
+import Sparkle
+
+// Sparkle's updater already has every member the surfaces need. Pass
+// `SPUStandardUpdaterController.updater` to `SurfaceUpdates(driver:)`.
+extension SPUUpdater: SurfaceUpdateDriver {}
+#endif
diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceUpdates.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceUpdates.swift
new file mode 100644
index 0000000..8b098c8
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceUpdates.swift
@@ -0,0 +1,173 @@
+#if os(macOS)
+import SwiftUI
+
+/// The updater calls the surfaces need. Sparkle's `SPUUpdater` already has
+/// every member; see SurfaceSparkle.swift. Main-actor isolated, as Sparkle's
+/// updater is.
+@MainActor
+public protocol SurfaceUpdateDriver: AnyObject {
+ var canCheckForUpdates: Bool { get }
+ var automaticallyChecksForUpdates: Bool { get set }
+ var updateCheckInterval: TimeInterval { get set }
+ var lastUpdateCheckDate: Date? { get }
+ func checkForUpdates()
+}
+
+public enum SurfaceUpdateInterval: String, CaseIterable, Identifiable, Sendable {
+ case daily
+ case weekly
+ case monthly
+
+ public var id: String { rawValue }
+
+ public var title: String {
+ switch self {
+ case .daily: "Daily"
+ case .weekly: "Weekly"
+ case .monthly: "Monthly"
+ }
+ }
+
+ public var seconds: TimeInterval {
+ switch self {
+ case .daily: 86_400
+ case .weekly: 604_800
+ case .monthly: 2_592_000
+ }
+ }
+
+ /// The nearest named interval to an updater's stored value.
+ public init(seconds: TimeInterval) {
+ if seconds <= 2 * 86_400 {
+ self = .daily
+ } else if seconds <= 14 * 86_400 {
+ self = .weekly
+ } else {
+ self = .monthly
+ }
+ }
+}
+
+/// Update state for the Updates pane and the Check for Updates… item.
+/// Automatic checks stay off until the user turns them on; set
+/// `SUEnableAutomaticChecks` to false in Info.plist so Sparkle does not ask.
+@MainActor
+public final class SurfaceUpdates: ObservableObject {
+ private let driver: any SurfaceUpdateDriver
+ public let channel: String
+ public let releaseNotes: URL?
+
+ @Published public private(set) var canCheck: Bool
+ @Published public private(set) var lastCheck: Date?
+ @Published public var checksAutomatically: Bool {
+ didSet {
+ if driver.automaticallyChecksForUpdates != checksAutomatically {
+ driver.automaticallyChecksForUpdates = checksAutomatically
+ }
+ }
+ }
+ @Published public var interval: SurfaceUpdateInterval {
+ didSet {
+ if SurfaceUpdateInterval(seconds: driver.updateCheckInterval) != interval {
+ driver.updateCheckInterval = interval.seconds
+ }
+ }
+ }
+
+ public init(driver: any SurfaceUpdateDriver, channel: String = "Stable", releaseNotes: URL? = nil) {
+ self.driver = driver
+ self.channel = channel
+ self.releaseNotes = releaseNotes
+ canCheck = driver.canCheckForUpdates
+ lastCheck = driver.lastUpdateCheckDate
+ checksAutomatically = driver.automaticallyChecksForUpdates
+ interval = SurfaceUpdateInterval(seconds: driver.updateCheckInterval)
+ }
+
+ public func check() {
+ driver.checkForUpdates()
+ refresh()
+ }
+
+ /// Re-reads the updater. Values that have not changed are not republished.
+ public func refresh() {
+ if canCheck != driver.canCheckForUpdates { canCheck = driver.canCheckForUpdates }
+ if lastCheck != driver.lastUpdateCheckDate { lastCheck = driver.lastUpdateCheckDate }
+ if checksAutomatically != driver.automaticallyChecksForUpdates {
+ checksAutomatically = driver.automaticallyChecksForUpdates
+ }
+ let stored = SurfaceUpdateInterval(seconds: driver.updateCheckInterval)
+ if interval != stored { interval = stored }
+ }
+}
+
+/// The sections of the Updates pane.
+public struct SurfaceUpdateSections: View {
+ @ObservedObject private var updates: SurfaceUpdates
+ private let build: SurfaceBuild
+
+ public init(updates: SurfaceUpdates, build: SurfaceBuild = .current) {
+ self.updates = updates
+ self.build = build
+ }
+
+ public var body: some View {
+ Section("This version") {
+ LabeledContent("Version", value: "\(build.marketingVersion) · \(updates.channel) channel")
+ LabeledContent("Last checked", value: lastChecked)
+ }
+ .onAppear { updates.refresh() }
+ Section("Automatic checks") {
+ Toggle("Check for updates automatically", isOn: $updates.checksAutomatically)
+ Picker("Frequency", selection: $updates.interval) {
+ ForEach(SurfaceUpdateInterval.allCases) { interval in
+ Text(interval.title).tag(interval)
+ }
+ }
+ .disabled(!updates.checksAutomatically)
+ }
+ Section {
+ HStack {
+ Button("Check Now") { updates.check() }
+ .disabled(!updates.canCheck)
+ if let notes = updates.releaseNotes {
+ Link("Release Notes", destination: notes)
+ }
+ }
+ } footer: {
+ if !updates.canCheck {
+ Text("This build cannot check for updates.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ private var lastChecked: String {
+ updates.lastCheck.map { $0.formatted(date: .abbreviated, time: .shortened) } ?? "Never"
+ }
+}
+
+extension SurfacePane {
+ /// The Updates pane, always the last tab in a Sparkle app. `extra` adds
+ /// the app's own sections above the shared ones, as for a Homebrew install
+ /// that is upgraded from the command line.
+ @MainActor
+ public static func updates(
+ _ updates: SurfaceUpdates,
+ build: SurfaceBuild = .current,
+ @ViewBuilder extra: () -> Extra
+ ) -> SurfacePane {
+ let extra = extra()
+ return SurfacePane("Updates", systemImage: "arrow.down.circle") {
+ extra
+ SurfaceUpdateSections(updates: updates, build: build)
+ }
+ }
+
+ @MainActor
+ public static func updates(_ updates: SurfaceUpdates, build: SurfaceBuild = .current) -> SurfacePane {
+ self.updates(updates, build: build) { EmptyView() }
+ }
+}
+#endif
diff --git a/privacycommand/privacycommand.xcodeproj/project.pbxproj b/privacycommand/privacycommand.xcodeproj/project.pbxproj
index 735f0db..d23bf75 100644
--- a/privacycommand/privacycommand.xcodeproj/project.pbxproj
+++ b/privacycommand/privacycommand.xcodeproj/project.pbxproj
@@ -415,6 +415,11 @@
path = privacycommandHelper;
sourceTree = "";
};
+ 0FFFFFFF0000000000005F01 /* MacSurfaces */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = MacSurfaces;
+ sourceTree = "";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -468,6 +473,7 @@
0CCCCCCC0000000000000A01 /* ViewModels */,
0CCCCCCC0000000000000A02 /* Views */,
0CCCCCCC0000000000000A04 /* Reporting */,
+ 0FFFFFFF0000000000005F01 /* MacSurfaces */,
);
name = privacycommand;
path = Sources/privacycommand;
@@ -830,6 +836,9 @@
dependencies = (
6600000000000000000000A1 /* PBXTargetDependency */,
);
+ fileSystemSynchronizedGroups = (
+ 0FFFFFFF0000000000005F01 /* MacSurfaces */,
+ );
name = privacycommand;
productName = privacycommand;
productReference = 0FFFFFFF000000000000E001 /* privacycommand.app */;
@@ -1218,7 +1227,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
OTHER_LDFLAGS = "-lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand;
PRODUCT_NAME = privacycommand;
@@ -1247,7 +1256,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
OTHER_LDFLAGS = "-lsqlite3";
PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand;
PRODUCT_NAME = privacycommand;
@@ -1265,7 +1274,7 @@
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 6S9Q286XS9;
GENERATE_INFOPLIST_FILE = YES;
- MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1284,7 +1293,7 @@
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 6S9Q286XS9;
GENERATE_INFOPLIST_FILE = YES;
- MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand.tests;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1347,7 +1356,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -1405,7 +1414,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
@@ -1431,7 +1440,7 @@
INFOPLIST_KEY_LSBackgroundOnly = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "privacycommand Helper — privileged helper for privacycommand. A privacykey product.";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand.HelperTool;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -1459,7 +1468,7 @@
INFOPLIST_KEY_LSBackgroundOnly = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "privacycommand Helper — privileged helper for privacycommand. A privacykey product.";
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MACOSX_DEPLOYMENT_TARGET = 13.0;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand.HelperTool;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/privacycommand/privacycommand.xcodeproj/xcshareddata/xcschemes/privacycommand.xcscheme b/privacycommand/privacycommand.xcodeproj/xcshareddata/xcschemes/privacycommand.xcscheme
index afeb77d..7931808 100644
--- a/privacycommand/privacycommand.xcodeproj/xcshareddata/xcschemes/privacycommand.xcscheme
+++ b/privacycommand/privacycommand.xcodeproj/xcshareddata/xcschemes/privacycommand.xcscheme
@@ -13,6 +13,13 @@
+
+
+
+
+
+
+