rastrillo / native Public

Merge branch 'merge/native-apple-chain' into main

Merge the native Apple chain (accounts, sign-in, editors, updates) into main

33ffe99..2295259: RastrilloApple, RastrilloNative, RastrilloMacUpdates
and the Go native sign-in server, plus CI steps for the server and
bundle tests. Reviewed by Fable 5.1 on #4; merged without green hub CI
(no macOS runner) on Paul's instruction.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 2a5d07b2c8d3dfb3fd8526dc7e1c802f97865c67 parent e51949fparent eacc073
52 files changed, +2972 −3
  • .amadan/ci.d/30-server +2 −0
  • .amadan/ci.d/40-bundle +2 −0
  • .gitignore +1 −0
  • ACCOUNTS-SIGNIN.md +30 −0
  • APPLE.md +29 −0
  • Makefile +7 −2
  • Package.resolved +14 −0
  • Package.swift +9 −1
  • README.md +32 −0
  • Sources/RastrilloApple/NativeAccount.swift +10 −0
  • Sources/RastrilloApple/NativeAccountsView.swift +55 −0
  • Sources/RastrilloApple/NativeBrowserSignIn.swift +46 −0
  • Sources/RastrilloApple/NativeConnection.swift +202 −0
  • Sources/RastrilloApple/NativeCredentials.swift +32 −0
  • Sources/RastrilloApple/NativeHTTP.swift +148 −0
  • Sources/RastrilloApple/NativeResponse.swift +9 −0
  • Sources/RastrilloApple/NativeSignInView.swift +53 −0
  • Sources/RastrilloApple/NativeStreamLines.swift +42 −0
  • 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/RastrilloAppleTests/HTTPStreamCancellationTests.swift +177 −0
  • Tests/RastrilloAppleTests/HTTPTests.swift +127 −0
  • Tests/RastrilloAppleTests/NativeAccountsTests.swift +46 −0
  • Tests/RastrilloAppleTests/OriginTests.swift +18 −0
  • Tests/RastrilloAppleTests/StreamLinesTests.swift +36 −0
  • Tests/RastrilloMacUpdatesTests/ConfigurationTests.swift +25 −0
  • docs/mac-updates.md +92 −0
  • server/LICENSE +374 −0
  • server/auth.go +259 −0
  • server/auth_test.go +123 −0
  • server/callback_test.go +67 −0
  • server/go.mod +23 −0
  • server/go.sum +73 −0
  • tools/bundle-mac.py +54 −0
  • tools/mac-icon.swift +23 −0
  • 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 +63 −0
  • tools/tests/test_mac_updates.py +201 −0

CI failed — run details

