Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .project/commands.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -131,6 +134,16 @@
],
"cwd": "."
}
],
"surfaces": [
{
"argv": [
"python3",
".project/mac_surfaces.py",
"sync"
],
"cwd": "."
}
]
}
},
Expand All @@ -144,6 +157,14 @@
".project/tests/test_projectctl.py"
],
"cwd": "."
},
{
"argv": [
"python3",
".project/mac_surfaces.py",
"check"
],
"cwd": "."
}
]
}
Expand Down
20 changes: 20 additions & 0 deletions .project/mac-surfaces.lock.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
215 changes: 215 additions & 0 deletions .project/mac_surfaces.py
Original file line number Diff line number Diff line change
@@ -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):
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
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())
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down
1 change: 1 addition & 0 deletions docs/PROJECT-COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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)}}
Expand Down
2 changes: 1 addition & 1 deletion privacycommand/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PackageDescription
let package = Package(
name: "privacycommand",
platforms: [
.macOS(.v13)
.macOS(.v14)
],
products: [
.library(name: "privacycommandCore", targets: ["privacycommandCore"]),
Expand Down
2 changes: 1 addition & 1 deletion privacycommand/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<string>14.0</string>
<key>LSUIElement</key>
<false/>
<key>NSAppleEventsUsageDescription</key>
Expand Down
Loading
Loading