From 26ec9743d522b8ef91c05ade373c465810f84a23 Mon Sep 17 00:00:00 2001 From: adamXbot <111877622+adamXbot@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:19:21 +1100 Subject: [PATCH 1/3] Add shared Settings, menu and About code with a surfaces command Vendor the shared macOS surface sources under privacycommand/Sources/privacycommand/MacSurfaces with a lock of their hashes, and compile the folder into the app as a synchronized group. `just surfaces` refreshes the copy, a scheme pre-action runs the same step before each build, and `just check` verifies the copy against its lock. The shared code needs macOS 14, so the minimum moves from macOS 13 to 14. Nothing in the app uses the new types yet. Co-Authored-By: Claude Fable 5.1 --- .project/commands.json | 21 ++ .project/mac-surfaces.lock.json | 18 ++ .project/mac_surfaces.py | 196 ++++++++++++++++ README.md | 2 +- docs/PROJECT-COMMANDS.md | 1 + justfile | 4 + privacycommand/Package.swift | 2 +- privacycommand/Resources/Info.plist | 2 +- .../MacSurfaces/SurfaceAbout.swift | 220 ++++++++++++++++++ .../MacSurfaces/SurfaceActivation.swift | 50 ++++ .../MacSurfaces/SurfaceBuild.swift | 51 ++++ .../MacSurfaces/SurfaceCommands.swift | 45 ++++ .../MacSurfaces/SurfaceMenuBar.swift | 208 +++++++++++++++++ .../MacSurfaces/SurfaceProfile.swift | 160 +++++++++++++ .../MacSurfaces/SurfaceRows.swift | 196 ++++++++++++++++ .../MacSurfaces/SurfaceSettings.swift | 165 +++++++++++++ .../MacSurfaces/SurfaceSparkle.swift | 7 + .../MacSurfaces/SurfaceUpdates.swift | 153 ++++++++++++ .../privacycommand.xcodeproj/project.pbxproj | 25 +- .../xcschemes/privacycommand.xcscheme | 7 + 20 files changed, 1522 insertions(+), 11 deletions(-) create mode 100644 .project/mac-surfaces.lock.json create mode 100644 .project/mac_surfaces.py create mode 100644 privacycommand/Sources/privacycommand/MacSurfaces/SurfaceAbout.swift create mode 100644 privacycommand/Sources/privacycommand/MacSurfaces/SurfaceActivation.swift create mode 100644 privacycommand/Sources/privacycommand/MacSurfaces/SurfaceBuild.swift create mode 100644 privacycommand/Sources/privacycommand/MacSurfaces/SurfaceCommands.swift create mode 100644 privacycommand/Sources/privacycommand/MacSurfaces/SurfaceMenuBar.swift create mode 100644 privacycommand/Sources/privacycommand/MacSurfaces/SurfaceProfile.swift create mode 100644 privacycommand/Sources/privacycommand/MacSurfaces/SurfaceRows.swift create mode 100644 privacycommand/Sources/privacycommand/MacSurfaces/SurfaceSettings.swift create mode 100644 privacycommand/Sources/privacycommand/MacSurfaces/SurfaceSparkle.swift create mode 100644 privacycommand/Sources/privacycommand/MacSurfaces/SurfaceUpdates.swift 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..e66029f --- /dev/null +++ b/.project/mac-surfaces.lock.json @@ -0,0 +1,18 @@ +{ + "schema": "mac-surfaces/v1", + "version": 2, + "destination": "privacycommand/Sources/privacycommand/MacSurfaces", + "tool": "b593d3ccbc2f0226517834db5d6e56a0aeff918f43dfc015668cac116adb9aa6", + "files": { + "SurfaceAbout.swift": "a1b43ec780abe86f225d30111416317ec688138dd932d43a103022238c529231", + "SurfaceActivation.swift": "88875baa038fa97ce9adbefad6998c64b5bfe6161f255d14437c6702dfaf6de8", + "SurfaceBuild.swift": "e02a17f9bde7ef4a003c0abe0f14fdecfabe8eacbfdeb8708d300a59873edbb6", + "SurfaceCommands.swift": "57de8bd4b2647d3877a440e46dc95d73940ba42b109c79d5e8140f34390ec23f", + "SurfaceMenuBar.swift": "88f0780c8d005b6ec494063acd1d36f2e4d703e949edff8b3bd1dab60d76b68c", + "SurfaceProfile.swift": "bbc564ded117f2619d5c520e53dc027cdc1631bc47f543661d5bfc5f292c7a24", + "SurfaceRows.swift": "b01b6864f50ce89b94b562cc8b052e3de5370901ab4f7efdd609e0749f2adbed", + "SurfaceSettings.swift": "7503434a92f6a2a94990f110939037b6b7c948a6f3d2a8efbcda4f5ae460203b", + "SurfaceSparkle.swift": "1ab6c60a27f34e0fc0551c28b82ef0c456a82e26803aabd98043dd6890ced436", + "SurfaceUpdates.swift": "6d7b755cb3fd2ad41b379007cbeb0f9420138fc3476440647445cd25d6e55ae5" + } +} diff --git a/.project/mac_surfaces.py b/.project/mac_surfaces.py new file mode 100644 index 0000000..7ed808d --- /dev/null +++ b/.project/mac_surfaces.py @@ -0,0 +1,196 @@ +#!/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 CI +or a contributor's clone, the committed copy is verified against its lock. +""" +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"])) + try: + common = subprocess.run(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], + cwd=root, capture_output=True, text=True, check=True).stdout.strip() + candidates.append(Path(common).parent.parent / STANDARD) + except (OSError, subprocess.CalledProcessError): + pass + 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 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"] + 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") + return check(root) + + manifest = read_json(source / "manifest.json") + actual = build_manifest(source, manifest["version"]) + if actual != manifest: + raise SurfaceError("The standards checkout has changes that manifest.json does not record; " + "run `python3 sync.py manifest --bump` there") + + if lock is not None: + problems = verify(root, dict(lock, destination=lock["destination"])) + if problems and not force: + raise SurfaceError("The shared surface code has drifted: " + "; ".join(problems) + + ". Change the standard, then sync (or pass --force to discard the edits).") + if manifest["version"] < lock["version"]: + print(f"Mac surfaces: checkout has version {manifest['version']}, older than this app's " + f"{lock['version']}; keeping the committed copy") + return + same = manifest["files"] == lock["files"] and destination == lock["destination"] + if same and not problems and manifest["tool"] == lock.get("tool"): + print(f"Mac surfaces: version {lock['version']}, up to date") + return + if manifest["version"] == lock["version"] and manifest["files"] != lock["files"]: + raise SurfaceError("The standard changed without a version bump; " + "run `python3 sync.py manifest --bump` in the standards checkout") + + target = root / destination + target.mkdir(parents=True, exist_ok=True) + previous = set(lock["files"]) if lock else set() + old_target = root / lock["destination"] if lock else target + for name in previous - set(manifest["files"]): + (old_target / name).unlink(missing_ok=True) + if lock and old_target != target: + for name in previous & set(manifest["files"]): + (old_target / name).unlink(missing_ok=True) + for path in source_files(source): + shutil.copyfile(path, target / path.name) + tool = root / TOOL + if not tool.is_file() or digest(tool) != manifest["tool"]: + tool.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / "sync.py", tool) + write_json(lock_path, {"schema": SCHEMA, "version": manifest["version"], "destination": destination, + "tool": manifest["tool"], "files": manifest["files"]}) + print(f"Mac surfaces: copied version {manifest['version']} ({len(manifest['files'])} files) " + f"into {destination}; review and commit the change") + + +def write_manifest(source, bump): + path = source / "manifest.json" + version = read_json(path)["version"] if path.is_file() else 0 + current = build_manifest(source, version) + if path.is_file() and current == read_json(path): + print(f"Mac surfaces: manifest is current at version {version}") + return + if not bump and path.is_file(): + raise SurfaceError("Sources changed; rerun with --bump to publish them as a new version") + write_json(path, build_manifest(source, version + 1)) + print(f"Mac surfaces: manifest written, version {version + 1}") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("action", choices=["sync", "check", "manifest"]) + parser.add_argument("--root", default=".", help="the app checkout (default: current directory)") + parser.add_argument("--destination", help="folder in the app for the copy, relative to the root") + parser.add_argument("--source", help="the standard's folder; found automatically when omitted") + parser.add_argument("--force", action="store_true", help="discard edits made to the copy") + parser.add_argument("--bump", action="store_true", help="manifest: publish changed sources as a new version") + options = parser.parse_args(argv) + try: + if options.action == "manifest": + write_manifest(Path(options.source or Path(__file__).parent).resolve(), options.bump) + elif options.action == "check": + check(Path(options.root).resolve()) + else: + sync(Path(options.root).resolve(), options.destination, options.source, options.force) + except (SurfaceError, OSError, ValueError, KeyError) as error: + print("Mac surfaces failed: " + str(error), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/README.md b/README.md index 394309f..c4b4151 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ All analysis happens on your machine, and the app ships no analytics of its own. ## Get it -Requires macOS 13 or later. +Requires macOS 14 or later. **Homebrew** — the cask lives in [`privacykey/homebrew-tap`](https://github.com/privacykey/homebrew-tap): diff --git a/docs/PROJECT-COMMANDS.md b/docs/PROJECT-COMMANDS.md index c0ad916..8785305 100644 --- a/docs/PROJECT-COMMANDS.md +++ b/docs/PROJECT-COMMANDS.md @@ -73,6 +73,7 @@ the captured identity. Credentials remain in the configured local/CI stores. - `release-check` — Validate release eligibility - `screenshot` — Capture reproducible screenshots - `dev` — Start an interactive development loop +- `surfaces` — Copy the shared Settings, menu and About code into this app - `release` — Create and trigger a versioned release - `release-local` — Keep the combined local release builder diff --git a/justfile b/justfile index 19ecd7f..7512895 100644 --- a/justfile +++ b/justfile @@ -111,6 +111,10 @@ screenshot value="": dev value="": @python3 .project/projectctl.py dev {{quote(value)}} --target {{quote(target)}} --platform {{quote(platform)}} --environment {{quote(environment)}} --channel {{quote(channel)}} --suite {{quote(suite)}} --package {{quote(package)}} --filter {{quote(filter)}} --state {{quote(state)}} --width {{quote(width)}} --height {{quote(height)}} +# Copy the shared Settings, menu and About code into this app +surfaces: + @python3 .project/projectctl.py surfaces --target {{quote(target)}} --platform {{quote(platform)}} --environment {{quote(environment)}} --channel {{quote(channel)}} --suite {{quote(suite)}} --package {{quote(package)}} --filter {{quote(filter)}} --state {{quote(state)}} --width {{quote(width)}} --height {{quote(height)}} + # Create and trigger a versioned release release chosen: @python3 .project/projectctl.py release {{quote(chosen)}} --target {{quote(target)}} --platform {{quote(platform)}} --environment {{quote(environment)}} --channel {{quote(channel)}} --suite {{quote(suite)}} --package {{quote(package)}} --filter {{quote(filter)}} --state {{quote(state)}} --width {{quote(width)}} --height {{quote(height)}} diff --git a/privacycommand/Package.swift b/privacycommand/Package.swift index f5bd447..db9b1f5 100644 --- a/privacycommand/Package.swift +++ b/privacycommand/Package.swift @@ -4,7 +4,7 @@ import PackageDescription let package = Package( name: "privacycommand", platforms: [ - .macOS(.v13) + .macOS(.v14) ], products: [ .library(name: "privacycommandCore", targets: ["privacycommandCore"]), diff --git a/privacycommand/Resources/Info.plist b/privacycommand/Resources/Info.plist index 3f37788..6ead983 100644 --- a/privacycommand/Resources/Info.plist +++ b/privacycommand/Resources/Info.plist @@ -27,7 +27,7 @@ LSApplicationCategoryType public.app-category.developer-tools LSMinimumSystemVersion - 13.0 + 14.0 LSUIElement NSAppleEventsUsageDescription diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceAbout.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceAbout.swift new file mode 100644 index 0000000..eaf83f4 --- /dev/null +++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceAbout.swift @@ -0,0 +1,220 @@ +#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) + } + .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 { + 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.openManual() } + Button("Keyboard Shortcuts") { help.openShortcuts() } + } + .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(spacing: 5) { + Text(provenance).font(.caption.monospaced()) + 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..3e22f13 --- /dev/null +++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceActivation.swift @@ -0,0 +1,50 @@ +#if os(macOS) +import AppKit + +/// 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. + public func start() { + guard observers.isEmpty else { return } + 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() } + } + } +} +#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..98b9595 --- /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.openManual() } + Button("Keyboard Shortcuts") { help.openShortcuts() } + .keyboardShortcut("?", modifiers: .command) + if let replay = help.replayWelcome { + Divider() + Button("Welcome to \(app.name)") { replay() } + } + } + } +} +#endif diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceMenuBar.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceMenuBar.swift new file mode 100644 index 0000000..e8d8760 --- /dev/null +++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceMenuBar.swift @@ -0,0 +1,208 @@ +#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" + ) { + self.defaults = defaults + shownKey = prefix + ".shown" + iconKey = prefix + ".icon" + isShown = defaults.object(forKey: shownKey) as? Bool ?? shownByDefault + icon = defaults.string(forKey: 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: Image + + public init(app: SurfaceApp, mark: Image) { + self.app = app + self.mark = mark + } + + public var body: some View { + HStack(spacing: 8) { + mark + Text(app.name).font(.headline) + Spacer() + } + } +} + +/// 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..de75fe5 --- /dev/null +++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceProfile.swift @@ -0,0 +1,160 @@ +#if os(macOS) +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("Website", url: website), SurfaceLink("Support", url: support), creator] + case let .openSource(repository, issues, _): + [SurfaceLink("Source on GitHub", url: repository, showsURL: true), + SurfaceLink("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. The app supplies the windows. +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: @escaping @MainActor () -> Void, + openShortcuts: @escaping @MainActor () -> Void, + replayWelcome: (@MainActor () -> Void)? = nil + ) { + self.openManual = openManual + self.openShortcuts = openShortcuts + self.replayWelcome = replayWelcome + } +} +#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/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..ccd6989 --- /dev/null +++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceUpdates.swift @@ -0,0 +1,153 @@ +#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() } + if let notes = updates.releaseNotes { + Link("Release Notes", destination: notes) + } + } + } + } + + 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. + @MainActor + public static func updates(_ updates: SurfaceUpdates, build: SurfaceBuild = .current) -> SurfacePane { + SurfacePane("Updates", systemImage: "arrow.down.circle") { + SurfaceUpdateSections(updates: updates, build: build) + } + } +} +#endif diff --git a/privacycommand/privacycommand.xcodeproj/project.pbxproj b/privacycommand/privacycommand.xcodeproj/project.pbxproj index 735f0db..d23bf75 100644 --- a/privacycommand/privacycommand.xcodeproj/project.pbxproj +++ b/privacycommand/privacycommand.xcodeproj/project.pbxproj @@ -415,6 +415,11 @@ path = privacycommandHelper; sourceTree = ""; }; + 0FFFFFFF0000000000005F01 /* MacSurfaces */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = MacSurfaces; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -468,6 +473,7 @@ 0CCCCCCC0000000000000A01 /* ViewModels */, 0CCCCCCC0000000000000A02 /* Views */, 0CCCCCCC0000000000000A04 /* Reporting */, + 0FFFFFFF0000000000005F01 /* MacSurfaces */, ); name = privacycommand; path = Sources/privacycommand; @@ -830,6 +836,9 @@ dependencies = ( 6600000000000000000000A1 /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 0FFFFFFF0000000000005F01 /* MacSurfaces */, + ); name = privacycommand; productName = privacycommand; productReference = 0FFFFFFF000000000000E001 /* privacycommand.app */; @@ -1218,7 +1227,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; OTHER_LDFLAGS = "-lsqlite3"; PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand; PRODUCT_NAME = privacycommand; @@ -1247,7 +1256,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; OTHER_LDFLAGS = "-lsqlite3"; PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand; PRODUCT_NAME = privacycommand; @@ -1265,7 +1274,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 6S9Q286XS9; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand.tests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1284,7 +1293,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 6S9Q286XS9; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand.tests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1347,7 +1356,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -1405,7 +1414,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = macosx; @@ -1431,7 +1440,7 @@ INFOPLIST_KEY_LSBackgroundOnly = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "privacycommand Helper — privileged helper for privacycommand. A privacykey product."; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand.HelperTool; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1459,7 +1468,7 @@ INFOPLIST_KEY_LSBackgroundOnly = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "privacycommand Helper — privileged helper for privacycommand. A privacykey product."; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MACOSX_DEPLOYMENT_TARGET = 13.0; + MACOSX_DEPLOYMENT_TARGET = 14.0; PRODUCT_BUNDLE_IDENTIFIER = org.privacykey.privacycommand.HelperTool; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/privacycommand/privacycommand.xcodeproj/xcshareddata/xcschemes/privacycommand.xcscheme b/privacycommand/privacycommand.xcodeproj/xcshareddata/xcschemes/privacycommand.xcscheme index afeb77d..7931808 100644 --- a/privacycommand/privacycommand.xcodeproj/xcshareddata/xcschemes/privacycommand.xcscheme +++ b/privacycommand/privacycommand.xcodeproj/xcshareddata/xcschemes/privacycommand.xcscheme @@ -13,6 +13,13 @@ + + + + + + + Date: Mon, 5 Oct 2026 02:21:28 +1100 Subject: [PATCH 2/3] Update the surfaces copy tool to version 3 CI and TestFlight or release builds now only verify the committed copy, so a publication build never changes its own sources. Co-Authored-By: Claude Fable 5.1 --- .project/mac-surfaces.lock.json | 4 ++-- .project/mac_surfaces.py | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.project/mac-surfaces.lock.json b/.project/mac-surfaces.lock.json index e66029f..b1964fe 100644 --- a/.project/mac-surfaces.lock.json +++ b/.project/mac-surfaces.lock.json @@ -1,8 +1,8 @@ { "schema": "mac-surfaces/v1", - "version": 2, + "version": 3, "destination": "privacycommand/Sources/privacycommand/MacSurfaces", - "tool": "b593d3ccbc2f0226517834db5d6e56a0aeff918f43dfc015668cac116adb9aa6", + "tool": "e9ec766d0aceba3327d9600de1727aaaf2e444a3a02db612faf3e25d80b95fbc", "files": { "SurfaceAbout.swift": "a1b43ec780abe86f225d30111416317ec688138dd932d43a103022238c529231", "SurfaceActivation.swift": "88875baa038fa97ce9adbefad6998c64b5bfe6161f255d14437c6702dfaf6de8", diff --git a/.project/mac_surfaces.py b/.project/mac_surfaces.py index 7ed808d..f71fb1a 100644 --- a/.project/mac_surfaces.py +++ b/.project/mac_surfaces.py @@ -3,8 +3,9 @@ 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 CI -or a contributor's clone, the committed copy is verified against its lock. +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 @@ -70,6 +71,12 @@ def find_source(root, explicit): return None +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"] @@ -106,6 +113,9 @@ def sync(root, destination, explicit_source, force): 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") + return check(root) source = find_source(root, explicit_source) if source is None: if lock is None: From 1df2a57924217a61e6562e814f7db90c32f56653 Mon Sep 17 00:00:00 2001 From: adamXbot <111877622+adamXbot@users.noreply.github.com> Date: Wed, 7 Oct 2026 00:28:01 +1100 Subject: [PATCH 3/3] Take version 8 of the shared surface code Version 8 tidies the vendored copy tool, .project/mac_surfaces.py, which the code quality review flagged: sync no longer returns check()'s always-None result, and the git lookup no longer ends in a bare except/pass. Behaviour is unchanged. The copy also moves from version 3 to the current Swift sources (the in-app manual and Keyboard Shortcuts windows among them), which the adoption PR stacked on this one already builds against. Co-Authored-By: Claude Opus 5.5 --- .project/mac-surfaces.lock.json | 18 +- .project/mac_surfaces.py | 25 +- .../MacSurfaces/SurfaceAbout.swift | 14 +- .../MacSurfaces/SurfaceActivation.swift | 51 +++- .../MacSurfaces/SurfaceCommands.swift | 4 +- .../MacSurfaces/SurfaceManual.swift | 288 ++++++++++++++++++ .../MacSurfaces/SurfaceMenuBar.swift | 47 ++- .../MacSurfaces/SurfaceProfile.swift | 33 +- .../MacSurfaces/SurfaceShortcuts.swift | 94 ++++++ .../MacSurfaces/SurfaceUpdates.swift | 26 +- 10 files changed, 557 insertions(+), 43 deletions(-) create mode 100644 privacycommand/Sources/privacycommand/MacSurfaces/SurfaceManual.swift create mode 100644 privacycommand/Sources/privacycommand/MacSurfaces/SurfaceShortcuts.swift diff --git a/.project/mac-surfaces.lock.json b/.project/mac-surfaces.lock.json index b1964fe..2a82b84 100644 --- a/.project/mac-surfaces.lock.json +++ b/.project/mac-surfaces.lock.json @@ -1,18 +1,20 @@ { "schema": "mac-surfaces/v1", - "version": 3, + "version": 8, "destination": "privacycommand/Sources/privacycommand/MacSurfaces", - "tool": "e9ec766d0aceba3327d9600de1727aaaf2e444a3a02db612faf3e25d80b95fbc", + "tool": "d667ee5efbd0ba513ddb48397881d97f259c837269e5f45d46a839fbed56350a", "files": { - "SurfaceAbout.swift": "a1b43ec780abe86f225d30111416317ec688138dd932d43a103022238c529231", - "SurfaceActivation.swift": "88875baa038fa97ce9adbefad6998c64b5bfe6161f255d14437c6702dfaf6de8", + "SurfaceAbout.swift": "8722b826c6b9e53214a2ab0171ecdddf89907d60f9b17759607f4409b1f3aebc", + "SurfaceActivation.swift": "48d77775caf80c48a295631bacefa07d8e774888e009f86c1ac04ebbc2439d5b", "SurfaceBuild.swift": "e02a17f9bde7ef4a003c0abe0f14fdecfabe8eacbfdeb8708d300a59873edbb6", - "SurfaceCommands.swift": "57de8bd4b2647d3877a440e46dc95d73940ba42b109c79d5e8140f34390ec23f", - "SurfaceMenuBar.swift": "88f0780c8d005b6ec494063acd1d36f2e4d703e949edff8b3bd1dab60d76b68c", - "SurfaceProfile.swift": "bbc564ded117f2619d5c520e53dc027cdc1631bc47f543661d5bfc5f292c7a24", + "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": "6d7b755cb3fd2ad41b379007cbeb0f9420138fc3476440647445cd25d6e55ae5" + "SurfaceUpdates.swift": "99fcb3c8db66cba9549697a56f5538bbf0eff46e9a27714c9628286bee6326e3" } } diff --git a/.project/mac_surfaces.py b/.project/mac_surfaces.py index f71fb1a..296114d 100644 --- a/.project/mac_surfaces.py +++ b/.project/mac_surfaces.py @@ -56,12 +56,7 @@ def find_source(root, explicit): candidates.append(Path(explicit)) if os.environ.get("MAC_SURFACES_SOURCE"): candidates.append(Path(os.environ["MAC_SURFACES_SOURCE"])) - try: - common = subprocess.run(["git", "rev-parse", "--path-format=absolute", "--git-common-dir"], - cwd=root, capture_output=True, text=True, check=True).stdout.strip() - candidates.append(Path(common).parent.parent / STANDARD) - except (OSError, subprocess.CalledProcessError): - pass + candidates.extend(main_checkout_candidates(root)) candidates.append(root.parent / STANDARD) for candidate in candidates: if (candidate / "manifest.json").is_file(): @@ -71,6 +66,18 @@ def find_source(root, 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", "") @@ -115,13 +122,15 @@ def sync(root, destination, explicit_source, force): destination = lock["destination"] if lock is not None and publishing(): print("Mac surfaces: CI or publication build; verifying the committed copy without copying") - return check(root) + 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") - return check(root) + check(root) + return manifest = read_json(source / "manifest.json") actual = build_manifest(source, manifest["version"]) diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceAbout.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceAbout.swift index eaf83f4..8fa8a59 100644 --- a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceAbout.swift +++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceAbout.swift @@ -21,6 +21,7 @@ public struct SurfaceAboutWindow: Scene { let title = "About \(app.name)" return Window(title, id: Self.id) { SurfaceAboutView(app: app, help: help, build: build) + .surfaceNotRestored() } .windowResizability(.contentSize) .defaultPosition(.center) @@ -47,6 +48,7 @@ public struct SurfaceAboutButton: View { } public struct SurfaceAboutView: View { + @Environment(\.openWindow) private var openWindow private let app: SurfaceApp private let help: SurfaceHelp private let build: SurfaceBuild @@ -78,8 +80,8 @@ public struct SurfaceAboutView: View { Divider() links HStack(spacing: 18) { - Button("\(app.name) Help") { help.openManual() } - Button("Keyboard Shortcuts") { help.openShortcuts() } + Button("\(app.name) Help") { help.manual(openWindow) } + Button("Keyboard Shortcuts") { help.shortcuts(openWindow) } } .buttonStyle(.link) footer @@ -116,8 +118,12 @@ public struct SurfaceAboutView: View { board.setString("\(build.versionLine)\n\(provenance)", forType: .string) copied = true } label: { - HStack(spacing: 5) { - Text(provenance).font(.caption.monospaced()) + 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) diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceActivation.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceActivation.swift index 3e22f13..a5d7bb3 100644 --- a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceActivation.swift +++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceActivation.swift @@ -1,5 +1,6 @@ #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 @@ -13,8 +14,13 @@ public final class SurfaceActivation { private init() {} /// Call once at launch in a menu bar utility. Does nothing when repeated. - public func start() { + /// `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) } @@ -47,4 +53,47 @@ public final class SurfaceActivation { } } } + +/// 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/SurfaceCommands.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceCommands.swift index 98b9595..ad604aa 100644 --- a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceCommands.swift +++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceCommands.swift @@ -32,8 +32,8 @@ public struct SurfaceCommands: Commands { } } CommandGroup(replacing: .help) { - Button("\(app.name) Help") { help.openManual() } - Button("Keyboard Shortcuts") { help.openShortcuts() } + Button("\(app.name) Help") { help.manual(openWindow) } + Button("Keyboard Shortcuts") { help.shortcuts(openWindow) } .keyboardShortcut("?", modifiers: .command) if let replay = help.replayWelcome { Divider() 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 index e8d8760..00e95f0 100644 --- a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceMenuBar.swift +++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceMenuBar.swift @@ -18,13 +18,15 @@ public final class SurfaceMenuBarPreference: ObservableObject { defaultIcon: String, shownByDefault: Bool = true, defaults: UserDefaults = .standard, - prefix: String = "surface.menuBar" + prefix: String = "surface.menuBar", + shownKey: String? = nil, + iconKey: String? = nil ) { self.defaults = defaults - shownKey = prefix + ".shown" - iconKey = prefix + ".icon" - isShown = defaults.object(forKey: shownKey) as? Bool ?? shownByDefault - icon = defaults.string(forKey: iconKey) ?? defaultIcon + 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) { @@ -131,24 +133,51 @@ struct SurfaceSettingsAction { } /// The top of a menu bar popover: the mark and the app name. -public struct SurfacePopoverHeader: View { +public struct SurfacePopoverHeader: View { private let app: SurfaceApp - private let mark: Image + private let mark: AnyView + private let trailing: Trailing - public init(app: SurfaceApp, mark: Image) { + /// `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 = mark + 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 { diff --git a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceProfile.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceProfile.swift index de75fe5..400b0cb 100644 --- a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceProfile.swift +++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceProfile.swift @@ -1,4 +1,5 @@ #if os(macOS) +import AppKit import SwiftUI /// How the app reaches its users. Decides the About links, the legal line, @@ -122,10 +123,11 @@ public struct SurfaceApp: Sendable { public var links: [SurfaceLink] { switch distribution { case let .appStore(website, support, creator): - [SurfaceLink("Website", url: website), SurfaceLink("Support", url: support), creator] + [SurfaceLink(String(localized: "Website"), url: website), + SurfaceLink(String(localized: "Support"), url: support), creator] case let .openSource(repository, issues, _): - [SurfaceLink("Source on GitHub", url: repository, showsURL: true), - SurfaceLink("Report an Issue", url: issues)] + [SurfaceLink(String(localized: "Source on GitHub"), url: repository, showsURL: true), + SurfaceLink(String(localized: "Report an Issue"), url: issues)] } } @@ -140,21 +142,36 @@ public struct SurfaceApp: Sendable { } } -/// What the three Help items do. The app supplies the windows. +/// 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 + 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: @escaping @MainActor () -> Void, - openShortcuts: @escaping @MainActor () -> Void, + 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/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/SurfaceUpdates.swift b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceUpdates.swift index ccd6989..8b098c8 100644 --- a/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceUpdates.swift +++ b/privacycommand/Sources/privacycommand/MacSurfaces/SurfaceUpdates.swift @@ -129,10 +129,17 @@ public struct SurfaceUpdateSections: View { 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) + } } } @@ -142,12 +149,25 @@ public struct SurfaceUpdateSections: View { } extension SurfacePane { - /// The Updates pane, always the last tab in a Sparkle app. + /// 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) -> SurfacePane { - SurfacePane("Updates", systemImage: "arrow.down.circle") { + 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