rastrillo / native Public

Add shared Mac Sparkle updates and verified Amadan release tooling

Paul Campbell pushed by paul@keymail.dev 4c61d6283a188c95647e1d0e609e69d29c2e7fa1 parent a49307c
26 files changed, +833 −6
  • .gitignore +1 −0
  • Makefile +1 −0
  • Package.resolved +14 −0
  • Package.swift +6 −1
  • Sources/RastrilloMacUpdates/NativeUpdateCommands.swift +21 −0
  • Sources/RastrilloMacUpdates/NativeUpdater.swift +53 −0
  • Sources/RastrilloMacUpdates/Resources/ar.lproj/Localizable.strings +2 −0
  • Sources/RastrilloMacUpdates/Resources/bn.lproj/Localizable.strings +2 −0
  • Sources/RastrilloMacUpdates/Resources/en.lproj/Localizable.strings +2 −0
  • Sources/RastrilloMacUpdates/Resources/es.lproj/Localizable.strings +2 −0
  • Sources/RastrilloMacUpdates/Resources/ga.lproj/Localizable.strings +2 −0
  • Sources/RastrilloMacUpdates/Resources/hi.lproj/Localizable.strings +2 −0
  • Sources/RastrilloMacUpdates/Resources/ja.lproj/Localizable.strings +2 −0
  • Sources/RastrilloMacUpdates/Resources/pt.lproj/Localizable.strings +2 −0
  • Sources/RastrilloMacUpdates/Resources/ru.lproj/Localizable.strings +2 −0
  • Sources/RastrilloMacUpdates/Resources/vi.lproj/Localizable.strings +2 −0
  • Sources/RastrilloMacUpdates/Resources/yue.lproj/Localizable.strings +2 −0
  • Sources/RastrilloMacUpdates/Resources/zh-Hans.lproj/Localizable.strings +2 −0
  • Tests/RastrilloMacUpdatesTests/ConfigurationTests.swift +25 −0
  • docs/mac-updates.md +92 −0
  • tools/bundle-mac.py +15 −3
  • tools/mac_updates.py +119 −0
  • tools/publish_mac_update.py +164 −0
  • tools/release_mac.py +87 −0
  • tools/tests/test_bundle_mac.py +10 −2
  • tools/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()