diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index 51c5579..fb57f8e 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -11,8 +11,8 @@ with:
- A description of the issue and its impact.
- Steps to reproduce, or a proof-of-concept if you have one.
-- The privacycommand version you observed it on (Settings → About,
- or `mdls -name kMDItemVersion /Applications/privacycommand.app`).
+- The privacycommand version you observed it on (privacycommand →
+ About privacycommand, or `mdls -name kMDItemVersion /Applications/privacycommand.app`).
- Your macOS version and chip family (Apple menu → About This Mac).
- Whether you've shared the finding anywhere else (e.g. coordinated
disclosure with another vendor).
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/Assets.xcassets/GitHub.imageset/Contents.json b/privacycommand/Resources/Assets.xcassets/GitHub.imageset/Contents.json
deleted file mode 100644
index 9681cf6..0000000
--- a/privacycommand/Resources/Assets.xcassets/GitHub.imageset/Contents.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
- "images" : [
- {
- "filename" : "github.svg",
- "idiom" : "universal"
- }
- ],
- "info" : {
- "version" : 1,
- "author" : "xcode"
- },
- "properties" : {
- "preserves-vector-representation" : true,
- "template-rendering-intent" : "template"
- }
-}
diff --git a/privacycommand/Resources/Assets.xcassets/GitHub.imageset/github.svg b/privacycommand/Resources/Assets.xcassets/GitHub.imageset/github.svg
deleted file mode 100644
index 178fc2b..0000000
--- a/privacycommand/Resources/Assets.xcassets/GitHub.imageset/github.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/privacycommand/Resources/Info.plist b/privacycommand/Resources/Info.plist
index 3f37788..98cc883 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
@@ -35,7 +35,7 @@
NSCameraUsageDescription
privacycommand reads the camera-in-use indicator while a monitored run is active. It never captures or records video.
NSHumanReadableCopyright
- privacycommand — inspects macOS apps. All analysis runs locally. A privacykey product.
+ © 2026 privacykey
NSMicrophoneUsageDescription
privacycommand reads the microphone-in-use indicator while a monitored run is active. It never captures or records audio.
NSPrincipalClass
diff --git a/privacycommand/Resources/Manual/01-getting-started.md b/privacycommand/Resources/Manual/01-getting-started.md
new file mode 100644
index 0000000..be53bbe
--- /dev/null
+++ b/privacycommand/Resources/Manual/01-getting-started.md
@@ -0,0 +1,50 @@
+# Getting started
+
+privacycommand takes a `.app` bundle (or a `.dmg` disk image) and reports what the app actually touches: the entitlements it claims, the permissions it will ask for, the domains and URLs compiled into its binary, the third-party SDKs it ships, the login items and helpers it registers and, if you let it, what it does while it runs.
+
+Every finding carries a plain-English explanation from the in-app knowledge base, so a report is readable without a reverse-engineering background. All analysis happens on your Mac; the app ships no analytics of its own.
+
+## What you need
+
+- macOS 14 or later.
+- Nothing else for static analysis and network monitoring. File-event monitoring, the Background Task Management audit and the network kill switch need the optional privileged helper. See [Settings](08-settings.md).
+
+## First launch
+
+The first time privacycommand runs it opens **Welcome to privacycommand**, a five-step tour:
+
+1. **Welcome.** What the app does.
+2. **Static analysis.** What is read from the bundle without running it.
+3. **Network monitoring.** How destinations are captured during a run.
+4. **File monitoring (optional).** Installs the privileged helper if you want file events.
+5. **You're all set.**
+
+You can skip it at any step. To see it again later choose **Help ▸ Welcome to privacycommand**.
+
+## Opening an app
+
+- Drag a `.app` bundle or a `.dmg` onto the main window, or
+- choose **File ▸ Open .app…** (⌘O) and pick one.
+
+A disk image is mounted read-only, the first `.app` inside is analysed, and an eject button appears in the header until you dismiss it. ZIP archives are not supported: extract them first.
+
+Static analysis runs straight away and the window fills with the report. Analysing an app you have opened before reuses the cached report; see [Settings](08-settings.md) for the cache.
+
+## The main window
+
+The header shows the app's name and bundle identifier, its risk tier, the signing team (click it for the developer name and Team ID) and the run controls. Below it the report is split into tabs:
+
+- **Summary.** The executive summary, the risk score and its contributors, the SDK heat map, App Store privacy labels, declared and inferred permissions, and the findings.
+- **Static.** Everything read from the bundle. See [Static analysis](02-static-analysis.md).
+- **Files.** File events from a monitored run (needs the helper).
+- **Network.** Destinations contacted during a run, with reverse-DNS labels.
+- **Resources.** What the app is holding open right now during a run: files, sockets and other resources, refreshed every poll cycle and filterable by kind, process and name.
+- **Probes.** Camera, microphone, screen-recording and pasteboard activity during a run.
+- **Timeline.** Every event of the run in order.
+- **History.** Saved runs, with compare. See [History and compare](06-history-and-compare.md).
+
+Monitored runs are covered in [Monitored runs](03-monitored-runs.md).
+
+## The knowledge base
+
+Every ⓘ button in a report opens the knowledge base entry for that finding. To browse the whole thing, choose **View ▸ Knowledge Base…** (⇧⌘K): a searchable window of every article, grouped by category.
diff --git a/privacycommand/Resources/Manual/02-static-analysis.md b/privacycommand/Resources/Manual/02-static-analysis.md
new file mode 100644
index 0000000..d04e909
--- /dev/null
+++ b/privacycommand/Resources/Manual/02-static-analysis.md
@@ -0,0 +1,31 @@
+# Static analysis
+
+Static analysis reads the bundle without running it. It is what fills the window the moment you open an app, and it never needs the helper.
+
+## What is read
+
+- **Info.plist and entitlements.** The usage-description keys the app declares (camera, microphone, contacts and so on), its URL schemes and document types, and every entitlement it claims. Declared permissions are compared with what the binary appears able to use.
+- **Code signing.** Whether the bundle is signed, by which Team ID, and the developer name expanded from the signing certificate (or the App Store seller name for App Store builds).
+- **Notarisation.** A deep dive through the stapled ticket, `spctl` and the bundle's SHA-256.
+- **Hard-coded domains and URLs.** Hosts and URL literals found in the binary, classified by category (analytics, advertising, cloud and so on), with false positives filtered out.
+- **SDK fingerprints.** Which analytics, advertising and attribution SDKs the bundle ships, with a heat-graded count and a per-category breakdown.
+- **Outbound call sites.** Functions that can open a connection, the networking symbols they reach for (BSD sockets, `getaddrinfo`, CFNetwork, `nw_*`), and any host or URL literal sitting next to them.
+- **Embedded launch agents and helpers.** LaunchAgents, LaunchDaemons, XPC services, login items and privileged helpers packaged inside the bundle.
+- **Feature flags, trial state, secrets and licence keys.** String scans for flag names, trial counters, API-key shapes and licence-key names.
+- **Anti-analysis signals.** Debugger, VM and jailbreak checks, and obfuscation markers.
+- **Dylib hijacking surface.** `@rpath` entries and library load paths that could be hijacked.
+- **Privacy manifest.** Apple's `PrivacyInfo.xcprivacy`, checked against what the binary actually uses.
+- **Frameworks and resources.** Embedded frameworks and bundles, and the resources the app ships.
+- **App Store privacy labels.** For Mac App Store bundles, the developer's declared Privacy Nutrition Labels are fetched and shown beside the static findings so you can see whether the claims match the binary. This is the one static lookup that goes to the network, keyed by the inspected app's bundle identifier only.
+
+## Background Task Management
+
+The Static tab also lists every login item, launch agent, daemon and helper the app has registered with macOS. Reading that registry needs administrator rights: with the privileged helper installed it happens silently; without it, privacycommand asks before triggering an admin prompt.
+
+## Decompiling
+
+Any outbound call site can be decompiled on demand if Ghidra is installed on your Mac, and the Static tab can reconstruct the app's classes and functions with a local Ghidra install (the first run analyses the binary; results are cached). privacycommand looks for `support/analyzeHeadless` inside any `ghidra*` folder under `/Applications`, `~/Applications`, `/opt`, `/usr/local` or `~/Tools`. To keep the heavy work off your Mac, decompile inside a VM instead; see [VM mode](04-vm-mode.md).
+
+## The risk score
+
+The Summary tab aggregates the static findings (and, after a run, the dynamic ones) into a risk tier with explainable contributors. Every contributor and every finding has an ⓘ button that opens its knowledge base entry.
diff --git a/privacycommand/Resources/Manual/03-monitored-runs.md b/privacycommand/Resources/Manual/03-monitored-runs.md
new file mode 100644
index 0000000..9174cfb
--- /dev/null
+++ b/privacycommand/Resources/Manual/03-monitored-runs.md
@@ -0,0 +1,43 @@
+# Monitored runs
+
+A monitored run launches the inspected app under privacycommand and records what it does, live, until you stop it.
+
+## Starting and stopping
+
+- **Run ▸ Start Monitored Run** (⌘R), or the **Start run** button in the header.
+- **Run ▸ Stop Monitored Run** (⌘.), or **Stop run** in the header. Stopping terminates the inspected app's process tree; so does quitting privacycommand while a run is active, so nothing is left running orphaned.
+
+An app opened from a disk image must stay mounted for the run: ejecting the image keeps the static report readable but a run will fail because the executable is gone.
+
+## What is captured
+
+- **Network destinations.** Polled with `lsof` and `nettop`, with reverse-DNS labels, so the Network tab can name `8.8.8.8` as `dns.google`. No special permission needed.
+- **File events.** Every file the app opens, reads, writes or creates, streamed from `fs_usage` by the privileged helper. Without the helper the Files tab stays empty. See [Settings](08-settings.md) for installing it.
+- **Child processes.** Helpers and tools the app spawns, tracked as part of its process tree.
+- **Probes.** Pasteboard reads, and the camera, microphone and screen-recording in-use indicators. privacycommand reads the indicators only; it never captures audio or video.
+- **USB devices.** Devices connected and disconnected during the run.
+- **Resource usage.** CPU and memory samples, with spikes flagged.
+- **Anomalies.** Periodic beacons, bursts and undeclared hosts, derived from the captured traffic.
+
+Everything lands in the Files, Network, Resources, Probes and Timeline tabs, and the dynamic findings join the risk score on the Summary tab.
+
+## Pausing the app
+
+**Run ▸ Pause App (Kill Switch)** (⇧⌘P), or **Pause** in the header, freezes the inspected app's whole process tree with SIGSTOP. While paused it cannot network, write files or run code at all. Choose the item again (**Resume App**) to continue it.
+
+## Blocking the network
+
+**Block network** in the header asks the privileged helper to install a `pf` anchor that drops outbound traffic to every address the app has contacted so far. The app keeps running, so you can watch how it copes with connection failures. Click again to lift the block.
+
+Limitations: `pf` cannot filter by process, so the block is system-wide for those addresses; destinations the app has not visited yet are not blocked; and new destinations are not added automatically. Lift the block and apply it again to refresh the address set. Let the run gather some traffic first, or there is nothing to block.
+
+## Watch mode
+
+**Run ▸ Start Watching…** (⇧⌘W), or the eye button in the header, keeps the run alive in the menu bar. If no run is active one is started first. While watching:
+
+- A menu bar item appears. Click it for a popover: the app's mark and name with the elapsed time, which app is being watched, and a log of what has changed since you started: new hosts contacted, new anomalies, surprising file and network events, probe activity and resource spikes. The icon fills in and shows a count while there are unread changes; opening the popover marks them read.
+- **Clear Log** empties the list. **Stop Watching** (⌘↩ while the popover is open) ends the watch and the run.
+- The footer has **Open privacycommand**, a gear that opens Settings (⌘,) and **Quit privacycommand** (⌘Q).
+- Closing the main window does not quit the app while a watch is active; the menu bar item is your handle on the run.
+
+The item's glyph is chosen in Settings ▸ General ▸ Menu bar. Removing the item from the menu bar (⌘-drag) stops the watch, the same as choosing **Stop Watching**.
diff --git a/privacycommand/Resources/Manual/04-vm-mode.md b/privacycommand/Resources/Manual/04-vm-mode.md
new file mode 100644
index 0000000..c9e943e
--- /dev/null
+++ b/privacycommand/Resources/Manual/04-vm-mode.md
@@ -0,0 +1,43 @@
+# VM mode
+
+VM mode runs the inspected app inside a separate macOS virtual machine, for apps you would rather not run on bare metal. Everything is set up in Settings ▸ VM agent.
+
+## What runs where
+
+Two binaries are involved, on two machines:
+
+- **On your Mac (host):** the privacycommand app you are using. Same window, same tabs. When VM mode is active, observations from the VM stream into the same Summary, Files, Network and Probes tabs, tagged with a small VM badge.
+- **Inside the VM (guest):** a small daemon called `privacycommand-guest`. It has no UI; it listens for commands from the host on TCP 49374 and ships observations back. You install it once and forget about it.
+
+You do not need a second copy of the privacycommand app inside the VM, only the agent.
+
+## The one manual step
+
+privacycommand can start a VM and reveal the installer disk image in Finder for you, but it cannot attach the image to the VM: VirtualBuddy, UTM, Parallels Desktop and VMware Fusion expose no public way for outside apps to mount a disk image into a running guest. So once the image is built, drag it onto your VM's window. Every supported tool accepts the drop and mounts the image as a shared disk inside the guest. (Parallels users can alternatively run `prlctl set --device-add cdrom --image=...`.)
+
+## Setting up, once
+
+1. **Build the installer disk image.** Settings ▸ VM agent ▸ Installer disk image ▸ **Build Disk Image** compiles `privacycommand-guest` in release mode and packages it, with its LaunchAgent plist and `Install.command`, into a small `.dmg`. It takes about 30 seconds the first time. The image is kept in `~/Library/Application Support/privacycommand`; **Reveal** selects it in Finder and **Rebuild** makes a fresh one.
+2. **Start the VM.** The VM tools section lists VirtualBuddy, UTM, Parallels Desktop and VMware Fusion when they are installed, with each tool's VMs and a **Start** button. VirtualBuddy can also start a VM by name when its library is not where the scan looks. If macOS blocks the Apple event that reads the VM list, enable the tool under privacycommand in System Settings ▸ Privacy & Security ▸ Automation and click **Refresh VMs**.
+3. **Attach the image.** Drag the revealed `.dmg` onto the running VM window.
+4. **Inside the VM:** open the **privacycommand-guest** volume in the guest's Finder, double-click **Install.command** and enter your password when `sudo` asks. Wait for the confirmation that the agent is listening on TCP 49374. The agent now starts automatically at every guest login.
+5. **Note the VM's address:** `ifconfig en0 | grep inet` inside the guest.
+
+## Running an app in the VM
+
+1. In Settings ▸ VM agent ▸ Guest agent, enter the VM's IP address and port and click **Test Connection**. The status line reports the handshake: connected (with the guest's macOS and agent version), a version mismatch (rebuild the image and reinstall the agent), or not reachable (check the VM is running, the agent is installed, and the address is right).
+2. Get the `.app` you want to inspect into the VM: drag it (or its `.dmg`) onto the VM window, AirDrop it, scp it, or download it inside the VM.
+3. Type the path of the `.app` inside the VM, for example `/Users/you/Downloads/Foo.app`, and click **Run in VM**. The agent launches the app inside the guest, monitors its process tree, network, file activity and probes, and ships every observation back over the same socket.
+4. Stop the run with **Stop VM Run**, or the usual Stop in the header. The agent terminates the process tree inside the VM and goes back to idle.
+
+## Decompiling in the VM
+
+The Decompile in VM section offloads a whole-app decompilation to the guest. Pick the scope (named classes or everything) and click **Decompile in VM**; the reconstructed classes stream back one at a time and open in the same class browser the local decompiler uses. The guest VM must have Ghidra installed (the agent looks in the same places the host does: `/Applications`, `~/Applications`, `/opt`, `/usr/local` and `~/Tools`). If it is not, the agent says so and nothing else happens.
+
+## The helpers, told apart
+
+privacycommand has a few helper components that do separate jobs:
+
+- **privacycommand (the app).** The GUI on your real Mac. Always needed.
+- **privacycommandHelper (the file-monitoring helper).** Settings ▸ Helper. A root daemon on your host Mac that wraps `fs_usage` for runs on the host. Unrelated to VM mode; if you only use VM mode you do not need it.
+- **privacycommand-guest (the VM agent).** Settings ▸ VM agent. A daemon that runs inside the VM, not on your host. It is what makes VM mode work.
diff --git a/privacycommand/Resources/Manual/05-batch-scan.md b/privacycommand/Resources/Manual/05-batch-scan.md
new file mode 100644
index 0000000..f640067
--- /dev/null
+++ b/privacycommand/Resources/Manual/05-batch-scan.md
@@ -0,0 +1,20 @@
+# Batch scan
+
+Batch scan analyses many apps at once and triages them in one table, using the same static analyser as the single-app flow.
+
+## Opening it
+
+Choose **File ▸ Scan Installed Apps…** (⇧⌘B). The Scan Apps window opens on its own, so you can keep a report open in the main window.
+
+## Scanning
+
+- **Scan** (⌘R) analyses everything in `/Applications` and `~/Applications`.
+- Hold ⇧ while clicking Scan, or click **Choose Folder…**, to scan a folder of your choosing.
+- **Include system apps** adds `/System/Applications`: Apple's own apps, all notarised and usually low signal.
+- **Stop** cancels a scan in progress. Apps already analysed stay in the table.
+
+## Triage
+
+Each row is one app with its risk tier, warning count and headline signals. Sort by any column and use the filter bar to narrow the table to, for example, unsigned apps, apps shipping tracking SDKs, apps without sandboxing, or apps with embedded launch items. The filter field matches on name, bundle identifier and path.
+
+Select a row and choose **Analyze in Main Window** to hand that app to the main window for the full report, a monitored run, or an export.
diff --git a/privacycommand/Resources/Manual/06-history-and-compare.md b/privacycommand/Resources/Manual/06-history-and-compare.md
new file mode 100644
index 0000000..3646a08
--- /dev/null
+++ b/privacycommand/Resources/Manual/06-history-and-compare.md
@@ -0,0 +1,17 @@
+# History and compare
+
+## Saved runs
+
+Every analysis and every monitored run is saved automatically to `~/Library/Application Support/privacycommand/runs//` as a JSON report. The empty main window lists the five most recent; the **History** tab lists them all.
+
+- Click a run to load it back into the window.
+- Right-click for **Reveal in Finder** or **Delete**.
+- **Refresh** re-reads the folder.
+
+Automatic saving can be turned off in Settings ▸ General ▸ Run history; a run is then only kept when you save a report from the File menu. The same section shows how many runs are saved and can delete them all; see [Settings](08-settings.md).
+
+## Comparing two runs
+
+Choose **Compare two runs** on the History tab. Pick a run for each side; the body lists, section by section, what was added and what was removed between them: entitlements, domains, SDKs, login items and findings, colour-cued. **Show only changes** hides everything the two reports have in common. Volatile build tokens that change with every build are collapsed into "modified" entries rather than shown as a removal and an addition.
+
+Comparing the same app across versions is the quickest way to see what an update brought in.
diff --git a/privacycommand/Resources/Manual/07-reports.md b/privacycommand/Resources/Manual/07-reports.md
new file mode 100644
index 0000000..1ca99b3
--- /dev/null
+++ b/privacycommand/Resources/Manual/07-reports.md
@@ -0,0 +1,13 @@
+# Reports
+
+Every report, static findings and dynamic events together, can be exported from the File menu once an app is open:
+
+- **File ▸ Save Run Report (JSON)…** writes the full report as JSON, the same format the History tab stores.
+- **File ▸ Save Run Report (HTML)…** writes a standalone page you can read in any browser or attach to a ticket.
+- **File ▸ Save Run Report (PDF)…** renders the same report as a PDF.
+
+Each item opens a save panel with a file name suggested from the app's name and version. The export reflects the report as it stands: export after a monitored run to include its events, or before one for the static findings alone.
+
+## The command line
+
+The repository also builds `auditctl`, a command-line front end over the same analyser, for scripting and CI. `auditctl ` audits one app (`--short`, `--tree`, `--json`, `--warnings`), and `auditctl preview` inspects outdated Homebrew casks before you update them. Building and using it is covered in the repository's CONTRIBUTING.md.
diff --git a/privacycommand/Resources/Manual/08-settings.md b/privacycommand/Resources/Manual/08-settings.md
new file mode 100644
index 0000000..3e1cb24
--- /dev/null
+++ b/privacycommand/Resources/Manual/08-settings.md
@@ -0,0 +1,39 @@
+# Settings
+
+Open Settings with **privacycommand ▸ Settings…** (⌘,) or from the gear in the watch-mode popover. There are four tabs.
+
+## General
+
+- **Run history.** Whether every run is saved automatically (see [History and compare](06-history-and-compare.md)), how many runs are saved, **Show in Finder**, and **Delete All Runs…**. Deleting asks you to confirm and then to type DELETE: saved reports cannot be recovered.
+- **Analysis cache.** Opening an app you have already analysed reuses its cached static report. The row shows how many reports are cached and their size; **Clear Cache…** asks once, then clears them. Nothing is lost: an app is simply analysed again the next time you open it.
+- **Menu bar.** The glyph for the watch-mode menu bar item (eye, magnifier, binoculars, radar or shield) and whether a watch is active. The item is only shown while you watch an app; see [Monitored runs](03-monitored-runs.md).
+- **About privacycommand** opens the About window.
+
+## Helper
+
+The privileged helper runs as a root daemon under launchd. It accepts XPC connections only from privacycommand, signed by the same Team ID, and its whole job is to run `fs_usage` for file events, read Background Task Management, and install the network kill switch's `pf` rules. It stops when a run ends and unloads after a few seconds idle. Without it the app still works; you lose file-event monitoring and the kill switch, and the Background Task Management audit asks before an admin prompt.
+
+The tab shows the helper's status and the one action it needs next:
+
+- **Not installed:** **Install Helper** registers it with macOS.
+- **Awaiting approval:** macOS wants you to allow it. **Open System Settings** takes you to General ▸ Login Items & Extensions, where you turn privacycommand on. The status updates when you return.
+- **Installed:** **Test Connection** checks the XPC link; **Uninstall Helper…** removes it after a confirmation.
+- **Helper not bundled:** this copy of the app shipped without the helper. The tab shows the path it looked for; see [Troubleshooting](09-troubleshooting.md).
+
+## VM agent
+
+Builds the guest installer, starts VMs, connects to the guest agent, runs an app inside the VM and decompiles there. The whole flow is in [VM mode](04-vm-mode.md).
+
+## Updates
+
+privacycommand updates itself with Sparkle from a signed feed at `privacykey.github.io`. The tab shows the version you are running and when it last checked.
+
+- **Check for updates automatically** is off until you turn it on. privacycommand contacts the feed only when this is on or when you check by hand. Pick **Daily**, **Weekly** or **Monthly** for the frequency.
+- **Check Now** checks straight away; **privacycommand ▸ Check for Updates…** does the same from the menu bar.
+- **Release Notes** opens the releases page on GitHub.
+
+If this copy was installed with Homebrew Cask, the tab says so and shows the `brew upgrade --cask privacycommand` command with a **Copy** button. Homebrew stays in charge of the on-disk bundle: automatic checks are turned off at every launch, and a manual check still tells you when a new version exists.
+
+## About
+
+**privacycommand ▸ About privacycommand** opens the About window: the icon and wordmark, the version and build, what the app does, links to the source on GitHub and to report an issue, this manual, the Keyboard Shortcuts window, the acknowledgements for bundled third-party code, and the licence line.
diff --git a/privacycommand/Resources/Manual/09-troubleshooting.md b/privacycommand/Resources/Manual/09-troubleshooting.md
new file mode 100644
index 0000000..bd390ff
--- /dev/null
+++ b/privacycommand/Resources/Manual/09-troubleshooting.md
@@ -0,0 +1,50 @@
+# Troubleshooting
+
+## The Files tab stays empty during a run
+
+File events come from the privileged helper. Check Settings ▸ Helper: install it, or approve it in System Settings ▸ General ▸ Login Items & Extensions if it is awaiting approval. Once the status reads Installed, start the run again.
+
+## "Helper not bundled"
+
+This copy of privacycommand shipped without the helper executable or its LaunchDaemon plist. The Helper tab shows the path it looked for (`Contents/Library/LaunchDaemons` inside the app). Release builds from GitHub and the Homebrew cask include it; if you built the app yourself, make sure the privacycommandHelper target was built and embedded.
+
+## The helper refuses the connection
+
+The helper validates callers by Team ID. The app and the helper must be signed by the same team; a self-built app with mismatched signing identities fails here. The reporting address in SECURITY.md covers anything that looks like a real security issue.
+
+## "Block network" does nothing
+
+The network kill switch needs the privileged helper and at least one captured destination. Let the run gather some traffic first, then try again. New destinations are not added to the block automatically: lift it and apply it again.
+
+## The VM is "not reachable"
+
+- Is the VM running, and has the guest finished logging in? The agent starts at login.
+- Was `Install.command` run inside the guest, and did it report that the agent is listening on TCP 49374?
+- Is the address right? Run `ifconfig en0 | grep inet` inside the guest; addresses change when the VM's network does.
+- A version mismatch means the agent inside the VM is older or newer than this app. Rebuild the installer disk image and reinstall the agent.
+
+## No VMs are listed for a VM tool
+
+macOS blocked the Apple event used to read the VM list. In System Settings ▸ Privacy & Security ▸ Automation, enable the VM tool under privacycommand, then click Refresh VMs. If the tool has not finished launching, open it and refresh again. VirtualBuddy can start a VM by its exact name even when its library was not found.
+
+## "Decompile in VM" says Ghidra is not installed
+
+Ghidra must be installed inside the guest, not on the host. The agent looks for `support/analyzeHeadless` inside any `ghidra*` folder under `/Applications`, `~/Applications`, `/opt`, `/usr/local` or `~/Tools` in the VM.
+
+## A ZIP archive is refused
+
+privacycommand opens `.app` bundles and `.dmg` disk images only. Extract the archive and open the `.app` inside it.
+
+## A monitored run fails after ejecting a disk image
+
+The app was opened from a `.dmg` that has since been ejected, so its executable is gone. Open the image again and start the run before ejecting.
+
+## Updates
+
+- "This build cannot check for updates" means the build has no feed URL: a local development build, not a release.
+- On a Homebrew install, update with `brew upgrade --cask privacycommand`; the Updates tab shows the command. Automatic checks stay off so Homebrew keeps control of the bundle.
+- If a check fails, the feed at `privacykey.github.io` could not be reached. Check your connection and try **Check Now** again.
+
+## Reporting a problem
+
+Choose **privacycommand ▸ About privacycommand ▸ Report an Issue** to open the issue tracker. Include the version shown in the About window. For anything that looks like a security issue, use the private address in the repository's SECURITY.md instead of a public issue.
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/Sources/privacycommand/ViewModels/UpdateController.swift b/privacycommand/Sources/privacycommand/ViewModels/UpdateController.swift
index 454a8a9..6e6c21b 100644
--- a/privacycommand/Sources/privacycommand/ViewModels/UpdateController.swift
+++ b/privacycommand/Sources/privacycommand/ViewModels/UpdateController.swift
@@ -1,229 +1,59 @@
import Foundation
-import SwiftUI
-#if canImport(Sparkle)
import Sparkle
-#endif
#if SWIFT_PACKAGE
import privacycommandCore
#endif
-/// `@MainActor` `ObservableObject` wrapping Sparkle 2's
-/// `SPUStandardUpdaterController` and exposing the bits the Settings
-/// UI needs as `@Published` properties.
-///
-/// **Why a wrapper rather than using Sparkle directly.** Sparkle's
-/// classes are AppKit-flavoured `NSObject`s; bridging them into a
-/// SwiftUI Settings view in a way that keeps Preview happy and lets
-/// us toggle automatic checks without a relaunch is cleaner with one
-/// glue class.
-///
-/// **Homebrew co-existence.** When `HomebrewDetector` reports that
-/// the running bundle lives under `/opt/homebrew/Caskroom/...`,
-/// Sparkle's `automaticallyChecksForUpdates` is forced to `false` and
-/// the Settings UI substitutes a "Run `brew upgrade --cask
-/// privacycommand`" banner for the install button. Manual "Check for
-/// updates" still works — it's helpful to know when a new version
-/// exists even when you're not the one applying it.
+/// Owns the one Sparkle updater for the app. The Updates pane and the
+/// Check for Updates… item are the shared surfaces, driven by
+/// `SurfaceUpdates(driver: updater, releaseNotes:)`; this class only keeps
+/// the updater alive, pins it to the stable channel and applies the
+/// Homebrew rule.
///
-/// **Gating Sparkle behind `canImport`.** The wrapper compiles
-/// cleanly when Sparkle isn't available (early SPM resolve, CI
-/// without the dep) by stubbing the operations to no-ops. That keeps
-/// `swift build` green while you're wiring the dependency into Xcode
-/// for the first time.
+/// **Homebrew co-existence.** When `HomebrewDetector` reports that the
+/// running bundle lives under `/opt/homebrew/Caskroom/...`, automatic
+/// checks are turned off at every launch so Sparkle never replaces a
+/// bundle that `brew upgrade --cask privacycommand` manages. A manual
+/// check still works: knowing a new version exists is useful regardless
+/// of who applies it, and the Updates pane says so.
@MainActor
final class UpdateController: ObservableObject {
- // MARK: - Published state
-
- /// True while Sparkle is fetching the appcast or downloading an
- /// update. Drives the spinner on the "Check for updates" button.
- @Published private(set) var isChecking: Bool = false
-
- /// Mirrors `UpdatePreferences.autoCheckEnabled`. Setting flips
- /// the underlying Sparkle property live.
- @Published var autoCheckEnabled: Bool {
- didSet {
- UpdatePreferences.setAutoCheckEnabled(autoCheckEnabled)
- applyAutoCheckSetting()
- }
- }
-
- /// Mirrors `UpdatePreferences.checkInterval`.
- @Published var checkInterval: UpdatePreferences.CheckInterval {
- didSet {
- UpdatePreferences.setCheckInterval(checkInterval)
- applyCheckIntervalSetting()
- }
- }
-
- /// Last time Sparkle finished fetching the appcast.
- @Published private(set) var lastCheckedAt: Date?
-
- /// Latest message we want to surface to the user — driven by the
- /// Sparkle delegate methods below. Never block-triggers an alert;
- /// the Settings UI shows it inline.
- @Published private(set) var lastStatusMessage: String?
-
- /// True when this bundle was installed via Homebrew Cask. The
- /// Settings UI swaps the "Install" path for a `brew upgrade`
- /// banner in that case.
+ /// True when this bundle was installed via Homebrew Cask.
let homebrew: HomebrewDetector.Result
- /// Display version pulled from `CFBundleShortVersionString`.
- /// Cached at init so the Settings UI doesn't have to re-read the
- /// bundle on each render.
- let currentVersion: String
-
- // MARK: - Init
-
- #if canImport(Sparkle)
- /// Sparkle's controller owns the lifecycle. We instantiate eagerly
- /// (`startingUpdater: true`) so background checks can run as soon
- /// as the user enables them.
+ /// Sparkle's controller owns the lifecycle. Instantiated eagerly
+ /// (`startingUpdater: true`) so background checks can run as soon as
+ /// the user turns them on. `SUEnableAutomaticChecks` is false in
+ /// Info.plist, so they start off and Sparkle never asks on its own.
private let updaterController: SPUStandardUpdaterController
private let delegate = UpdaterDelegate()
- #endif
- init() {
- self.homebrew = HomebrewDetector.detect()
- self.autoCheckEnabled = UpdatePreferences.autoCheckEnabled
- self.checkInterval = UpdatePreferences.checkInterval
- self.lastCheckedAt = UpdatePreferences.lastCheckedAt
- self.currentVersion = (Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String)
- ?? "(unknown)"
+ /// The driver the shared Updates pane and app menu work with.
+ var updater: SPUUpdater { updaterController.updater }
- #if canImport(Sparkle)
- // `startingUpdater: true` boots Sparkle's background scheduler
- // immediately. The `applyAutoCheckSetting()` below will turn
- // that scheduler off if the user hasn't opted in.
- self.updaterController = SPUStandardUpdaterController(
+ init() {
+ homebrew = HomebrewDetector.detect()
+ updaterController = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: delegate,
userDriverDelegate: nil
)
- delegate.controller = self
- #endif
-
- applyAutoCheckSetting()
- applyCheckIntervalSetting()
- }
-
- // MARK: - Public actions
-
- /// Run an explicit "Check for updates" against the appcast.
- /// Always allowed, even on Homebrew installs — knowing a new
- /// version exists is useful regardless of who's applying it.
- func checkForUpdates() {
- #if canImport(Sparkle)
- isChecking = true
- lastStatusMessage = "Checking for updates…"
- updaterController.checkForUpdates(nil)
- #else
- lastStatusMessage = "Sparkle is not linked into this build."
- #endif
- }
-
- /// Open the GitHub Releases page in the default browser. Used by
- /// the "Release notes" link in Settings, and by the Homebrew
- /// banner's "View latest release" action.
- func openReleasesPage() {
- NSWorkspace.shared.open(UpdateChannel.releasesPageURL)
- }
-
- /// Stop skipping a previously-skipped version, so Sparkle will
- /// surface it again on the next check. The Settings UI exposes
- /// this when `lastStatusMessage` indicates a skip is in effect.
- func clearSkippedVersion() {
- #if canImport(Sparkle)
- // Sparkle stores the skipped version under
- // `SUSkippedVersion` / `SUSkippedMinorVersion` in
- // UserDefaults; clearing both is the documented way to undo
- // a skip without API for it.
- UserDefaults.standard.removeObject(forKey: "SUSkippedVersion")
- UserDefaults.standard.removeObject(forKey: "SUSkippedMinorVersion")
- UserDefaults.standard.removeObject(forKey: UpdatePreferences.Key.skippedVersion)
- lastStatusMessage = "Cleared the skipped-version preference."
- #endif
- }
-
- // MARK: - Internal — called by the Sparkle delegate
-
- fileprivate func didFinishCheck(success: Bool, message: String?) {
- isChecking = false
- if success {
- UpdatePreferences.recordCheckCompleted()
- lastCheckedAt = UpdatePreferences.lastCheckedAt
+ if homebrew.isHomebrewInstall {
+ updaterController.updater.automaticallyChecksForUpdates = false
}
- if let message { lastStatusMessage = message }
- }
-
- // MARK: - Sparkle setting application
-
- private func applyAutoCheckSetting() {
- #if canImport(Sparkle)
- // Homebrew installs override the user's preference. We never
- // let Sparkle silently install over a Cask-managed bundle;
- // the "Check for updates" button still works, but the
- // background scheduler stays off so users aren't tempted
- // toward a broken install path.
- let effective = autoCheckEnabled && !homebrew.isHomebrewInstall
- updaterController.updater.automaticallyChecksForUpdates = effective
- #endif
- }
-
- private func applyCheckIntervalSetting() {
- #if canImport(Sparkle)
- updaterController.updater.updateCheckInterval = checkInterval.sparkleSeconds
- #endif
}
}
// MARK: - Sparkle delegate
-#if canImport(Sparkle)
-/// Sparkle's delegate methods are `@objc` and pre-Swift-concurrency,
-/// so we keep them on a separate `NSObject` and bounce results back
-/// to the `@MainActor`-isolated controller. Holding the controller
-/// weakly avoids a retain cycle (the controller owns Sparkle, which
-/// holds a reference to this delegate).
+/// Sparkle's delegate methods are `@objc` and pre-Swift-concurrency, so
+/// they live on a separate `NSObject`.
private final class UpdaterDelegate: NSObject, SPUUpdaterDelegate {
- weak var controller: UpdateController?
-
- /// Sparkle's preferred channels — single-element list keeps us
- /// on stable. If we ever add a beta channel, this is where the
- /// opt-in toggle plumbs through.
+ /// Sparkle's preferred channels — single-element list keeps us on
+ /// stable. If we ever add a beta channel, this is where the opt-in
+ /// toggle plumbs through.
func allowedChannels(for updater: SPUUpdater) -> Set {
[UpdateChannel.channel]
}
-
- func updater(_ updater: SPUUpdater, didFinishLoading appcast: SUAppcast) {
- Task { @MainActor [weak controller] in
- controller?.didFinishCheck(success: true, message: nil)
- }
- }
-
- func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: Error?) {
- let message = error.map { "Update check failed: \($0.localizedDescription)" }
- Task { @MainActor [weak controller] in
- controller?.didFinishCheck(success: error == nil, message: message)
- }
- }
-
- func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
- let v = item.displayVersionString
- Task { @MainActor [weak controller] in
- controller?.didFinishCheck(
- success: true,
- message: "Update available: v\(v).")
- }
- }
-
- func updaterDidNotFindUpdate(_ updater: SPUUpdater) {
- Task { @MainActor [weak controller] in
- controller?.didFinishCheck(
- success: true,
- message: "You're on the latest version.")
- }
- }
}
-#endif
diff --git a/privacycommand/Sources/privacycommand/ViewModels/WatchModeManager.swift b/privacycommand/Sources/privacycommand/ViewModels/WatchModeManager.swift
index 3e44b65..07652a3 100644
--- a/privacycommand/Sources/privacycommand/ViewModels/WatchModeManager.swift
+++ b/privacycommand/Sources/privacycommand/ViewModels/WatchModeManager.swift
@@ -192,13 +192,13 @@ enum WatchModeIconStyle: String, CaseIterable, Identifiable {
var id: String { rawValue }
- /// Human-readable name in the settings dropdown.
+ /// Caption under the tile in the Settings icon picker.
var displayName: String {
switch self {
case .eye: return "Eye"
- case .magnifier: return "Search bar (magnifying glass)"
+ case .magnifier: return "Magnifier"
case .binoculars: return "Binoculars"
- case .radar: return "Radar antenna"
+ case .radar: return "Radar"
case .shield: return "Shield"
}
}
diff --git a/privacycommand/Sources/privacycommand/Views/GuestAgentSettingsView.swift b/privacycommand/Sources/privacycommand/Views/GuestAgentSettingsView.swift
index c532dce..dbe4123 100644
--- a/privacycommand/Sources/privacycommand/Views/GuestAgentSettingsView.swift
+++ b/privacycommand/Sources/privacycommand/Views/GuestAgentSettingsView.swift
@@ -4,13 +4,18 @@ import AppKit
import privacycommandCore
#endif
-/// Settings tab that walks the user through getting privacycommand-
-/// guest installed on a macOS VM.
+/// The VM agent pane of Settings: the sections that get privacycommand-guest
+/// installed on a macOS VM and a run started inside it.
///
-/// Three sections:
+/// Four sections:
/// 1. Build the installer DMG (calls Scripts/build-guest-installer.sh)
-/// 2. Detected VM tools — VirtualBuddy / UTM / Parallels / VMware
-/// 3. Per-tool VM list with Start + Reveal-installer buttons
+/// 2. Detected VM tools — VirtualBuddy / UTM / Parallels / VMware — with
+/// a per-tool VM list and Start + Reveal-installer buttons
+/// 3. Connect to the guest agent and run an app inside the VM
+/// 4. Decompile an app inside the VM
+///
+/// The walkthrough itself (what runs where, the steps inside the guest, the
+/// helper glossary) is the VM mode chapter of the manual.
struct GuestAgentSettingsView: View {
@EnvironmentObject private var coordinator: AnalysisCoordinator
@@ -32,58 +37,46 @@ struct GuestAgentSettingsView: View {
@State private var vmDecompileScopeKind: DecompileScope.Kind = .namedClasses
@State private var showingVMDecompileResult = false
+ private static let overview = "VM mode runs the inspected app inside a separate macOS virtual machine. A small daemon, privacycommand-guest, runs inside the VM with no UI of its own: it listens for commands from this app on TCP 49374 and ships its observations back, so they appear in the same Summary, Files, Network and Probes tabs with a VM badge. You do not need a second copy of privacycommand inside the VM. The manual's VM mode chapter walks through the whole setup."
+
+ private static let buildInfo = "Compiles privacycommand-guest in release mode and packages it, with its LaunchAgent plist and Install.command, into a small .dmg. Takes about 30 seconds the first time. Then drag the disk image onto the running VM's window (VirtualBuddy, UTM, Parallels and VMware all accept disk-image drops), open the privacycommand-guest volume inside the guest and double-click Install.command."
+
+ private static let addressInfo = "Once Install.command has finished, find the VM's address inside the guest with `ifconfig en0 | grep inet` and enter it here. Test Connection runs a version handshake with the agent; Run in VM enables once it answers."
+
+ private static let appPathInfo = "The path of the .app inside the VM, for example /Users/you/Downloads/Foo.app. Copy the app into the guest first: drag it (or its .dmg) onto the VM window, AirDrop it, or scp it."
+
+ private static let decompileInfo = "Runs Ghidra inside the guest and streams the reconstructed classes back, so the CPU-heavy analysis never touches your Mac. Ghidra must be installed in the VM; if it is not, the agent says so and nothing else happens."
+
var body: some View {
- Form {
- Section("How VM mode works") {
- howItWorksSection
- }
- Section("Step 1 · Installer disk image") {
- buildSection
- }
- Section("Step 2 · Detected VM tools") {
- if detectedTools.isEmpty {
- Text("No supported VM tools found on this Mac. Install VirtualBuddy, UTM, Parallels Desktop, or VMware Fusion first.")
- .font(.callout).foregroundStyle(.secondary)
- } else {
- HStack {
- Spacer()
- Button {
- refreshVMs()
- } label: {
- Label("Refresh VMs", systemImage: "arrow.clockwise")
- }
- .controlSize(.small)
- .help("Re-query each VM tool. Use this after granting Automation access so the lists repopulate without restarting privacycommand.")
- }
- ForEach(detectedTools, id: \.kind) { tool in
- toolSection(tool)
- }
- }
- }
- Section("Step 3 · Inside the VM") {
- Text("Once the installer disk image is mounted in your guest VM:")
- .font(.callout)
- Text("1. Open the **privacycommand-guest** volume in the guest's Finder.")
- Text("2. Double-click **Install.command**. Enter your password when sudo asks.")
- Text("3. Wait for the confirmation that the agent is listening on TCP 49374.")
- Text("4. Note the VM's IP address: `ifconfig en0 | grep inet`. Plug that IP into the connection panel below.")
- .font(.callout)
- }
- Section("Step 3½ · Connect to the guest agent & run") {
- connectionSection
- }
- Section("Step 4 · Picking an app to inspect") {
- pickAppSection
- }
- Section("Common confusion") {
- glossarySection
- }
+ Section("Installer disk image") {
+ buildSection
}
- .formStyle(.grouped)
.task {
detectedTools = VMHostDetection.detectInstalled()
refreshVMs()
}
+ Section("VM tools") {
+ if detectedTools.isEmpty {
+ Text("No supported VM tools found on this Mac. Install VirtualBuddy, UTM, Parallels Desktop or VMware Fusion first.")
+ .font(.callout).foregroundStyle(.secondary)
+ } else {
+ LabeledContent {
+ Button("Refresh VMs") { refreshVMs() }
+ .help("Re-query each VM tool. Use this after granting Automation access so the lists repopulate without restarting privacycommand.")
+ } label: {
+ SurfaceInfoLabel("Detected", info: "\(detectedTools.count) VM tool\(detectedTools.count == 1 ? "" : "s") installed. privacycommand can start a VM and reveal the installer for you, but no VM tool exposes a way to attach a disk image from outside, so that one step is a drag onto the VM window.")
+ }
+ ForEach(detectedTools, id: \.kind) { tool in
+ toolSection(tool)
+ }
+ }
+ }
+ Section("Guest agent") {
+ connectionSection
+ }
+ Section("Decompile in VM") {
+ vmDecompileControls
+ }
}
/// Re-query every detected tool for its VM list. Called on first
@@ -109,53 +102,50 @@ struct GuestAgentSettingsView: View {
@ViewBuilder
private var buildSection: some View {
- VStack(alignment: .leading, spacing: 8) {
- if let url = installerURL, FileManager.default.fileExists(atPath: url.path) {
- LabeledContent("Installer DMG") {
- HStack(spacing: 8) {
- Text(url.lastPathComponent)
- .font(.caption.monospaced())
- .lineLimit(1).truncationMode(.middle)
- Button("Reveal") {
- VMHostDetection.revealInstallerInFinder(at: url)
- }
- Button("Rebuild") { Task { await build() } }
- .disabled(isBuilding)
+ if let url = installerURL, FileManager.default.fileExists(atPath: url.path) {
+ LabeledContent {
+ HStack(spacing: 8) {
+ Text(url.lastPathComponent)
+ .font(.caption.monospaced())
+ .lineLimit(1).truncationMode(.middle)
+ Button("Reveal") {
+ VMHostDetection.revealInstallerInFinder(at: url)
}
+ Button("Rebuild") { Task { await build() } }
+ .disabled(isBuilding)
}
- Text("Drag this DMG onto a running VM window — VirtualBuddy, UTM, Parallels and VMware all accept disk-image drops. Or attach it via your VM tool's menu.")
- .font(.caption).foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- } else {
+ } label: {
+ SurfaceInfoLabel("Disk image", info: Self.buildInfo)
+ }
+ } else {
+ LabeledContent {
Button {
Task { await build() }
} label: {
- Label("Build installer disk image",
- systemImage: "hammer")
+ Label("Build Disk Image", systemImage: "hammer")
}
.disabled(isBuilding)
.buttonStyle(.borderedProminent)
- Text("Compiles privacycommand-guest in release mode and packages it (plus the LaunchAgent plist and Install.command) into a small .dmg. Takes about 30 seconds the first time.")
- .font(.caption).foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
+ } label: {
+ SurfaceInfoLabel("Disk image", info: Self.overview + "\n\n" + Self.buildInfo)
}
- if isBuilding {
- ProgressView("Building…").controlSize(.small)
- }
- if let err = buildError {
- Label(err, systemImage: "xmark.octagon.fill")
- .foregroundStyle(.red).font(.caption)
- }
- if !buildLog.isEmpty {
- DisclosureGroup("Build log") {
- ScrollView {
- Text(buildLog)
- .font(.caption.monospaced())
- .frame(maxWidth: .infinity, alignment: .leading)
- .textSelection(.enabled)
- }
- .frame(maxHeight: 120)
+ }
+ if isBuilding {
+ ProgressView("Building…").controlSize(.small)
+ }
+ if let err = buildError {
+ Label(err, systemImage: "xmark.octagon.fill")
+ .foregroundStyle(.red).font(.caption)
+ }
+ if !buildLog.isEmpty {
+ DisclosureGroup("Build log") {
+ ScrollView {
+ Text(buildLog)
+ .font(.caption.monospaced())
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .textSelection(.enabled)
}
+ .frame(maxHeight: 120)
}
}
}
@@ -169,7 +159,13 @@ struct GuestAgentSettingsView: View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Image(systemName: "macwindow.badge.plus").foregroundStyle(.blue)
- Text(tool.displayName).font(.subheadline.bold())
+ // None of the VM front-ends expose a public AppleScript verb
+ // for "attach this disk image"; the universally-supported
+ // path is drag-and-drop onto the VM's window, which is what
+ // Reveal Installer is for.
+ SurfaceInfoLabel(tool.displayName,
+ info: "privacycommand can start a VM and reveal the installer disk image in Finder, but it cannot attach the image to the VM: \(tool.displayName) does not expose an attach-image API. Drag the revealed file onto the running \(tool.displayName) window once; the tool mounts it as a shared disk inside the guest.")
+ .font(.subheadline.bold())
Spacer()
if case .ok = outcome {
Text("\(vms.count) VM\(vms.count == 1 ? "" : "s")")
@@ -177,17 +173,6 @@ struct GuestAgentSettingsView: View {
}
}
- // Important caveat — explained once per tool so the user
- // doesn't expect a one-click attach. None of the VM
- // front-ends expose a public AppleScript verb for "attach
- // this disk image"; the universally-supported path is
- // drag-and-drop onto the VM's window, which is what
- // Reveal-installer is for.
- Text("**privacycommand can start a VM and reveal the installer DMG in Finder for you, but it can't attach the DMG to the VM automatically — \(tool.displayName) doesn't expose an attach-image API. Drag the highlighted file onto the running \(tool.displayName) window once; the tool mounts it as a shared disk inside the guest.**")
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
-
switch outcome {
case .notAuthorized:
VStack(alignment: .leading, spacing: 4) {
@@ -197,7 +182,7 @@ struct GuestAgentSettingsView: View {
Text("macOS blocked the Apple event used to read the VM list. Enable **\(tool.displayName)** under **privacycommand** in System Settings → Privacy & Security → Automation, then click Refresh VMs.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
- Button("Open Automation settings") { openAutomationSettings() }
+ Button("Open Automation Settings") { openAutomationSettings() }
.buttonStyle(.borderless).controlSize(.small)
}
case .scriptError(let code, let message):
@@ -211,7 +196,7 @@ struct GuestAgentSettingsView: View {
.fixedSize(horizontal: false, vertical: true)
}
case .unsupported:
- Text("\(tool.displayName) doesn't expose a VM-list API privacycommand can read. Start the VM yourself, then drag the installer DMG onto its window.")
+ Text("\(tool.displayName) doesn't expose a VM list privacycommand can read. Start the VM yourself, then drag the installer disk image onto its window.")
.font(.caption).foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
case .ok where vms.isEmpty:
@@ -239,11 +224,11 @@ struct GuestAgentSettingsView: View {
}
.controlSize(.small)
if let url = installerURL {
- Button("Reveal installer") {
+ Button("Reveal Installer") {
VMHostDetection.revealInstallerInFinder(at: url)
}
.controlSize(.small)
- .help("Selects the installer DMG in Finder. Drag it onto the running \(tool.displayName) window to attach it as a shared disk inside the guest. We can't do this automatically — \(tool.displayName) doesn't expose an attach-image API.")
+ .help("Selects the installer disk image in Finder. Drag it onto the running \(tool.displayName) window to attach it as a shared disk inside the guest.")
}
}
}
@@ -272,11 +257,11 @@ struct GuestAgentSettingsView: View {
.controlSize(.small)
.disabled(trimmed.isEmpty)
if let url = installerURL {
- Button("Reveal installer") {
+ Button("Reveal Installer") {
VMHostDetection.revealInstallerInFinder(at: url)
}
.controlSize(.small)
- .help("Selects the installer DMG in Finder. Drag it onto the running \(tool.displayName) window to attach it as a shared disk inside the guest.")
+ .help("Selects the installer disk image in Finder. Drag it onto the running \(tool.displayName) window to attach it as a shared disk inside the guest.")
}
}
if tool.kind == .virtualBuddy {
@@ -295,44 +280,40 @@ struct GuestAgentSettingsView: View {
// MARK: - Connect & run-in-VM
- /// The connection panel: VM address + port, a Test button with a live
- /// status badge, and (once reachable) a control to launch a run inside
- /// the guest. The run's observations stream into the normal tabs.
+ /// The connection rows: VM address + port with a Test button and a live
+ /// status line, then the guest app path with the control that launches
+ /// a run inside the guest. The run's observations stream into the
+ /// normal tabs.
@ViewBuilder
private var connectionSection: some View {
- VStack(alignment: .leading, spacing: 10) {
- Text("Point privacycommand at the guest agent running inside your VM, then launch the app there. The run shows up in the Dashboard / Network / Files / Probes tabs, just tagged as a VM run.")
- .font(.callout).foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
-
+ LabeledContent {
HStack {
TextField("VM IP address (e.g. 192.168.64.5)", text: $coordinator.vmHost)
.textFieldStyle(.roundedBorder)
- .frame(maxWidth: 240)
+ .frame(maxWidth: 200)
TextField("Port", value: $coordinator.vmPort,
format: .number.grouping(.never))
.textFieldStyle(.roundedBorder)
- .frame(maxWidth: 70)
- Button("Test connection") {
+ .frame(maxWidth: 64)
+ Button("Test Connection") {
Task { await coordinator.testVMConnection() }
}
.controlSize(.small)
.disabled(coordinator.vmConnection == .checking)
}
+ } label: {
+ SurfaceInfoLabel("Address", info: Self.addressInfo)
+ }
- connectionStatusBadge
-
- Divider().padding(.vertical, 2)
+ connectionStatusBadge
- Text("Path to the .app **inside the VM** to launch (you transfer it in via drag-drop / AirDrop / scp first):")
- .font(.caption).foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
+ LabeledContent {
HStack {
TextField("/Users/you/Downloads/Foo.app", text: $guestBundlePath)
.textFieldStyle(.roundedBorder)
- .frame(maxWidth: 320)
+ .frame(maxWidth: 260)
if coordinator.isVMRun && coordinator.isMonitoring {
- Button("Stop VM run") {
+ Button("Stop VM Run") {
Task { await coordinator.stopMonitoredRun() }
}
.controlSize(.small)
@@ -347,14 +328,13 @@ struct GuestAgentSettingsView: View {
|| guestBundlePath.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
- if !coordinator.canStartVMRun
- && !(coordinator.isVMRun && coordinator.isMonitoring) {
- Text("Test the connection first — “Run in VM” enables once the guest agent answers.")
- .font(.caption2).foregroundStyle(.secondary)
- }
-
- Divider().padding(.vertical, 2)
- vmDecompileControls
+ } label: {
+ SurfaceInfoLabel("App inside the VM", info: Self.appPathInfo)
+ }
+ if !coordinator.canStartVMRun
+ && !(coordinator.isVMRun && coordinator.isMonitoring) {
+ Text("Test the connection first — Run in VM enables once the guest agent answers.")
+ .font(.caption).foregroundStyle(.secondary)
}
}
@@ -363,15 +343,13 @@ struct GuestAgentSettingsView: View {
/// shows the result in the shared `DecompilationBrowser`.
@ViewBuilder
private var vmDecompileControls: some View {
- VStack(alignment: .leading, spacing: 6) {
- Text("Or decompile the app **inside the VM** (needs Ghidra installed in the guest):")
- .font(.caption).foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
+ LabeledContent {
HStack {
Picker("Scope", selection: $vmDecompileScopeKind) {
Text("Named classes").tag(DecompileScope.Kind.namedClasses)
Text("Everything").tag(DecompileScope.Kind.everything)
}
+ .labelsHidden()
.pickerStyle(.segmented).fixedSize()
.disabled(coordinator.vmDecompiling)
@@ -395,14 +373,12 @@ struct GuestAgentSettingsView: View {
}
if let result = coordinator.vmDecompileResult {
- Button("View \(result.classCount) classes") { showingVMDecompileResult = true }
+ Button("View \(result.classCount) Classes") { showingVMDecompileResult = true }
.controlSize(.small)
}
}
- if let error = coordinator.vmDecompileError {
- Text(error).font(.caption2).foregroundStyle(.orange)
- .fixedSize(horizontal: false, vertical: true)
- }
+ } label: {
+ SurfaceInfoLabel("Scope", info: Self.decompileInfo)
}
.sheet(isPresented: $showingVMDecompileResult) {
if let index = coordinator.vmDecompileResult {
@@ -420,6 +396,10 @@ struct GuestAgentSettingsView: View {
.frame(minWidth: 900, minHeight: 560)
}
}
+ if let error = coordinator.vmDecompileError {
+ Text(error).font(.caption).foregroundStyle(.orange)
+ .fixedSize(horizontal: false, vertical: true)
+ }
}
@ViewBuilder
@@ -439,7 +419,7 @@ struct GuestAgentSettingsView: View {
.font(.caption).foregroundStyle(.green)
.fixedSize(horizontal: false, vertical: true)
case .versionMismatch(let guestVersion, let hostVersion):
- Label("Guest agent is v\(guestVersion) but this host speaks v\(hostVersion). Rebuild the installer DMG and reinstall the agent inside the VM.",
+ Label("Guest agent is v\(guestVersion) but this host speaks v\(hostVersion). Rebuild the installer disk image and reinstall the agent inside the VM.",
systemImage: "exclamationmark.triangle.fill")
.font(.caption).foregroundStyle(.orange)
.fixedSize(horizontal: false, vertical: true)
@@ -452,114 +432,6 @@ struct GuestAgentSettingsView: View {
}
}
- // MARK: - Explanatory sections
-
- /// Architecture explainer at the top of the panel — sets
- /// expectations before the user starts clicking buttons.
- private var howItWorksSection: some View {
- VStack(alignment: .leading, spacing: 6) {
- Text("VM mode runs the inspected app inside a separate macOS VM. Two binaries are involved:")
- .font(.callout).fixedSize(horizontal: false, vertical: true)
-
- architectureRow(
- icon: "macbook",
- title: "On your Mac (host)",
- text: "The privacycommand app you're using right now. Same UI, same Dashboard / Static / Files / Network / Probes tabs. You don't need a second window — when VM mode is active, observations from the VM stream into the same tabs.")
-
- architectureRow(
- icon: "macwindow.on.rectangle",
- title: "Inside the VM (guest)",
- text: "A small daemon called **privacycommand-guest**. No UI — it's a background process that listens for commands from the host on TCP 49374 and ships observations back. You install it once with the DMG built below, then forget about it.")
-
- Text("**You do not need a second copy of the privacycommand app inside the VM.** Just the agent.")
- .font(.callout)
- .padding(.top, 4)
- .fixedSize(horizontal: false, vertical: true)
-
- Text("**One thing privacycommand can't do for you:** automatically attach the installer DMG to your VM. VirtualBuddy, UTM, and VMware Fusion don't expose a public way for outside apps to mount disk images into a running guest. So the workflow has one manual step — once the DMG is built, drag it onto your VM's window. Every supported VM tool accepts this drop and mounts the image as a shared disk inside the guest. Parallels Desktop users can alternatively shell out to `prlctl set --device-add cdrom --image=...`, but the drag-drop path is uniform.")
- .font(.caption).foregroundStyle(.secondary)
- .padding(.top, 4)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
-
- /// "Step 4" — actually walking the user through using VM mode
- /// after the agent is installed.
- private var pickAppSection: some View {
- VStack(alignment: .leading, spacing: 6) {
- Text("Once the agent is installed and you've connected the host to the VM:")
- .font(.callout).fixedSize(horizontal: false, vertical: true)
-
- instructionRow("1", "Get a copy of the .app you want to inspect into the VM. The easiest path is to drag the .app (or the .dmg it came on) onto the VM window — VirtualBuddy / UTM / Parallels all accept drops as a shared file. You can also AirDrop, or scp, or download it inside the VM directly.")
-
- instructionRow("2", "Note the path to the .app inside the VM. Usually somewhere like /Users//Downloads/Foo.app or /Applications/Foo.app once the user drags it there.")
-
- instructionRow("3", "On the host, drag a .app or .dmg onto privacycommand's window the same way you always have. When VM mode is active, the host UI shows a chooser asking whether to inspect on the host or in the connected VM. Pick the VM.")
-
- instructionRow("4", "If you picked the VM, the host sends the bundle path you typed (or one we propose, like /tmp/privacycommand/inspect.app) to the agent. The agent launches the app inside the VM, monitors its process tree / network / file activity / live probes, and ships every observation back over the same TCP socket. The host UI shows it all in the existing tabs — just labelled with a small \"VM\" badge so you know the events came from the guest, not your real Mac.")
-
- Text("Stop a VM run the same way you'd stop a host run — Stop button in the toolbar. The agent terminates the process tree inside the VM and goes back to idle, ready for the next launch.")
- .font(.callout).foregroundStyle(.secondary)
- .padding(.top, 4)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
-
- /// Glossary of the various "helper" things — privacycommand has
- /// accumulated a few daemons and it's easy to confuse them.
- private var glossarySection: some View {
- VStack(alignment: .leading, spacing: 8) {
- Text("privacycommand has a few \"helper\" components. They're separate things that do separate jobs:")
- .font(.callout).fixedSize(horizontal: false, vertical: true)
-
- glossaryRow(
- title: "privacycommand (the app)",
- desc: "What you're looking at. The GUI on your real Mac. You always need this; the other components are optional.")
-
- glossaryRow(
- title: "privacycommandHelper (the file-monitoring helper)",
- desc: "Tab next to this one. A root daemon on your **host** Mac that wraps fs_usage to capture file-system events for runs that happen on your host. **Unrelated to VM mode.** If you only use VM mode, you don't need this helper installed.")
-
- glossaryRow(
- title: "privacycommand-guest (this tab)",
- desc: "A small daemon that runs **inside the VM**, not on your host. It's what makes VM mode work. Installed via the DMG built below.")
- }
- }
-
- private func architectureRow(icon: String, title: String, text: String) -> some View {
- HStack(alignment: .top, spacing: 12) {
- Image(systemName: icon)
- .font(.title3)
- .foregroundStyle(.blue)
- .frame(width: 28, alignment: .center)
- VStack(alignment: .leading, spacing: 2) {
- Text(title).font(.subheadline.bold())
- Text(text).font(.callout).foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
- }
-
- private func instructionRow(_ n: String, _ text: String) -> some View {
- HStack(alignment: .firstTextBaseline, spacing: 10) {
- Text(n)
- .font(.subheadline.bold())
- .frame(width: 22, height: 22)
- .background(Color.accentColor.opacity(0.15), in: Circle())
- .foregroundStyle(Color.accentColor)
- Text(text).font(.callout).foregroundStyle(.primary)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
-
- private func glossaryRow(title: String, desc: String) -> some View {
- VStack(alignment: .leading, spacing: 1) {
- Text(title).font(.subheadline.bold())
- Text(desc).font(.callout).foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
-
// MARK: - Build action
private func build() async {
diff --git a/privacycommand/Sources/privacycommand/Views/SettingsView.swift b/privacycommand/Sources/privacycommand/Views/SettingsView.swift
index c181b57..a57e507 100644
--- a/privacycommand/Sources/privacycommand/Views/SettingsView.swift
+++ b/privacycommand/Sources/privacycommand/Views/SettingsView.swift
@@ -4,140 +4,125 @@ import AppKit
import privacycommandCore
#endif
-/// macOS-style preferences window with three tabs. Reachable via ⌘, or the
-/// "privacycommand → Settings…" menu item that SwiftUI auto-installs when
-/// you declare a `Settings` scene.
-struct SettingsView: View {
+/// The Settings panes, in tab order: General, Helper, VM agent, Updates.
+/// `SurfaceSettings` wraps each one in a grouped form and adds the About
+/// button to General itself.
+@MainActor
+enum PrivacycommandSettings {
+ static func panes(
+ app: SurfaceApp,
+ menuBar: SurfaceMenuBarPreference,
+ watchManager: WatchModeManager,
+ updates: SurfaceUpdates,
+ updateController: UpdateController
+ ) -> [SurfacePane] {
+ [
+ SurfacePane("General", systemImage: "gearshape") {
+ GeneralPane(app: app, menuBar: menuBar, watchManager: watchManager)
+ },
+ SurfacePane("Helper", systemImage: "shield.lefthalf.filled") {
+ HelperPane()
+ },
+ SurfacePane("VM agent", systemImage: "macwindow.on.rectangle") {
+ GuestAgentSettingsView()
+ },
+ updatesPane(updates: updates, controller: updateController),
+ ]
+ }
- var body: some View {
- TabView {
- GeneralSettingsView()
- .tabItem { Label("General", systemImage: "gearshape") }
- .frame(minWidth: 540, minHeight: 380)
- HelperSettingsView()
- .tabItem { Label("Helper", systemImage: "shield.lefthalf.filled") }
- .frame(minWidth: 540, minHeight: 380)
- GuestAgentSettingsView()
- .tabItem { Label("VM agent", systemImage: "macwindow.on.rectangle") }
- .frame(minWidth: 540, minHeight: 380)
- UpdatesSettingsView()
- .tabItem { Label("Updates", systemImage: "arrow.down.circle") }
- .frame(minWidth: 540, minHeight: 380)
- AboutSettingsView()
- .tabItem { Label("About", systemImage: "info.circle") }
- .frame(minWidth: 540, minHeight: 380)
+ /// The standard Updates pane. A copy installed through Homebrew Cask
+ /// gets a section above it pointing at `brew upgrade`, which stays in
+ /// charge of the on-disk bundle.
+ private static func updatesPane(updates: SurfaceUpdates, controller: UpdateController) -> SurfacePane {
+ guard controller.homebrew.isHomebrewInstall else { return .updates(updates) }
+ return SurfacePane("Updates", systemImage: "arrow.down.circle") {
+ HomebrewSection(cask: controller.homebrew.caskName)
+ SurfaceUpdateSections(updates: updates)
}
- .padding()
- .frame(width: 620, height: 520)
}
}
// MARK: - General
-private struct GeneralSettingsView: View {
+private struct GeneralPane: View {
+ let app: SurfaceApp
+ @ObservedObject var menuBar: SurfaceMenuBarPreference
+ @ObservedObject var watchManager: WatchModeManager
@AppStorage("autoSaveRuns") private var autoSaveRuns = true
- @AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
- @AppStorage("watchModeIconStyle") private var watchModeIconRaw = WatchModeIconStyle.shield.rawValue
- @EnvironmentObject var coordinator: AnalysisCoordinator
- @State private var showingClearConfirm = false
+ @EnvironmentObject private var coordinator: AnalysisCoordinator
@State private var cachedCount = 0
@State private var cachedSizeBytes: Int64 = 0
var body: some View {
- Form {
- Section("Run history") {
- Toggle("Save runs to history automatically", isOn: $autoSaveRuns)
- .help("When off, runs are only written to disk via the Export menu actions.")
- LabeledContent("Saved runs") {
- HStack(spacing: 8) {
- Text("\(coordinator.recentRuns.count)")
- .monospacedDigit()
- Button("Show in Finder") {
- NSWorkspace.shared.activateFileViewerSelecting([RunStore.shared.baseURL])
- }
- Button("Clear all…", role: .destructive) {
- showingClearConfirm = true
- }
- }
- }
+ Section("Run history") {
+ Toggle(isOn: $autoSaveRuns) {
+ SurfaceInfoLabel("Save runs to history automatically",
+ info: "Every analysis and monitored run is written to ~/Library/Application Support/privacycommand/runs and listed in the History tab. When this is off, a run is only kept when you save a report from the File menu.")
}
- Section("Analysis cache") {
- LabeledContent("Cached app analyses") {
- HStack(spacing: 8) {
- Text("\(cachedCount)").monospacedDigit()
- if cachedCount > 0 {
- Text("·").foregroundStyle(.secondary)
- Text(cacheSizeLabel).foregroundStyle(.secondary).monospacedDigit()
- }
- Button("Show in Finder") {
- NSWorkspace.shared.activateFileViewerSelecting([StaticReportCache.shared.baseURL])
- }
- Button("Clear cache") {
- StaticReportCache.shared.clear()
- cachedCount = 0
- cachedSizeBytes = 0
+ LabeledContent("Saved runs") {
+ HStack(spacing: 8) {
+ Text("\(coordinator.recentRuns.count)").monospacedDigit()
+ Button("Show in Finder") {
+ NSWorkspace.shared.activateFileViewerSelecting([RunStore.shared.baseURL])
+ }
+ SurfaceDestructiveButton(
+ "Delete All Runs…", gate: .typed("DELETE"),
+ question: "Delete all saved runs?",
+ consequence: "This removes the saved report for every run, \(coordinator.recentRuns.count) in total. It cannot be undone.",
+ confirmTitle: "Delete Runs"
+ ) {
+ for meta in coordinator.recentRuns {
+ coordinator.deleteRun(id: meta.id)
}
- .disabled(cachedCount == 0)
}
+ .disabled(coordinator.recentRuns.isEmpty)
}
- .help("Lets re-opening an already-scanned app skip re-analysis. Safe to clear — apps are simply re-analysed on next open.")
}
- Section("Watch mode") {
- LabeledContent("Menu-bar icon") {
- Picker("", selection: $watchModeIconRaw) {
- ForEach(WatchModeIconStyle.allCases) { style in
- HStack {
- Image(systemName: style.idleSymbol)
- Text(style.displayName)
- }
- .tag(style.rawValue)
- }
+ }
+ .task {
+ coordinator.refreshRecents()
+ await refreshCacheStats()
+ }
+ Section("Analysis cache") {
+ LabeledContent {
+ HStack(spacing: 8) {
+ Text("\(cachedCount)").monospacedDigit()
+ if cachedCount > 0 {
+ Text("·").foregroundStyle(.secondary)
+ Text(cacheSizeLabel).foregroundStyle(.secondary).monospacedDigit()
}
- .labelsHidden()
- .pickerStyle(.menu)
- .frame(maxWidth: 280)
- }
- .help("Changes the icon shown in the menu bar while watching an app.")
- LabeledContent("Preview") {
- let style = WatchModeIconStyle(rawValue: watchModeIconRaw) ?? .shield
- HStack(spacing: 16) {
- Label("Idle", systemImage: style.idleSymbol)
- .labelStyle(.titleAndIcon)
- .foregroundStyle(.secondary)
- Label("3 unread", systemImage: style.alertSymbol)
- .labelStyle(.titleAndIcon)
- .foregroundStyle(.primary)
+ Button("Show in Finder") {
+ NSWorkspace.shared.activateFileViewerSelecting([StaticReportCache.shared.baseURL])
}
- }
- }
- Section("Onboarding") {
- LabeledContent("Show wizard on next launch") {
- Button("Replay") {
- hasCompletedOnboarding = false
+ SurfaceDestructiveButton(
+ "Clear Cache…", gate: .confirm,
+ question: "Clear the analysis cache?",
+ consequence: "The cached static report for every app is removed. Nothing else is lost: an app is simply analysed again the next time you open it.",
+ confirmTitle: "Clear Cache"
+ ) {
+ StaticReportCache.shared.clear()
+ cachedCount = 0
+ cachedSizeBytes = 0
}
+ .disabled(cachedCount == 0)
}
+ } label: {
+ SurfaceInfoLabel("Cached analyses",
+ info: "Opening an app you have already analysed reuses its cached static report instead of analysing it again. Safe to clear at any time.")
}
}
- .formStyle(.grouped)
- .task {
- coordinator.refreshRecents()
- let stats = await Task.detached {
- (count: StaticReportCache.shared.count, size: StaticReportCache.shared.sizeBytes)
- }.value
- cachedCount = stats.count
- cachedSizeBytes = stats.size
- }
- .confirmationDialog(
- "Delete all saved runs?",
- isPresented: $showingClearConfirm,
- titleVisibility: .visible
- ) {
- Button("Delete \(coordinator.recentRuns.count) run(s)", role: .destructive) {
- clearAll()
+ SurfaceMenuBarSection(app: app, preference: menuBar, icons: WatchModeIconStyle.allCases.map { style in
+ SurfaceMenuBarIcon(id: style.rawValue, title: style.displayName, image: Image(systemName: style.idleSymbol))
+ }) {
+ LabeledContent {
+ Text(watchManager.isWatching
+ ? "Watching \(watchManager.watchedBundleName)"
+ : "Appears while you watch an app")
+ .foregroundStyle(.secondary)
+ } label: {
+ SurfaceInfoLabel("Status",
+ info: "The menu bar item is shown while a watched run is active (Run ▸ Start Watching…) and disappears when the run stops. The icon fills in while there are unread changes.")
}
- Button("Cancel", role: .cancel) {}
- } message: {
- Text("This permanently removes the saved JSON reports for every run. You can't undo this.")
}
}
@@ -145,85 +130,73 @@ private struct GeneralSettingsView: View {
ByteCountFormatter.string(fromByteCount: cachedSizeBytes, countStyle: .file)
}
- private func clearAll() {
- for meta in coordinator.recentRuns {
- coordinator.deleteRun(id: meta.id)
- }
+ private func refreshCacheStats() async {
+ let stats = await Task.detached {
+ (count: StaticReportCache.shared.count, size: StaticReportCache.shared.sizeBytes)
+ }.value
+ cachedCount = stats.count
+ cachedSizeBytes = stats.size
}
}
// MARK: - Helper
-private struct HelperSettingsView: View {
- @EnvironmentObject var helperInstaller: HelperInstaller
+private struct HelperPane: View {
+ @EnvironmentObject private var helperInstaller: HelperInstaller
+
+ private static let howItWorks = "The helper runs as a root daemon under launchd. It accepts XPC connections only from this app, signed by the same Team ID. It runs fs_usage to observe a target process's file activity and streams the parsed events back to the app, stops when the run ends and unloads after a few seconds idle. It also reads Background Task Management and installs the network kill switch's pf rules."
var body: some View {
- Form {
- Section("Privileged helper") {
- statusRow
- if let version = helperInstaller.helperVersion {
- LabeledContent("Reported version") {
- Text(version)
- .font(.caption.monospaced()).foregroundStyle(.secondary)
- }
+ Section("Privileged helper") {
+ LabeledContent {
+ HStack(spacing: 6) {
+ Image(systemName: statusIcon).foregroundStyle(statusColor)
+ Text(statusTitle).foregroundStyle(statusColor)
}
- actionRow
+ } label: {
+ SurfaceInfoLabel("Status", info: Self.howItWorks)
}
-
- Section("How it works") {
- Text("The helper runs as a `root` daemon under launchd. It accepts XPC connections only from this app, signed by the same Team ID. It spawns `fs_usage(1)` to observe a target process's file activity and streams parsed events back to the GUI. It auto-stops when the run ends and unloads after a few seconds idle.")
- .font(.callout).foregroundStyle(.secondary)
+ if let version = helperInstaller.helperVersion {
+ LabeledContent("Reported version") {
+ Text(version).font(.caption.monospaced()).foregroundStyle(.secondary)
+ }
}
+ actionRow
}
- .formStyle(.grouped)
.onAppear { helperInstaller.refresh() }
}
- private var statusRow: some View {
- LabeledContent("Status") {
- HStack(spacing: 6) {
- Image(systemName: statusIcon)
- .foregroundStyle(statusColor)
- Text(statusTitle)
- .foregroundStyle(statusColor)
- }
- }
- }
-
@ViewBuilder
private var actionRow: some View {
switch helperInstaller.status {
case .notFound:
- VStack(alignment: .leading, spacing: 6) {
- LabeledContent("Bundle the helper") {
- Text("See HELPER.md").font(.caption).foregroundStyle(.secondary)
- }
- // Surface the path we tried so the user can verify
- // whether the .app actually has the plist where we
- // expect it. If this points at a file that exists,
- // the lookup itself is broken; if it points at a
- // missing file, the build's Copy Files phase didn't
- // run.
- let plistURL = Bundle.main.bundleURL
- .appendingPathComponent("Contents/Library/LaunchDaemons")
- .appendingPathComponent(HelperToolID.daemonPlistName)
- Text("Looking for: \(plistURL.path)")
- .font(.caption.monospaced())
- .foregroundStyle(.secondary)
- .textSelection(.enabled)
- .lineLimit(3)
- .fixedSize(horizontal: false, vertical: true)
- Button("Reveal app contents in Finder") {
- NSWorkspace.shared.activateFileViewerSelecting([
- Bundle.main.bundleURL.appendingPathComponent("Contents")
- ])
+ // Surface the path we tried so the user can verify whether
+ // the .app actually has the plist where we expect it. If this
+ // points at a file that exists, the lookup itself is broken;
+ // if it points at a missing file, the build's Copy Files
+ // phase didn't run.
+ let plistURL = Bundle.main.bundleURL
+ .appendingPathComponent("Contents/Library/LaunchDaemons")
+ .appendingPathComponent(HelperToolID.daemonPlistName)
+ LabeledContent("Bundled helper") {
+ VStack(alignment: .trailing, spacing: 6) {
+ Button("Reveal App Contents in Finder") {
+ NSWorkspace.shared.activateFileViewerSelecting([
+ Bundle.main.bundleURL.appendingPathComponent("Contents")
+ ])
+ }
+ Text("Not found at \(plistURL.path)")
+ .font(.caption.monospaced())
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ .multilineTextAlignment(.trailing)
+ .lineLimit(3)
+ .fixedSize(horizontal: false, vertical: true)
}
- .buttonStyle(.borderless)
- .controlSize(.small)
}
case .notRegistered, .unknown:
LabeledContent("Install") {
- Button("Install helper") { helperInstaller.install() }
+ Button("Install Helper") { helperInstaller.install() }
.buttonStyle(.borderedProminent)
}
case .requiresApproval:
@@ -234,20 +207,25 @@ private struct HelperSettingsView: View {
case .installed:
LabeledContent("Manage") {
HStack {
- Button("Test connection") { _ = helperInstaller.ensureConnected() }
- Button("Uninstall", role: .destructive) { helperInstaller.uninstall() }
+ Button("Test Connection") { _ = helperInstaller.ensureConnected() }
+ SurfaceDestructiveButton(
+ "Uninstall Helper…", gate: .confirm,
+ question: "Uninstall the privileged helper?",
+ consequence: "File-event monitoring, the Background Task Management audit and the network kill switch stop working until you install it again. No saved runs are affected.",
+ confirmTitle: "Uninstall"
+ ) {
+ helperInstaller.uninstall()
+ }
}
}
case .error:
LabeledContent("Retry") {
- Button("Try again") { helperInstaller.install() }
+ Button("Try Again") { helperInstaller.install() }
.buttonStyle(.borderedProminent)
}
}
}
- // Visual helpers
-
private var statusIcon: String {
switch helperInstaller.status {
case .unknown: return "questionmark.circle"
@@ -258,6 +236,7 @@ private struct HelperSettingsView: View {
case .error: return "exclamationmark.triangle.fill"
}
}
+
private var statusColor: Color {
switch helperInstaller.status {
case .unknown, .notRegistered: return .secondary
@@ -267,148 +246,48 @@ private struct HelperSettingsView: View {
case .error: return .red
}
}
+
private var statusTitle: String {
switch helperInstaller.status {
case .unknown: return "Checking…"
case .notFound: return "Helper not bundled"
case .notRegistered: return "Not installed"
- case .requiresApproval: return "Awaiting approval"
+ case .requiresApproval: return "Awaiting approval in System Settings"
case .installed: return "Installed"
case .error(let m): return "Error: \(m)"
}
}
}
-// MARK: - About
+// MARK: - Updates (Homebrew installs)
-/// Reused both by Settings → About and by a dedicated Window scene
-/// driven from the App menu's "About" command. Internal access (no
-/// `private` modifier) so privacycommandApp can reach it.
-struct AboutSettingsView: View {
- private let version: String = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.1.0"
- private let build: String = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "1"
+/// Shown above the standard Updates sections when the running bundle lives
+/// in the Homebrew Caskroom.
+private struct HomebrewSection: View {
+ let cask: String?
- var body: some View {
- ScrollView {
- VStack(spacing: 14) {
- if let icon = NSApp.applicationIconImage {
- Image(nsImage: icon)
- .resizable()
- .frame(width: 128, height: 128)
- }
- WordmarkView(fontSize: 44)
- Text("Version \(version) (\(build))")
- .font(.callout).foregroundStyle(.secondary)
+ private var command: String { "brew upgrade --cask \(cask ?? "privacycommand")" }
- Text("Inspect any macOS app to see what it can ask for and what it actually does.")
- .multilineTextAlignment(.center)
- .padding(.horizontal)
- .foregroundStyle(.secondary)
-
- Divider().padding(.horizontal, 60)
-
- LazyVGrid(
- columns: [
- GridItem(.flexible(), spacing: 20, alignment: .topLeading),
- GridItem(.flexible(), spacing: 20, alignment: .topLeading)
- ],
- alignment: .leading,
- spacing: 12
- ) {
- capability("Static analysis", "Info.plist, entitlements, signing, frameworks, hard-coded URLs.")
- capability("Network monitoring", "lsof + nettop polling — no special permission needed.")
- capability("File monitoring", "Optional privileged helper backed by fs_usage(1).")
- capability("Risk scoring", "Aggregate static + dynamic findings with explainable contributors.")
- capability("Run history", "Persisted to ~/Library/Application Support/privacycommand/runs/")
- capability("Compare runs", "Diff two saved reports side-by-side.")
- capability("Reports", "Export to JSON, HTML, or PDF.")
- }
- .padding(.horizontal, 28)
-
- Divider().padding(.horizontal, 60)
-
- if let repoURL = URL(string: "https://github.com/privacykey/privacycommand") {
- Link(destination: repoURL) {
- HStack(spacing: 8) {
- Image("GitHub")
- .resizable()
- .scaledToFit()
- .frame(width: 16, height: 16)
- Text("Source on GitHub")
- Image(systemName: "arrow.up.forward.square")
- .imageScale(.small)
- .foregroundStyle(.secondary)
- }
- .font(.callout)
- }
- Text(repoURL.absoluteString)
+ var body: some View {
+ Section("Homebrew") {
+ LabeledContent {
+ HStack(spacing: 8) {
+ Text(command)
.font(.caption.monospaced())
- .foregroundStyle(.tertiary)
+ .padding(.horizontal, 6).padding(.vertical, 3)
+ .background(Color.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: 4))
.textSelection(.enabled)
+ Button("Copy") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(command, forType: .string)
+ }
+ .buttonStyle(.borderless)
+ .controlSize(.small)
}
-
- Spacer(minLength: 8)
- }
- .padding(.vertical, 12)
- .frame(maxWidth: .infinity)
- }
- }
-
- private func capability(_ title: String, _ desc: String) -> some View {
- HStack(alignment: .firstTextBaseline, spacing: 8) {
- Image(systemName: "checkmark.circle.fill")
- .foregroundStyle(.green)
- .font(.caption)
- VStack(alignment: .leading, spacing: 1) {
- Text(title).font(.subheadline.bold())
- Text(desc).font(.caption).foregroundStyle(.secondary)
+ } label: {
+ SurfaceInfoLabel("Installed with Homebrew",
+ info: "This copy of privacycommand was installed as a Homebrew Cask, so brew upgrade is the supported way to update it and stays in charge of the on-disk bundle. Automatic checks are turned off at every launch; Check Now still tells you when a new version exists.")
}
}
}
}
-
-// MARK: - Wordmark
-
-/// SwiftUI rendering of the privacycommand wordmark — `privacy` in regular
-/// weight using primary text color, `command` in medium weight filled with
-/// the brand purple gradient. Mirrors the SVG wordmark in `brand/lockup.svg`
-/// without depending on an asset-catalog SVG (avoids Xcode SVG-rendering
-/// quirks around currentColor).
-///
-/// The colors are placeholders (Tailwind purple-400 → purple-800) until the
-/// PrivacyKey brand-guidelines doc is dropped into this repo. Swap the two
-/// `Color(red:green:blue:)` values to match the brand purples exactly.
-struct WordmarkView: View {
- var fontSize: CGFloat = 44
-
- var body: some View {
- HStack(spacing: 0) {
- Text("privacy")
- .fontWeight(.regular)
- .foregroundStyle(.primary)
- Text("command")
- .fontWeight(.medium)
- .foregroundStyle(
- LinearGradient(
- colors: [
- Color(red: 167.0/255.0, green: 139.0/255.0, blue: 250.0/255.0), // #A78BFA
- Color(red: 91.0/255.0, green: 33.0/255.0, blue: 182.0/255.0) // #5B21B6
- ],
- startPoint: .top,
- endPoint: .bottom
- )
- )
- }
- .font(.system(size: fontSize))
- .kerning(-fontSize * 0.025)
- .accessibilityLabel("privacycommand")
- }
-}
-
-#Preview("Wordmark") {
- VStack(spacing: 24) {
- WordmarkView(fontSize: 44)
- WordmarkView(fontSize: 64)
- }
- .padding(40)
-}
diff --git a/privacycommand/Sources/privacycommand/Views/UpdatesSettingsView.swift b/privacycommand/Sources/privacycommand/Views/UpdatesSettingsView.swift
deleted file mode 100644
index 89d1bb6..0000000
--- a/privacycommand/Sources/privacycommand/Views/UpdatesSettingsView.swift
+++ /dev/null
@@ -1,166 +0,0 @@
-import SwiftUI
-import AppKit
-#if SWIFT_PACKAGE
-import privacycommandCore
-#endif
-
-/// "Updates" tab in Settings. Shows the running version, lets the
-/// user fire a manual check, configure automatic checks, and — if
-/// the bundle was installed via Homebrew Cask — directs them to
-/// `brew upgrade` instead of the in-app installer.
-///
-/// The view owns a `@StateObject UpdateController` so it survives
-/// across tab switches without re-instantiating Sparkle. Every
-/// branch on `controller.homebrew.isHomebrewInstall` is intentional:
-/// Homebrew users get a slightly different surface that nudges them
-/// toward the Cask path without hiding the underlying release info.
-struct UpdatesSettingsView: View {
- /// Reads the singleton injected by `privacycommandApp` so this
- /// tab and the app-menu "Check for Updates…" command share one
- /// Sparkle stack. Must be `@EnvironmentObject` rather than
- /// `@StateObject` to avoid a parallel Sparkle instance racing
- /// over the same UserDefaults keys.
- @EnvironmentObject private var controller: UpdateController
-
- var body: some View {
- Form {
- currentVersionSection
- if controller.homebrew.isHomebrewInstall {
- homebrewBanner
- }
- automaticCheckSection
- actionsSection
- statusSection
- }
- .formStyle(.grouped)
- }
-
- // MARK: - Sections
-
- private var currentVersionSection: some View {
- Section("This build") {
- LabeledContent("Version") {
- HStack(spacing: 8) {
- Text("v\(controller.currentVersion)").monospacedDigit()
- Text("·").foregroundStyle(.tertiary)
- Text("Stable channel").font(.caption).foregroundStyle(.secondary)
- }
- }
- LabeledContent("Last checked") {
- if let date = controller.lastCheckedAt {
- Text(date, style: .relative).foregroundStyle(.secondary)
- } else {
- Text("Never").foregroundStyle(.secondary)
- }
- }
- }
- }
-
- private var homebrewBanner: some View {
- Section {
- VStack(alignment: .leading, spacing: 6) {
- HStack(spacing: 6) {
- Image(systemName: "shippingbox").foregroundStyle(.orange)
- Text("This bundle was installed via Homebrew Cask.")
- .font(.callout.bold())
- }
- Text("In-app installs are disabled for Cask-managed apps so `brew upgrade` stays in control of the on-disk version. The 'Check for updates' button below still works — it just stops short of replacing the bundle.")
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- if let cask = controller.homebrew.caskName {
- HStack(spacing: 8) {
- Text("brew upgrade --cask \(cask)")
- .font(.caption.monospaced())
- .padding(.horizontal, 6).padding(.vertical, 3)
- .background(Color.secondary.opacity(0.12),
- in: RoundedRectangle(cornerRadius: 4))
- .textSelection(.enabled)
- Button("Copy") {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(
- "brew upgrade --cask \(cask)",
- forType: .string)
- }
- .buttonStyle(.borderless)
- .controlSize(.small)
- }
- }
- }
- .padding(.vertical, 4)
- }
- }
-
- private var automaticCheckSection: some View {
- Section("Automatic checks") {
- Toggle("Check for updates in the background",
- isOn: $controller.autoCheckEnabled)
- .disabled(controller.homebrew.isHomebrewInstall)
- .help(controller.homebrew.isHomebrewInstall
- ? "Disabled because this bundle is managed by Homebrew."
- : "When on, the app polls the appcast on the schedule below. Off by default.")
-
- Picker("Frequency", selection: $controller.checkInterval) {
- ForEach(UpdatePreferences.CheckInterval.allCases, id: \.self) { interval in
- Text(interval.displayName).tag(interval)
- }
- }
- .pickerStyle(.menu)
- .disabled(!controller.autoCheckEnabled
- || controller.homebrew.isHomebrewInstall)
-
- Text("Privacycommand contacts the appcast feed at \(UpdateChannel.appcastURL.absoluteString) only when this is on or you tap Check for updates.")
- .font(.caption)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
-
- private var actionsSection: some View {
- Section("Actions") {
- HStack(spacing: 12) {
- Button {
- controller.checkForUpdates()
- } label: {
- HStack(spacing: 6) {
- if controller.isChecking {
- ProgressView().controlSize(.small)
- }
- Text("Check for updates")
- }
- }
- .disabled(controller.isChecking)
- .keyboardShortcut("u", modifiers: [.command])
-
- Button("Open release notes") {
- controller.openReleasesPage()
- }
- .buttonStyle(.borderless)
-
- Spacer()
- }
- }
- }
-
- @ViewBuilder
- private var statusSection: some View {
- if let message = controller.lastStatusMessage {
- Section {
- HStack(alignment: .top, spacing: 6) {
- Image(systemName: "info.circle").foregroundStyle(.secondary)
- Text(message)
- .font(.callout)
- .foregroundStyle(.secondary)
- .fixedSize(horizontal: false, vertical: true)
- Spacer()
- Button("Stop skipping") {
- controller.clearSkippedVersion()
- }
- .buttonStyle(.borderless)
- .controlSize(.small)
- .opacity(message.localizedCaseInsensitiveContains("skip") ? 1 : 0)
- }
- }
- }
- }
-}
diff --git a/privacycommand/Sources/privacycommand/Views/WatchModePopover.swift b/privacycommand/Sources/privacycommand/Views/WatchModePopover.swift
index 72fa00d..0abf0b0 100644
--- a/privacycommand/Sources/privacycommand/Views/WatchModePopover.swift
+++ b/privacycommand/Sources/privacycommand/Views/WatchModePopover.swift
@@ -4,14 +4,14 @@ import AppKit
import privacycommandCore
#endif
-/// Popover content shown from the menu-bar `MenuBarExtra`. Lists recent
-/// watch-mode changes plus controls for stopping watch / opening the main
-/// window.
+/// Popover content shown from the menu-bar `MenuBarExtra`. The standard
+/// header and footer frame the app's own content: the watched app, the
+/// recent watch-mode changes, and the controls for the run.
struct WatchModePopover: View {
@ObservedObject var manager: WatchModeManager
@ObservedObject var coordinator: AnalysisCoordinator
- /// SwiftUI helper for activating an existing window scene.
- @Environment(\.openWindow) private var openWindow
+
+ private let app = PrivacycommandSurface.app
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -23,10 +23,13 @@ struct WatchModePopover: View {
list
}
Divider()
- footer
+ controls
+ Divider()
+ SurfacePopoverFooter(app: app, openApp: { bringMainWindowFront() })
+ .padding(10)
}
.frame(width: 380)
- .frame(maxHeight: 540)
+ .frame(maxHeight: 600)
.onAppear {
// Visiting the popover counts as having seen the changes.
manager.markAllRead()
@@ -36,18 +39,19 @@ struct WatchModePopover: View {
// MARK: - Sections
private var header: some View {
- VStack(alignment: .leading, spacing: 2) {
- HStack(spacing: 6) {
- Image(systemName: "eye.fill").foregroundStyle(.blue)
- Text("Watching \(manager.watchedBundleName)")
- .font(.headline)
- Spacer()
+ VStack(alignment: .leading, spacing: 6) {
+ SurfacePopoverHeader(app: app, mark: Image(nsImage: NSApplication.shared.applicationIconImage)) {
if let started = manager.startedAt {
Text(durationString(since: started))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
+ HStack(spacing: 6) {
+ Image(systemName: "eye.fill").foregroundStyle(.blue)
+ Text("Watching \(manager.watchedBundleName)")
+ .font(.callout.weight(.semibold))
+ }
Text("\(manager.changes.count) change\(manager.changes.count == 1 ? "" : "s") · \(coordinator.events.count) total events")
.font(.caption)
.foregroundStyle(.secondary)
@@ -99,23 +103,20 @@ struct WatchModePopover: View {
.padding(.vertical, 6)
}
- private var footer: some View {
+ /// The run's own controls. Stopping the watch is the primary action.
+ private var controls: some View {
HStack(spacing: 6) {
- Button {
- bringMainWindowFront()
- } label: {
- Label("Open auditor", systemImage: "macwindow")
- }
- Spacer()
if !manager.changes.isEmpty {
- Button("Clear log") { manager.clearLog() }
+ Button("Clear Log") { manager.clearLog() }
.controlSize(.small)
}
+ Spacer()
Button(role: .destructive) {
Task { await stopWatching() }
} label: {
- Label("Stop watching", systemImage: "stop.circle")
+ Label("Stop Watching", systemImage: "stop.circle")
}
+ .keyboardShortcut(.return, modifiers: .command)
}
.padding(10)
}
diff --git a/privacycommand/Sources/privacycommand/privacycommandApp.swift b/privacycommand/Sources/privacycommand/privacycommandApp.swift
index a0ce9c5..2b1b1ab 100644
--- a/privacycommand/Sources/privacycommand/privacycommandApp.swift
+++ b/privacycommand/Sources/privacycommand/privacycommandApp.swift
@@ -7,25 +7,42 @@ import privacycommandCore
@main
struct privacycommandApp: App {
@AppStorage("hasCompletedOnboarding") private var hasCompletedOnboarding = false
- /// User-chosen menu-bar icon for watch mode. Defaults to the shield —
- /// matches the privacycommand brand mark.
- @AppStorage("watchModeIconStyle") private var watchModeIconRaw = WatchModeIconStyle.shield.rawValue
@StateObject private var coordinator = AnalysisCoordinator()
@StateObject private var helperInstaller = HelperInstaller()
@StateObject private var watchManager = WatchModeManager()
- /// Singleton update controller — shared between the menu-bar
- /// "Check for Updates…" command and the Settings → Updates tab.
- /// Both surfaces read the same `@StateObject` via the SwiftUI
- /// environment so they don't instantiate parallel Sparkle stacks
- /// (which would race over the same UserDefaults keys).
- @StateObject private var updateController = UpdateController()
+ /// Owns the one Sparkle updater; `updates` drives the Updates pane and
+ /// the Check for Updates… item from it.
+ @StateObject private var updateController: UpdateController
+ @StateObject private var updates: SurfaceUpdates
+ /// The watch-mode menu bar icon, stored under the key the app has
+ /// always used so an existing choice carries over.
+ @StateObject private var menuBar = SurfaceMenuBarPreference(
+ defaultIcon: WatchModeIconStyle.shield.rawValue,
+ iconKey: "watchModeIconStyle"
+ )
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
- /// Decoded icon style — falls back to `.shield` if the persisted raw
- /// string doesn't match any case (e.g. after we add/remove options
- /// in a future build).
+ private let app = PrivacycommandSurface.app
+
+ init() {
+ let controller = UpdateController()
+ _updateController = StateObject(wrappedValue: controller)
+ _updates = StateObject(wrappedValue: SurfaceUpdates(
+ driver: controller.updater,
+ releaseNotes: PrivacycommandSurface.releaseNotes
+ ))
+ }
+
+ /// The shared manual and shortcut windows open by default; the welcome
+ /// replays the first-run onboarding.
+ private var help: SurfaceHelp {
+ SurfaceHelp(replayWelcome: { hasCompletedOnboarding = false })
+ }
+
+ /// Decoded icon style, falling back to the shield if the stored value
+ /// no longer matches a case.
private var watchIcon: WatchModeIconStyle {
- WatchModeIconStyle(rawValue: watchModeIconRaw) ?? .shield
+ WatchModeIconStyle(rawValue: menuBar.icon) ?? .shield
}
var body: some Scene {
@@ -38,7 +55,6 @@ struct privacycommandApp: App {
.environmentObject(coordinator)
.environmentObject(helperInstaller)
.environmentObject(watchManager)
- .environmentObject(updateController)
} else {
OnboardingView(onComplete: { hasCompletedOnboarding = true })
.environmentObject(helperInstaller)
@@ -46,10 +62,6 @@ struct privacycommandApp: App {
}
.frame(minWidth: 980, minHeight: 640)
.onAppear {
- // Note: AppIconRenderer.install() now runs in
- // AppDelegate.applicationDidFinishLaunching so the
- // About panel and Dock both have the rendered icon
- // even before the first window appears.
helperInstaller.refresh()
coordinator.helperInstaller = helperInstaller
// Hand the coordinator to the delegate so it can ask for
@@ -61,24 +73,30 @@ struct privacycommandApp: App {
}
}
.commands {
- CommandGroup(replacing: .newItem) { } // single-window app
-
- // Replace the default macOS About panel (which only shows
- // CFBundleName + version + copyright) with our richer
- // SwiftUI About window. The default panel doesn't show
- // capability / GitHub-link content, and on cold launch
- // doesn't even pick up the rendered icon because
- // AppIconRenderer.install() only runs when a content
- // window appears.
- CommandGroup(replacing: .appInfo) {
- OpenAboutMenuItem()
- }
+ // About, Check for Updates… and the three Help items.
+ SurfaceCommands(app: app, help: help, updates: updates)
- CommandMenu("Run") {
+ // File holds the app's own open and export actions in place of
+ // New Window: this is a single-window app.
+ CommandGroup(replacing: .newItem) {
Button("Open .app…") { coordinator.presentOpenPanel() }
.keyboardShortcut("o")
OpenBatchScanMenuItem()
- Divider()
+ }
+ CommandGroup(replacing: .saveItem) {
+ Button("Save Run Report (JSON)…") { coordinator.exportJSON() }
+ .disabled(!coordinator.hasRunReport)
+ Button("Save Run Report (HTML)…") { coordinator.exportHTML() }
+ .disabled(!coordinator.hasRunReport)
+ Button("Save Run Report (PDF)…") { coordinator.exportPDF() }
+ .disabled(!coordinator.hasRunReport)
+ }
+
+ CommandGroup(after: .toolbar) {
+ OpenKnowledgeBaseMenuItem()
+ }
+
+ CommandMenu("Run") {
Button("Start Monitored Run") { Task { await coordinator.startMonitoredRun() } }
.keyboardShortcut("r")
.disabled(!coordinator.canStartRun)
@@ -105,60 +123,34 @@ struct privacycommandApp: App {
.keyboardShortcut("w", modifiers: [.command, .shift])
.disabled(!coordinator.canStartRun && !watchManager.isWatching)
}
- CommandMenu("Export") {
- Button("Save Run Report (JSON)…") { coordinator.exportJSON() }
- .disabled(!coordinator.hasRunReport)
- Button("Save Run Report (HTML)…") { coordinator.exportHTML() }
- .disabled(!coordinator.hasRunReport)
- Button("Save Run Report (PDF)…") { coordinator.exportPDF() }
- .disabled(!coordinator.hasRunReport)
- }
- CommandGroup(after: .help) {
- Divider()
- OpenKnowledgeBaseMenuItem()
- Button("Show Onboarding…") { hasCompletedOnboarding = false }
- Button("Refresh Helper Status") { helperInstaller.refresh() }
- }
-
- // Conventional macOS placement for the updater item: just
- // after "About " inside the application menu. ⌘U
- // matches the Settings → Updates "Check for updates"
- // button so muscle-memory carries between the two.
- CommandGroup(after: .appInfo) {
- Button("Check for Updates…") {
- updateController.checkForUpdates()
- }
- .keyboardShortcut("u", modifiers: [.command])
- }
}
// Adds ⌘, support and the "Settings…" menu item under the app menu.
Settings {
- SettingsView()
- .environmentObject(coordinator)
- .environmentObject(helperInstaller)
- .environmentObject(updateController)
+ SurfaceSettings(app: app, panes: PrivacycommandSettings.panes(
+ app: app,
+ menuBar: menuBar,
+ watchManager: watchManager,
+ updates: updates,
+ updateController: updateController
+ ))
+ .environmentObject(coordinator)
+ .environmentObject(helperInstaller)
}
+ SurfaceAboutWindow(app: app, help: help)
+ SurfaceManualWindow(app: app)
+ SurfaceShortcutsWindow(groups: PrivacycommandSurface.shortcuts)
+
// Standalone window so users can keep the KB open while browsing
- // their report. Identified by id so the Help → Knowledge Base… item
+ // their report. Identified by id so the View → Knowledge Base… item
// can request it via @Environment(\.openWindow).
Window("Knowledge Base", id: "knowledge-base") {
KnowledgeBaseBrowserView()
}
- // Custom About window — replaces the default Apple About panel
- // (set up via CommandGroup(replacing: .appInfo) above). Renders
- // the same content the Settings → About tab does so we don't
- // maintain two copies.
- Window("About privacycommand", id: "about") {
- AboutSettingsView()
- .frame(minWidth: 480, idealWidth: 540, minHeight: 520, idealHeight: 600)
- }
- .windowResizability(.contentSize)
-
// Batch mode — scan many apps at once and triage them in a sortable,
- // filterable table. Standalone window (id) so the Run menu item can
+ // filterable table. Standalone window (id) so the File menu item can
// request it via @Environment(\.openWindow). Shares the coordinator so
// "Analyze in Main Window" can hand an app to the deep-dive flow.
Window("Scan Apps", id: "batch-scan") {
@@ -176,7 +168,8 @@ struct privacycommandApp: App {
// Binding here. A `false` write — caused by the user removing the
// menu-bar icon via the system, or anything else SwiftUI deems a
// dismissal — gets translated into `manager.stop()` plus a
- // monitor stop, matching the explicit "Stop watching" path.
+ // monitor stop, matching the explicit "Stop watching" path. Writes
+ // that do not change the value are ignored.
MenuBarExtra(isInserted: Binding(
get: { watchManager.isWatching },
set: { newValue in
@@ -190,6 +183,9 @@ struct privacycommandApp: App {
} label: {
Image(systemName: watchManager.unreadCount > 0
? watchIcon.alertSymbol : watchIcon.idleSymbol)
+ .accessibilityLabel(watchManager.unreadCount > 0
+ ? "privacycommand, \(watchManager.unreadCount) unread changes"
+ : "privacycommand, watching")
if watchManager.unreadCount > 0 {
Text(" \(watchManager.unreadCount)")
}
@@ -211,7 +207,7 @@ private struct OpenKnowledgeBaseMenuItem: View {
}
}
-/// Opens the batch-scan window from the Run menu. Same `openWindow`-from-
+/// Opens the batch-scan window from the File menu. Same `openWindow`-from-
/// commands trick as the Knowledge Base item.
private struct OpenBatchScanMenuItem: View {
@Environment(\.openWindow) private var openWindow
@@ -224,18 +220,6 @@ private struct OpenBatchScanMenuItem: View {
}
}
-/// Same trick for the About panel. SwiftUI's default `.appInfo` group
-/// shows Apple's stock About sheet; this opens our custom Window scene.
-private struct OpenAboutMenuItem: View {
- @Environment(\.openWindow) private var openWindow
-
- var body: some View {
- Button("About privacycommand") {
- openWindow(id: "about")
- }
- }
-}
-
/// Tracks a weak reference to the active coordinator so we can terminate the
/// target's process tree if the auditor is being quit (cmd-Q, force-quit
/// dialog approval, etc.) — without leaving Chrome / Slack / whatever still
@@ -245,7 +229,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
weak var watchManager: WatchModeManager?
/// The bundled `AppIcon` asset catalog entry (and the legacy
- /// `AppIcon.icns` in Resources) supply the Dock / About-panel icon at
+ /// `AppIcon.icns` in Resources) supply the Dock / About-window icon at
/// build time, so no runtime override is needed. The previous
/// `AppIconRenderer.install()` call rendered a SwiftUI placeholder via
/// `NSApp.applicationIconImage` — keeping it would clobber the real
diff --git a/privacycommand/Sources/privacycommand/privacycommandSurface.swift b/privacycommand/Sources/privacycommand/privacycommandSurface.swift
new file mode 100644
index 0000000..a362a01
--- /dev/null
+++ b/privacycommand/Sources/privacycommand/privacycommandSurface.swift
@@ -0,0 +1,218 @@
+import AppKit
+import SwiftUI
+#if SWIFT_PACKAGE
+import privacycommandCore
+#endif
+
+/// What the shared Settings, About, menu and Help surfaces know about
+/// privacycommand. Defined once; every surface reads it from here.
+enum PrivacycommandSurface {
+ static let repository = URL(string: "https://github.com/privacykey/privacycommand")!
+
+ /// Where "Release Notes" in the Updates pane goes.
+ static let releaseNotes = UpdateChannel.releasesPageURL
+
+ static let app = SurfaceApp(
+ wordmark: SurfaceWordmark(lead: "privacy", accent: "command"),
+ accent: Color(red: 124 / 255, green: 58 / 255, blue: 237 / 255), // the wordmark's purple
+ summary: "Drop an app. See everything it touches: the permissions it will ask for, the hosts compiled into it, the SDKs it ships, and what it does while it runs.",
+ capabilities: [
+ SurfaceCapability("Static analysis",
+ detail: "Entitlements, signing and notarisation, hard-coded domains, SDK fingerprints, secrets, anti-analysis signals and the privacy manifest."),
+ SurfaceCapability("Monitored runs",
+ detail: "File events, network destinations, child processes, camera, microphone and pasteboard use, USB and resource usage, live."),
+ SurfaceCapability("Kill switches",
+ detail: "Freeze the inspected app, or cut it off from the hosts it has contacted and watch how it copes."),
+ SurfaceCapability("VM mode",
+ detail: "A guest agent runs the app inside a macOS virtual machine and streams observations back."),
+ SurfaceCapability("Batch scan and compare",
+ detail: "Triage every installed app in one table; diff any two saved runs."),
+ SurfaceCapability("Local and explained",
+ detail: "Analysis never leaves your Mac, and every finding links to a plain-English knowledge base entry."),
+ ],
+ distribution: .openSource(
+ repository: repository,
+ issues: repository.appending(path: "issues"),
+ licence: "MIT License"
+ ),
+ shape: .dockApp,
+ acknowledgements: [
+ SurfaceAcknowledgement(
+ name: "Sparkle",
+ licence: "MIT License, with the licences of its bundled components",
+ text: sparkleLicence
+ ),
+ SurfaceAcknowledgement(
+ name: "osx-and-ios-security-awesome",
+ licence: "CC0 1.0 Universal",
+ text: "The in-app catalogue of further analysis tools is curated from ashishb/osx-and-ios-security-awesome, dedicated to the public domain under CC0-1.0. No code is vendored."
+ ),
+ ]
+ )
+
+ /// Every shortcut the app answers to, the standard group first.
+ static let shortcuts: [SurfaceShortcutGroup] = [
+ .standard(for: app),
+ SurfaceShortcutGroup("File", items: [
+ SurfaceShortcut("⌘O", "Open .app…", detail: "An .app bundle or a .dmg disk image"),
+ SurfaceShortcut("⇧⌘B", "Scan Installed Apps…"),
+ ]),
+ SurfaceShortcutGroup("Run", items: [
+ SurfaceShortcut("⌘R", "Start Monitored Run"),
+ SurfaceShortcut("⌘.", "Stop Monitored Run"),
+ SurfaceShortcut("⇧⌘P", "Pause App (Kill Switch)", detail: "Freezes the inspected app's process tree; press again to resume"),
+ SurfaceShortcut("⇧⌘W", "Start Watching…", detail: "Keeps the run alive in the menu bar; press again to stop"),
+ ]),
+ SurfaceShortcutGroup("View", items: [
+ SurfaceShortcut("⇧⌘K", "Knowledge Base…"),
+ ]),
+ SurfaceShortcutGroup("Scan Apps window", items: [
+ SurfaceShortcut("⌘R", "Scan", detail: "Hold ⇧ while clicking Scan to choose a folder"),
+ ]),
+ SurfaceShortcutGroup("Menu bar item", items: [
+ SurfaceShortcut("⌘↩", "Stop Watching", detail: "While the popover is open"),
+ ]),
+ SurfaceShortcutGroup("Welcome", items: [
+ SurfaceShortcut("↩", "Continue"),
+ SurfaceShortcut("⌘←", "Back"),
+ ]),
+ ]
+
+ /// Sparkle's LICENSE file as shipped in the framework the app embeds.
+ private static let sparkleLicence = """
+Copyright (c) 2006-2013 Andy Matuschak.
+Copyright (c) 2009-2013 Elgato Systems GmbH.
+Copyright (c) 2011-2014 Kornel Lesiński.
+Copyright (c) 2015-2017 Mayur Pawashe.
+Copyright (c) 2014 C.W. Betts.
+Copyright (c) 2014 Petroules Corporation.
+Copyright (c) 2014 Big Nerd Ranch.
+All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+=================
+EXTERNAL LICENSES
+=================
+
+bspatch.c and bsdiff.c, from bsdiff 4.3 :
+
+Copyright 2003-2005 Colin Percival
+All rights reserved
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted providing that the following conditions
+are met:
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
+IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+--
+
+sais.c and sais.h, from sais-lite (2010/08/07) :
+
+The sais-lite copyright is as follows:
+
+Copyright (c) 2008-2010 Yuta Mori All Rights Reserved.
+
+Permission is hereby granted, free of charge, to any person
+obtaining a copy of this software and associated documentation
+files (the "Software"), to deal in the Software without
+restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the
+Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+--
+
+Portable C implementation of Ed25519, from https://github.com/orlp/ed25519
+
+Copyright (c) 2015 Orson Peters
+
+This software is provided 'as-is', without any express or implied warranty. In no event will the
+authors be held liable for any damages arising from the use of this software.
+
+Permission is granted to anyone to use this software for any purpose, including commercial
+applications, and to alter it and redistribute it freely, subject to the following restrictions:
+
+1. The origin of this software must not be misrepresented; you must not claim that you wrote the
+ original software. If you use this software in a product, an acknowledgment in the product
+ documentation would be appreciated but is not required.
+
+2. Altered source versions must be plainly marked as such, and must not be misrepresented as
+ being the original software.
+
+3. This notice may not be removed or altered from any source distribution.
+
+--
+
+SUSignatureVerifier.m:
+
+Copyright (c) 2011 Mark Hamlin.
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted providing that the following conditions
+are met:
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
+IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
+STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
+IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+"""
+}
diff --git a/privacycommand/privacycommand.xcodeproj/project.pbxproj b/privacycommand/privacycommand.xcodeproj/project.pbxproj
index 735f0db..b10bead 100644
--- a/privacycommand/privacycommand.xcodeproj/project.pbxproj
+++ b/privacycommand/privacycommand.xcodeproj/project.pbxproj
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
0BBBBBBB000000000000A001 /* privacycommandApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000A001 /* privacycommandApp.swift */; };
+ 0BBBBBBB000000000000A00A /* privacycommandSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000A00A /* privacycommandSurface.swift */; };
0BBBBBBB000000000000A002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000A002 /* ContentView.swift */; };
0BBBBBBB000000000000A003 /* AnalysisCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000A003 /* AnalysisCoordinator.swift */; };
0BBBBBBB000000000000A004 /* HelperInstaller.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000A004 /* HelperInstaller.swift */; };
@@ -63,7 +64,6 @@
0BBBBBBB000000000000A12D /* FlagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000A12D /* FlagsView.swift */; };
0BBBBBBB000000000000A12E /* TelemetrySummaryCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000A12E /* TelemetrySummaryCard.swift */; };
0BBBBBBB000000000000A12F /* PrivacyLabelsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000A12F /* PrivacyLabelsCard.swift */; };
- 0BBBBBBB000000000000A130 /* UpdatesSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000A130 /* UpdatesSettingsView.swift */; };
0BBBBBBB000000000000A131 /* BatchScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000A131 /* BatchScanView.swift */; };
0BBBBBBB000000000000A132 /* PermissionMatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000A132 /* PermissionMatrixView.swift */; };
0BBBBBBB000000000000A133 /* DecompiledClassesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000A133 /* DecompiledClassesView.swift */; };
@@ -172,6 +172,7 @@
0DDDDDDD000000000000B201 /* org.privacykey.privacycommand.guest.plist in Resources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000B201 /* org.privacykey.privacycommand.guest.plist */; };
0DDDDDDD000000000000B202 /* Install.command in Resources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000B202 /* Install.command */; };
0DDDDDDD000000000000B203 /* README.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000B203 /* README.txt */; };
+ 0DDDDDDD000000000000B300 /* Manual in Resources */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000B300 /* Manual */; };
0EEEEEEE000000000000F101 /* privacycommandHelper in Embed Privileged Helper */ = {isa = PBXBuildFile; fileRef = A954CFEA2F9E0506005AC054 /* privacycommandHelper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
0EEEEEEE000000000000F102 /* org.privacykey.privacycommand.HelperTool.plist in Embed LaunchDaemon plist */ = {isa = PBXBuildFile; fileRef = 0FFFFFFF000000000000B006 /* org.privacykey.privacycommand.HelperTool.plist */; };
55505BF12FA1EF0700ED7616 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 55505BF02FA1EF0700ED7616 /* Sparkle */; };
@@ -230,6 +231,7 @@
/* Begin PBXFileReference section */
0FFFFFFF000000000000A001 /* privacycommandApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = privacycommandApp.swift; sourceTree = ""; };
+ 0FFFFFFF000000000000A00A /* privacycommandSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = privacycommandSurface.swift; sourceTree = ""; };
0FFFFFFF000000000000A002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
0FFFFFFF000000000000A003 /* AnalysisCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalysisCoordinator.swift; sourceTree = ""; };
0FFFFFFF000000000000A004 /* HelperInstaller.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperInstaller.swift; sourceTree = ""; };
@@ -285,7 +287,6 @@
0FFFFFFF000000000000A12D /* FlagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlagsView.swift; sourceTree = ""; };
0FFFFFFF000000000000A12E /* TelemetrySummaryCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TelemetrySummaryCard.swift; sourceTree = ""; };
0FFFFFFF000000000000A12F /* PrivacyLabelsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyLabelsCard.swift; sourceTree = ""; };
- 0FFFFFFF000000000000A130 /* UpdatesSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatesSettingsView.swift; sourceTree = ""; };
0FFFFFFF000000000000A131 /* BatchScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchScanView.swift; sourceTree = ""; };
0FFFFFFF000000000000A132 /* PermissionMatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionMatrixView.swift; sourceTree = ""; };
0FFFFFFF000000000000A133 /* DecompiledClassesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecompiledClassesView.swift; sourceTree = ""; };
@@ -304,6 +305,7 @@
0FFFFFFF000000000000B201 /* org.privacykey.privacycommand.guest.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = org.privacykey.privacycommand.guest.plist; path = Resources/GuestInstaller/org.privacykey.privacycommand.guest.plist; sourceTree = SOURCE_ROOT; };
0FFFFFFF000000000000B202 /* Install.command */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; name = Install.command; path = Resources/GuestInstaller/Install.command; sourceTree = SOURCE_ROOT; };
0FFFFFFF000000000000B203 /* README.txt */ = {isa = PBXFileReference; lastKnownFileType = text; name = README.txt; path = Resources/GuestInstaller/README.txt; sourceTree = SOURCE_ROOT; };
+ 0FFFFFFF000000000000B300 /* Manual */ = {isa = PBXFileReference; lastKnownFileType = folder; path = Manual; sourceTree = ""; };
0FFFFFFF000000000000C001 /* AppBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppBundle.swift; sourceTree = ""; };
0FFFFFFF000000000000C002 /* PrivacyKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyKey.swift; sourceTree = ""; };
0FFFFFFF000000000000C003 /* StaticReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticReport.swift; sourceTree = ""; };
@@ -415,6 +417,11 @@
path = privacycommandHelper;
sourceTree = "";
};
+ 0FFFFFFF0000000000005F01 /* MacSurfaces */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ path = MacSurfaces;
+ sourceTree = "";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -463,11 +470,13 @@
isa = PBXGroup;
children = (
0FFFFFFF000000000000A001 /* privacycommandApp.swift */,
+ 0FFFFFFF000000000000A00A /* privacycommandSurface.swift */,
0FFFFFFF000000000000A002 /* ContentView.swift */,
0FFFFFFF000000000000A005 /* AppIconView.swift */,
0CCCCCCC0000000000000A01 /* ViewModels */,
0CCCCCCC0000000000000A02 /* Views */,
0CCCCCCC0000000000000A04 /* Reporting */,
+ 0FFFFFFF0000000000005F01 /* MacSurfaces */,
);
name = privacycommand;
path = Sources/privacycommand;
@@ -536,7 +545,6 @@
0FFFFFFF000000000000A12D /* FlagsView.swift */,
0FFFFFFF000000000000A12E /* TelemetrySummaryCard.swift */,
0FFFFFFF000000000000A12F /* PrivacyLabelsCard.swift */,
- 0FFFFFFF000000000000A130 /* UpdatesSettingsView.swift */,
0FFFFFFF000000000000A131 /* BatchScanView.swift */,
0FFFFFFF000000000000A133 /* DecompiledClassesView.swift */,
0FFFFFFF000000000000A132 /* PermissionMatrixView.swift */,
@@ -573,6 +581,7 @@
0FFFFFFF000000000000B006 /* org.privacykey.privacycommand.HelperTool.plist */,
0FFFFFFF000000000000B007 /* privacycommandHelper.entitlements */,
0FFFFFFF000000000000B100 /* Assets.xcassets */,
+ 0FFFFFFF000000000000B300 /* Manual */,
);
path = Resources;
sourceTree = "";
@@ -830,6 +839,9 @@
dependencies = (
6600000000000000000000A1 /* PBXTargetDependency */,
);
+ fileSystemSynchronizedGroups = (
+ 0FFFFFFF0000000000005F01 /* MacSurfaces */,
+ );
name = privacycommand;
productName = privacycommand;
productReference = 0FFFFFFF000000000000E001 /* privacycommand.app */;
@@ -931,6 +943,7 @@
0DDDDDDD000000000000B201 /* org.privacykey.privacycommand.guest.plist in Resources */,
0DDDDDDD000000000000B202 /* Install.command in Resources */,
0DDDDDDD000000000000B203 /* README.txt in Resources */,
+ 0DDDDDDD000000000000B300 /* Manual in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -1008,6 +1021,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 0BBBBBBB000000000000A00A /* privacycommandSurface.swift in Sources */,
0BBBBBBB000000000000A001 /* privacycommandApp.swift in Sources */,
0BBBBBBB000000000000A002 /* ContentView.swift in Sources */,
0BBBBBBB000000000000A003 /* AnalysisCoordinator.swift in Sources */,
@@ -1108,7 +1122,6 @@
0BBBBBBB000000000000A12D /* FlagsView.swift in Sources */,
0BBBBBBB000000000000A12E /* TelemetrySummaryCard.swift in Sources */,
0BBBBBBB000000000000A12F /* PrivacyLabelsCard.swift in Sources */,
- 0BBBBBBB000000000000A130 /* UpdatesSettingsView.swift in Sources */,
0BBBBBBB000000000000A008 /* UpdateController.swift in Sources */,
0BBBBBBB000000000000C902 /* UpdateChannel.swift in Sources */,
0BBBBBBB000000000000C903 /* HomebrewDetector.swift in Sources */,
@@ -1218,7 +1231,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 +1260,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 +1278,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 +1297,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 +1360,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 +1418,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 +1444,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 +1472,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 @@
+
+
+
+
+
+
+