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