Merge the native Apple chain (33ffe99..2295259) into main
Every Oficina app pins a revision from this chain, and none of it was on main: main held only CoalescedRunner. Apps were split across four pins (33ffe99, a49307c, 4c61d62, 2295259) with no shared line to move to. The chain, oldest first: - 33ffe99 shared Apple browser sign-in and development app bundling - 6ddea4d bounded native editor streams and document responses - a49307c private file exports and portable Mac resource bundles - 4c61d62 shared Mac Sparkle updates and release tooling - cf3ea46 remembered accounts and system-browser sign-in return - 2295259 refresh saved accounts before per-window account changes Merged as-is; no conflicts with main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
50 files changed,
+2968
−3
.gitignore+1 −0ACCOUNTS-SIGNIN.md+30 −0APPLE.md+29 −0Makefile+7 −2Package.resolved+14 −0Package.swift+9 −1README.md+32 −0Sources/RastrilloApple/NativeAccount.swift+10 −0Sources/RastrilloApple/NativeAccountsView.swift+55 −0Sources/RastrilloApple/NativeBrowserSignIn.swift+46 −0Sources/RastrilloApple/NativeConnection.swift+202 −0Sources/RastrilloApple/NativeCredentials.swift+32 −0Sources/RastrilloApple/NativeHTTP.swift+148 −0Sources/RastrilloApple/NativeResponse.swift+9 −0Sources/RastrilloApple/NativeSignInView.swift+53 −0Sources/RastrilloApple/NativeStreamLines.swift+42 −0Sources/RastrilloMacUpdates/NativeUpdateCommands.swift+21 −0Sources/RastrilloMacUpdates/NativeUpdater.swift+53 −0Sources/RastrilloMacUpdates/Resources/ar.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/bn.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/en.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/es.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/ga.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/hi.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/ja.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/pt.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/ru.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/vi.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/yue.lproj/Localizable.strings+2 −0Sources/RastrilloMacUpdates/Resources/zh-Hans.lproj/Localizable.strings+2 −0Tests/RastrilloAppleTests/HTTPStreamCancellationTests.swift+177 −0Tests/RastrilloAppleTests/HTTPTests.swift+127 −0Tests/RastrilloAppleTests/NativeAccountsTests.swift+46 −0Tests/RastrilloAppleTests/OriginTests.swift+18 −0Tests/RastrilloAppleTests/StreamLinesTests.swift+36 −0Tests/RastrilloMacUpdatesTests/ConfigurationTests.swift+25 −0docs/mac-updates.md+92 −0server/LICENSE+374 −0server/auth.go+259 −0server/auth_test.go+123 −0server/callback_test.go+67 −0server/go.mod+23 −0server/go.sum+73 −0tools/bundle-mac.py+54 −0tools/mac-icon.swift+23 −0tools/mac_updates.py+119 −0tools/publish_mac_update.py+164 −0tools/release_mac.py+87 −0tools/tests/test_bundle_mac.py+63 −0tools/tests/test_mac_updates.py+201 −0
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() |