diff --git a/.amadan/ci.d/30-server b/.amadan/ci.d/30-server
new file mode 100755
index 0000000..c7985bc
--- /dev/null
+++ b/.amadan/ci.d/30-server
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec make server-test
diff --git a/.amadan/ci.d/40-bundle b/.amadan/ci.d/40-bundle
new file mode 100755
index 0000000..90c8a31
--- /dev/null
+++ b/.amadan/ci.d/40-bundle
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec make bundle-test
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/ACCOUNTS-SIGNIN.md b/ACCOUNTS-SIGNIN.md
new file mode 100644
index 0000000..b402fad
--- /dev/null
+++ b/ACCOUNTS-SIGNIN.md
@@ -0,0 +1,30 @@
+# Native accounts and browser return
+
+User direction, 2026-09-13: all Oficina apps use the Eleven account model. Open
+the most recent account; Back returns to saved accounts; adding an account keeps
+existing sessions. Explicit Sign out revokes only the selected native session.
+Store opaque credentials in per-app Keychain entries keyed by account UUID;
+UserDefaults stores only display names, server origins and the most recent ID.
+Migrate the existing origin-keyed credential only after its new entry is saved.
+Account switches recreate workspace models so content cannot cross accounts.
+
+Use ASWebAuthenticationSession for system browser authentication and normal
+browser session sharing, allowing existing Home sessions when the server enables
+Home. Each app registers works.oficina.<app> as its custom URL scheme. The server
+accepts only the exact callback for its app; after explicit browser approval it
+returns a random request ID, never a credential. Only the originating app's
+verifier can exchange approval for a session. Reject foreign/stale callbacks.
+Old servers may ignore callbacks; in-app polling still dismisses the authentication
+session after a successful proof exchange. Cancellation invalidates pending work.
+
+Sheets library: shape-only native thumbnail rows; top-right More menu for Import,
+appearance and Sign out; native bottom search and New toolbar; Accounts back
+button is disabled while edits or writes need completion. Editor-to-library Back
+keeps the editor model alive, preserving existing draft protection.
+
+Meet retains its existing meeting/invitation-bound Go sign-in protocol. Its verified
+result is adopted through NativeConnection.rememberSession, which validates the
+canonical origin and cookie format before saving. Its browser uses the same system
+authentication presenter. Saved account sessions are reused only for meeting links
+on the same origin; invitation redirects are resolved without forwarding cookies
+to another origin. Adding another Meet account starts a new explicit browser proof.
diff --git a/APPLE.md b/APPLE.md
new file mode 100644
index 0000000..32c1319
--- /dev/null
+++ b/APPLE.md
@@ -0,0 +1,29 @@
+# Apple clients · 2026-09-12
+
+The `RastrilloApple` product adds browser approval, ephemeral same-origin HTTP,
+Keychain sessions and a SwiftUI sign-in view for macOS 14+ and iOS 17+.
+The platform-independent RastrilloNative product remains separate.
+Calendar and Memoria use these components in their first native clients.
+
+`server/` is a separate Go module, `amadan.net/rastrillo/native/server`.
+Applications supply their existing session and admission middleware. Browser
+approval binds a five-minute, single-use request to a 256-bit verifier hash.
+The app and browser display the same code. Exchange authenticates the approved
+browser cookie from a fresh context and rechecks admission before minting a
+separate ordinary session. Resource authorization stays in each application.
+There is no cross-origin cookie jar, HTTP cache or external redirect following.
+
+`tools/bundle-mac.py <native-directory> <Name>` builds a SwiftPM executable
+`<Name>Mac`, derives a padded rounded Mac icon from the iOS master, bundles the
+Lucide notice, and signs the development app ad hoc. It does not notarize it.
+
+Run `make ci` for Swift unit tests, the existing Mac/iOS companion builds,
+and Go authentication tests with the race detector.
+
+Independent review: `/root/native_review`, dispatched by this Codex session,
+found an inherited-session-context vulnerability in exchange. Fixed using a
+fresh cancelable context. Its regression presents Bob's session while exchanging
+Alice's approval after Alice loses membership. Independently selected mutations
+removing proof checking, expiration, one-time consumption, approval admission,
+exchange admission and fresh-context handling all failed the regression suite
+on 2026-09-12; all mutations were reverted. Review did not approve a merge.
diff --git a/Makefile b/Makefile
index 8f9c720..cc068ed 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,12 @@
-.PHONY: ci test companion
-ci: test companion
+.PHONY: ci test companion bundle-test
+ci: test companion server-test bundle-test
test:
swift 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
xcodebuild -project examples/companion/Companion.xcodeproj -scheme CompanionMac -destination 'platform=macOS' -derivedDataPath examples/companion/build CODE_SIGNING_ALLOWED=NO build
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 dab2708..22a3ee0 100644
--- a/Package.swift
+++ b/Package.swift
@@ -3,10 +3,18 @@ import PackageDescription
let package = Package(
name: "RastrilloNative",
+ defaultLocalization: "en",
platforms: [.iOS(.v17), .macOS(.v14)],
- products: [.library(name: "RastrilloNative", targets: ["RastrilloNative"])],
+ products: [.library(name: "RastrilloNative", targets: ["RastrilloNative"]),
+ .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/README.md b/README.md
index 7eaf6e6..57415fa 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,38 @@ when it removes duplication: see [docs/go-mobile.md](docs/go-mobile.md).
## Validate
+Apple editor clients use `RastrilloApple.NativeHTTP` for ephemeral, origin-bound
+requests. `responseDetails` returns body, status and case-insensitive headers for
+protocol cursors. `response` and `request` retain the 4 MiB default body bound;
+an explicit `maxBytes` allows larger snapshots/exports up to 256 MiB. Oversized
+responses fail rather than returning truncated content.
+
+`stream(path:onOpen:onLine:)` requires a 200 `text/event-stream` response and supplies
+UTF-8 lines, including empty event delimiters. It accepts CR, LF and CRLF,
+limits each line to 256 KiB and each event to 1 MiB, and honors task cancellation.
+The optional `onOpen` callback runs after response validation and completes before
+any lines are delivered. A server that subscribes before flushing response headers
+allows this callback to fetch an initial snapshot without missing intervening edits.
+The consumer parses events, revalidates permissions through its server, and
+refetches/reconnects after EOF. An unfinished event at EOF must not be applied.
+Streams use the same no-redirect session and origin-bound credentials as ordinary
+requests. Responses and streams are not persisted.
+
+`download(_:to:)` streams an explicit user export into a new file, with the same
+origin and redirect restrictions. It requires HTTP 200 and a file URL, applies
+0600 permissions, and refuses to overwrite an existing destination. The caller
+owns that temporary export and must remove it after the save panel completes
+or is cancelled. Unlike in-memory responses, downloads have no body-size cap.
+
+`tools/bundle-mac.py` includes SwiftPM resource bundles in `Contents/Resources`
+and reads the minimum macOS version from the package manifest. A SwiftPM
+consumer with resources should first locate its resource bundle with
+`Bundle.main.url(forResource: "Package_Target", withExtension: "bundle")`,
+then lazily fall back to `Bundle.module` for command-line development builds.
+SwiftPM's generated accessor alone looks in the app root and the original build
+directory; neither is the signed Mac app's resource directory. The packaging
+test relocates a signed app, deletes its build tree, and verifies resource access.
+
`make ci` runs the Swift tests and compiles both companion targets. It
requires macOS, Xcode and XcodeGen. Builds are unsigned; this gate does not
claim physical-device, signing or store-distribution validation.
diff --git a/Sources/RastrilloApple/NativeAccount.swift b/Sources/RastrilloApple/NativeAccount.swift
new file mode 100644
index 0000000..fce237d
--- /dev/null
+++ b/Sources/RastrilloApple/NativeAccount.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+public struct NativeAccount: Codable, Identifiable, Hashable, Sendable {
+ public let id: String
+ public let origin: URL
+ public let name: String
+ // The Keychain lookup binds the UUID to its canonical origin. Altering the
+ // display metadata cannot redirect an existing credential to another server.
+ var credentialKey: String { "account:\(id)|\(origin.absoluteString)" }
+}
diff --git a/Sources/RastrilloApple/NativeAccountsView.swift b/Sources/RastrilloApple/NativeAccountsView.swift
new file mode 100644
index 0000000..c395b58
--- /dev/null
+++ b/Sources/RastrilloApple/NativeAccountsView.swift
@@ -0,0 +1,55 @@
+import SwiftUI
+
+@MainActor public struct NativeAccountsView: View {
+ @ObservedObject private var connection: NativeConnection
+ private let name: String
+ private let symbol: String
+ private let addAccount: (() -> Void)?
+ @State private var adding = false
+
+ public init(_ name: String, systemImage: String, connection: NativeConnection, addAccount: (() -> Void)? = nil) {
+ self.addAccount = addAccount
+ self.name = name
+ self.symbol = systemImage
+ self.connection = connection
+ }
+
+ public var body: some View {
+ NavigationStack {
+ List {
+ ForEach(connection.accounts) { account in
+ Button {
+ connection.selectAccount(account)
+ } label: {
+ Label {
+ VStack(alignment: .leading) {
+ Text(verbatim: account.name).foregroundStyle(.primary)
+ Text(verbatim: account.origin.host ?? account.origin.absoluteString)
+ .font(.caption).foregroundStyle(.secondary)
+ }
+ } icon: { Image(systemName: "person.crop.circle") }
+ }
+ }
+ Button("Add account", systemImage: "plus") {
+ connection.accountName = ""
+ connection.error = ""
+ if let addAccount { addAccount() } else { adding = true }
+ }
+ if !connection.error.isEmpty {
+ Text(connection.error).foregroundStyle(.red)
+ }
+ }
+ .navigationTitle("Accounts")
+ .sheet(isPresented: $adding, onDismiss: { connection.cancel() }) {
+ NavigationStack {
+ NativeSignInView(name, systemImage: symbol, connection: connection)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel", role: .cancel) { adding = false }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Sources/RastrilloApple/NativeBrowserSignIn.swift b/Sources/RastrilloApple/NativeBrowserSignIn.swift
new file mode 100644
index 0000000..e253967
--- /dev/null
+++ b/Sources/RastrilloApple/NativeBrowserSignIn.swift
@@ -0,0 +1,46 @@
+import AuthenticationServices
+import Foundation
+#if os(iOS)
+import UIKit
+#elseif os(macOS)
+import AppKit
+#endif
+
+/// Keeps browser authentication attached to the app, including servers that still
+/// finish through the proof-bound exchange instead of issuing a callback.
+@MainActor public final class NativeBrowserSignIn: NSObject, ASWebAuthenticationPresentationContextProviding {
+ private var session: ASWebAuthenticationSession?
+ private var generation = UUID()
+ public override init() { super.init() }
+
+ public func start(url: URL, scheme: String, completion: @escaping @MainActor (URL?) -> Void) -> Bool {
+ cancel()
+ let generation = self.generation
+ let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { url, _ in
+ Task { @MainActor [weak self] in
+ guard self?.generation == generation else { return }
+ completion(url)
+ }
+ }
+ session.presentationContextProvider = self
+ session.prefersEphemeralWebBrowserSession = false
+ self.session = session
+ return session.start()
+ }
+
+ public func cancel() {
+ generation = UUID()
+ session?.cancel()
+ session = nil
+ }
+
+ public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
+ #if os(iOS)
+ return UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
+ .filter { $0.activationState == .foregroundActive }
+ .flatMap(\.windows).first(where: \.isKeyWindow) ?? ASPresentationAnchor()
+ #else
+ return NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first ?? ASPresentationAnchor()
+ #endif
+ }
+}
diff --git a/Sources/RastrilloApple/NativeConnection.swift b/Sources/RastrilloApple/NativeConnection.swift
new file mode 100644
index 0000000..dfc7ce0
--- /dev/null
+++ b/Sources/RastrilloApple/NativeConnection.swift
@@ -0,0 +1,202 @@
+import Combine
+import CryptoKit
+import Foundation
+import Security
+
+@MainActor public final class NativeConnection: ObservableObject {
+ @Published public var serverAddress: String
+ @Published public private(set) var client: NativeHTTP?
+ @Published public private(set) var signingIn = false
+ @Published public private(set) var code = ""
+ @Published public private(set) var browserURL: URL?
+ @Published public var error = ""
+ @Published public private(set) var accounts: [NativeAccount] = []
+ @Published public private(set) var activeAccountID: String?
+ @Published public var accountName = ""
+ public let callbackScheme: String
+ private let browser = NativeBrowserSignIn()
+ private var pendingID: String?
+ private var task: Task<Void, Never>?
+ private let accountsKey = "native.accounts"
+ private let defaults: UserDefaults
+
+ private func reloadAccounts() {
+ if let data = defaults.data(forKey: accountsKey),
+ let saved = try? JSONDecoder().decode([NativeAccount].self, from: data) { accounts = saved }
+ }
+
+ private func persistAccounts() {
+ if let data = try? JSONEncoder().encode(accounts) {
+ defaults.set(data, forKey: accountsKey)
+ }
+ }
+
+ public func showAccounts() {
+ reloadAccounts()
+ cancel()
+ client = nil
+ activeAccountID = nil
+ }
+
+ public func selectAccount(_ account: NativeAccount) {
+ reloadAccounts()
+ cancel()
+ guard accounts.contains(account), (try? NativeHTTP.origin(account.origin.absoluteString)) == account.origin,
+ let credential = NativeCredentials.read(account.credentialKey) else {
+ error = "This account needs to sign in again."
+ return
+ }
+ accounts.removeAll { $0.id == account.id }
+ accounts.insert(account, at: 0)
+ persistAccounts()
+ serverAddress = account.origin.absoluteString
+ activeAccountID = account.id
+ defaults.set(account.id, forKey: "native.activeAccount")
+ client = NativeHTTP(origin: account.origin, credential: credential)
+ }
+
+ /// Adopts a session returned by a consumer's proof-bound sign-in protocol.
+ public func rememberSession(_ credential: NativeSession, origin: URL, name: String = "") throws {
+ guard (try? NativeHTTP.origin(origin.absoluteString)) == origin,
+ credential.name == (origin.scheme == "https" ? "__Host-rastrillo_session" : "rastrillo_session"),
+ Self.valid(credential.value) else { throw NativeClientError.message("Invalid sign-in response.") }
+ let label = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ let account = NativeAccount(id: UUID().uuidString, origin: origin,
+ name: label.isEmpty ? (origin.host ?? origin.absoluteString) : label)
+ try NativeCredentials.save(credential, origin: account.credentialKey)
+ reloadAccounts()
+ accounts.insert(account, at: 0)
+ persistAccounts()
+ activeAccountID = account.id
+ defaults.set(account.id, forKey: "native.activeAccount")
+ defaults.set(origin.absoluteString, forKey: "native.server")
+ serverAddress = origin.absoluteString
+ client = NativeHTTP(origin: origin, credential: credential)
+ }
+
+ public func handleOpenURL(_ url: URL) {
+ guard let pendingID, let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ components.scheme == callbackScheme, components.host == "signin",
+ components.path.isEmpty, components.user == nil, components.password == nil,
+ components.port == nil, components.fragment == nil,
+ components.queryItems == [URLQueryItem(name: "state", value: pendingID)] else { return }
+ // The URL carries no credential. Only the original verifier can complete
+ // the running exchange; a forged callback cannot sign the app in.
+ browser.cancel()
+ }
+
+ public func signIn() {
+ signIn { [weak self] url in
+ guard let self else { return false }
+ let id = self.pendingID
+ return self.browser.start(url: url, scheme: self.callbackScheme) { [weak self] callback in
+ guard let self, self.pendingID == id else { return }
+ if let callback { self.handleOpenURL(callback) } else { self.cancel() }
+ }
+ }
+ }
+
+ public init(defaultServer: String, callbackScheme: String? = nil, defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ self.callbackScheme = callbackScheme ?? "works.oficina.\(URL(string: defaultServer)?.host?.components(separatedBy: ".").first ?? "app")"
+ serverAddress = defaults.string(forKey: "native.server") ?? defaultServer
+ if let data = defaults.data(forKey: accountsKey),
+ let saved = try? JSONDecoder().decode([NativeAccount].self, from: data) { accounts = saved }
+ if accounts.isEmpty, let origin = try? NativeHTTP.origin(serverAddress),
+ let credential = NativeCredentials.read(origin.absoluteString) {
+ let account = NativeAccount(id: UUID().uuidString, origin: origin, name: origin.host ?? origin.absoluteString)
+ if (try? NativeCredentials.save(credential, origin: account.credentialKey)) != nil {
+ accounts = [account]
+ persistAccounts()
+ NativeCredentials.remove(origin.absoluteString)
+ }
+ }
+ let savedID = defaults.string(forKey: "native.activeAccount")
+ if let account = accounts.first(where: { $0.id == savedID }) ?? accounts.first {
+ selectAccount(account)
+ }
+ }
+
+ public func signIn(open: @escaping @MainActor (URL) async -> Bool) {
+ cancel()
+ error = ""
+ signingIn = true
+ task = Task {
+ do {
+ let origin = try NativeHTTP.origin(serverAddress)
+ let http = NativeHTTP(origin: origin)
+ var random = [UInt8](repeating: 0, count: 32)
+ guard SecRandomCopyBytes(kSecRandomDefault, random.count, &random) == errSecSuccess else {
+ throw NativeClientError.message("Could not start secure sign-in.")
+ }
+ let verifier = Self.token(Data(random))
+ let challenge = Self.token(Data(SHA256.hash(data: Data(verifier.utf8))))
+ struct Begin: Decodable { let id: String }
+ let begin: Begin = try await http.request("/native/request", method: "POST",
+ body: JSONEncoder().encode(["challenge": challenge, "callback": "\(callbackScheme)://signin"]))
+ guard Self.valid(begin.id) else { throw NativeClientError.message("Invalid sign-in response.") }
+ try Task.checkCancellation()
+ pendingID = begin.id
+ let hex = SHA256.hash(data: Data(begin.id.utf8)).prefix(4).map { String(format: "%02X", $0) }.joined()
+ code = String(hex.prefix(4)) + "-" + String(hex.suffix(4))
+ let url = origin.appendingPathComponent("native/start").appending(queryItems: [URLQueryItem(name: "id", value: begin.id)])
+ browserURL = url
+ guard await open(url) else { throw NativeClientError.message("Could not open your browser.") }
+ let deadline = Date().addingTimeInterval(300)
+ while Date() < deadline {
+ try await Task.sleep(for: .seconds(2))
+ let (data, status) = try await http.response("/native/exchange", method: "POST",
+ body: JSONEncoder().encode(["id": begin.id, "verifier": verifier]))
+ if status == 202 { continue }
+ guard status == 200, let credential = try? JSONDecoder().decode(NativeSession.self, from: data),
+ credential.name == (origin.scheme == "https" ? "__Host-rastrillo_session" : "rastrillo_session"),
+ Self.valid(credential.value) else { throw NativeClientError.message("Sign-in expired or was refused. Please try again.") }
+ try Task.checkCancellation()
+ try rememberSession(credential, origin: origin, name: accountName)
+ pendingID = nil
+ browser.cancel()
+ signingIn = false
+ browserURL = nil
+ return
+ }
+ throw NativeClientError.message("Sign-in expired. Please try again.")
+ } catch is CancellationError { return }
+ catch {
+ guard !Task.isCancelled else { return }
+ pendingID = nil
+ browser.cancel()
+ self.error = error.localizedDescription
+ signingIn = false
+ browserURL = nil
+ }
+ }
+ }
+ public func cancel() { pendingID = nil; task?.cancel(); task = nil; browser.cancel(); signingIn = false; code = ""; browserURL = nil }
+ public func signOut() {
+ reloadAccounts()
+ cancel()
+ if let client {
+ if let activeAccountID {
+ if let account = accounts.first(where: { $0.id == activeAccountID }) {
+ NativeCredentials.remove(account.credentialKey)
+ }
+ accounts.removeAll { $0.id == activeAccountID }
+ persistAccounts()
+ }
+ defaults.removeObject(forKey: "native.activeAccount")
+ Task { _ = try? await client.response("/signout", method: "POST", body: Data()) }
+ }
+ activeAccountID = nil
+ client = nil
+ }
+ private static func token(_ data: Data) -> String {
+ data.base64EncodedString().replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
+ }
+ private static func valid(_ text: String) -> Bool {
+ guard text.count == 43 else { return false }
+ let raw = text.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + "="
+ guard let data = Data(base64Encoded: raw), data.count == 32 else { return false }
+ return token(data) == text
+ }
+}
diff --git a/Sources/RastrilloApple/NativeCredentials.swift b/Sources/RastrilloApple/NativeCredentials.swift
new file mode 100644
index 0000000..d969bb3
--- /dev/null
+++ b/Sources/RastrilloApple/NativeCredentials.swift
@@ -0,0 +1,32 @@
+import Foundation
+import Security
+
+enum NativeCredentials {
+ private static var service: String { (Bundle.main.bundleIdentifier ?? "oficina.native") + ".session" }
+ private static func query(_ origin: String) -> [String: Any] {
+ [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service,
+ kSecAttrAccount as String: origin]
+ }
+ static func read(_ origin: String) -> NativeSession? {
+ var q = query(origin)
+ q[kSecReturnData as String] = true
+ q[kSecMatchLimit as String] = kSecMatchLimitOne
+ var result: CFTypeRef?
+ guard SecItemCopyMatching(q as CFDictionary, &result) == errSecSuccess,
+ let data = result as? Data else { return nil }
+ return try? JSONDecoder().decode(NativeSession.self, from: data)
+ }
+ static func save(_ session: NativeSession, origin: String) throws {
+ let data = try JSONEncoder().encode(session)
+ let updated = SecItemUpdate(query(origin) as CFDictionary, [kSecValueData as String: data] as CFDictionary)
+ if updated == errSecSuccess { return }
+ guard updated == errSecItemNotFound else { throw NativeClientError.message("Could not update this session in Keychain.") }
+ var q = query(origin)
+ q[kSecValueData as String] = data
+ q[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
+ guard SecItemAdd(q as CFDictionary, nil) == errSecSuccess else {
+ throw NativeClientError.message("Could not save this session in Keychain.")
+ }
+ }
+ static func remove(_ origin: String) { SecItemDelete(query(origin) as CFDictionary) }
+}
diff --git a/Sources/RastrilloApple/NativeHTTP.swift b/Sources/RastrilloApple/NativeHTTP.swift
new file mode 100644
index 0000000..f9422e2
--- /dev/null
+++ b/Sources/RastrilloApple/NativeHTTP.swift
@@ -0,0 +1,148 @@
+import Foundation
+
+public struct NativeSession: Codable, Sendable {
+ public let name: String
+ public let value: String
+ public init(name: String, value: String) { self.name = name; self.value = value }
+}
+
+public enum NativeClientError: LocalizedError {
+ case message(String)
+ public var errorDescription: String? { if case .message(let value) = self { return value }; return nil }
+}
+
+private final class NoRedirects: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
+ func urlSession(_ session: URLSession, task: URLSessionTask,
+ willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
+ completionHandler: @escaping (URLRequest?) -> Void) { completionHandler(nil) }
+}
+
+/// Ephemeral transport: no HTTP cache, shared cookie jar, or off-origin redirects.
+public final class NativeHTTP: @unchecked Sendable {
+ public let origin: URL
+ public let credential: NativeSession?
+ private let session: URLSession
+
+ public init(origin: URL, credential: NativeSession? = nil) {
+ self.origin = origin
+ self.credential = credential
+ let config = URLSessionConfiguration.ephemeral
+ config.httpShouldSetCookies = false
+ config.urlCache = nil
+ config.timeoutIntervalForRequest = 30
+ session = URLSession(configuration: config, delegate: NoRedirects(), delegateQueue: nil)
+ }
+
+ public static func origin(_ text: String) throws -> URL {
+ guard let c = URLComponents(string: text.trimmingCharacters(in: .whitespacesAndNewlines)),
+ let host = c.host, !host.isEmpty, c.user == nil, c.password == nil,
+ c.query == nil, c.fragment == nil, c.path.isEmpty || c.path == "/",
+ c.scheme == "https" || (c.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)),
+ let url = c.url else {
+ throw NativeClientError.message("Enter your server’s HTTPS address, without a page path.")
+ }
+ var clean = c
+ clean.path = ""
+ return clean.url ?? url
+ }
+
+ private func makeRequest(_ path: String, method: String, body: Data?, contentType: String) throws -> URLRequest {
+ guard path.hasPrefix("/"), !path.hasPrefix("//"),
+ let url = URL(string: path, relativeTo: origin)?.absoluteURL,
+ url.scheme == origin.scheme, url.host == origin.host, url.port == origin.port else {
+ throw NativeClientError.message("Invalid server path.")
+ }
+ var request = URLRequest(url: url)
+ request.httpMethod = method
+ request.httpBody = body
+ request.setValue(origin.absoluteString, forHTTPHeaderField: "Origin")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ if body != nil { request.setValue(contentType, forHTTPHeaderField: "Content-Type") }
+ if let credential { request.setValue("\(credential.name)=\(credential.value)", forHTTPHeaderField: "Cookie") }
+ return request
+ }
+
+ public func response(_ path: String, method: String = "GET", body: Data? = nil,
+ contentType: String = "application/json", maxBytes: Int = 4 * 1024 * 1024) async throws -> (Data, Int) {
+ let result = try await responseDetails(path, method: method, body: body, contentType: contentType, maxBytes: maxBytes)
+ return (result.data, result.status)
+ }
+
+ /// An explicit export to a new file, streamed by URLSession without retaining
+ /// the workbook/document in memory. The caller owns deletion after export or
+ /// cancellation of its save panel. Existing destinations are never replaced.
+ public func download(_ path: String, to destination: URL) async throws {
+ guard destination.isFileURL else { throw NativeClientError.message("Invalid export destination.") }
+ let request = try makeRequest(path, method: "GET", body: nil, contentType: "application/json")
+ let (temporary, response) = try await session.download(for: request)
+ defer { try? FileManager.default.removeItem(at: temporary) }
+ try Task.checkCancellation()
+ guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
+ throw NativeClientError.message("The export could not be completed. Refresh your session and try again.")
+ }
+ try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: temporary.path)
+ try FileManager.default.moveItem(at: temporary, to: destination)
+ }
+
+ /// Exposes protocol cursors (for example Docs-Seq) without sharing a cookie jar.
+ public func responseDetails(_ path: String, method: String = "GET", body: Data? = nil,
+ contentType: String = "application/json", maxBytes: Int = 4 * 1024 * 1024) async throws -> NativeResponse {
+ guard maxBytes > 0, maxBytes <= 256 * 1024 * 1024 else {
+ throw NativeClientError.message("Invalid response size limit.")
+ }
+ let request = try makeRequest(path, method: method, body: body, contentType: contentType)
+ let (bytes, response) = try await session.bytes(for: request)
+ defer { bytes.task.cancel() }
+ guard let response = response as? HTTPURLResponse else { throw NativeClientError.message("Invalid server response.") }
+ var data = Data()
+ for try await byte in bytes {
+ try Task.checkCancellation()
+ guard data.count < maxBytes else { throw NativeClientError.message("The server response is too large.") }
+ data.append(byte)
+ }
+ var headers: [String: String] = [:]
+ for (key, value) in response.allHeaderFields {
+ if let key = key as? String, let value = value as? String { headers[key.lowercased()] = value }
+ }
+ return NativeResponse(data: data, status: response.statusCode, headers: headers)
+ }
+
+ /// Delivers bounded UTF-8 SSE lines, including blank event delimiters. Callers
+ /// own event parsing and reconnect cursors; a clean EOF is never a saved edit.
+ public func stream(_ path: String, onOpen: @Sendable () async throws -> Void = {},
+ onLine: @Sendable (String) async throws -> Void) async throws {
+ var request = try makeRequest(path, method: "GET", body: nil, contentType: "application/json")
+ request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
+ let (bytes, response) = try await session.bytes(for: request)
+ defer { bytes.task.cancel() }
+ guard let response = response as? HTTPURLResponse,
+ response.statusCode == 200,
+ response.value(forHTTPHeaderField: "Content-Type")?.split(separator: ";").first?.trimmingCharacters(in: .whitespaces).lowercased() == "text/event-stream" else {
+ throw NativeClientError.message("The live connection was refused. Reconnect to refresh your session.")
+ }
+ // A server that subscribes before flushing headers lets this callback fetch
+ // a snapshot without a subscribe-after-snapshot gap. Lines remain buffered
+ // until the callback completes; consumers reconcile their version cursors.
+ try Task.checkCancellation()
+ try await onOpen()
+ var parser = NativeStreamLines()
+ for try await byte in bytes {
+ try Task.checkCancellation()
+ if let line = try parser.append(byte) { try await onLine(line) }
+ }
+ // An unterminated event is not delivered at EOF; the next snapshot is the
+ // authority after reconnection, rather than an incomplete network frame.
+ }
+
+ public func request<T: Decodable>(_ path: String, method: String = "GET", body: Data? = nil,
+ contentType: String = "application/json", maxBytes: Int = 4 * 1024 * 1024) async throws -> T {
+ let (data, status) = try await response(path, method: method, body: body, contentType: contentType, maxBytes: maxBytes)
+ guard (200..<300).contains(status) else {
+ if status == 401 || status == 303 { throw NativeClientError.message("Your session has ended. Sign out and sign in again.") }
+ if status == 404 { throw NativeClientError.message("This server does not support this native feature yet.") }
+ throw NativeClientError.message("The request could not be completed (\(status)). Your changes have not been confirmed.")
+ }
+ do { return try JSONDecoder().decode(T.self, from: data) }
+ catch { throw NativeClientError.message("The server returned an unsupported response.") }
+ }
+}
diff --git a/Sources/RastrilloApple/NativeResponse.swift b/Sources/RastrilloApple/NativeResponse.swift
new file mode 100644
index 0000000..4b174cd
--- /dev/null
+++ b/Sources/RastrilloApple/NativeResponse.swift
@@ -0,0 +1,9 @@
+import Foundation
+
+public struct NativeResponse: Sendable {
+ public let data: Data
+ public let status: Int
+ public let headers: [String: String]
+
+ public func header(_ name: String) -> String? { headers[name.lowercased()] }
+}
diff --git a/Sources/RastrilloApple/NativeSignInView.swift b/Sources/RastrilloApple/NativeSignInView.swift
new file mode 100644
index 0000000..1a06694
--- /dev/null
+++ b/Sources/RastrilloApple/NativeSignInView.swift
@@ -0,0 +1,53 @@
+import SwiftUI
+
+@MainActor public struct NativeSignInView: View {
+ @ObservedObject private var connection: NativeConnection
+ private let name: String
+ private let symbol: String
+ @Environment(\.openURL) private var openURL
+
+ public init(_ name: String, systemImage: String, connection: NativeConnection) {
+ self.name = name
+ symbol = systemImage
+ self.connection = connection
+ }
+ public var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 24) {
+ Image(systemName: symbol).font(.largeTitle).foregroundStyle(.tint)
+ .padding(18).background(.tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 20))
+ .accessibilityHidden(true)
+ Text(name).font(.largeTitle.bold())
+ Text("Your team, wherever you work.").foregroundStyle(.secondary)
+ if connection.signingIn {
+ ProgressView("Finish signing in in your browser")
+ Text(connection.code).font(.title.monospaced().bold()).textSelection(.enabled)
+ Text("Compare this code with the one in your browser before approving sign-in.")
+ .foregroundStyle(.secondary)
+
+ Button("Cancel") { connection.cancel() }
+ } else {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Server address").font(.headline)
+ TextField("https://your-server", text: $connection.serverAddress)
+ .textFieldStyle(.roundedBorder).accessibilityIdentifier("serverAddress")
+ #if os(iOS)
+ .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
+ #endif
+ .onSubmit(signIn)
+ }
+ TextField("Account name (optional)", text: $connection.accountName)
+ .textFieldStyle(.roundedBorder)
+ Button("Sign in", systemImage: "arrow.right", action: signIn)
+ .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
+ }
+ if !connection.error.isEmpty {
+ Label(connection.error, systemImage: "exclamationmark.circle")
+ .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true)
+ }
+ }.controlSize(.large).frame(maxWidth: 440, alignment: .leading).padding(32)
+ .frame(maxWidth: .infinity)
+ }
+ }
+ private func signIn() { connection.signIn() }
+}
diff --git a/Sources/RastrilloApple/NativeStreamLines.swift b/Sources/RastrilloApple/NativeStreamLines.swift
new file mode 100644
index 0000000..9b9217a
--- /dev/null
+++ b/Sources/RastrilloApple/NativeStreamLines.swift
@@ -0,0 +1,42 @@
+import Foundation
+
+/// Bound before decoding: AsyncBytes.lines can allocate an unbounded line.
+struct NativeStreamLines {
+ private var buffer = Data()
+ private var eventBytes = 0
+ private var afterCR = false
+ private var firstLine = true
+ let lineLimit: Int
+ let eventLimit: Int
+
+ init(lineLimit: Int = 256 * 1024, eventLimit: Int = 1024 * 1024) {
+ self.lineLimit = lineLimit
+ self.eventLimit = eventLimit
+ }
+
+ mutating func append(_ byte: UInt8) throws -> String? {
+ if afterCR {
+ afterCR = false
+ if byte == 10 { return nil }
+ }
+ if byte == 10 || byte == 13 {
+ afterCR = byte == 13
+ guard var line = String(data: buffer, encoding: .utf8) else {
+ throw NativeClientError.message("The live connection returned invalid text.")
+ }
+ if firstLine {
+ firstLine = false
+ if line.hasPrefix("\u{FEFF}") { line.removeFirst() }
+ }
+ buffer.removeAll(keepingCapacity: true)
+ if line.isEmpty { eventBytes = 0 }
+ return line
+ }
+ guard buffer.count < lineLimit, eventBytes < eventLimit else {
+ throw NativeClientError.message("The live update is too large. Reconnect to refresh it.")
+ }
+ buffer.append(byte)
+ eventBytes += 1
+ return nil
+ }
+}
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/RastrilloAppleTests/HTTPStreamCancellationTests.swift b/Tests/RastrilloAppleTests/HTTPStreamCancellationTests.swift
new file mode 100644
index 0000000..fc1cbe8
--- /dev/null
+++ b/Tests/RastrilloAppleTests/HTTPStreamCancellationTests.swift
@@ -0,0 +1,177 @@
+#if os(macOS)
+import Foundation
+import XCTest
+@testable import RastrilloApple
+
+private actor OpenEvents {
+ var values: [String] = []
+ func append(_ value: String) { values.append(value) }
+}
+
+/// A live socket must close when its consumer rejects or cancels the response;
+/// returning a Swift error alone can leave URLSession downloading indefinitely.
+final class HTTPStreamCancellationTests: XCTestCase {
+ private struct Fixture {
+ let process: Process
+ let client: NativeHTTP
+ let directory: URL
+ var closed: URL { directory.appendingPathComponent("closed") }
+ func stop() {
+ if process.isRunning { process.terminate() }
+ try? FileManager.default.removeItem(at: directory)
+ }
+ }
+
+ private func fixture(contentType: String = "text/event-stream", prefix: String = ": ready\n\n") throws -> Fixture {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
+ process.arguments = ["-u", "-c", #"""
+import http.server, pathlib, socket, sys
+marker, content_type, prefix = sys.argv[1:]
+class Handler(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *args): pass
+ def do_GET(self):
+ self.send_response(200)
+ self.send_header('Content-Type', content_type)
+ self.end_headers()
+ self.wfile.write(prefix.encode('utf-8'))
+ self.wfile.flush()
+ # Keep the response open without more data. A read blocks in the client
+ # until cancellation; the server sees EOF only when the socket closes.
+ self.connection.settimeout(4)
+ try:
+ if self.connection.recv(1) == b'':
+ pathlib.Path(marker).write_text('closed')
+ except (ConnectionResetError, BrokenPipeError):
+ pathlib.Path(marker).write_text('closed')
+ except socket.timeout:
+ pass
+server = http.server.HTTPServer(('127.0.0.1', 0), Handler)
+print(server.server_port, flush=True)
+server.handle_request()
+"""#, directory.appendingPathComponent("closed").path, contentType, prefix]
+ let output = Pipe()
+ process.standardOutput = output
+ do { try process.run() } catch {
+ try? FileManager.default.removeItem(at: directory)
+ throw error
+ }
+ var port = Data()
+ while port.count < 12 {
+ let byte = output.fileHandleForReading.readData(ofLength: 1)
+ if byte.isEmpty || byte == Data([10]) { break }
+ port.append(byte)
+ }
+ guard let number = String(data: port, encoding: .utf8).flatMap(Int.init), number > 0,
+ let origin = URL(string: "http://127.0.0.1:\(number)") else {
+ if process.isRunning { process.terminate() }
+ try? FileManager.default.removeItem(at: directory)
+ throw NativeClientError.message("The isolated cancellation fixture could not start.")
+ }
+ return Fixture(process: process, client: NativeHTTP(origin: origin), directory: directory)
+ }
+
+ private func assertClosed(_ fixture: Fixture, file: StaticString = #filePath, line: UInt = #line) async throws {
+ for _ in 0..<60 {
+ if FileManager.default.fileExists(atPath: fixture.closed.path) { return }
+ try await Task.sleep(nanoseconds: 25_000_000)
+ }
+ XCTFail("The rejected or cancelled response left its network socket open", file: file, line: line)
+ }
+
+ func testRejectedContentTypeClosesLiveSocket() async throws {
+ // Clear Foundation's small text-response sniffing buffer before waiting.
+ let server = try fixture(contentType: "text/plain", prefix: String(repeating: "x", count: 4096))
+ defer { server.stop() }
+ do {
+ try await server.client.stream("/live", onOpen: { XCTFail("A refused response invoked onOpen") }) { _ in
+ XCTFail("Rejected content reached the consumer")
+ }
+ XCTFail("Accepted a response with the wrong content type")
+ } catch { XCTAssertTrue(error.localizedDescription.contains("refused")) }
+ try await assertClosed(server)
+ }
+
+ func testResponseSizeFailureClosesLiveSocket() async throws {
+ let server = try fixture(contentType: "application/octet-stream", prefix: "12345")
+ defer { server.stop() }
+ do {
+ _ = try await server.client.responseDetails("/live", maxBytes: 4)
+ XCTFail("Accepted an oversized response")
+ } catch { XCTAssertTrue(error.localizedDescription.contains("too large")) }
+ try await assertClosed(server)
+ }
+
+ func testThrowingLineConsumerClosesLiveSocket() async throws {
+ enum ConsumerFailure: Error { case stopped }
+ let server = try fixture()
+ defer { server.stop() }
+ do {
+ try await server.client.stream("/live") { _ in throw ConsumerFailure.stopped }
+ XCTFail("A consumer failure was swallowed")
+ } catch ConsumerFailure.stopped {} catch { XCTFail("Unexpected error: \(error)") }
+ try await assertClosed(server)
+ }
+
+ func testOpenCallbackCompletesBeforeFirstLine() async throws {
+ enum Stop: Error { case finished }
+ let server = try fixture()
+ defer { server.stop() }
+ let events = OpenEvents()
+ do {
+ try await server.client.stream("/live", onOpen: {
+ // Suspending proves the transport awaits completion rather than merely
+ // scheduling the snapshot callback concurrently with incoming lines.
+ try await Task.sleep(nanoseconds: 25_000_000)
+ await events.append("opened")
+ }) { line in
+ await events.append(line)
+ throw Stop.finished
+ }
+ XCTFail("The line callback failure was swallowed")
+ } catch Stop.finished {} catch { XCTFail("Unexpected error: \(error)") }
+ let received = await events.values
+ XCTAssertEqual(received, ["opened", ": ready"])
+ try await assertClosed(server)
+ }
+
+ func testThrowingOpenCallbackClosesWithoutDeliveringLines() async throws {
+ enum Stop: Error { case finished }
+ let server = try fixture()
+ defer { server.stop() }
+ do {
+ try await server.client.stream("/live", onOpen: { throw Stop.finished }) { _ in
+ XCTFail("Lines arrived after snapshot initialization failed")
+ }
+ XCTFail("The open callback failure was swallowed")
+ } catch Stop.finished {} catch { XCTFail("Unexpected error: \(error)") }
+ try await assertClosed(server)
+ }
+
+ func testCancellationWhileAwaitingBytesClosesLiveSocket() async throws {
+ let server = try fixture()
+ defer { server.stop() }
+ let ready = expectation(description: "received stream prefix")
+ let finished = expectation(description: "cancelled stream returned")
+ let task = Task {
+ do {
+ try await server.client.stream("/live") { line in
+ if line == ": ready" { ready.fulfill() }
+ }
+ XCTFail("A cancelled stream completed successfully")
+ } catch {
+ XCTAssertTrue(Task.isCancelled)
+ }
+ finished.fulfill()
+ }
+ await fulfillment(of: [ready], timeout: 2)
+ // Give the iterator an opportunity to suspend waiting for the next byte.
+ try await Task.sleep(nanoseconds: 50_000_000)
+ task.cancel()
+ await fulfillment(of: [finished], timeout: 2)
+ try await assertClosed(server)
+ }
+}
+#endif
diff --git a/Tests/RastrilloAppleTests/HTTPTests.swift b/Tests/RastrilloAppleTests/HTTPTests.swift
new file mode 100644
index 0000000..f8d5fa5
--- /dev/null
+++ b/Tests/RastrilloAppleTests/HTTPTests.swift
@@ -0,0 +1,127 @@
+#if os(macOS)
+import Foundation
+import Darwin
+import XCTest
+@testable import RastrilloApple
+
+private actor ReceivedLines {
+ var values: [String] = []
+ func append(_ line: String) { values.append(line) }
+}
+
+final class HTTPTests: XCTestCase {
+ private func stop(_ process: Process) {
+ process.terminate()
+ let deadline = Date().addingTimeInterval(0.5)
+ while process.isRunning && Date() < deadline { Thread.sleep(forTimeInterval: 0.01) }
+ if process.isRunning { kill(process.processIdentifier, SIGKILL) }
+ }
+
+ private func fixture() throws -> (Process, NativeHTTP) {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
+ process.arguments = ["-u", "-c", #"""
+import http.server
+class Handler(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *args): pass
+ def do_GET(self):
+ if self.path == '/redirect':
+ self.send_response(302)
+ self.send_header('Location', '/unexpected-follow')
+ self.end_headers()
+ return
+ if self.path == '/stream':
+ body = b'event: update\r\ndata: 42\r\n\r\n'
+ content_type = 'text/event-stream; charset=utf-8'
+ else:
+ body = b'12345'
+ content_type = 'application/octet-stream'
+ self.send_response(200)
+ self.send_header('Content-Type', content_type)
+ self.send_header('Content-Length', str(len(body)))
+ self.send_header('Docs-Seq', '42')
+ self.send_header('Seen-Origin', self.headers.get('Origin', 'missing'))
+ self.send_header('Seen-Cookie', self.headers.get('Cookie', 'missing'))
+ self.end_headers()
+ self.wfile.write(body)
+server = http.server.HTTPServer(('127.0.0.1', 0), Handler)
+print(server.server_port, flush=True)
+server.serve_forever()
+"""#]
+ let output = Pipe()
+ process.standardOutput = output
+ try process.run()
+ var port = Data()
+ while port.count < 12 {
+ let byte = output.fileHandleForReading.readData(ofLength: 1)
+ if byte.isEmpty || byte == Data([10]) { break }
+ port.append(byte)
+ }
+ guard let portText = String(data: port, encoding: .utf8), let number = Int(portText), number > 0,
+ let origin = URL(string: "http://127.0.0.1:\(number)") else {
+ process.terminate()
+ throw NativeClientError.message("The isolated HTTP fixture could not start.")
+ }
+ return (process, NativeHTTP(origin: origin, credential: NativeSession(name: "rastrillo_session", value: "fixture")))
+ }
+
+ func testResponseLimitHeadersAndOrigin() async throws {
+ let (server, client) = try fixture()
+ defer { stop(server) }
+ let response = try await client.responseDetails("/bytes", maxBytes: 5)
+ XCTAssertEqual(response.data, Data("12345".utf8))
+ XCTAssertEqual(response.header("Docs-Seq"), "42")
+ XCTAssertEqual(response.header("Seen-Origin"), client.origin.absoluteString)
+ XCTAssertEqual(response.header("Seen-Cookie"), "rastrillo_session=fixture")
+ do {
+ _ = try await client.response("/bytes", maxBytes: 4)
+ XCTFail("A truncated response must fail, never succeed with a prefix")
+ } catch { XCTAssertTrue(error.localizedDescription.contains("too large")) }
+ }
+
+ func testRedirectIsReturnedWithoutFollowing() async throws {
+ let (server, client) = try fixture()
+ defer { stop(server) }
+ let response = try await client.responseDetails("/redirect")
+ XCTAssertEqual(response.status, 302)
+ XCTAssertTrue(response.data.isEmpty)
+ }
+
+ func testFileExportIsPrivateAndNeverOverwritesOrFollowsRedirect() async throws {
+ let (server, client) = try fixture()
+ defer { stop(server) }
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let file = directory.appendingPathComponent("export.xlsx")
+ try await client.download("/bytes", to: file)
+ XCTAssertEqual(try Data(contentsOf: file), Data("12345".utf8))
+ let permissions = try FileManager.default.attributesOfItem(atPath: file.path)[.posixPermissions] as? NSNumber
+ XCTAssertEqual(permissions?.intValue, 0o600)
+ try Data("keep existing file".utf8).write(to: file)
+ do {
+ try await client.download("/bytes", to: file)
+ XCTFail("Export overwrote an existing file")
+ } catch { XCTAssertEqual(try Data(contentsOf: file), Data("keep existing file".utf8)) }
+ let redirected = directory.appendingPathComponent("redirect.xlsx")
+ do {
+ try await client.download("/redirect", to: redirected)
+ XCTFail("Export followed or accepted a redirect")
+ } catch { XCTAssertFalse(FileManager.default.fileExists(atPath: redirected.path)) }
+ }
+
+ func testLiveTransportAndWrongContentType() async throws {
+ let (server, client) = try fixture()
+ defer { stop(server) }
+ let lines = ReceivedLines()
+ try await client.stream("/stream") { line in await lines.append(line) }
+ let received = await lines.values
+ XCTAssertEqual(received, ["event: update", "data: 42", ""])
+ do {
+ try await client.stream("/bytes") { _ in XCTFail("Non-SSE content reached the live editor") }
+ XCTFail("Non-SSE response was accepted")
+ } catch { XCTAssertTrue(error.localizedDescription.contains("refused")) }
+ }
+}
+
+#endif
diff --git a/Tests/RastrilloAppleTests/NativeAccountsTests.swift b/Tests/RastrilloAppleTests/NativeAccountsTests.swift
new file mode 100644
index 0000000..9355d31
--- /dev/null
+++ b/Tests/RastrilloAppleTests/NativeAccountsTests.swift
@@ -0,0 +1,46 @@
+import XCTest
+@testable import RastrilloApple
+
+final class NativeAccountsTests: XCTestCase {
+ @MainActor func testChangingSavedOriginCannotRedirectCredential() throws {
+ let suite = "OficinaAccountsTest.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let original = NativeAccount(id: UUID().uuidString, origin: URL(string: "https://trusted.test")!, name: "Work")
+ try NativeCredentials.save(NativeSession(name: "__Host-rastrillo_session", value: "secret"), origin: original.credentialKey)
+ defer { NativeCredentials.remove(original.credentialKey) }
+ for origin in ["https://other.test", "http://trusted.test", "https://trusted.test/path"] {
+ let modified = NativeAccount(id: original.id, origin: URL(string: origin)!, name: original.name)
+ defaults.set(try JSONEncoder().encode([modified]), forKey: "native.accounts")
+ defaults.set(original.id, forKey: "native.activeAccount")
+ let connection = NativeConnection(defaultServer: "https://trusted.test", defaults: defaults)
+ XCTAssertNil(connection.client)
+ XCTAssertNil(connection.activeAccountID)
+ }
+ }
+
+ @MainActor func testBackPreservesSessionsAndRestoresMostRecentAccount() throws {
+ let suite = "OficinaAccountsTest.\(UUID().uuidString)"
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
+ defer { defaults.removePersistentDomain(forName: suite) }
+ let origin = URL(string: "https://trusted.test")!
+ let first = NativeAccount(id: UUID().uuidString, origin: origin, name: "Work")
+ let second = NativeAccount(id: UUID().uuidString, origin: origin, name: "Personal")
+ for (account, token) in [(first,"first"), (second,"second")] {
+ try NativeCredentials.save(NativeSession(name: "__Host-rastrillo_session", value: token), origin: account.credentialKey)
+ }
+ defer { for account in [first,second] { NativeCredentials.remove(account.credentialKey) } }
+ defaults.set(try JSONEncoder().encode([first,second]), forKey: "native.accounts")
+ let connection = NativeConnection(defaultServer: origin.absoluteString, defaults: defaults)
+ connection.selectAccount(second)
+ XCTAssertEqual(connection.client?.credential?.value, "second")
+ connection.showAccounts()
+ XCTAssertNil(connection.client)
+ XCTAssertEqual(connection.accounts.count, 2)
+ let restored = NativeConnection(defaultServer: origin.absoluteString, defaults: defaults)
+ XCTAssertEqual(restored.activeAccountID, second.id)
+ XCTAssertEqual(restored.client?.credential?.value, "second")
+ restored.selectAccount(first)
+ XCTAssertEqual(restored.client?.credential?.value, "first")
+ }
+}
diff --git a/Tests/RastrilloAppleTests/OriginTests.swift b/Tests/RastrilloAppleTests/OriginTests.swift
new file mode 100644
index 0000000..78a7bd5
--- /dev/null
+++ b/Tests/RastrilloAppleTests/OriginTests.swift
@@ -0,0 +1,18 @@
+import XCTest
+@testable import RastrilloApple
+final class OriginTests: XCTestCase {
+ func testOriginRejectsCredentialAndCrossOriginShapes() throws {
+ for value in ["http://example.com", "https://user:password@example.com", "https://example.com/path", "https://example.com?secret=1", "https://example.com#token", "file:///etc/passwd"] {
+ XCTAssertThrowsError(try NativeHTTP.origin(value))
+ }
+ XCTAssertEqual(try NativeHTTP.origin(" https://example.com/ ").absoluteString, "https://example.com")
+ XCTAssertEqual(try NativeHTTP.origin("http://127.0.0.1:8080").host, "127.0.0.1")
+ }
+ func testRequestCannotOverrideOrigin() async throws {
+ let client = NativeHTTP(origin: try NativeHTTP.origin("https://example.com"))
+ for path in ["//evil.test/x", "https://evil.test", "relative"] {
+ do { _ = try await client.response(path); XCTFail("accepted unsafe path") }
+ catch { XCTAssertEqual(error.localizedDescription, "Invalid server path.") }
+ }
+ }
+}
diff --git a/Tests/RastrilloAppleTests/StreamLinesTests.swift b/Tests/RastrilloAppleTests/StreamLinesTests.swift
new file mode 100644
index 0000000..0794e4e
--- /dev/null
+++ b/Tests/RastrilloAppleTests/StreamLinesTests.swift
@@ -0,0 +1,36 @@
+import XCTest
+@testable import RastrilloApple
+
+final class StreamLinesTests: XCTestCase {
+ func testUTF8AndAllSSELineEndings() throws {
+ var parser = NativeStreamLines()
+ let input = "\u{FEFF}event: update\r\ndata: café 👩🏽‍💻\r\r: ping\n\n"
+ let lines = try input.utf8.compactMap { try parser.append($0) }
+ XCTAssertEqual(lines, ["event: update", "data: café 👩🏽‍💻", "", ": ping", ""])
+ }
+
+ func testInvalidUTF8DoesNotReachConsumer() throws {
+ var parser = NativeStreamLines()
+ XCTAssertNil(try parser.append(0xff))
+ XCTAssertThrowsError(try parser.append(10))
+ }
+
+ func testLineLimitRejectsBeforeUnboundedAllocation() throws {
+ var parser = NativeStreamLines(lineLimit: 4)
+ for byte in "data".utf8 { XCTAssertNil(try parser.append(byte)) }
+ XCTAssertThrowsError(try parser.append(58))
+ }
+
+ func testEventLimitSpansLinesAndResetsAtDelimiter() throws {
+ var parser = NativeStreamLines(lineLimit: 8, eventLimit: 8)
+ _ = try "1234\n5678\n".utf8.compactMap { try parser.append($0) }
+ XCTAssertThrowsError(try parser.append(57))
+ XCTAssertEqual(try parser.append(10), "")
+ XCTAssertNil(try parser.append(57))
+ }
+
+ func testUnterminatedLineIsNotDelivered() throws {
+ var parser = NativeStreamLines()
+ XCTAssertEqual(try "event: update\ndata: partial".utf8.compactMap { try parser.append($0) }, ["event: update"])
+ }
+}
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/server/LICENSE b/server/LICENSE
new file mode 100644
index 0000000..570135f
--- /dev/null
+++ b/server/LICENSE
@@ -0,0 +1,374 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of others (if any) used
+ by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+ means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+ means Source Code Form to which the initial Contributor has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+ means
+
+ (a) that the initial Contributor has attached the notice described
+ in Exhibit B to the Covered Software; or
+
+ (b) that the Covered Software was made available under the terms of
+ version 1.1 or earlier of the License, but not also under the
+ terms of a Secondary License.
+
+1.6. "Executable Form"
+ means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+ means a work that combines Covered Software with other material, in
+ a separate file or files, that is not Covered Software.
+
+1.8. "License"
+ means this document.
+
+1.9. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently, any and
+ all of the rights conveyed by this License.
+
+1.10. "Modifications"
+ means any of the following:
+
+ (a) any file in Source Code Form that results from an addition to,
+ deletion from, or modification of the contents of Covered
+ Software; or
+
+ (b) any new file in Source Code Form that contains any Covered
+ Software.
+
+1.11. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by the making, using, selling, offering for sale, having
+ made, import, or transfer of either its Contributions or its
+ Contributor Version.
+
+1.12. "Secondary License"
+ means either the GNU General Public License, Version 2.0, the GNU
+ Lesser General Public License, Version 2.1, the GNU Affero General
+ Public License, Version 3.0, or any later versions of those
+ licenses.
+
+1.13. "Source Code Form"
+ means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You. For
+ purposes of this definition, "control" means (a) the power, direct
+ or indirect, to cause the direction or management of such entity,
+ whether by contract or otherwise, or (b) ownership of more than
+ fifty percent (50%) of the outstanding shares or beneficial
+ ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+ Licensable by such Contributor to use, reproduce, make available,
+ modify, display, perform, distribute, and otherwise exploit its
+ Contributions, either on an unmodified basis, with Modifications, or
+ as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+ for sale, have made, import, and otherwise transfer either its
+ Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+ or
+
+(b) for infringements caused by: (i) Your and any other third party's
+ modifications of Covered Software, or (ii) the combination of its
+ Contributions with other software (except as part of its Contributor
+ Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+ its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+ Form, as described in Section 3.1, and You must inform recipients of
+ the Executable Form how they can obtain a copy of such Source Code
+ Form by reasonable means in a timely manner, at a charge no more
+ than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+ License, or sublicense it under different terms, provided that the
+ license for the Executable Form does not attempt to limit or alter
+ the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+* *
+* 6. Disclaimer of Warranty *
+* ------------------------- *
+* *
+* Covered Software is provided under this License on an "as is" *
+* basis, without warranty of any kind, either expressed, implied, or *
+* statutory, including, without limitation, warranties that the *
+* Covered Software is free of defects, merchantable, fit for a *
+* particular purpose or non-infringing. The entire risk as to the *
+* quality and performance of the Covered Software is with You. *
+* Should any Covered Software prove defective in any respect, You *
+* (not any Contributor) assume the cost of any necessary servicing, *
+* repair, or correction. This disclaimer of warranty constitutes an *
+* essential part of this License. No use of any Covered Software is *
+* authorized under this License except under this disclaimer. *
+* *
+************************************************************************
+
+************************************************************************
+* *
+* 7. Limitation of Liability *
+* -------------------------- *
+* *
+* Under no circumstances and under no legal theory, whether tort *
+* (including negligence), contract, or otherwise, shall any *
+* Contributor, or anyone who distributes Covered Software as *
+* permitted above, be liable to You for any direct, indirect, *
+* special, incidental, or consequential damages of any character *
+* including, without limitation, damages for lost profits, loss of *
+* goodwill, work stoppage, computer failure or malfunction, or any *
+* and all other commercial damages or losses, even if such party *
+* shall have been informed of the possibility of such damages. This *
+* limitation of liability shall not apply to liability for death or *
+* personal injury resulting from such party's negligence to the *
+* extent applicable law prohibits such limitation. Some *
+* jurisdictions do not allow the exclusion or limitation of *
+* incidental or consequential damages, so this exclusion and *
+* limitation may not apply to You. *
+* *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+ This Source Code Form is "Incompatible With Secondary Licenses", as
+ defined by the Mozilla Public License, v. 2.0.
+
diff --git a/server/auth.go b/server/auth.go
new file mode 100644
index 0000000..47a8b3e
--- /dev/null
+++ b/server/auth.go
@@ -0,0 +1,259 @@
+// Package nativeauth provides proof-bound browser approval for native clients.
+// Apps retain their existing session, admission and resource authorization rules.
+package nativeauth
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "html/template"
+ "io"
+ "net/http"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/rastrillo/csrf"
+ "github.com/carlosframework/rastrillo/sessions"
+)
+
+type Auth struct {
+ mu sync.Mutex
+ pending map[string]*request
+ sessions *sessions.Sessions
+ require func(http.Handler) http.Handler
+ origin, name string
+}
+type request struct {
+ challenge, cookie, callback string
+ expires time.Time
+}
+
+func New(s *sessions.Sessions, origin, name string, require func(http.Handler) http.Handler) *Auth {
+ return &Auth{pending: map[string]*request{}, sessions: s, origin: origin, name: name, require: require}
+}
+func token() string {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+func valid(s string) bool {
+ b, e := base64.RawURLEncoding.DecodeString(s)
+ return e == nil && len(b) == 32 && base64.RawURLEncoding.EncodeToString(b) == s
+}
+func code(id string) string {
+ b := sha256.Sum256([]byte(id))
+ s := strings.ToUpper(hex.EncodeToString(b[:4]))
+ return s[:4] + "-" + s[4:]
+}
+func (a *Auth) find(id string) *request {
+ for key, p := range a.pending {
+ if !time.Now().Before(p.expires) {
+ delete(a.pending, key)
+ }
+ }
+ return a.pending[id]
+}
+func (a *Auth) pendingName() string {
+ if strings.HasPrefix(a.origin, "https:") {
+ return "__Host-native_pending"
+ }
+ return "native_pending"
+}
+func (a *Auth) Resume(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "GET" && r.URL.Path == "/" {
+ if c, e := r.Cookie(a.pendingName()); e == nil && valid(c.Value) {
+ if _, ok := a.sessions.From(r); ok {
+ http.SetCookie(w, &http.Cookie{Name: a.pendingName(), Path: "/", MaxAge: -1, Secure: strings.HasPrefix(a.origin, "https:"), HttpOnly: true, SameSite: http.SameSiteLaxMode})
+ http.Redirect(w, r, "/native/authorize?id="+c.Value, 303)
+ return
+ }
+ }
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+func (a *Auth) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Referrer-Policy", "no-referrer")
+ csrf.Protect(a.origin)(http.HandlerFunc(a.route)).ServeHTTP(w, r)
+}
+func (a *Auth) route(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == "POST" && r.URL.Path == "/native/request":
+ a.begin(w, r)
+ case r.Method == "POST" && r.URL.Path == "/native/exchange":
+ a.exchange(w, r)
+ case r.Method == "GET" && r.URL.Path == "/native/start":
+ id := r.URL.Query().Get("id")
+ a.mu.Lock()
+ ok := a.find(id) != nil
+ a.mu.Unlock()
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ http.SetCookie(w, &http.Cookie{Name: a.pendingName(), Value: id, Path: "/", MaxAge: 300, Secure: strings.HasPrefix(a.origin, "https:"), HttpOnly: true, SameSite: http.SameSiteLaxMode})
+ http.Redirect(w, r, "/", 303)
+ case r.URL.Path == "/native/authorize" && (r.Method == "GET" || r.Method == "POST"):
+ a.require(http.HandlerFunc(a.authorize)).ServeHTTP(w, r)
+ default:
+ http.NotFound(w, r)
+ }
+}
+func decode(w http.ResponseWriter, r *http.Request, out any) bool {
+ if r.Header.Get("Content-Type") != "application/json" {
+ http.Error(w, "Expected JSON", 415)
+ return false
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, 2048)
+ d := json.NewDecoder(r.Body)
+ if d.Decode(out) != nil || d.Decode(new(any)) != io.EOF {
+ http.Error(w, "Invalid request", 400)
+ return false
+ }
+ return true
+}
+func jsonReply(w http.ResponseWriter, value any) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(value)
+}
+func (a *Auth) begin(w http.ResponseWriter, r *http.Request) {
+ var in struct {
+ Challenge string `json:"challenge"`
+ Callback string `json:"callback"`
+ }
+ if !decode(w, r, &in) {
+ return
+ }
+ if !valid(in.Challenge) || (in.Callback != "" && in.Callback != a.callbackURL()) {
+ http.Error(w, "Invalid proof", 400)
+ return
+ }
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ a.find("")
+ if len(a.pending) >= 256 {
+ http.Error(w, "Try again later", 429)
+ return
+ }
+ id := token()
+ a.pending[id] = &request{challenge: in.Challenge, callback: in.Callback, expires: time.Now().Add(5 * time.Minute)}
+ jsonReply(w, map[string]string{"id": id})
+}
+func (a *Auth) authorize(w http.ResponseWriter, r *http.Request) {
+ id := r.URL.Query().Get("id")
+ a.mu.Lock()
+ p := a.find(id)
+ ok := p != nil && p.cookie == ""
+ a.mu.Unlock()
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ done := false
+ callback := ""
+ if r.Method == "POST" {
+ c, e := r.Cookie(a.sessions.CookieName())
+ if e != nil {
+ http.Error(w, "Sign in again", 401)
+ return
+ }
+ a.mu.Lock()
+ p = a.find(id)
+ if p == nil || p.cookie != "" {
+ a.mu.Unlock()
+ http.NotFound(w, r)
+ return
+ }
+ p.cookie = c.Value
+ if p.callback != "" {
+ callback = p.callback + "?state=" + id
+ }
+ a.mu.Unlock()
+ done = true
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _ = approval.Execute(w, struct {
+ Name, Code, ID string
+ Done bool
+ Callback template.URL
+ }{a.name, code(id), id, done, template.URL(callback)})
+}
+func (a *Auth) exchange(w http.ResponseWriter, r *http.Request) {
+ var in struct {
+ ID string `json:"id"`
+ Verifier string `json:"verifier"`
+ }
+ if !decode(w, r, &in) {
+ return
+ }
+ if !valid(in.ID) || !valid(in.Verifier) {
+ http.Error(w, "Invalid proof", 400)
+ return
+ }
+ hash := sha256.Sum256([]byte(in.Verifier))
+ challenge := base64.RawURLEncoding.EncodeToString(hash[:])
+ a.mu.Lock()
+ p := a.find(in.ID)
+ if p == nil || subtle.ConstantTimeCompare([]byte(p.challenge), []byte(challenge)) != 1 {
+ a.mu.Unlock()
+ http.NotFound(w, r)
+ return
+ }
+ if p.cookie == "" {
+ a.mu.Unlock()
+ w.WriteHeader(202)
+ return
+ }
+ cookie := p.cookie
+ delete(a.pending, in.ID)
+ a.mu.Unlock()
+ // Authenticate the approved cookie from scratch. The outer router may have
+ // attached a different browser session to the exchange request's context.
+ ctx, cancel := context.WithCancel(context.Background())
+ stop := context.AfterFunc(r.Context(), cancel)
+ defer func() { stop(); cancel() }()
+ check := r.Clone(ctx)
+ check.Header = make(http.Header)
+ check.AddCookie(&http.Cookie{Name: a.sessions.CookieName(), Value: cookie})
+ allowed := false
+ a.require(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { allowed = true })).ServeHTTP(discardResponse{}, check)
+ session, ok := a.sessions.From(check)
+ if !allowed || !ok {
+ http.Error(w, "Sign in again", 401)
+ return
+ }
+ value, err := a.sessions.Mint(session)
+ if err != nil {
+ http.Error(w, "Cannot sign in", 500)
+ return
+ }
+ jsonReply(w, map[string]string{"name": a.sessions.CookieName(), "value": value})
+}
+
+type discardResponse struct{}
+
+func (discardResponse) Header() http.Header { return make(http.Header) }
+func (discardResponse) Write(b []byte) (int, error) { return len(b), nil }
+func (discardResponse) WriteHeader(int) {}
+
+var approval = template.Must(template.New("approval").Parse(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Sign in to {{.Name}}</title>{{if .Callback}}<meta http-equiv="refresh" content="0;url={{.Callback}}">{{end}}<style>:root{color-scheme:light dark}body{font:17px/1.5 system-ui,sans-serif;margin:0;background:light-dark(#f5f4f8,#19181d);color:light-dark(#24212c,#f7f4fa)}main{max-width:28rem;margin:12vh auto;padding:2rem;border-radius:1.5rem;background:light-dark(white,#28252e);box-shadow:0 8px 40px #0001}h1{font-size:1.7rem;line-height:1.2}h2{font:600 2rem ui-monospace,monospace;letter-spacing:.1em}button{font:600 1rem system-ui;border:0;border-radius:.8rem;padding:.9rem 1.2rem;background:#7052a2;color:white;cursor:pointer}button:focus-visible{outline:3px solid #ad8ddc;outline-offset:4px}@media(max-width:36rem){main{margin:2rem 1rem}}</style><body><main><h1>{{if .Done}}{{.Name}} is signed in{{else}}Sign in to {{.Name}}{{end}}</h1>{{if .Done}}{{if .Callback}}<p><a href="{{.Callback}}">Return to {{.Name}}</a></p>{{else}}<p>Return to the app to continue.</p>{{end}}{{else}}<p>Check that the app shows this same code before continuing.</p><h2>{{.Code}}</h2><form method="post" action="/native/authorize?id={{.ID}}"><button>Sign in to {{.Name}} app</button></form>{{end}}</main></body></html>`))
+
+// A fixed app-specific callback prevents this endpoint becoming an open redirect.
+// The callback only wakes the app: session exchange still requires its verifier.
+func (a *Auth) callbackURL() string {
+ switch a.name {
+ case "Docs", "Sheets", "Calendar", "Memoria", "Meet":
+ return "works.oficina." + strings.ToLower(a.name) + "://signin"
+ default:
+ return ""
+ }
+}
diff --git a/server/auth_test.go b/server/auth_test.go
new file mode 100644
index 0000000..55f2bcd
--- /dev/null
+++ b/server/auth_test.go
@@ -0,0 +1,123 @@
+package nativeauth
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/carlosframework/rastrillo/sessions"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Negative cases independently selected by native_review on 2026-09-12.
+// Mutation-checked that day: removing proof, expiry, consume, approval admission,
+// exchange admission or fresh-context handling each fails; all reverted.
+func TestProofApprovalAndRevocation(t *testing.T) {
+ d, err := db.Open(filepath.Join(t.TempDir(), "sessions.db"), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.Close()
+ if _, err = migrate.Apply(t.Context(), d, sessions.Schema); err != nil {
+ t.Fatal(err)
+ }
+ s, err := sessions.New(sessions.Config{DB: d.Writer(), Origin: "https://app.test"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ admitted := map[string]bool{"alice": true, "bob": true}
+ require := func(next http.Handler) http.Handler {
+ return s.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ v, _ := sessions.Current(r)
+ if !admitted[v.Subject] {
+ http.Error(w, "denied", 403)
+ return
+ }
+ next.ServeHTTP(w, r)
+ }))
+ }
+ a := New(s, "https://app.test", "Test", require)
+ alice, _ := s.Mint(sessions.Session{Subject: "alice"})
+ bob, _ := s.Mint(sessions.Session{Subject: "bob"})
+ drive := func(method, path, body, cookie, origin string) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(method, "https://app.test"+path, strings.NewReader(body))
+ r.Header.Set("Content-Type", "application/json")
+ r.Header.Set("Origin", origin)
+ if cookie != "" {
+ r.AddCookie(&http.Cookie{Name: s.CookieName(), Value: cookie})
+ }
+ w := httptest.NewRecorder()
+ s.Middleware(a).ServeHTTP(w, r)
+ return w
+ }
+ begin := func() (string, string) {
+ v := token()
+ hash := sha256.Sum256([]byte(v))
+ w := drive("POST", "/native/request", `{"challenge":"`+base64.RawURLEncoding.EncodeToString(hash[:])+`"}`, "", "https://app.test")
+ if w.Code != 200 {
+ t.Fatalf("begin: %d", w.Code)
+ }
+ var out map[string]string
+ json.Unmarshal(w.Body.Bytes(), &out)
+ return out["id"], v
+ }
+ exchange := func(id, v, cookie string) *httptest.ResponseRecorder {
+ return drive("POST", "/native/exchange", `{"id":"`+id+`","verifier":"`+v+`"}`, cookie, "https://app.test")
+ }
+ approve := func(id, cookie string) *httptest.ResponseRecorder {
+ return drive("POST", "/native/authorize?id="+id, "", cookie, "https://app.test")
+ }
+ id, v := begin()
+ if w := exchange(id, v, ""); w.Code != 202 {
+ t.Fatalf("pending %d", w.Code)
+ }
+ if w := drive("POST", "/native/authorize?id="+id, "", alice, "https://evil.test"); w.Code < 400 {
+ t.Fatal("cross-origin approval accepted")
+ }
+ if w := approve(id, alice); w.Code != 200 {
+ t.Fatalf("approve %d", w.Code)
+ }
+ if w := exchange(id, token(), ""); w.Code != 404 {
+ t.Fatal("wrong proof accepted")
+ }
+ w := exchange(id, v, "")
+ if w.Code != 200 {
+ t.Fatalf("exchange %d", w.Code)
+ }
+ var out map[string]string
+ json.Unmarshal(w.Body.Bytes(), &out)
+ r := httptest.NewRequest("GET", "https://app.test/", nil)
+ r.AddCookie(&http.Cookie{Name: out["name"], Value: out["value"]})
+ got, ok := s.From(r)
+ if !ok || got.Subject != "alice" || out["value"] == alice {
+ t.Fatal("exchange did not mint separate approved identity")
+ }
+ if exchange(id, v, "").Code != 404 {
+ t.Fatal("replay accepted")
+ }
+ id, v = begin()
+ a.pending[id].expires = time.Now().Add(-time.Second)
+ if exchange(id, v, "").Code != 404 {
+ t.Fatal("expired proof accepted")
+ }
+ id, v = begin()
+ if approve(id, alice).Code != 200 {
+ t.Fatal("approval failed")
+ }
+ admitted["alice"] = false
+ // An authenticated exchange caller must never substitute its admission for
+ // the identity approved in the browser (the review's concrete regression).
+ if exchange(id, v, bob).Code != 401 {
+ t.Fatal("revoked approval accepted through another session's context")
+ }
+ id, _ = begin()
+ if approve(id, alice).Code != 403 {
+ t.Fatal("removed member could approve")
+ }
+}
diff --git a/server/callback_test.go b/server/callback_test.go
new file mode 100644
index 0000000..48bf3f8
--- /dev/null
+++ b/server/callback_test.go
@@ -0,0 +1,67 @@
+package nativeauth
+
+import (
+ "encoding/json"
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/carlosframework/rastrillo/sessions"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestCallbackIsFixedAndContainsNoSession(t *testing.T) {
+ d, err := db.Open(filepath.Join(t.TempDir(), "sessions.db"), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.Close()
+ if _, err = migrate.Apply(t.Context(), d, sessions.Schema); err != nil {
+ t.Fatal(err)
+ }
+ s, err := sessions.New(sessions.Config{DB: d.Writer(), Origin: "https://app.test"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ a := New(s, "https://app.test", "Docs", s.Require)
+ for _, callback := range []string{"https://evil.test", "works.oficina.sheets://signin", "works.oficina.docs://signin?state=evil", "works.oficina.docs://signin/", "javascript:alert(1)"} {
+ data, _ := json.Marshal(map[string]string{"challenge": token(), "callback": callback})
+ req := httptest.NewRequest("POST", "https://app.test/native/request", strings.NewReader(string(data)))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Origin", "https://app.test")
+ w := httptest.NewRecorder()
+ a.ServeHTTP(w, req)
+ if w.Code != 400 {
+ t.Fatalf("callback %q status%d", callback, w.Code)
+ }
+ }
+ for _, callback := range []string{"", "works.oficina.docs://signin"} {
+ id := token()
+ a.pending[id] = &request{challenge: token(), callback: callback, expires: time.Now().Add(time.Minute)}
+ cookie, err := s.Mint(sessions.Session{Subject: "alice"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest("POST", "https://app.test/native/authorize?id="+id, nil)
+ req.Header.Set("Origin", "https://app.test")
+ req.AddCookie(&http.Cookie{Name: s.CookieName(), Value: cookie})
+ w := httptest.NewRecorder()
+ s.Middleware(a).ServeHTTP(w, req)
+ if w.Code != 200 {
+ t.Fatal(w.Code)
+ }
+ body := w.Body.String()
+ if strings.Contains(body, cookie) || strings.Contains(body, "#ZgotmplZ") {
+ t.Fatal("unsafe or broken callback")
+ }
+ if callback != "" && !strings.Contains(body, `href="works.oficina.docs://signin?state=`+id+`"`) {
+ t.Fatal("missing bound return link")
+ }
+ if callback == "" && strings.Contains(body, `http-equiv="refresh"`) {
+ t.Fatal("legacy flow redirected")
+ }
+ }
+}
diff --git a/server/go.mod b/server/go.mod
new file mode 100644
index 0000000..1a7ae7e
--- /dev/null
+++ b/server/go.mod
@@ -0,0 +1,23 @@
+module amadan.net/rastrillo/native/server
+
+go 1.25.0
+
+require github.com/carlosframework/rastrillo v0.23.0
+
+require (
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.20.0 // indirect
+ gorm.io/gorm v1.31.2 // indirect
+ gorm.io/plugin/dbresolver v1.6.2 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.55.0 // indirect
+)
diff --git a/server/go.sum b/server/go.sum
new file mode 100644
index 0000000..6715d56
--- /dev/null
+++ b/server/go.sum
@@ -0,0 +1,73 @@
+github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
+github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q=
+github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
+gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
+gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/tools/bundle-mac.py b/tools/bundle-mac.py
new file mode 100644
index 0000000..1cf757c
--- /dev/null
+++ b/tools/bundle-mac.py
@@ -0,0 +1,54 @@
+"""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"), "--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"), "--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")
+for resource in products.glob("*.bundle"):
+ target = bundle / "Resources" / resource.name
+ if target.exists():
+ shutil.rmtree(target)
+ shutil.copytree(resource, target)
+shutil.copy2(root / "LICENSE.lucide", bundle / "Resources/LICENSE.lucide")
+icon = root / "build/MacIcon.png"
+subprocess.run(["swift", str(Path(__file__).with_name("mac-icon.swift")), str(root / "ios/Assets.xcassets/AppIcon.appiconset/AppIcon.png"), str(icon)], check=True)
+iconset = root / "build/AppIcon.iconset"
+iconset.mkdir(exist_ok=True)
+for size in [16, 32, 128, 256, 512]:
+ for scale in [1, 2]:
+ filename = f"icon_{size}x{size}" + ("@2x" if scale == 2 else "") + ".png"
+ subprocess.run(["sips", "-z", str(size * scale), str(size * scale), str(icon), "--out", str(iconset / filename)], check=True, stdout=subprocess.DEVNULL)
+subprocess.run(["iconutil", "-c", "icns", str(iconset), "-o", str(bundle / "Resources/AppIcon.icns")], check=True)
+(bundle / "Info.plist").write_bytes(plistlib.dumps({
+ "CFBundleName": f"Oficina {name}", "CFBundleDisplayName": f"Oficina {name}",
+ "CFBundleIdentifier": f"net.amadan.oficina.{name.lower()}",
+ "CFBundleExecutable": f"{name}Mac", "CFBundlePackageType": "APPL",
+ "CFBundleIconFile": "AppIcon.icns", "CFBundleShortVersionString": "0.1.0",
+ "CFBundleVersion": "1", "LSMinimumSystemVersion": minimum_os,
+ "NSHighResolutionCapable": 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-icon.swift b/tools/mac-icon.swift
new file mode 100644
index 0000000..27ef20f
--- /dev/null
+++ b/tools/mac-icon.swift
@@ -0,0 +1,23 @@
+import AppKit
+
+// macOS displays the supplied silhouette; unlike iOS it does not mask an opaque master.
+// Keep the approved square master intact and derive a padded Mac icon from it.
+let sourceURL = URL(fileURLWithPath: CommandLine.arguments[1])
+let outputURL = URL(fileURLWithPath: CommandLine.arguments[2])
+guard let source = NSImage(contentsOf: sourceURL),
+ let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1024, pixelsHigh: 1024,
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
+ colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0),
+ let context = NSGraphicsContext(bitmapImageRep: bitmap)
+else { fatalError("Cannot load or render the app’s icon") }
+NSGraphicsContext.saveGraphicsState()
+NSGraphicsContext.current = context
+context.imageInterpolation = .high
+let tile = NSRect(x: 100, y: 100, width: 824, height: 824)
+NSBezierPath(roundedRect: tile, xRadius: 184, yRadius: 184).addClip()
+source.draw(in: tile, from: .zero, operation: .copy, fraction: 1)
+NSGraphicsContext.restoreGraphicsState()
+guard let png = bitmap.representation(using: .png, properties: [:]) else {
+ fatalError("Cannot encode the app’s icon")
+}
+try png.write(to: outputURL)
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
new file mode 100644
index 0000000..140fb09
--- /dev/null
+++ b/tools/tests/test_bundle_mac.py
@@ -0,0 +1,63 @@
+"""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
+import subprocess
+import tempfile
+import unittest
+import zlib
+
+
+class BundleMacTests(unittest.TestCase):
+ def test_relocated_resource_bundle_and_deployment_target(self):
+ tool = Path(__file__).resolve().parents[1] / "bundle-mac.py"
+ with tempfile.TemporaryDirectory(prefix="native-bundle-test-") as temporary:
+ root = Path(temporary)
+ source = root / "apple/Sources/Probe"
+ (source / "Resources").mkdir(parents=True)
+ (root / "apple/Package.swift").write_text('''// swift-tools-version: 5.9
+import PackageDescription
+let package = Package(name: "Probe", platforms: [.macOS("26.0")],
+ products: [.executable(name: "ProbeMac", targets: ["Probe"])],
+ dependencies: [.package(name: "native", 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) }
+print(text)
+''')
+ (source / "Resources/sentinel.txt").write_text("portable native resources")
+ (root / "LICENSE.lucide").write_text("Synthetic packaging test fixture")
+ icons = root / "ios/Assets.xcassets/AppIcon.appiconset"
+ icons.mkdir(parents=True)
+
+ def chunk(kind, payload):
+ return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload))
+
+ png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 1024, 1024, 8, 6, 0, 0, 0))
+ png += chunk(b"IDAT", zlib.compress((b"\0" + b"\x30\x70\xb0\xff" * 1024) * 1024)) + chunk(b"IEND", b"")
+ (icons / "AppIcon.png").write_bytes(png)
+ subprocess.run(["python3", str(tool), str(root), "Probe"], check=True, stdout=subprocess.DEVNULL)
+ app = root / "relocated/Probe.app"
+ app.parent.mkdir()
+ shutil.move(root / "build/Oficina Probe.app", app)
+ shutil.rmtree(root / "apple")
+ info = plistlib.loads((app / "Contents/Info.plist").read_bytes())
+ self.assertEqual(info["LSMinimumSystemVersion"], "26.0")
+ output = subprocess.check_output([str(app / "Contents/MacOS/ProbeMac")], text=True)
+ self.assertEqual(output.strip(), "portable native resources")
+ subprocess.run(["codesign", "--verify", "--strict", "--deep", str(app)], check=True)
+
+
+if __name__ == "__main__":
+ unittest.main()
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()