Add shared Mac Sparkle updates and verified Amadan release tooling
26 files changed,
+833
−6
.gitignore+1 −0Makefile+1 −0Package.resolved+14 −0Package.swift+6 −1Sources/RastrilloMacUpdates/NativeUpdateCommands.swift+21 −0Sources/RastrilloMacUpdates/NativeUpdater.swift+53 −0Sources/RastrilloMacUpdates/Resources/ar.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/bn.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/en.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/es.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/ga.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/hi.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/ja.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/pt.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/ru.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/vi.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/yue.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/zh-Hans.lproj/Localizable.strings+2 −0Tests/RastrilloMacUpdatesTests/ConfigurationTests.swift+25 −0docs/mac-updates.md+92 −0tools/bundle-mac.py+15 −3tools/mac_updates.py+119 −0tools/publish_mac_update.py+164 −0tools/release_mac.py+87 −0tools/tests/test_bundle_mac.py+10 −2tools/tests/test_mac_updates.py+201 −0
CI failed — run details
CI log
=== test === swift test Fetching https://github.com/sparkle-project/Sparkle [1/44048] Fetching sparkle Fetched https://github.com/sparkle-project/Sparkle from cache (3.23s) Computing version for https://github.com/sparkle-project/Sparkle Computed https://github.com/sparkle-project/Sparkle at 2.9.6 (4.20s) Creating working copy for https://github.com/sparkle-project/Sparkle Working copy of https://github.com/sparkle-project/Sparkle resolved at 2.9.6 Downloading binary artifact https://github.com/sparkle-project/Sparkle/releases/download/2.9.6/Sparkle-for-Swift-Package-Manager.zip [16375/11577555] Downloading https://github.com/sparkle-project/Sparkle/releases/download/2.9.6/Sparkle-for-Swift-Package-Manager.zip error: failed validating archive from 'https://github.com/sparkle-project/Sparkle/releases/download/2.9.6/Sparkle-for-Swift-Package-Manager.zip' which is required by binary target 'Sparkle': could not find executable for 'unzip' error: fatalError make: *** [Makefile:4: test] Error 1 amadan: step test failed: exit status 2 amadan: job failed at step test — its output is above, not at the tail of this log
diff --git a/.gitignore b/.gitignore| index 3761d4b..cc0b532 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -3,3 +3,4 @@ |
| .claude/ |
| *.xcodeproj/ |
| examples/companion/build/ |
| +__pycache__/ |
diff --git a/Makefile b/Makefile| index 61d1bf7..cc068ed 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -5,6 +5,7 @@ test: |
| server-test: |
| cd server && go test -race ./... |
| bundle-test: |
| + python3 tools/tests/test_mac_updates.py |
| python3 tools/tests/test_bundle_mac.py |
| companion: |
| cd examples/companion && xcodegen generate |
diff --git a/Package.resolved b/Package.resolved| new file mode 100644 |
| index 0000000..a7c58f2 |
| --- /dev/null |
| +++ b/Package.resolved |
| @@ -0,0 +1,14 @@ |
| +{ |
| + "pins" : [ |
| + { |
| + "identity" : "sparkle", |
| + "kind" : "remoteSourceControl", |
| + "location" : "https://github.com/sparkle-project/Sparkle", |
| + "state" : { |
| + "revision" : "ac2def288cbff5cfc7df3ffef6abdf45b72bcb0a", |
| + "version" : "2.9.6" |
| + } |
| + } |
| + ], |
| + "version" : 2 |
| +} |
diff --git a/Package.swift b/Package.swift| index 5b1cd72..22a3ee0 100644 |
| --- a/Package.swift |
| +++ b/Package.swift |
| @@ -3,12 +3,17 @@ import PackageDescription |
| let package = Package( |
| name: "RastrilloNative", |
| + defaultLocalization: "en", |
| platforms: [.iOS(.v17), .macOS(.v14)], |
| products: [.library(name: "RastrilloNative", targets: ["RastrilloNative"]), |
| - .library(name: "RastrilloApple", targets: ["RastrilloApple"])], |
| + .library(name: "RastrilloApple", targets: ["RastrilloApple"]), |
| + .library(name: "RastrilloMacUpdates", targets: ["RastrilloMacUpdates"])], |
| + dependencies: [.package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.9.6")], |
| targets: [ |
| .target(name: "RastrilloNative"), |
| .target(name: "RastrilloApple"), |
| + .target(name: "RastrilloMacUpdates", dependencies: [.product(name: "Sparkle", package: "Sparkle", condition: .when(platforms: [.macOS]))], resources: [.process("Resources")]), |
| + .testTarget(name: "RastrilloMacUpdatesTests", dependencies: ["RastrilloMacUpdates"]), |
| .testTarget(name: "RastrilloAppleTests", dependencies: ["RastrilloApple"]), |
| .testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative"]), |
| ] |
diff --git a/Sources/RastrilloMacUpdates/NativeUpdateCommands.swift b/Sources/RastrilloMacUpdates/NativeUpdateCommands.swift| new file mode 100644 |
| index 0000000..6fa1f10 |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/NativeUpdateCommands.swift |
| @@ -0,0 +1,21 @@ |
| +#if os(macOS) |
| +import SwiftUI |
| + |
| +/// Sparkle owns installation and its UI; apps retain their normal quit/save handling. |
| +public struct NativeUpdateCommands: Commands { |
| + @Bindable private var updater: NativeUpdater |
| + |
| + public init(updater: NativeUpdater) { self.updater = updater } |
| + |
| + private static let resources = Bundle.main.url(forResource: "RastrilloNative_RastrilloMacUpdates", withExtension: "bundle").flatMap(Bundle.init(url:)) ?? Bundle.module |
| + |
| + public var body: some Commands { |
| + CommandGroup(after: .appInfo) { |
| + Button(action: updater.checkForUpdates) { Text("Check for Updates…", bundle: Self.resources) } |
| + .disabled(!updater.canCheckForUpdates) |
| + Toggle(isOn: $updater.automaticallyChecksForUpdates) { Text("Automatically Check for Updates", bundle: Self.resources) } |
| + .disabled(!updater.isConfigured) |
| + } |
| + } |
| +} |
| +#endif |
diff --git a/Sources/RastrilloMacUpdates/NativeUpdater.swift b/Sources/RastrilloMacUpdates/NativeUpdater.swift| new file mode 100644 |
| index 0000000..868f8f6 |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/NativeUpdater.swift |
| @@ -0,0 +1,53 @@ |
| +#if os(macOS) |
| +import Combine |
| +import Foundation |
| +import Observation |
| +import Sparkle |
| + |
| +/// One app-owned updater. Unconfigured development bundles never contact a feed. |
| +@MainActor @Observable public final class NativeUpdater { |
| + public private(set) var isConfigured = false |
| + public private(set) var canCheckForUpdates = false |
| + private var automaticChecks = false |
| + @ObservationIgnored private var controller: SPUStandardUpdaterController? |
| + @ObservationIgnored private var observations = Set<AnyCancellable>() |
| + |
| + public init() { |
| + guard Self.validConfiguration(Bundle.main.infoDictionary ?? [:]) else { return } |
| + let controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) |
| + self.controller = controller |
| + isConfigured = true |
| + controller.updater.publisher(for: \.canCheckForUpdates) |
| + .receive(on: DispatchQueue.main) |
| + .sink { [weak self] in self?.canCheckForUpdates = $0 } |
| + .store(in: &observations) |
| + controller.updater.publisher(for: \.automaticallyChecksForUpdates) |
| + .receive(on: DispatchQueue.main) |
| + .sink { [weak self] in self?.automaticChecks = $0 } |
| + .store(in: &observations) |
| + } |
| + |
| + public var automaticallyChecksForUpdates: Bool { |
| + get { automaticChecks } |
| + set { |
| + guard let controller, newValue != controller.updater.automaticallyChecksForUpdates else { return } |
| + controller.updater.automaticallyChecksForUpdates = newValue |
| + } |
| + } |
| + |
| + public func checkForUpdates() { |
| + guard canCheckForUpdates else { return } |
| + controller?.checkForUpdates(nil) |
| + } |
| + |
| + static func validConfiguration(_ info: [String: Any]) -> Bool { |
| + guard let feed = info["SUFeedURL"] as? String, |
| + let url = URLComponents(string: feed), url.scheme == "https", |
| + let host = url.host, !host.isEmpty, |
| + url.user == nil, url.password == nil, url.fragment == nil, |
| + let publicKey = info["SUPublicEDKey"] as? String, |
| + Data(base64Encoded: publicKey)?.count == 32 else { return false } |
| + return true |
| + } |
| +} |
| +#endif |
diff --git a/Sources/RastrilloMacUpdates/Resources/ar.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/ar.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..436decc |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/Resources/ar.lproj/Localizable.strings |
| @@ -0,0 +1,2 @@ |
| +"Check for Updates\u2026" = "التحقق من وجود تحديثات…"; |
| +"Automatically Check for Updates" = "التحقق من وجود تحديثات تلقائيًا"; |
diff --git a/Sources/RastrilloMacUpdates/Resources/bn.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/bn.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..289c0d1 |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/Resources/bn.lproj/Localizable.strings |
| @@ -0,0 +1,2 @@ |
| +"Check for Updates\u2026" = "আপডেট পরীক্ষা করুন…"; |
| +"Automatically Check for Updates" = "স্বয়ংক্রিয়ভাবে আপডেট পরীক্ষা করুন"; |
diff --git a/Sources/RastrilloMacUpdates/Resources/en.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/en.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..b7bd360 |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/Resources/en.lproj/Localizable.strings |
| @@ -0,0 +1,2 @@ |
| +"Check for Updates\u2026" = "Check for Updates…"; |
| +"Automatically Check for Updates" = "Automatically Check for Updates"; |
diff --git a/Sources/RastrilloMacUpdates/Resources/es.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/es.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..6183e36 |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/Resources/es.lproj/Localizable.strings |
| @@ -0,0 +1,2 @@ |
| +"Check for Updates\u2026" = "Buscar actualizaciones…"; |
| +"Automatically Check for Updates" = "Buscar actualizaciones automáticamente"; |
diff --git a/Sources/RastrilloMacUpdates/Resources/ga.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/ga.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..1ac595d |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/Resources/ga.lproj/Localizable.strings |
| @@ -0,0 +1,2 @@ |
| +"Check for Updates\u2026" = "Seiceáil le haghaidh nuashonruithe…"; |
| +"Automatically Check for Updates" = "Seiceáil le haghaidh nuashonruithe go huathoibríoch"; |
diff --git a/Sources/RastrilloMacUpdates/Resources/hi.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/hi.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..54142ca |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/Resources/hi.lproj/Localizable.strings |
| @@ -0,0 +1,2 @@ |
| +"Check for Updates\u2026" = "अपडेट की जाँच करें…"; |
| +"Automatically Check for Updates" = "अपडेट की अपने आप जाँच करें"; |
diff --git a/Sources/RastrilloMacUpdates/Resources/ja.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/ja.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..01b3d49 |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/Resources/ja.lproj/Localizable.strings |
| @@ -0,0 +1,2 @@ |
| +"Check for Updates\u2026" = "アップデートを確認…"; |
| +"Automatically Check for Updates" = "アップデートを自動的に確認"; |
diff --git a/Sources/RastrilloMacUpdates/Resources/pt.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/pt.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..d444fb1 |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/Resources/pt.lproj/Localizable.strings |
| @@ -0,0 +1,2 @@ |
| +"Check for Updates\u2026" = "Buscar atualizações…"; |
| +"Automatically Check for Updates" = "Buscar atualizações automaticamente"; |
diff --git a/Sources/RastrilloMacUpdates/Resources/ru.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/ru.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..fa8ba6f |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/Resources/ru.lproj/Localizable.strings |
| @@ -0,0 +1,2 @@ |
| +"Check for Updates\u2026" = "Проверить обновления…"; |
| +"Automatically Check for Updates" = "Проверять обновления автоматически"; |
diff --git a/Sources/RastrilloMacUpdates/Resources/vi.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/vi.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..f82758f |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/Resources/vi.lproj/Localizable.strings |
| @@ -0,0 +1,2 @@ |
| +"Check for Updates\u2026" = "Kiểm tra bản cập nhật…"; |
| +"Automatically Check for Updates" = "Tự động kiểm tra bản cập nhật"; |
diff --git a/Sources/RastrilloMacUpdates/Resources/yue.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/yue.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..4b429ac |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/Resources/yue.lproj/Localizable.strings |
| @@ -0,0 +1,2 @@ |
| +"Check for Updates\u2026" = "檢查更新…"; |
| +"Automatically Check for Updates" = "自動檢查更新"; |
diff --git a/Sources/RastrilloMacUpdates/Resources/zh-Hans.lproj/Localizable.strings b/Sources/RastrilloMacUpdates/Resources/zh-Hans.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..5a572e4 |
| --- /dev/null |
| +++ b/Sources/RastrilloMacUpdates/Resources/zh-Hans.lproj/Localizable.strings |
| @@ -0,0 +1,2 @@ |
| +"Check for Updates\u2026" = "检查更新…"; |
| +"Automatically Check for Updates" = "自动检查更新"; |
diff --git a/Tests/RastrilloMacUpdatesTests/ConfigurationTests.swift b/Tests/RastrilloMacUpdatesTests/ConfigurationTests.swift| new file mode 100644 |
| index 0000000..c51f861 |
| --- /dev/null |
| +++ b/Tests/RastrilloMacUpdatesTests/ConfigurationTests.swift |
| @@ -0,0 +1,25 @@ |
| +#if os(macOS) |
| +import XCTest |
| +@testable import RastrilloMacUpdates |
| + |
| +@MainActor final class ConfigurationTests: XCTestCase { |
| + func testOnlyConfiguredHTTPSFeedWithEd25519KeyCanStart() { |
| + let key = Data(repeating: 7, count: 32).base64EncodedString() |
| + XCTAssertTrue(NativeUpdater.validConfiguration(["SUFeedURL": "https://raw.amadan.net/oficina/native-releases/main/docs/appcast.xml", "SUPublicEDKey": key])) |
| + for feed in ["http://example.com/feed.xml", "https://token@example.com/feed.xml", "https://example.com/feed.xml#fragment", "https://"] { |
| + XCTAssertFalse(NativeUpdater.validConfiguration(["SUFeedURL": feed, "SUPublicEDKey": key])) |
| + } |
| + XCTAssertFalse(NativeUpdater.validConfiguration([:])) |
| + XCTAssertFalse(NativeUpdater.validConfiguration(["SUFeedURL": "https://example.com/feed.xml", "SUPublicEDKey": Data(repeating: 7, count: 31).base64EncodedString()])) |
| + } |
| + |
| + func testDevelopmentBundleDoesNotStartUpdater() { |
| + let updater = NativeUpdater() |
| + XCTAssertFalse(updater.isConfigured) |
| + XCTAssertFalse(updater.canCheckForUpdates) |
| + updater.checkForUpdates() |
| + updater.automaticallyChecksForUpdates = true |
| + XCTAssertFalse(updater.automaticallyChecksForUpdates) |
| + } |
| +} |
| +#endif |
diff --git a/docs/mac-updates.md b/docs/mac-updates.md| new file mode 100644 |
| index 0000000..7875194 |
| --- /dev/null |
| +++ b/docs/mac-updates.md |
| @@ -0,0 +1,92 @@ |
| +# Oficina Mac updates |
| + |
| +Meet, Calendar, Memoria, Docs and Sheets share `RastrilloMacUpdates`, a macOS-only |
| +Sparkle 2.9.6 integration. Each app owns one updater and puts Check for Updates |
| +and the automatic-check preference in its application menu. Sparkle asks before |
| +enabling automatic checks. Installation uses the standard confirmation/relaunch |
| +flow and the app's normal quit handling. iOS uses Apple's distribution mechanisms. |
| + |
| +Unconfigured development builds disable update controls. A release must pass its |
| +app-specific `native/mac-updates.json` to packaging. These files contain only the |
| +public Ed25519 key, Keychain account name, bundle ID and HTTPS appcast URL. The |
| +private Sparkle key stays in the login Keychain under the `oficina` account; it is |
| +separate from Apple signing and notarization credentials. Keep that key for future |
| +updates; rotating it requires a deliberate Sparkle key migration. |
| + |
| +## Build and notarize |
| + |
| +Use Team Tito Limited's Developer ID Application certificate, team `9U4A84C4WK`. |
| +The existing `slopbox-notary` Keychain profile authenticated successfully during |
| +setup; successful notarization of the actual release is the final validation. |
| +One team profile is reusable across the five apps. No credentials belong in Git. |
| + |
| +For the four standard apps, from their `native` directory: |
| + |
| +```sh |
| +scripts/build-mac.sh --configuration release --updates-config mac-updates.json \ |
| + --version 0.1.0 --build 2 \ |
| + --sign-identity 'Developer ID Application: Team Tito Limited (9U4A84C4WK)' |
| +``` |
| + |
| +Meet uses `OFICINA_BUILD_CONFIGURATION=release` instead of `--configuration`. |
| +`OFICINA_NATIVE_PACKAGE_PATH` can select an unpublished local shared-library |
| +checkout for development; omit it to use the reviewed pinned dependency. |
| + |
| +`tools/release_mac.py` takes the built app, `--config`, `--sparkle-tools` (the |
| +Sparkle SwiftPM artifact's `bin` directory), `--identity`, `--team`, |
| +`--notary-profile` and a new `--output` directory. It copies the app, signs helpers |
| +and frameworks before the host, verifies the selected Developer ID team, |
| +notarizes and staples the app, checks Gatekeeper, then creates and EdDSA-signs the |
| +final ZIP. Meet receives camera and audio-input hardened-runtime entitlements. |
| +The result contains the ZIP and a `release.json` audit manifest. Nothing is |
| +installed or published by preparation. |
| + |
| +The initial binaries built here target Apple silicon. `generate_appcast` reads |
| +minimum macOS and architecture requirements from the archive, so incompatible |
| +machines are not offered these updates. Docs and Sheets require macOS 26; |
| +Calendar, Memoria and Meet require macOS 14. |
| + |
| +## Amadan hosting |
| + |
| +Use a dedicated **public, artifacts-only** repository `oficina/native-releases`. |
| +Do not change the visibility of any application source repository. Initialize its |
| +`main` branch and clone it before publishing. The proposed feeds are: |
| + |
| +``` |
| +https://raw.amadan.net/oficina/native-releases/main/<app>/appcast.xml |
| +``` |
| + |
| +Archives use full immutable Git commit SHA URLs. Amadan raw hosting caps each |
| +file at 100 MiB; its Git receive request limit is 128 MiB. The publisher rejects |
| +oversize archives and pushes one archive at a time. Public raw responses need no |
| +application credentials. Pages hosting and expiring private download URLs are |
| +not used. |
| + |
| +Run `tools/publish_mac_update.py RELEASE --config CONFIG --repo ARTIFACT_CLONE |
| +--sparkle-tools TOOLS` to validate and print a plan. Add `--publish` only when the |
| +concrete release is ready to publish. It verifies the archive signature, actual |
| +archived app metadata, manifest, Keychain public key, monotonic build number, |
| +clean main checkout and correct origin. It pushes the archive first, downloads |
| +it anonymously by immutable SHA URL and checks length and SHA-256. Only then does |
| +it generate and push the appcast using Sparkle's official tool. The generated |
| +version must match the signed app. Existing feed items are preserved; deltas are |
| +disabled to keep upload sizes bounded. |
| + |
| +If publication stops after pushing an archive, the old feed stays usable. This |
| +first implementation deliberately refuses to overwrite that archive on rerun. |
| +Inspect the committed archive and failure, then either publish a new higher build |
| +or perform a reviewed manual feed recovery referencing that exact verified SHA. |
| +Do not force-push or replace a published archive. |
| + |
| +Users of pre-Sparkle builds need one initial installation of a Sparkle-enabled |
| +release. Subsequent releases can update in place. The first publication should |
| +include a real update/relaunch check before broad distribution. |
| + |
| +## Validation |
| + |
| +`make ci` includes Swift tests, Mac/iOS companion builds, Go race tests, relocated |
| +resource packaging and release-boundary tests. Publication tests check that |
| +metadata tampering, archive tampering, failed public download and generated |
| +version mismatch cannot publish a feed; successful publication must verify the |
| +archive between the two pushes. Independent mutation probes confirmed these |
| +checks detect removal of the corresponding guards. |
diff --git a/tools/bundle-mac.py b/tools/bundle-mac.py| index 6b14f54..1cf757c 100644 |
| --- a/tools/bundle-mac.py |
| +++ b/tools/bundle-mac.py |
| @@ -1,17 +1,25 @@ |
| """Bundle a SwiftPM app with the suite's padded macOS icon silhouette.""" |
| from pathlib import Path |
| import argparse, json, plistlib, shutil, subprocess |
| +from mac_updates import configure, embed_sparkle, sign_bundle |
| parser = argparse.ArgumentParser() |
| parser.add_argument("native", type=Path) |
| parser.add_argument("name") |
| +parser.add_argument("--configuration", choices=["debug", "release"], default="debug") |
| +parser.add_argument("--updates-config", type=Path) |
| +parser.add_argument("--version", default="0.1.0") |
| +parser.add_argument("--build", default="1") |
| +parser.add_argument("--sign-identity", default="-") |
| args = parser.parse_args() |
| root = args.native.resolve() |
| name = args.name |
| -subprocess.run(["swift", "build", "--package-path", str(root / "apple"), "--product", f"{name}Mac"], check=True) |
| +subprocess.run(["swift", "build", "--package-path", str(root / "apple"), "--configuration", args.configuration, "--product", f"{name}Mac"], check=True) |
| manifest = json.loads(subprocess.check_output(["swift", "package", "--package-path", str(root / "apple"), "dump-package"])) |
| minimum_os = next((p["version"] for p in manifest.get("platforms", []) if p["platformName"] == "macos"), "14.0") |
| -products = Path(subprocess.check_output(["swift", "build", "--package-path", str(root / "apple"), "--show-bin-path"], text=True).strip()) |
| +products = Path(subprocess.check_output(["swift", "build", "--package-path", str(root / "apple"), "--configuration", args.configuration, "--show-bin-path"], text=True).strip()) |
| bundle = root / f"build/Oficina {name}.app/Contents" |
| +if bundle.parent.exists(): |
| + shutil.rmtree(bundle.parent) |
| for directory in ["MacOS", "Resources"]: |
| (bundle / directory).mkdir(parents=True, exist_ok=True) |
| shutil.copy2(products / f"{name}Mac", bundle / f"MacOS/{name}Mac") |
| @@ -38,5 +46,9 @@ subprocess.run(["iconutil", "-c", "icns", str(iconset), "-o", str(bundle / "Reso |
| "CFBundleVersion": "1", "LSMinimumSystemVersion": minimum_os, |
| "NSHighResolutionCapable": True, |
| })) |
| -subprocess.run(["codesign", "--force", "--deep", "--sign", "-", str(bundle.parent)], check=True) |
| +configure(bundle.parent, args.updates_config, args.version, args.build) |
| +embedded = embed_sparkle(bundle.parent, root / "apple") |
| +if args.updates_config and not embedded: |
| + raise ValueError("configured updater requires an embedded Sparkle framework") |
| +sign_bundle(bundle.parent, args.sign_identity) |
| print(bundle.parent) |
diff --git a/tools/mac_updates.py b/tools/mac_updates.py| new file mode 100644 |
| index 0000000..d6e5160 |
| --- /dev/null |
| +++ b/tools/mac_updates.py |
| @@ -0,0 +1,119 @@ |
| +"""Shared Sparkle bundle configuration and inside-out signing (macOS only).""" |
| +import argparse |
| +import base64 |
| +import json |
| +from pathlib import Path |
| +import plistlib |
| +import re |
| +import shutil |
| +import subprocess |
| +import tempfile |
| +from urllib.parse import urlsplit |
| + |
| + |
| +def read_configuration(path): |
| + config = json.loads(Path(path).read_text()) |
| + url = urlsplit(config['feed_url']) |
| + if url.scheme != 'https' or not url.hostname or url.username or url.password or url.fragment: |
| + raise ValueError('feed_url must be an HTTPS URL without credentials or fragment') |
| + if len(base64.b64decode(config['public_ed_key'], validate=True)) != 32: |
| + raise ValueError('public_ed_key must encode an Ed25519 public key') |
| + if not re.fullmatch(r'[A-Za-z0-9.-]+', config['bundle_id']): |
| + raise ValueError('invalid bundle_id') |
| + return config |
| + |
| + |
| +def configure(app, configuration=None, version=None, build=None): |
| + info_path = app / 'Contents/Info.plist' |
| + info = plistlib.loads(info_path.read_bytes()) |
| + if version is not None: |
| + if not re.fullmatch(r'[0-9]+(?:\.[0-9]+){0,2}', version): |
| + raise ValueError('version must be a numeric marketing version') |
| + info['CFBundleShortVersionString'] = version |
| + if build is not None: |
| + if not re.fullmatch(r'[1-9][0-9]*', build): |
| + raise ValueError('build must be a positive increasing integer') |
| + info['CFBundleVersion'] = build |
| + for key in ['SUFeedURL', 'SUPublicEDKey', 'SUEnableAutomaticChecks', 'SUAutomaticallyUpdate', 'SUAllowsAutomaticUpdates', 'SUVerifyUpdateBeforeExtraction']: |
| + info.pop(key, None) |
| + if configuration: |
| + config = read_configuration(configuration) |
| + if config['bundle_id'] != info['CFBundleIdentifier']: |
| + raise ValueError('update configuration belongs to another app') |
| + info.update(SUFeedURL=config['feed_url'], SUPublicEDKey=config['public_ed_key'], |
| + SUVerifyUpdateBeforeExtraction=True, SUAllowsAutomaticUpdates=False) |
| + # Do not set SUEnableAutomaticChecks: Sparkle asks and preserves the user's choice. |
| + # Installs require the normal explicit relaunch prompt; editor quit guards remain active. |
| + info_path.write_bytes(plistlib.dumps(info)) |
| + |
| + |
| +def embed_sparkle(app, package): |
| + frameworks = list((package / '.build/artifacts').rglob('Sparkle.framework')) |
| + frameworks = [p for p in frameworks if p.is_dir() and not p.is_symlink()] |
| + if not frameworks: |
| + return False |
| + if len(frameworks) != 1: |
| + raise ValueError('ambiguous Sparkle framework artifact') |
| + destination = app / 'Contents/Frameworks/Sparkle.framework' |
| + destination.parent.mkdir(parents=True, exist_ok=True) |
| + if destination.exists(): |
| + shutil.rmtree(destination) |
| + shutil.copytree(frameworks[0], destination, symlinks=True) |
| + info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) |
| + executable = app / 'Contents/MacOS' / info['CFBundleExecutable'] |
| + loads = subprocess.check_output(['otool', '-l', str(executable)], text=True) |
| + if 'path @executable_path/../Frameworks ' not in loads: |
| + subprocess.run(['install_name_tool', '-add_rpath', '@executable_path/../Frameworks', str(executable)], check=True) |
| + return True |
| + |
| + |
| +def sign_bundle(app, identity='-'): |
| + command = ['codesign', '--force', '--sign', identity] |
| + if identity != '-': |
| + command += ['--options', 'runtime', '--timestamp'] |
| + frameworks = app / 'Contents/Frameworks' |
| + # Files and nested bundles must be signed before their containing framework. |
| + sparkle = frameworks / 'Sparkle.framework' |
| + if sparkle.exists(): |
| + current = sparkle / 'Versions/Current' |
| + for relative in ['XPCServices/Downloader.xpc', 'XPCServices/Installer.xpc', 'Autoupdate', 'Updater.app']: |
| + target = current / relative |
| + if not target.exists(): |
| + raise ValueError(f'Sparkle helper missing: {relative}') |
| + subprocess.run(command + ['--preserve-metadata=entitlements', str(target)], check=True) |
| + if frameworks.exists(): |
| + for framework in sorted(frameworks.glob('*.framework')): |
| + subprocess.run(command + [str(framework)], check=True) |
| + info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) |
| + entitlements = {} |
| + if info.get('NSCameraUsageDescription'): |
| + entitlements['com.apple.security.device.camera'] = True |
| + if info.get('NSMicrophoneUsageDescription'): |
| + entitlements['com.apple.security.device.audio-input'] = True |
| + # Keep device grants on the host app only, including when release preparation |
| + # re-signs an existing development bundle with the hardened runtime enabled. |
| + with tempfile.NamedTemporaryFile(suffix='.plist') as file: |
| + file.write(plistlib.dumps(entitlements)) |
| + file.flush() |
| + subprocess.run(command + ['--entitlements', file.name, str(app)], check=True) |
| + subprocess.run(['codesign', '--verify', '--deep', '--strict', str(app)], check=True) |
| + |
| + |
| +def main(): |
| + parser = argparse.ArgumentParser(description=__doc__) |
| + parser.add_argument('app', type=Path) |
| + parser.add_argument('--package', type=Path, required=True) |
| + parser.add_argument('--updates-config', type=Path) |
| + parser.add_argument('--version') |
| + parser.add_argument('--build') |
| + parser.add_argument('--sign-identity', default='-') |
| + args = parser.parse_args() |
| + configure(args.app, args.updates_config, args.version, args.build) |
| + embedded = embed_sparkle(args.app, args.package) |
| + if args.updates_config and not embedded: |
| + raise ValueError('configured updater requires an embedded Sparkle framework') |
| + sign_bundle(args.app, args.sign_identity) |
| + |
| + |
| +if __name__ == '__main__': |
| + main() |
diff --git a/tools/publish_mac_update.py b/tools/publish_mac_update.py| new file mode 100644 |
| index 0000000..a260ef0 |
| --- /dev/null |
| +++ b/tools/publish_mac_update.py |
| @@ -0,0 +1,164 @@ |
| +"""Publish a prepared Sparkle release to an existing public Amadan artifact repo. |
| + |
| +Without --publish, validate and print the publication plan without changing the repo. |
| +Archives are committed and pushed individually; the feed is pushed only after an |
| +anonymous download verifies the immutable archive's complete length and digest. |
| +""" |
| +import argparse |
| +import hashlib |
| +import json |
| +from pathlib import Path |
| +import re |
| +import plistlib |
| +import zipfile |
| +import shutil |
| +import subprocess |
| +import tempfile |
| +import time |
| +from urllib.parse import quote, urlsplit |
| +from urllib.request import urlopen |
| +import xml.etree.ElementTree as ET |
| + |
| +from mac_updates import read_configuration |
| +from release_mac import MAX_ARCHIVE_BYTES |
| + |
| +SPARKLE = '{http://www.andymatuschak.org/xml-namespaces/sparkle}' |
| + |
| + |
| +def git(repo, *args): |
| + return subprocess.check_output(['git', '-C', str(repo), *args], text=True).strip() |
| + |
| + |
| +def check_build(feed, build): |
| + if not str(build).isdecimal() or int(build) < 1: |
| + raise ValueError('release build must be a positive integer') |
| + if not feed.exists(): |
| + return |
| + root = ET.parse(feed).getroot() |
| + versions = [element.text for element in root.iter(SPARKLE + 'version')] |
| + versions += [node.attrib[SPARKLE + 'version'] for node in root.iter('enclosure') if SPARKLE + 'version' in node.attrib] |
| + if not versions or any(not value or not value.isdecimal() for value in versions): |
| + raise ValueError('existing appcast has no comparable integer versions') |
| + if int(build) <= max(map(int, versions)): |
| + raise ValueError('release build must be greater than every published build') |
| + |
| + |
| +def verify_download(url, expected_length, expected_digest): |
| + for attempt in range(5): |
| + try: |
| + digest = hashlib.sha256() |
| + length = 0 |
| + with urlopen(url, timeout=60) as response: |
| + if urlsplit(response.url).scheme != 'https': |
| + raise ValueError('archive redirected to an insecure URL') |
| + while data := response.read(1024 * 1024): |
| + length += len(data) |
| + if length > expected_length: |
| + raise ValueError('published archive is larger than the signed archive') |
| + digest.update(data) |
| + if length != expected_length or digest.hexdigest() != expected_digest: |
| + raise ValueError('published archive does not match the signed archive') |
| + return |
| + except (OSError, ValueError): |
| + if attempt == 4: |
| + raise |
| + time.sleep(2 ** attempt) |
| + |
| + |
| +def publish(release, config_path, repo, tools, do_publish=False): |
| + config = read_configuration(config_path) |
| + manifest = json.loads((release / 'release.json').read_text()) |
| + if manifest['bundle_id'] != config['bundle_id'] or manifest['feed_url'] != config['feed_url']: |
| + raise ValueError('release and update configuration do not match') |
| + archive_name = manifest['archive'] |
| + if Path(archive_name).name != archive_name or not archive_name.endswith('.zip'): |
| + raise ValueError('invalid archive filename') |
| + archive = release / archive_name |
| + length = archive.stat().st_size |
| + with archive.open('rb') as data: |
| + digest = hashlib.file_digest(data, 'sha256').hexdigest() |
| + if length != manifest['length'] or digest != manifest['sha256'] or length > MAX_ARCHIVE_BYTES: |
| + raise ValueError('archive changed or exceeds the Amadan size limit') |
| + with zipfile.ZipFile(archive) as zipped: |
| + plists = [name for name in zipped.namelist() if re.fullmatch(r'[^/]+\.app/Contents/Info\.plist', name)] |
| + if len(plists) != 1: |
| + raise ValueError('archive must contain exactly one top-level app') |
| + info = plistlib.loads(zipped.read(plists[0])) |
| + expected = {'CFBundleIdentifier': config['bundle_id'], 'SUFeedURL': config['feed_url'], |
| + 'SUPublicEDKey': config['public_ed_key'], 'CFBundleVersion': manifest['build'], |
| + 'CFBundleShortVersionString': manifest['version']} |
| + if any(info.get(key) != value for key, value in expected.items()): |
| + raise ValueError('signed archive metadata does not match the release and update configuration') |
| + public_key = subprocess.check_output([str(tools / 'generate_keys'), '--account', config['keychain_account'], '-p'], text=True).strip() |
| + if public_key != config['public_ed_key']: |
| + raise ValueError('Keychain signing key does not match the app public key') |
| + feed = urlsplit(config['feed_url']) |
| + match = re.fullmatch(r'/([a-z0-9_-]+)/([a-z0-9_-]+)/main/([a-z0-9_-]+)/appcast.xml', feed.path) |
| + if feed.netloc != 'raw.amadan.net' or feed.query or not match: |
| + raise ValueError('feed must use an Amadan raw main/<app>/appcast.xml URL') |
| + namespace, repository, app = match.groups() |
| + expected_remote = f'https://amadan.net/{namespace}/{repository}' |
| + if git(repo, 'remote', 'get-url', '--push', 'origin').removesuffix('.git') != expected_remote: |
| + raise ValueError('origin must point to the configured Amadan artifact repository') |
| + if git(repo, 'status', '--porcelain') or git(repo, 'branch', '--show-current') != 'main': |
| + raise ValueError('artifact repository must be clean and on main') |
| + relative_archive = Path(app) / 'releases' / archive_name |
| + relative_feed = Path(app) / 'appcast.xml' |
| + check_build(repo / relative_feed, manifest['build']) |
| + if (repo / relative_archive).exists(): |
| + raise ValueError('release archive path already exists; use a new build number') |
| + subprocess.run([str(tools / 'sign_update'), '--account', config['keychain_account'], '--verify', str(archive), manifest['signature']], check=True) |
| + plan = dict(repository=expected_remote, archive=str(relative_archive), feed=config['feed_url'], build=manifest['build']) |
| + if not do_publish: |
| + return plan |
| + git(repo, 'fetch', 'origin', 'main') |
| + if git(repo, 'rev-parse', 'HEAD') != git(repo, 'rev-parse', 'origin/main'): |
| + raise ValueError('main must exactly match the fetched origin/main before publication') |
| + target = repo / relative_archive |
| + target.parent.mkdir(parents=True, exist_ok=True) |
| + shutil.copy2(archive, target) |
| + git(repo, 'add', '--', str(relative_archive)) |
| + git(repo, 'commit', '-m', f"Release {app} build {manifest['build']} archive") |
| + archive_commit = git(repo, 'rev-parse', 'HEAD') |
| + prefix = f'https://raw.amadan.net/{namespace}/{repository}/{archive_commit}/{app}/releases/' |
| + # One archive per push stays below Amadan's 128 MiB git receive limit. |
| + git(repo, 'push', 'origin', 'HEAD:refs/heads/main') |
| + verify_download(prefix + quote(archive_name), length, digest) |
| + with tempfile.TemporaryDirectory(prefix='oficina-appcast-') as directory: |
| + staging = Path(directory) |
| + shutil.copy2(archive, staging / archive_name) |
| + output = staging / 'appcast.xml' |
| + if (repo / relative_feed).exists(): |
| + shutil.copy2(repo / relative_feed, output) |
| + subprocess.run([str(tools / 'generate_appcast'), '--account', config['keychain_account'], '--maximum-deltas', '0', '--maximum-versions', '0', '--download-url-prefix', prefix, '-o', str(output), str(staging)], check=True) |
| + tree = ET.parse(output) |
| + matching_items = [item for item in tree.iter('item') if any(node.get('url') == prefix + quote(archive_name) for node in item.findall('enclosure'))] |
| + if len(matching_items) != 1: |
| + raise ValueError('generated appcast must have one matching release item') |
| + actual_build = matching_items[0].findtext(SPARKLE + 'version') |
| + if actual_build != manifest['build']: |
| + raise ValueError('generated appcast build does not match the signed archive') |
| + check_build(repo / relative_feed, actual_build) |
| + enclosures = [node for node in tree.iter('enclosure') if node.get('url') == prefix + quote(archive_name)] |
| + if len(enclosures) != 1 or enclosures[0].get(SPARKLE + 'edSignature') != manifest['signature'] or enclosures[0].get('length') != str(length): |
| + raise ValueError('generated appcast does not reference the verified signed archive') |
| + shutil.copy2(output, repo / relative_feed) |
| + git(repo, 'add', '--', str(relative_feed)) |
| + git(repo, 'commit', '-m', f"Publish {app} build {manifest['build']} appcast") |
| + git(repo, 'push', 'origin', 'HEAD:refs/heads/main') |
| + return dict(plan, archive_commit=archive_commit, feed_commit=git(repo, 'rev-parse', 'HEAD')) |
| + |
| + |
| +def main(): |
| + parser = argparse.ArgumentParser(description=__doc__) |
| + parser.add_argument('release', type=Path) |
| + parser.add_argument('--config', type=Path, required=True) |
| + parser.add_argument('--repo', type=Path, required=True) |
| + parser.add_argument('--sparkle-tools', type=Path, required=True) |
| + parser.add_argument('--publish', action='store_true') |
| + args = parser.parse_args() |
| + print(json.dumps(publish(args.release, args.config, args.repo, args.sparkle_tools, args.publish), indent=2)) |
| + |
| + |
| +if __name__ == '__main__': |
| + main() |
diff --git a/tools/release_mac.py b/tools/release_mac.py| new file mode 100644 |
| index 0000000..d205ecc |
| --- /dev/null |
| +++ b/tools/release_mac.py |
| @@ -0,0 +1,87 @@ |
| +"""Prepare a signed, notarized Sparkle archive. Does not publish or install it.""" |
| +import argparse |
| +import hashlib |
| +import json |
| +from pathlib import Path |
| +import plistlib |
| +import shutil |
| +import subprocess |
| +import tempfile |
| + |
| +from mac_updates import read_configuration, sign_bundle |
| + |
| +MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 |
| + |
| + |
| +def run(*args): |
| + subprocess.run([str(arg) for arg in args], check=True) |
| + |
| + |
| +def prepare(app, config_path, tools, identity, team, notary_profile, output): |
| + config = read_configuration(config_path) |
| + if not identity or identity == '-' or not team or not notary_profile: |
| + raise ValueError('Developer ID identity, team and notarization profile are required') |
| + info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) |
| + if (info.get('CFBundleIdentifier') != config['bundle_id'] or |
| + info.get('SUFeedURL') != config['feed_url'] or |
| + info.get('SUPublicEDKey') != config['public_ed_key']): |
| + raise ValueError('build the app with this update configuration first') |
| + build = info['CFBundleVersion'] |
| + if not build.isdecimal() or int(build) < 1: |
| + raise ValueError('CFBundleVersion must be a positive integer') |
| + public_key = subprocess.check_output([str(tools / 'generate_keys'), '--account', config['keychain_account'], '-p'], text=True).strip() |
| + if public_key != config['public_ed_key']: |
| + raise ValueError('Keychain signing key does not match the bundled public key') |
| + archive_name = f"{config['bundle_id']}-{build}.zip" |
| + if output.exists(): |
| + raise ValueError('output directory must be new to avoid replacing a release') |
| + with tempfile.TemporaryDirectory(prefix='oficina-release-') as temporary: |
| + stage = Path(temporary) |
| + staged_app = stage / app.name |
| + shutil.copytree(app, staged_app, symlinks=True) |
| + sign_bundle(staged_app, identity) |
| + details = subprocess.run(['codesign', '-dv', '--verbose=4', str(staged_app)], capture_output=True, text=True, check=True).stderr |
| + if f'TeamIdentifier={team}\n' not in details or 'Authority=Developer ID Application:' not in details: |
| + raise ValueError('release was not signed with the selected Developer ID team') |
| + submission = stage / 'submission.zip' |
| + run('ditto', '-c', '-k', '--keepParent', staged_app, submission) |
| + result = subprocess.check_output(['xcrun', 'notarytool', 'submit', str(submission), '--keychain-profile', notary_profile, '--wait', '--output-format', 'json'], text=True) |
| + notarization = json.loads(result) |
| + if notarization.get('status') != 'Accepted': |
| + raise RuntimeError(f"notarization failed; submission {notarization.get('id')}") |
| + run('xcrun', 'stapler', 'staple', staged_app) |
| + run('xcrun', 'stapler', 'validate', staged_app) |
| + run('spctl', '--assess', '--type', 'execute', '--verbose=2', staged_app) |
| + archive = stage / archive_name |
| + run('ditto', '-c', '-k', '--keepParent', staged_app, archive) |
| + if archive.stat().st_size > MAX_ARCHIVE_BYTES: |
| + raise ValueError('archive exceeds Amadan raw hosting limit of 100 MiB') |
| + signature = subprocess.check_output([str(tools / 'sign_update'), '--account', config['keychain_account'], '-p', str(archive)], text=True).strip() |
| + run(tools / 'sign_update', '--account', config['keychain_account'], '--verify', archive, signature) |
| + with archive.open('rb') as data: |
| + digest = hashlib.file_digest(data, 'sha256').hexdigest() |
| + manifest = dict(bundle_id=config['bundle_id'], feed_url=config['feed_url'], build=build, |
| + version=info['CFBundleShortVersionString'], archive=archive_name, |
| + length=archive.stat().st_size, sha256=digest, |
| + signature=signature, team_id=team, notarization_id=notarization['id']) |
| + output.mkdir(parents=True) |
| + shutil.copy2(archive, output / archive_name) |
| + (output / 'release.json').write_text(json.dumps(manifest, indent=2) + '\n') |
| + return output |
| + |
| + |
| +def main(): |
| + parser = argparse.ArgumentParser(description=__doc__) |
| + parser.add_argument('app', type=Path) |
| + parser.add_argument('--config', type=Path, required=True) |
| + parser.add_argument('--sparkle-tools', type=Path, required=True) |
| + parser.add_argument('--identity', required=True) |
| + parser.add_argument('--team', required=True) |
| + parser.add_argument('--notary-profile', required=True) |
| + parser.add_argument('--output', type=Path, required=True) |
| + args = parser.parse_args() |
| + print(prepare(args.app, args.config, args.sparkle_tools, args.identity, args.team, args.notary_profile, args.output)) |
| + |
| + |
| +if __name__ == '__main__': |
| + main() |
diff --git a/tools/tests/test_bundle_mac.py b/tools/tests/test_bundle_mac.py| index 2660bb8..a5a6365 100644 |
| --- a/tools/tests/test_bundle_mac.py |
| +++ b/tools/tests/test_bundle_mac.py |
| @@ -1,5 +1,6 @@ |
| """A packaged app must load resources after its original build tree is gone.""" |
| import plistlib |
| +import json |
| from pathlib import Path |
| import shutil |
| import struct |
| @@ -20,9 +21,16 @@ class BundleMacTests(unittest.TestCase): |
| import PackageDescription |
| let package = Package(name: "Probe", platforms: [.macOS("26.0")], |
| products: [.executable(name: "ProbeMac", targets: ["Probe"])], |
| - targets: [.executableTarget(name: "Probe", resources: [.process("Resources")])]) |
| -''') |
| + dependencies: [.package(path: SHARED_NATIVE_PATH)], |
| + targets: [.executableTarget(name: "Probe", dependencies: [.product(name: "RastrilloMacUpdates", package: "native")], resources: [.process("Resources")])]) |
| +'''.replace('SHARED_NATIVE_PATH', json.dumps(str(tool.parents[1])))) |
| (source / "main.swift").write_text('''import Foundation |
| +import RastrilloMacUpdates |
| +let updater = MainActor.assumeIsolated { NativeUpdater() } |
| +MainActor.assumeIsolated { |
| + precondition(!updater.isConfigured) |
| + _ = NativeUpdateCommands(updater: updater).body |
| +} |
| let resources = Bundle.main.url(forResource: "Probe_Probe", withExtension: "bundle").flatMap(Bundle.init(url:)) ?? Bundle.module |
| guard let url = resources.url(forResource: "sentinel", withExtension: "txt"), |
| let text = try? String(contentsOf: url, encoding: .utf8) else { exit(7) } |
diff --git a/tools/tests/test_mac_updates.py b/tools/tests/test_mac_updates.py| new file mode 100644 |
| index 0000000..5bfac33 |
| --- /dev/null |
| +++ b/tools/tests/test_mac_updates.py |
| @@ -0,0 +1,201 @@ |
| +"""Release boundary checks: configuration identity, signing grants and feed order.""" |
| +import base64 |
| +import hashlib |
| +import json |
| +from pathlib import Path |
| +import plistlib |
| +import sys |
| +import tempfile |
| +import unittest |
| +import zipfile |
| +from unittest.mock import patch |
| + |
| +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) |
| +from mac_updates import configure, read_configuration, sign_bundle |
| +from publish_mac_update import check_build, publish, verify_download |
| + |
| + |
| +class MacUpdatesTests(unittest.TestCase): |
| + def setUp(self): |
| + self.temporary = tempfile.TemporaryDirectory() |
| + self.addCleanup(self.temporary.cleanup) |
| + self.root = Path(self.temporary.name) |
| + self.app = self.root / 'Meet.app' |
| + (self.app / 'Contents').mkdir(parents=True) |
| + self.info = self.app / 'Contents/Info.plist' |
| + self.info.write_bytes(plistlib.dumps(dict(CFBundleIdentifier='net.amadan.oficina.meet', NSCameraUsageDescription='Camera', NSMicrophoneUsageDescription='Microphone'))) |
| + self.config = self.root / 'updates.json' |
| + self.values = dict(bundle_id='net.amadan.oficina.meet', feed_url='https://raw.amadan.net/oficina/native-releases/main/meet/appcast.xml', public_ed_key=base64.b64encode(bytes(32)).decode(), keychain_account='oficina-test') |
| + self.config.write_text(json.dumps(self.values)) |
| + |
| + def test_configuration_rejects_cross_app_and_insecure_feeds(self): |
| + for field, value in [('bundle_id', 'net.amadan.oficina.docs'), ('feed_url', 'http://example.com/feed'), ('feed_url', 'https://user:secret@example.com/feed'), ('public_ed_key', base64.b64encode(bytes(31)).decode())]: |
| + with self.subTest(field=field, value=value): |
| + self.config.write_text(json.dumps(self.values | {field: value})) |
| + with self.assertRaises(ValueError): |
| + configure(self.app, self.config) |
| + |
| + def test_dev_build_removes_feed_without_forcing_preferences(self): |
| + configure(self.app, self.config, '1.2.3', '2') |
| + info = plistlib.loads(self.info.read_bytes()) |
| + self.assertTrue(info['SUVerifyUpdateBeforeExtraction']) |
| + self.assertFalse(info['SUAllowsAutomaticUpdates']) |
| + self.assertNotIn('SUEnableAutomaticChecks', info) |
| + configure(self.app) |
| + self.assertNotIn('SUFeedURL', plistlib.loads(self.info.read_bytes())) |
| + |
| + def test_signing_keeps_device_grants_on_host_only(self): |
| + current = self.app / 'Contents/Frameworks/Sparkle.framework/Versions/Current' |
| + for path in ['XPCServices/Downloader.xpc', 'XPCServices/Installer.xpc', 'Autoupdate', 'Updater.app']: |
| + (current / path).mkdir(parents=True) |
| + calls = [] |
| + entitlements = [] |
| + def capture(command, **kwargs): |
| + calls.append(command) |
| + if '--entitlements' in command: |
| + entitlements.append(plistlib.loads(Path(command[command.index('--entitlements') + 1]).read_bytes())) |
| + with patch('mac_updates.subprocess.run', side_effect=capture): |
| + sign_bundle(self.app, 'Developer ID Application: Test') |
| + self.assertEqual(entitlements, [{'com.apple.security.device.camera': True, 'com.apple.security.device.audio-input': True}]) |
| + self.assertEqual(calls[-2][-1], str(self.app)) |
| + self.assertEqual(calls[-3][-1], str(self.app / 'Contents/Frameworks/Sparkle.framework')) |
| + self.assertIn('--preserve-metadata=entitlements', calls[0]) |
| + self.assertIn('runtime', calls[-2]) |
| + self.assertEqual(calls[-1][1:5], ['--verify', '--deep', '--strict', str(self.app)]) |
| + |
| + def test_rejects_non_increasing_builds_in_current_and_old_appcast_formats(self): |
| + feed = self.root / 'appcast.xml' |
| + for version in ['<sparkle:version>7</sparkle:version>', '<enclosure sparkle:version="7"/>']: |
| + feed.write_text('<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><item>' + version + '</item></channel></rss>') |
| + for build in ['6', '7', 'invalid']: |
| + with self.assertRaises(ValueError): |
| + check_build(feed, build) |
| + check_build(feed, '8') |
| + |
| + def test_public_archive_must_match_exact_signed_bytes(self): |
| + import hashlib |
| + import io |
| + def response(*args, **kwargs): |
| + stream = io.BytesIO(b'tampered') |
| + stream.url = 'https://raw.amadan.net/oficina/native-releases/commit/archive.zip' |
| + return stream |
| + with patch('publish_mac_update.urlopen', side_effect=response), patch('publish_mac_update.time.sleep'): |
| + with self.assertRaises(ValueError): |
| + verify_download('https://raw.amadan.net/archive.zip', 8, hashlib.sha256(b'original').hexdigest()) |
| + |
| + |
| +class PublicationBoundaryTests(unittest.TestCase): |
| + """Exercise publication failures without Git, Keychain, signing or network access.""" |
| + |
| + def setUp(self): |
| + temporary = tempfile.TemporaryDirectory(prefix='sparkle-publication-test-') |
| + self.addCleanup(temporary.cleanup) |
| + self.root = Path(temporary.name) |
| + self.release = self.root / 'release' |
| + self.release.mkdir() |
| + self.repo = self.root / 'repository' |
| + (self.repo / 'meet').mkdir(parents=True) |
| + self.feed = self.repo / 'meet/appcast.xml' |
| + self.original_feed = '<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><item><sparkle:version>2</sparkle:version></item></channel></rss>' |
| + self.feed.write_text(self.original_feed) |
| + self.config = self.root / 'config.json' |
| + self.values = dict(bundle_id='net.amadan.oficina.meet', feed_url='https://raw.amadan.net/oficina/native-releases/main/meet/appcast.xml', public_ed_key=base64.b64encode(bytes(32)).decode(), keychain_account='test-only') |
| + self.config.write_text(json.dumps(self.values)) |
| + self.archive = self.release / 'meet-3.zip' |
| + info = dict(CFBundleIdentifier=self.values['bundle_id'], SUFeedURL=self.values['feed_url'], SUPublicEDKey=self.values['public_ed_key'], CFBundleVersion='3', CFBundleShortVersionString='1.0') |
| + with zipfile.ZipFile(self.archive, 'w') as zipped: |
| + zipped.writestr('Meet.app/Contents/Info.plist', plistlib.dumps(info)) |
| + self.manifest = dict(bundle_id=self.values['bundle_id'], feed_url=self.values['feed_url'], build='3', version='1.0', archive=self.archive.name, length=self.archive.stat().st_size, sha256=hashlib.sha256(self.archive.read_bytes()).hexdigest(), signature='test-signature') |
| + self.write_manifest() |
| + self.events = [] |
| + self.generated_build = '3' |
| + self.download_error = None |
| + self.addCleanup(patch.stopall) |
| + patch('publish_mac_update.git', side_effect=self.git).start() |
| + patch('publish_mac_update.subprocess.check_output', return_value=self.values['public_ed_key']).start() |
| + patch('publish_mac_update.subprocess.run', side_effect=self.tool).start() |
| + patch('publish_mac_update.verify_download', side_effect=self.download).start() |
| + |
| + def write_manifest(self): |
| + (self.release / 'release.json').write_text(json.dumps(self.manifest)) |
| + |
| + def git(self, repo, *args): |
| + self.events.append(('git', *args)) |
| + if args[:3] == ('remote', 'get-url', '--push'): |
| + return 'https://amadan.net/oficina/native-releases' |
| + if args[0] == 'branch': |
| + return 'main' |
| + if args[0] == 'rev-parse': |
| + return 'a' * 40 |
| + return '' |
| + |
| + def tool(self, command, **kwargs): |
| + tool = Path(command[0]).name |
| + self.events.append((tool,)) |
| + if tool != 'generate_appcast': |
| + return |
| + prefix = command[command.index('--download-url-prefix') + 1] |
| + output = Path(command[command.index('-o') + 1]) |
| + output.write_text(f'<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><item><sparkle:version>{self.generated_build}</sparkle:version><enclosure url="{prefix}{self.archive.name}" sparkle:edSignature="test-signature" length="{self.manifest["length"]}"/></item></channel></rss>') |
| + |
| + def download(self, *args): |
| + self.events.append(('verify_download',)) |
| + if self.download_error: |
| + raise self.download_error |
| + |
| + def publish(self, enabled=True): |
| + return publish(self.release, self.config, self.repo, self.root / 'tools', enabled) |
| + |
| + def pushes(self): |
| + return [event for event in self.events if event[:2] == ('git', 'push')] |
| + |
| + def test_manifest_cannot_claim_a_newer_build_than_the_signed_archive(self): |
| + self.manifest['build'] = '4' |
| + self.write_manifest() |
| + with self.assertRaisesRegex(ValueError, 'metadata does not match'): |
| + self.publish() |
| + self.assertEqual(self.events, []) |
| + self.assertFalse((self.repo / 'meet/releases').exists()) |
| + |
| + def test_changed_archive_is_rejected_before_publication(self): |
| + with self.archive.open('ab') as archive: |
| + archive.write(b'changed after signing') |
| + with self.assertRaisesRegex(ValueError, 'archive changed'): |
| + self.publish() |
| + self.assertEqual(self.events, []) |
| + |
| + def test_plan_does_not_commit_push_or_copy_artifacts(self): |
| + plan = self.publish(enabled=False) |
| + self.assertEqual(plan['build'], '3') |
| + self.assertFalse((self.repo / 'meet/releases').exists()) |
| + self.assertEqual(self.feed.read_text(), self.original_feed) |
| + self.assertFalse(any(event[0] == 'git' and event[1] in ('fetch', 'add', 'commit', 'push') for event in self.events)) |
| + |
| + def test_failed_public_download_leaves_existing_feed_untouched(self): |
| + self.download_error = ValueError('download checksum mismatch') |
| + with self.assertRaisesRegex(ValueError, 'checksum mismatch'): |
| + self.publish() |
| + self.assertEqual(len(self.pushes()), 1) |
| + self.assertNotIn(('generate_appcast',), self.events) |
| + self.assertEqual(self.feed.read_text(), self.original_feed) |
| + |
| + def test_generated_feed_with_wrong_version_is_never_published(self): |
| + self.generated_build = '4' |
| + with self.assertRaisesRegex(ValueError, 'build does not match'): |
| + self.publish() |
| + self.assertEqual(len(self.pushes()), 1) |
| + self.assertEqual(self.feed.read_text(), self.original_feed) |
| + |
| + def test_archive_is_publicly_verified_before_feed_publication(self): |
| + self.publish() |
| + pushes = [index for index, event in enumerate(self.events) if event[:2] == ('git', 'push')] |
| + self.assertEqual(len(pushes), 2) |
| + verified = self.events.index(('verify_download',)) |
| + generated = self.events.index(('generate_appcast',)) |
| + self.assertLess(pushes[0], verified) |
| + self.assertLess(verified, generated) |
| + self.assertLess(generated, pushes[1]) |
| + |
| + |
| +if __name__ == '__main__': |
| + unittest.main() |