Merge branch 'feat/k3-outbox' into main
Merge K3: NativeSealedStore, KeyProvider, Outbox, OutboxDrainer, SyncStatus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
41 files changed,
+2816
−11
Package.swift+4 −4Sources/RastrilloApple/NativeOfflineBanner.swift+15 −7Sources/RastrilloApple/NativeSealedStore.swift+277 −0Sources/RastrilloApple/NativeSyncStatusView.swift+43 −0Sources/RastrilloApple/NativeSyncStrings.swift+63 −0Sources/RastrilloApple/OutboxDrainer.swift+246 −0Sources/RastrilloApple/Resources/ar.lproj/Localizable.strings+11 −0Sources/RastrilloApple/Resources/ar.lproj/Localizable.stringsdict+34 −0Sources/RastrilloApple/Resources/bn.lproj/Localizable.strings+11 −0Sources/RastrilloApple/Resources/bn.lproj/Localizable.stringsdict+34 −0Sources/RastrilloApple/Resources/en.lproj/Localizable.strings+11 −0Sources/RastrilloApple/Resources/en.lproj/Localizable.stringsdict+34 −0Sources/RastrilloApple/Resources/es.lproj/Localizable.strings+11 −0Sources/RastrilloApple/Resources/es.lproj/Localizable.stringsdict+38 −0Sources/RastrilloApple/Resources/ga.lproj/Localizable.strings+11 −0Sources/RastrilloApple/Resources/ga.lproj/Localizable.stringsdict+34 −0Sources/RastrilloApple/Resources/hi.lproj/Localizable.strings+11 −0Sources/RastrilloApple/Resources/hi.lproj/Localizable.stringsdict+34 −0Sources/RastrilloApple/Resources/ja.lproj/Localizable.strings+11 −0Sources/RastrilloApple/Resources/ja.lproj/Localizable.stringsdict+34 −0Sources/RastrilloApple/Resources/pt.lproj/Localizable.strings+11 −0Sources/RastrilloApple/Resources/pt.lproj/Localizable.stringsdict+38 −0Sources/RastrilloApple/Resources/ru.lproj/Localizable.strings+11 −0Sources/RastrilloApple/Resources/ru.lproj/Localizable.stringsdict+40 −0Sources/RastrilloApple/Resources/vi.lproj/Localizable.strings+11 −0Sources/RastrilloApple/Resources/vi.lproj/Localizable.stringsdict+34 −0Sources/RastrilloApple/Resources/yue.lproj/Localizable.strings+11 −0Sources/RastrilloApple/Resources/yue.lproj/Localizable.stringsdict+34 −0Sources/RastrilloApple/Resources/zh-Hans.lproj/Localizable.strings+11 −0Sources/RastrilloApple/Resources/zh-Hans.lproj/Localizable.stringsdict+34 −0Sources/RastrilloNative/BlobStore.swift+38 −0Sources/RastrilloNative/Outbox.swift+260 −0Sources/RastrilloNative/SyncStatus.swift+59 −0Sources/RastrilloTestSupport/FaultingBlobStore.swift+98 −0Tests/RastrilloAppleTests/OutboxDrainerTests.swift+353 −0Tests/RastrilloAppleTests/SealedStoreTests.swift+223 −0Tests/RastrilloAppleTests/SyncStringsTests.swift+63 −0Tests/RastrilloNativeTests/OutboxTests.swift+310 −0Tests/RastrilloNativeTests/SyncStatusTests.swift+71 −0Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift+48 −0docs/testing/k3-evidence.md+91 −0
CI failed — run details
diff --git a/Package.swift b/Package.swift| index 974a178..4be7b0e 100644 |
| --- a/Package.swift |
| +++ b/Package.swift |
| @@ -12,12 +12,12 @@ let package = Package( |
| dependencies: [.package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.9.6")], |
| targets: [ |
| .target(name: "RastrilloNative"), |
| - .target(name: "RastrilloApple", dependencies: ["RastrilloNative"]), |
| - .target(name: "RastrilloTestSupport", dependencies: ["RastrilloApple"]), |
| + .target(name: "RastrilloApple", dependencies: ["RastrilloNative"], resources: [.process("Resources")]), |
| + .target(name: "RastrilloTestSupport", dependencies: ["RastrilloApple", "RastrilloNative"]), |
| .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", "RastrilloNative"]), |
| - .testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative"]), |
| + .testTarget(name: "RastrilloAppleTests", dependencies: ["RastrilloApple", "RastrilloNative", "RastrilloTestSupport"]), |
| + .testTarget(name: "RastrilloNativeTests", dependencies: ["RastrilloNative", "RastrilloTestSupport"]), |
| .testTarget(name: "RastrilloTestSupportTests", dependencies: ["RastrilloTestSupport", "RastrilloApple"]), |
| ] |
| ) |
diff --git a/Sources/RastrilloApple/NativeOfflineBanner.swift b/Sources/RastrilloApple/NativeOfflineBanner.swift| index e4bb586..080c4ea 100644 |
| --- a/Sources/RastrilloApple/NativeOfflineBanner.swift |
| +++ b/Sources/RastrilloApple/NativeOfflineBanner.swift |
| @@ -1,17 +1,25 @@ |
| +import RastrilloNative |
| import SwiftUI |
| -/// A thin inset bar shown while `origin` is offline. It says only what is |
| -/// true without a durable outbox: pass the durable wording ("Changes are saved |
| -/// on this device…") once the app's edits survive a relaunch. Entering the |
| -/// offline state is announced to assistive technologies; leaving it is not. |
| +/// A thin inset bar shown while `origin` is offline. Entering the offline |
| +/// state is announced to assistive technologies; leaving it is not. |
| +/// |
| +/// Pass `summary` once the app's edits are kept in an `Outbox`: the banner |
| +/// then says they are saved on this device, which is only true from then on. |
| public struct NativeOfflineBanner: View { |
| private let connectivity: NativeConnectivity |
| private let origin: URL |
| private let message: String |
| - @MainActor public init(connectivity: NativeConnectivity? = nil, origin: URL, |
| - message: String = "You’re offline. Waiting for a connection.") { |
| - self.connectivity = connectivity ?? .shared; self.origin = origin; self.message = message |
| + @MainActor public init(connectivity: NativeConnectivity? = nil, origin: URL, message: String? = nil) { |
| + self.connectivity = connectivity ?? .shared; self.origin = origin |
| + self.message = message ?? NativeSyncStrings.kit.offlineWaiting |
| + } |
| + |
| + /// The durable wording, for an app whose changes go through an outbox. |
| + @MainActor public init(connectivity: NativeConnectivity? = nil, origin: URL, summary: OutboxSummary, |
| + strings: NativeSyncStrings = .kit) { |
| + self.init(connectivity: connectivity, origin: origin, message: strings.offlineDurable) |
| } |
| public var body: some View { |
diff --git a/Sources/RastrilloApple/NativeSealedStore.swift b/Sources/RastrilloApple/NativeSealedStore.swift| new file mode 100644 |
| index 0000000..0b98ec4 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeSealedStore.swift |
| @@ -0,0 +1,277 @@ |
| +import CryptoKit |
| +import Foundation |
| +import RastrilloNative |
| +import Security |
| + |
| +/// A single Keychain item makes key rotation crash recoverable: retain the old |
| +/// key until every file has been atomically resealed for its original path. |
| +public struct StoreKeyState: Codable, Sendable { |
| + public var current: Data |
| + public var previous: Data? |
| + public var seals: UInt64 |
| + public init(current: Data, previous: Data? = nil, seals: UInt64 = 0) { |
| + self.current = current; self.previous = previous; self.seals = seals |
| + } |
| +} |
| + |
| +/// Where an account's store key lives. `KeychainKeyProvider` is the real one; |
| +/// tests script every Keychain status with `ScriptedKeyProvider`. |
| +/// |
| +/// It answers in raw Keychain statuses on purpose: telling *unavailable* |
| +/// from *lost* is the store's job, and it must see the status to do it. |
| +public protocol KeyProvider: Sendable { |
| + /// `errSecSuccess` with the key, or the status the Keychain gave. |
| + func copyKey() -> (OSStatus, SymmetricKey?) |
| + func addKey(_ key: SymmetricKey) -> OSStatus |
| + func deleteKey() -> OSStatus |
| + func sealState() -> (OSStatus, StoreKeyState?) |
| + func saveSealState(_ state: StoreKeyState) -> OSStatus |
| +} |
| + |
| +/// One AES-256 key per account, a generic password under service |
| +/// `<bundle id>.store` and account `store:<account id>|<origin>`, readable |
| +/// only while the device is unlocked, on this device, and never synchronised. |
| +public struct KeychainKeyProvider: KeyProvider { |
| + public let service: String |
| + public let account: String |
| + |
| + public init(accountID: String, origin: URL, bundleID: String = Bundle.main.bundleIdentifier ?? "oficina.native") { |
| + service = bundleID + ".store" |
| + account = "store:\(accountID)|\(origin.absoluteString)" |
| + } |
| + |
| + private var query: [String: Any] { |
| + [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, |
| + kSecAttrAccount as String: account, kSecAttrSynchronizable as String: false] |
| + } |
| + |
| + public func copyKey() -> (OSStatus, SymmetricKey?) { |
| + var q = query |
| + q[kSecReturnData as String] = true |
| + q[kSecMatchLimit as String] = kSecMatchLimitOne |
| + var result: CFTypeRef? |
| + let status = SecItemCopyMatching(q as CFDictionary, &result) |
| + guard status == errSecSuccess else { return (status, nil) } |
| + guard let data = result as? Data else { return (errSecDecode, nil) } |
| + if data.count == 32 { return (status, SymmetricKey(data: data)) } |
| + guard let state = try? JSONDecoder().decode(StoreKeyState.self, from: data), state.current.count == 32 else { return (errSecDecode, nil) } |
| + return (status, SymmetricKey(data: state.current)) |
| + } |
| + |
| + public func addKey(_ key: SymmetricKey) -> OSStatus { |
| + var q = query |
| + q[kSecValueData as String] = key.withUnsafeBytes { Data($0) } |
| + q[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly |
| + return SecItemAdd(q as CFDictionary, nil) |
| + } |
| + |
| + public func sealState() -> (OSStatus, StoreKeyState?) { |
| + var q = query; q[kSecReturnData as String] = true; q[kSecMatchLimit as String] = kSecMatchLimitOne |
| + var result: CFTypeRef? |
| + let status = SecItemCopyMatching(q as CFDictionary, &result) |
| + guard status == errSecSuccess, let data = result as? Data else { return (status, nil) } |
| + if data.count == 32 { return (status, StoreKeyState(current: data)) } |
| + guard let state = try? JSONDecoder().decode(StoreKeyState.self, from: data), state.current.count == 32, |
| + state.previous == nil || state.previous?.count == 32 else { return (errSecDecode, nil) } |
| + return (status, state) |
| + } |
| + public func saveSealState(_ state: StoreKeyState) -> OSStatus { |
| + guard let bytes = try? JSONEncoder().encode(state) else { return errSecParam } |
| + return SecItemUpdate(query as CFDictionary, [kSecValueData as String: bytes] as CFDictionary) |
| + } |
| + |
| + public func deleteKey() -> OSStatus { SecItemDelete(query as CFDictionary) } |
| +} |
| + |
| +/// Atomic authenticated files, bound to their final relative paths. A lock |
| +/// covers key creation and disk mutations; no plaintext is written to disk. |
| +public final class NativeSealedStore: BlobStore, @unchecked Sendable { |
| + public static let formatVersion = 1 |
| + public let root: URL |
| + public let accountID: String |
| + private let keys: KeyProvider |
| + private let lock = NSLock() |
| + private let fm = FileManager.default |
| + // One store owns an account directory. Cached material remains protected on |
| + // disk by completeFileProtection; cold opens still distinguish locked/lost. |
| + private var cachedState: StoreKeyState? |
| + private var cachedKey: SymmetricKey? |
| + private var reservedUntil: UInt64 = 0 |
| + private var backupExcluded = false |
| + var rotationLimit: UInt64 = 1 << 31 |
| + public init(root: URL, accountID: String, keys: KeyProvider) { self.root = root; self.accountID = accountID; self.keys = keys } |
| + static let writeOptions: Data.WritingOptions = [.atomic, .completeFileProtection] |
| + private func url(_ path: String, allowRoot: Bool = false) throws -> URL { |
| + guard (allowRoot || !path.isEmpty), !path.hasPrefix("/"), !path.contains("\0"), |
| + !path.split(separator: "/", omittingEmptySubsequences: false).contains(where: { $0 == ".." || $0 == "." }) else { throw BlobStoreError.unavailable } |
| + let result = root.appendingPathComponent(path) |
| + // Reject symlinks too: a relative spelling alone does not contain I/O. |
| + let resolvedRoot = root.resolvingSymlinksInPath().standardizedFileURL.path |
| + let resolved = result.resolvingSymlinksInPath().standardizedFileURL.path |
| + guard resolved == resolvedRoot || resolved.hasPrefix(resolvedRoot + "/") else { throw BlobStoreError.unavailable } |
| + return result |
| + } |
| + private func missing(_ error: Error) -> Bool { |
| + let error = error as NSError |
| + return error.domain == NSCocoaErrorDomain && [NSFileReadNoSuchFileError, NSFileNoSuchFileError].contains(error.code) |
| + } |
| + private func io<T>(_ body: () throws -> T) throws -> T { |
| + do { return try body() } catch { |
| + // A failed write may have committed its reservation. Abandon its entire |
| + // unused tail before retrying, including interrupted key rotation. |
| + cachedState = nil; cachedKey = nil; reservedUntil = 0; backupExcluded = false |
| + throw (error as? BlobStoreError) ?? .unavailable |
| + } |
| + } |
| + private func hasFiles(_ directory: URL) throws -> Bool { |
| + let children: [URL] |
| + do { children = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) } |
| + catch { if missing(error) { return false }; throw error } |
| + for child in children { |
| + if try child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true { |
| + if try hasFiles(child) { return true } |
| + } else { return true } |
| + } |
| + return false |
| + } |
| + private func key(create: Bool) throws -> SymmetricKey? { |
| + if let cachedKey { return cachedKey } |
| + let (status, key) = keys.copyKey() |
| + if status == errSecSuccess, let key { return key } |
| + guard status == errSecItemNotFound else { throw BlobStoreError.unavailable } |
| + if try hasFiles(root) { |
| + try fm.removeItem(at: root) |
| + throw BlobStoreError.keyLost |
| + } |
| + guard create else { return nil } |
| + let new = SymmetricKey(size: .bits256) |
| + let added = keys.addKey(new) |
| + if added == errSecSuccess { return new } |
| + if added == errSecDuplicateItem { |
| + let (status, existing) = keys.copyKey() |
| + if status == errSecSuccess, let existing { return existing } |
| + } |
| + throw BlobStoreError.unavailable |
| + } |
| + private func aad(_ path: String) -> Data { Data("\(accountID)|\(Self.formatVersion)|\(path)".utf8) } |
| + private func readKeyState() throws -> StoreKeyState { |
| + if let cachedState { return cachedState } |
| + let (status, state) = keys.sealState() |
| + guard status == errSecSuccess, let state else { throw BlobStoreError.unavailable } |
| + cachedState = state; cachedKey = SymmetricKey(data: state.current) |
| + reservedUntil = state.seals |
| + return state |
| + } |
| + private func saveKeyState(_ state: StoreKeyState) throws { |
| + var durable = state |
| + durable.seals = max(state.seals, reservedUntil) |
| + guard keys.saveSealState(durable) == errSecSuccess else { throw BlobStoreError.unavailable } |
| + cachedState = state; cachedKey = SymmetricKey(data: state.current) |
| + } |
| + private func reserveSeal(_ state: inout StoreKeyState) throws { |
| + guard state.seals < rotationLimit else { throw BlobStoreError.unavailable } |
| + if state.seals == reservedUntil { |
| + // Persist a high-water mark before using any slot. Relaunch starts at |
| + // that mark, sacrificing the unused tail rather than reusing a quota. |
| + reservedUntil = state.seals + min(1024, rotationLimit - state.seals) |
| + try saveKeyState(state) |
| + } |
| + state.seals += 1 |
| + cachedState = state |
| + } |
| + private func open(_ data: Data, path: String, state: StoreKeyState) throws -> Data { |
| + let box = try AES.GCM.SealedBox(combined: data) |
| + do { return try AES.GCM.open(box, using: SymmetricKey(data: state.current), authenticating: aad(path)) } |
| + catch { |
| + guard let previous = state.previous else { throw error } |
| + return try AES.GCM.open(box, using: SymmetricKey(data: previous), authenticating: aad(path)) |
| + } |
| + } |
| + private func files(_ directory: URL) throws -> [URL] { |
| + let children: [URL] |
| + do { children = try fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) } |
| + catch { if missing(error) { return [] }; throw error } |
| + return try children.flatMap { child in |
| + try child.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true ? files(child) : [child] |
| + } |
| + } |
| + private func rotateIfNeeded(_ state: inout StoreKeyState) throws { |
| + guard state.previous != nil || state.seals >= rotationLimit else { return } |
| + if state.previous == nil { |
| + state = StoreKeyState(current: SymmetricKey(size: .bits256).withUnsafeBytes { Data($0) }, previous: state.current) |
| + reservedUntil = 0 |
| + try saveKeyState(state) |
| + } |
| + for file in try files(root) { |
| + let path = String(file.resolvingSymlinksInPath().path.dropFirst(root.resolvingSymlinksInPath().path.count + 1)) |
| + let combined = try Data(contentsOf: file) |
| + if let box = try? AES.GCM.SealedBox(combined: combined), |
| + (try? AES.GCM.open(box, using: SymmetricKey(data: state.current), authenticating: aad(path))) != nil { continue } |
| + let plaintext: Data |
| + do { plaintext = try open(combined, path: path, state: state) } |
| + catch { try fm.removeItem(at: file); throw BlobStoreError.corrupt(path) } |
| + try reserveSeal(&state) |
| + let resealed = try AES.GCM.seal(plaintext, using: SymmetricKey(data: state.current), authenticating: aad(path)) |
| + try resealed.combined!.write(to: file, options: Self.writeOptions) |
| + } |
| + state.previous = nil |
| + try saveKeyState(state) |
| + } |
| + public func write(_ data: Data, to path: String) throws { |
| + try lock.withLock { try io { |
| + let target = try url(path) |
| + _ = try key(create: true) |
| + var state = try readKeyState() |
| + try rotateIfNeeded(&state) |
| + try reserveSeal(&state) |
| + let key = cachedKey! |
| + try fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true) |
| + if !backupExcluded { |
| + var directory = root; var values = URLResourceValues(); values.isExcludedFromBackup = true |
| + try directory.setResourceValues(values); backupExcluded = true |
| + } |
| + let sealed = try AES.GCM.seal(data, using: key, authenticating: aad(path)) |
| + guard let combined = sealed.combined else { throw BlobStoreError.unavailable } |
| + try combined.write(to: target, options: Self.writeOptions) |
| + } } |
| + } |
| + public func read(_ path: String) throws -> Data? { |
| + try lock.withLock { try io { |
| + let target = try url(path) |
| + let data: Data |
| + do { data = try Data(contentsOf: target) } catch { if missing(error) { return nil }; throw error } |
| + guard try key(create: false) != nil else { throw BlobStoreError.unavailable } |
| + let state = try readKeyState() |
| + do { return try open(data, path: path, state: state) } |
| + catch { |
| + // Only an authentication/decode failure reaches this catch. I/O or |
| + // Keychain refusal must never turn into a destructive corruption path. |
| + try fm.removeItem(at: target) |
| + throw BlobStoreError.corrupt(path) |
| + } |
| + } } |
| + } |
| + public func list(_ directory: String) throws -> [String] { |
| + try lock.withLock { try io { |
| + let target = try url(directory, allowRoot: true) |
| + do { |
| + return try fm.contentsOfDirectory(at: target, includingPropertiesForKeys: [.isRegularFileKey]) |
| + .filter { try $0.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile == true }.map(\.lastPathComponent).sorted() |
| + } catch { if missing(error) { return [] }; throw error } |
| + } } |
| + } |
| + public func remove(_ path: String) throws { |
| + try lock.withLock { try io { |
| + let target = try url(path) |
| + do { try fm.removeItem(at: target) } catch { if !missing(error) { throw error } } |
| + } } |
| + } |
| + public func wipe() throws { |
| + try lock.withLock { try io { |
| + let status = keys.deleteKey() |
| + guard status == errSecSuccess || status == errSecItemNotFound else { throw BlobStoreError.unavailable } |
| + cachedState = nil; cachedKey = nil; reservedUntil = 0; backupExcluded = false |
| + do { try fm.removeItem(at: root) } catch { if !missing(error) { throw error } } |
| + } } |
| + } |
| +} |
diff --git a/Sources/RastrilloApple/NativeSyncStatusView.swift b/Sources/RastrilloApple/NativeSyncStatusView.swift| new file mode 100644 |
| index 0000000..4c47f71 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeSyncStatusView.swift |
| @@ -0,0 +1,43 @@ |
| +import RastrilloNative |
| +import SwiftUI |
| + |
| +/// A compact save status for a toolbar or title bar. Silent when saved, but |
| +/// the accessibility element stays present while silent, so the first |
| +/// failure is announced from a region that already exists (estilo |
| +/// savestatus). Entering offline, needs-sign-in and failed is announced; a |
| +/// saving flicker never is. |
| +public struct NativeSyncStatusView: View { |
| + private let status: SyncStatus |
| + private let strings: NativeSyncStrings |
| + |
| + public init(status: SyncStatus, strings: NativeSyncStrings = .kit) { |
| + self.status = status |
| + self.strings = strings |
| + } |
| + |
| + public var body: some View { |
| + let text = strings.text(for: status) ?? "" |
| + Text(text) |
| + .font(.callout) |
| + .fontWeight(status.saveState == .failed ? .semibold : .regular) |
| + .foregroundStyle(status.saveState == .failed ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary)) |
| + .lineLimit(2) |
| + .frame(minWidth: 8) |
| + .accessibilityElement(children: .ignore) |
| + .accessibilityLabel(text) |
| + .accessibilityAddTraits(.updatesFrequently) |
| + .onChange(of: status) { old, new in |
| + guard new.isAnnounced, !Self.sameKind(old, new), let text = strings.text(for: new) else { return } |
| + AccessibilityNotification.Announcement(text).post() |
| + } |
| + } |
| + |
| + /// A count changing inside the same state is not news. |
| + static func sameKind(_ a: SyncStatus, _ b: SyncStatus) -> Bool { |
| + switch (a, b) { |
| + case (.offline, .offline), (.needsSignIn, .needsSignIn), (.retrying, .retrying): return true |
| + case (.failed(_, let x), .failed(_, let y)): return x == y |
| + default: return a == b |
| + } |
| + } |
| +} |
diff --git a/Sources/RastrilloApple/NativeSyncStrings.swift b/Sources/RastrilloApple/NativeSyncStrings.swift| new file mode 100644 |
| index 0000000..54e2855 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/NativeSyncStrings.swift |
| @@ -0,0 +1,63 @@ |
| +import Foundation |
| +import RastrilloNative |
| + |
| +/// The words the kit's sync views say. `.kit` reads the package's own |
| +/// catalog, in the same twelve languages as the apps; an app overrides any |
| +/// of them, for example to count cells instead of documents, by changing a |
| +/// copy. |
| +public struct NativeSyncStrings: Sendable { |
| + public var saving: String |
| + public var retrying: String |
| + /// "Offline · N pending". |
| + public var offline: @Sendable (Int) -> String |
| + /// "Sign in to send N pending". |
| + public var needsSignIn: @Sendable (Int) -> String |
| + public var signIn: String |
| + /// A held entry's words are the app's; this is the fallback. |
| + public var failed: @Sendable (SyncFailure) -> String |
| + /// The banner while the path or server is down and edits are not durable. |
| + public var offlineWaiting: String |
| + /// The banner once edits are kept on this device. |
| + public var offlineDurable: String |
| + /// Reported once after a lost key or corrupt entry removed unsent changes. |
| + public var unsentLost: String |
| + |
| + public init(saving: String, retrying: String, offline: @escaping @Sendable (Int) -> String, |
| + needsSignIn: @escaping @Sendable (Int) -> String, signIn: String, |
| + failed: @escaping @Sendable (SyncFailure) -> String, offlineWaiting: String, |
| + offlineDurable: String, unsentLost: String) { |
| + self.saving = saving; self.retrying = retrying; self.offline = offline; self.needsSignIn = needsSignIn |
| + self.signIn = signIn; self.failed = failed; self.offlineWaiting = offlineWaiting |
| + self.offlineDurable = offlineDurable; self.unsentLost = unsentLost |
| + } |
| + |
| + public static var kit: NativeSyncStrings { kit(localization: Bundle.module.preferredLocalizations.first ?? "en") } |
| + public static func kit(localization: String) -> NativeSyncStrings { |
| + let path = Bundle.module.path(forResource: localization, ofType: "lproj") |
| + ?? Bundle.module.path(forResource: "en", ofType: "lproj")! |
| + let bundle = Bundle(path: path)! |
| + @Sendable func text(_ key: String) -> String { bundle.localizedString(forKey: key, value: nil, table: nil) } |
| + @Sendable func plural(_ key: String, _ count: Int) -> String { |
| + String(format: text(key), locale: Locale(identifier: localization), arguments: [count]) |
| + } |
| + return NativeSyncStrings(saving: text("sync.saving"), retrying: text("sync.retrying"), |
| + offline: { plural("sync.offline", $0) }, needsSignIn: { plural("sync.needsSignIn", $0) }, signIn: text("sync.signIn"), |
| + failed: { reason in |
| + switch reason { |
| + case .cannotSaveOnDevice: return text("sync.failed.cannotSave") |
| + case .tooManyUnsent: return text("sync.failed.tooManyUnsent") |
| + case .held: return text("sync.failed.held") |
| + } |
| + }, offlineWaiting: text("offline.waiting"), offlineDurable: text("offline.durable"), unsentLost: text("sync.unsentLost")) |
| + } |
| + public func text(for status: SyncStatus) -> String? { |
| + switch status { |
| + case .saved: return nil |
| + case .saving: return saving |
| + case .retrying: return retrying |
| + case .offline(let count): return offline(count) |
| + case .needsSignIn(let count): return needsSignIn(count) |
| + case .failed(_, let reason): return failed(reason) |
| + } |
| + } |
| +} |
diff --git a/Sources/RastrilloApple/OutboxDrainer.swift b/Sources/RastrilloApple/OutboxDrainer.swift| new file mode 100644 |
| index 0000000..9dde528 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/OutboxDrainer.swift |
| @@ -0,0 +1,246 @@ |
| +import Foundation |
| +import Observation |
| +import RastrilloNative |
| + |
| +/// Sends one account's outbox. The app supplies `send`, which turns a batch |
| +/// of one lane into requests; the drainer decides when to call it, and what |
| +/// an error it throws means, so every app reacts the same way: |
| +/// |
| +/// | Thrown | Reaction | |
| +/// |---|---| |
| +/// | `.offline`, path down | stop; wait for a connectivity change, with no timer | |
| +/// | `.offline`, path up (a timeout, a lost response) | back off, retry the same batch | |
| +/// | `.unauthorized` | session ended: stop every lane; never hold or drop | |
| +/// | `.rateLimited(after)` | back off at least `after` | |
| +/// | `.server`, `.transport`, `.invalidResponse` | back off, retry the same batch | |
| +/// | `BlobStoreError.unavailable` | when thrown by send, retry this lane; hold after three consecutive throws | |
| +/// | `.forbidden`, `.notFound`, `.conflict`, `.tooLarge`, `.rejected` | hold the batch rather than spin | |
| +/// |
| +/// A failure to acknowledge or hold after a send is a `.retry`: the server |
| +/// may already have the batch, and each app's server-side idempotency makes |
| +/// sending it again harmless. |
| +/// |
| +/// At most one batch is in flight, across all lanes: the simplest correct |
| +/// ordering against one server. |
| +@MainActor @Observable public final class OutboxDrainer { |
| + public enum Disposition: Sendable, Equatable { |
| + case sent |
| + case retry |
| + case hold(Hold) |
| + } |
| + |
| + /// Where the drainer is. `SyncStatus` reads it. |
| + public enum Phase: Sendable, Equatable { |
| + case idle |
| + case sending |
| + case waitingForNetwork |
| + case backingOff |
| + case sessionEnded |
| + case storeUnavailable |
| + } |
| + |
| + /// What one `drain()` ended on. |
| + public enum Outcome: Sendable, Equatable { |
| + /// Nothing left that can be sent. |
| + case idle |
| + case waitingForNetwork |
| + /// Retry after this delay (a connectivity change may cut it short). |
| + case backingOff(TimeInterval) |
| + /// The server’s Retry-After: neither connectivity nor "Try again" crosses it. |
| + case rateLimited(TimeInterval) |
| + case sessionEnded |
| + case storeUnavailable |
| + } |
| + |
| + /// Returns a disposition per entry. An entry it leaves out was not sent |
| + /// this time (for example, a merge that had to fall back to the first |
| + /// entry): it goes back to sendable with no backoff. |
| + public typealias Send = @MainActor (_ lane: String, _ entries: [OutboxEntry], _ transport: NativeTransport) async throws -> [UUID: Disposition] |
| + |
| + public let outbox: Outbox |
| + public private(set) var phase: Phase = .idle |
| + public private(set) var summary = OutboxSummary() |
| + public private(set) var sessionEnded = false |
| + private let transport: @MainActor () -> NativeTransport? |
| + private let connectivity: NativeConnectivity |
| + private let send: Send |
| + private let maxBatchBytes: Int? |
| + private var backoff: Backoff |
| + @ObservationIgnored private var knownStatuses: [String: SyncStatus] = [:] |
| + private var consecutiveThrows: [String: Int] = [:] |
| + private var draining = false |
| + private var drainWaiters: [CheckedContinuation<Outcome, Never>] = [] |
| + private var worker: Task<Void, Never>? |
| + private var observation: Task<Void, Never>? |
| + private var networkObservation: Task<Void, Never>? |
| + private var timer: Task<Void, Never>? |
| + private var wake: AsyncStream<Void>.Continuation? |
| + private var retryDeadline: TimeInterval? |
| + private var serverDeadline: TimeInterval? |
| + private var now: TimeInterval { ProcessInfo.processInfo.systemUptime } |
| + |
| + public init(outbox: Outbox, transport: @escaping @MainActor () -> NativeTransport?, connectivity: NativeConnectivity, |
| + maxBatchBytes: Int? = nil, backoff: Backoff = Backoff(), send: @escaping Send) { |
| + self.outbox = outbox; self.transport = transport; self.connectivity = connectivity |
| + self.maxBatchBytes = maxBatchBytes; self.backoff = backoff; self.send = send |
| + } |
| + public func start() { |
| + guard worker == nil else { return } |
| + let (events, continuation) = AsyncStream<Void>.makeStream(bufferingPolicy: .bufferingNewest(1)) |
| + wake = continuation |
| + observation = Task { [weak self, outbox] in |
| + for await summary in await outbox.changes { |
| + guard let self else { return } |
| + if self.summary != summary { self.summary = summary; self.kick() } |
| + } |
| + } |
| + let changes = connectivity.changes |
| + networkObservation = Task { [weak self] in |
| + for await _ in changes { |
| + guard let self else { return } |
| + self.backoff.reset(); self.retryDeadline = nil; self.kick() |
| + } |
| + } |
| + worker = Task { [weak self] in |
| + for await _ in events { |
| + guard let self, !Task.isCancelled else { return } |
| + if !self.connectivity.isPathSatisfied { |
| + self.timer?.cancel(); self.timer = nil |
| + self.phase = .waitingForNetwork; continue |
| + } |
| + let deadline = max(self.retryDeadline ?? 0, self.serverDeadline ?? 0) |
| + if deadline > self.now { self.schedule(deadline - self.now); continue } |
| + switch await self.drain() { |
| + case .backingOff(let delay): self.retryDeadline = self.now + delay; self.schedule(delay) |
| + case .rateLimited(let delay): self.schedule(delay) |
| + default: break |
| + } |
| + } |
| + } |
| + kick() |
| + } |
| + private func schedule(_ delay: TimeInterval) { |
| + timer?.cancel() |
| + timer = Task { [weak self] in |
| + do { try await Task.sleep(for: .seconds(delay)) } catch { return } |
| + self?.kick() |
| + } |
| + } |
| + public func stop() { |
| + worker?.cancel(); observation?.cancel(); networkObservation?.cancel(); timer?.cancel(); wake?.finish() |
| + worker = nil; observation = nil; networkObservation = nil; timer = nil; wake = nil |
| + } |
| + public func kick() { wake?.yield() } |
| + public func retryNow() { retryDeadline = nil; backoff.reset(); kick() } |
| + public func sessionRestored() { sessionEnded = false; retryNow() } |
| + private func retry() -> Outcome { phase = .backingOff; return .backingOff(backoff.next()) } |
| + |
| + @discardableResult public func drain() async -> Outcome { |
| + if draining { return await withCheckedContinuation { drainWaiters.append($0) } } |
| + draining = true |
| + let outcome = await drainOnce() |
| + draining = false |
| + let waiters = drainWaiters; drainWaiters.removeAll() |
| + for waiter in waiters { waiter.resume(returning: outcome) } |
| + return outcome |
| + } |
| + private func drainOnce() async -> Outcome { |
| + do { try await outbox.load() } |
| + catch { summary = await outbox.summary; phase = .storeUnavailable; return .storeUnavailable } |
| + summary = await outbox.summary |
| + if sessionEnded { phase = .sessionEnded; return .sessionEnded } |
| + if !connectivity.isPathSatisfied { phase = .waitingForNetwork; return .waitingForNetwork } |
| + if let deadline = serverDeadline, deadline > now { phase = .backingOff; return .rateLimited(deadline - now) } |
| + serverDeadline = nil |
| + var retryLanes: Set<String> = [] |
| + while !Task.isCancelled { |
| + summary = await outbox.summary |
| + var batch: [OutboxEntry] = [] |
| + do { |
| + for lane in summary.lanes.keys.sorted() where !retryLanes.contains(lane) { |
| + batch = try await outbox.beginSend(lane: lane, maxBytes: maxBatchBytes) |
| + if !batch.isEmpty { break } |
| + } |
| + } catch { phase = .storeUnavailable; return .storeUnavailable } |
| + guard let first = batch.first else { |
| + if !retryLanes.isEmpty { return retry() } |
| + phase = .idle; return .idle |
| + } |
| + let ids = batch.map(\.id) |
| + guard let transport = transport() else { |
| + await outbox.endSend(ids, failed: false); sessionEnded = true; phase = .sessionEnded; return .sessionEnded |
| + } |
| + phase = .sending |
| + do { |
| + let dispositions: [UUID: Disposition] |
| + do { |
| + dispositions = try await send(first.lane, batch, transport) |
| + consecutiveThrows[first.lane] = nil |
| + } |
| + catch let error as NativeClientError { |
| + consecutiveThrows[first.lane] = nil |
| + let hold: Hold? |
| + switch error { |
| + case .forbidden, .notFound: hold = Hold(.accessLost) |
| + case .conflict(let data): hold = Hold(.conflict(data)) |
| + case .tooLarge: hold = Hold(.tooLarge) |
| + case .rejected(let status, _): hold = Hold(.rejected(String(status))) |
| + default: hold = nil |
| + } |
| + guard let hold else { throw error } |
| + dispositions = Dictionary(uniqueKeysWithValues: ids.map { ($0, .hold(hold)) }) |
| + } |
| + catch { |
| + guard !Task.isCancelled else { throw error } |
| + let count = (consecutiveThrows[first.lane] ?? 0) + 1 |
| + consecutiveThrows[first.lane] = count |
| + // An app decoder/programming failure belongs to this lane, not the |
| + // account. Three consecutive throws require an explicit resolution. |
| + let disposition: Disposition = count >= 3 ? .hold(Hold(.rejected("send"))) : .retry |
| + dispositions = Dictionary(uniqueKeysWithValues: ids.map { ($0, disposition) }) |
| + if count >= 3 { consecutiveThrows[first.lane] = nil } |
| + } |
| + var mustRetry = false; var progressed = false |
| + for entry in batch { |
| + switch dispositions[entry.id] { |
| + case .sent: try await outbox.ack([entry.id]); progressed = true |
| + case .hold(let hold): try await outbox.hold([entry.id], hold); progressed = true |
| + case .retry: await outbox.endSend([entry.id]); mustRetry = true |
| + case nil: break |
| + } |
| + } |
| + await outbox.endSend(ids, failed: false) |
| + summary = await outbox.summary |
| + if mustRetry || !progressed { retryLanes.insert(first.lane); continue } |
| + backoff.reset(); retryDeadline = nil |
| + } catch { |
| + await outbox.endSend(ids); summary = await outbox.summary |
| + switch error { |
| + case NativeClientError.unauthorized: |
| + sessionEnded = true; phase = .sessionEnded; return .sessionEnded |
| + case NativeClientError.offline where !connectivity.isPathSatisfied: |
| + phase = .waitingForNetwork; return .waitingForNetwork |
| + case NativeClientError.rateLimited(let after): |
| + let delay = backoff.next(retryAfter: after); serverDeadline = now + delay |
| + phase = .backingOff; return .rateLimited(delay) |
| + default: return retry() |
| + } |
| + } |
| + if !connectivity.isPathSatisfied { phase = .waitingForNetwork; return .waitingForNetwork } |
| + } |
| + phase = .idle; return .idle |
| + } |
| + public func status(lane: String? = nil, localFailure: SyncFailure? = nil, |
| + count: (OutboxSummary) -> Int = { $0.pendingLanes }) -> SyncStatus { |
| + let state = lane.map { summary.only($0) } ?? summary |
| + let offline = transport().map { connectivity.isOffline($0.origin) } ?? !connectivity.isPathSatisfied |
| + let key = lane.map { "lane:" + $0 } ?? "account" |
| + if state.unavailable, localFailure == nil, let known = knownStatuses[key] { return known } |
| + let status = SyncStatus(summary: state, sending: state.inFlight > 0, sessionEnded: sessionEnded, |
| + offline: offline, backingOff: phase == .backingOff, localFailure: localFailure, count: count) |
| + // A locked store retains the last observable status. An app's explicit |
| + // enqueue failure still reports input that has not reached durable storage. |
| + if !state.unavailable, localFailure == nil { knownStatuses[key] = status } |
| + return status |
| + } |
| +} |
diff --git a/Sources/RastrilloApple/Resources/ar.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/ar.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..3293509 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/ar.lproj/Localizable.strings |
| @@ -0,0 +1,11 @@ |
| +"sync.saving" = "جارٍ الحفظ…"; |
| +"sync.retrying" = "تعذّر الحفظ · جارٍ إعادة المحاولة…"; |
| +"sync.signIn" = "تسجيل الدخول"; |
| +"sync.failed.cannotSave" = "يتعذّر الحفظ على هذا الجهاز."; |
| +"sync.failed.tooManyUnsent" = "هناك تغييرات كثيرة غير مرسلة لا يمكن الاحتفاظ بها على هذا الجهاز. اتصل للحفظ."; |
| +"sync.failed.held" = "تعذّر حفظ بعض التغييرات."; |
| +"offline.waiting" = "أنت غير متصل. في انتظار الاتصال."; |
| +"offline.durable" = "أنت غير متصل. التغييرات محفوظة على هذا الجهاز وستُرسل عند الاتصال مجددًا."; |
| +"sync.unsentLost" = "تعذّرت قراءة التغييرات غير المرسلة على هذا الجهاز وتمت إزالتها."; |
| +"sync.offline" = "غير متصل · %d قيد الانتظار"; |
| +"sync.needsSignIn" = "سجّل الدخول لإرسال %d قيد الانتظار"; |
diff --git a/Sources/RastrilloApple/Resources/ar.lproj/Localizable.stringsdict b/Sources/RastrilloApple/Resources/ar.lproj/Localizable.stringsdict| new file mode 100644 |
| index 0000000..7354e01 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/ar.lproj/Localizable.stringsdict |
| @@ -0,0 +1,34 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>sync.needsSignIn</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>سجّل الدخول لإرسال %d قيد الانتظار</string> |
| + </dict> |
| + </dict> |
| + <key>sync.offline</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>غير متصل · %d قيد الانتظار</string> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/Sources/RastrilloApple/Resources/bn.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/bn.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..71594da |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/bn.lproj/Localizable.strings |
| @@ -0,0 +1,11 @@ |
| +"sync.saving" = "সংরক্ষণ করা হচ্ছে…"; |
| +"sync.retrying" = "সংরক্ষণে সমস্যা · আবার চেষ্টা করা হচ্ছে…"; |
| +"sync.signIn" = "সাইন ইন করুন"; |
| +"sync.failed.cannotSave" = "এই ডিভাইসে সংরক্ষণ করা যাচ্ছে না।"; |
| +"sync.failed.tooManyUnsent" = "এই ডিভাইসে রাখার জন্য অনেক বেশি পরিবর্তন পাঠানো বাকি আছে। সংরক্ষণ করতে সংযোগ করুন।"; |
| +"sync.failed.held" = "কিছু পরিবর্তন সংরক্ষণ করা যায়নি।"; |
| +"offline.waiting" = "আপনি অফলাইনে আছেন। সংযোগের অপেক্ষায়।"; |
| +"offline.durable" = "আপনি অফলাইনে আছেন। পরিবর্তনগুলো এই ডিভাইসে সংরক্ষিত আছে এবং আবার সংযোগ হলে পাঠানো হবে।"; |
| +"sync.unsentLost" = "এই ডিভাইসে না পাঠানো পরিবর্তনগুলো পড়া যায়নি এবং সরিয়ে দেওয়া হয়েছে।"; |
| +"sync.offline" = "অফলাইন · %dটি অপেক্ষমাণ"; |
| +"sync.needsSignIn" = "অপেক্ষমাণ %dটি পাঠাতে সাইন ইন করুন"; |
diff --git a/Sources/RastrilloApple/Resources/bn.lproj/Localizable.stringsdict b/Sources/RastrilloApple/Resources/bn.lproj/Localizable.stringsdict| new file mode 100644 |
| index 0000000..07879af |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/bn.lproj/Localizable.stringsdict |
| @@ -0,0 +1,34 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>sync.needsSignIn</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>অপেক্ষমাণ %dটি পাঠাতে সাইন ইন করুন</string> |
| + </dict> |
| + </dict> |
| + <key>sync.offline</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>অফলাইন · %dটি অপেক্ষমাণ</string> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/Sources/RastrilloApple/Resources/en.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/en.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..a039b03 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/en.lproj/Localizable.strings |
| @@ -0,0 +1,11 @@ |
| +"sync.saving" = "Saving…"; |
| +"sync.retrying" = "Trouble saving · Retrying…"; |
| +"sync.signIn" = "Sign in"; |
| +"sync.failed.cannotSave" = "Can’t save on this device."; |
| +"sync.failed.tooManyUnsent" = "Too many unsent changes to keep on this device. Connect to save."; |
| +"sync.failed.held" = "Some changes could not be saved."; |
| +"offline.waiting" = "You’re offline. Waiting for a connection."; |
| +"offline.durable" = "You’re offline. Changes are saved on this device and will be sent when you reconnect."; |
| +"sync.unsentLost" = "Unsent changes on this device could not be read and were removed."; |
| +"sync.offline" = "Offline · %d pending"; |
| +"sync.needsSignIn" = "Sign in to send %d pending"; |
diff --git a/Sources/RastrilloApple/Resources/en.lproj/Localizable.stringsdict b/Sources/RastrilloApple/Resources/en.lproj/Localizable.stringsdict| new file mode 100644 |
| index 0000000..9d3436d |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/en.lproj/Localizable.stringsdict |
| @@ -0,0 +1,34 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>sync.needsSignIn</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>Sign in to send %d pending</string> |
| + </dict> |
| + </dict> |
| + <key>sync.offline</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>Offline · %d pending</string> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/Sources/RastrilloApple/Resources/es.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/es.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..8040158 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/es.lproj/Localizable.strings |
| @@ -0,0 +1,11 @@ |
| +"sync.saving" = "Guardando…"; |
| +"sync.retrying" = "Problemas al guardar · Reintentando…"; |
| +"sync.signIn" = "Iniciar sesión"; |
| +"sync.failed.cannotSave" = "No se puede guardar en este dispositivo."; |
| +"sync.failed.tooManyUnsent" = "Hay demasiados cambios sin enviar para guardarlos en este dispositivo. Conéctate para guardar."; |
| +"sync.failed.held" = "No se pudieron guardar algunos cambios."; |
| +"offline.waiting" = "Sin conexión. Esperando una conexión."; |
| +"offline.durable" = "Sin conexión. Los cambios se guardan en este dispositivo y se enviarán cuando vuelvas a conectarte."; |
| +"sync.unsentLost" = "No se pudieron leer los cambios sin enviar de este dispositivo y se eliminaron."; |
| +"sync.offline" = "Sin conexión · %d pendientes"; |
| +"sync.needsSignIn" = "Inicia sesión para enviar %d pendientes"; |
diff --git a/Sources/RastrilloApple/Resources/es.lproj/Localizable.stringsdict b/Sources/RastrilloApple/Resources/es.lproj/Localizable.stringsdict| new file mode 100644 |
| index 0000000..6ecc453 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/es.lproj/Localizable.stringsdict |
| @@ -0,0 +1,38 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>sync.needsSignIn</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>one</key> |
| + <string>Inicia sesión para enviar %d pendiente</string> |
| + <key>other</key> |
| + <string>Inicia sesión para enviar %d pendientes</string> |
| + </dict> |
| + </dict> |
| + <key>sync.offline</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>one</key> |
| + <string>Sin conexión · %d pendiente</string> |
| + <key>other</key> |
| + <string>Sin conexión · %d pendientes</string> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/Sources/RastrilloApple/Resources/ga.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/ga.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..34423ac |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/ga.lproj/Localizable.strings |
| @@ -0,0 +1,11 @@ |
| +"sync.saving" = "Á shábháil…"; |
| +"sync.retrying" = "Fadhb leis an sábháil · Ag triail arís…"; |
| +"sync.signIn" = "Sínigh isteach"; |
| +"sync.failed.cannotSave" = "Ní féidir sábháil ar an ngléas seo."; |
| +"sync.failed.tooManyUnsent" = "Tá an iomarca athruithe gan seoladh le coinneáil ar an ngléas seo. Ceangail chun sábháil."; |
| +"sync.failed.held" = "Níorbh fhéidir roinnt athruithe a shábháil."; |
| +"offline.waiting" = "Tá tú as líne. Ag fanacht le nasc."; |
| +"offline.durable" = "Tá tú as líne. Tá na hathruithe sábháilte ar an ngléas seo agus seolfar iad nuair a athcheanglóidh tú."; |
| +"sync.unsentLost" = "Níorbh fhéidir na hathruithe gan seoladh ar an ngléas seo a léamh agus baineadh iad."; |
| +"sync.offline" = "As líne · %d ar feitheamh"; |
| +"sync.needsSignIn" = "Sínigh isteach chun %d atá ar feitheamh a sheoladh"; |
diff --git a/Sources/RastrilloApple/Resources/ga.lproj/Localizable.stringsdict b/Sources/RastrilloApple/Resources/ga.lproj/Localizable.stringsdict| new file mode 100644 |
| index 0000000..98b3c0f |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/ga.lproj/Localizable.stringsdict |
| @@ -0,0 +1,34 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>sync.needsSignIn</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>Sínigh isteach chun %d atá ar feitheamh a sheoladh</string> |
| + </dict> |
| + </dict> |
| + <key>sync.offline</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>As líne · %d ar feitheamh</string> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/Sources/RastrilloApple/Resources/hi.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/hi.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..482b18e |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/hi.lproj/Localizable.strings |
| @@ -0,0 +1,11 @@ |
| +"sync.saving" = "सहेजा जा रहा है…"; |
| +"sync.retrying" = "सहेजने में समस्या · फिर से कोशिश हो रही है…"; |
| +"sync.signIn" = "साइन इन करें"; |
| +"sync.failed.cannotSave" = "इस डिवाइस पर सहेजा नहीं जा सकता।"; |
| +"sync.failed.tooManyUnsent" = "इस डिवाइस पर रखने के लिए बहुत अधिक बदलाव भेजे जाने बाकी हैं। सहेजने के लिए कनेक्ट करें।"; |
| +"sync.failed.held" = "कुछ बदलाव सहेजे नहीं जा सके।"; |
| +"offline.waiting" = "आप ऑफ़लाइन हैं। कनेक्शन की प्रतीक्षा है।"; |
| +"offline.durable" = "आप ऑफ़लाइन हैं। बदलाव इस डिवाइस पर सहेजे गए हैं और दोबारा कनेक्ट होने पर भेजे जाएँगे।"; |
| +"sync.unsentLost" = "इस डिवाइस पर न भेजे गए बदलाव पढ़े नहीं जा सके और हटा दिए गए।"; |
| +"sync.offline" = "ऑफ़लाइन · %d लंबित"; |
| +"sync.needsSignIn" = "%d लंबित बदलाव भेजने के लिए साइन इन करें"; |
diff --git a/Sources/RastrilloApple/Resources/hi.lproj/Localizable.stringsdict b/Sources/RastrilloApple/Resources/hi.lproj/Localizable.stringsdict| new file mode 100644 |
| index 0000000..5667c87 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/hi.lproj/Localizable.stringsdict |
| @@ -0,0 +1,34 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>sync.needsSignIn</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>%d लंबित बदलाव भेजने के लिए साइन इन करें</string> |
| + </dict> |
| + </dict> |
| + <key>sync.offline</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>ऑफ़लाइन · %d लंबित</string> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/Sources/RastrilloApple/Resources/ja.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/ja.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..582c4f0 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/ja.lproj/Localizable.strings |
| @@ -0,0 +1,11 @@ |
| +"sync.saving" = "保存中…"; |
| +"sync.retrying" = "保存できません · 再試行中…"; |
| +"sync.signIn" = "サインイン"; |
| +"sync.failed.cannotSave" = "このデバイスに保存できません。"; |
| +"sync.failed.tooManyUnsent" = "このデバイスに保存できる未送信の変更が多すぎます。接続して保存してください。"; |
| +"sync.failed.held" = "一部の変更を保存できませんでした。"; |
| +"offline.waiting" = "オフラインです。接続を待っています。"; |
| +"offline.durable" = "オフラインです。変更はこのデバイスに保存されており、再接続すると送信されます。"; |
| +"sync.unsentLost" = "このデバイスの未送信の変更を読み取れなかったため、削除しました。"; |
| +"sync.offline" = "オフライン · %d 件送信待ち"; |
| +"sync.needsSignIn" = "サインインして送信待ちの %d 件を送信"; |
diff --git a/Sources/RastrilloApple/Resources/ja.lproj/Localizable.stringsdict b/Sources/RastrilloApple/Resources/ja.lproj/Localizable.stringsdict| new file mode 100644 |
| index 0000000..b9b1d09 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/ja.lproj/Localizable.stringsdict |
| @@ -0,0 +1,34 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>sync.needsSignIn</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>サインインして送信待ちの %d 件を送信</string> |
| + </dict> |
| + </dict> |
| + <key>sync.offline</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>オフライン · %d 件送信待ち</string> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/Sources/RastrilloApple/Resources/pt.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/pt.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..409ee71 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/pt.lproj/Localizable.strings |
| @@ -0,0 +1,11 @@ |
| +"sync.saving" = "A guardar…"; |
| +"sync.retrying" = "Problemas ao guardar · A tentar novamente…"; |
| +"sync.signIn" = "Iniciar sessão"; |
| +"sync.failed.cannotSave" = "Não é possível guardar neste dispositivo."; |
| +"sync.failed.tooManyUnsent" = "Há demasiadas alterações por enviar para guardar neste dispositivo. Ligue-se para guardar."; |
| +"sync.failed.held" = "Não foi possível guardar algumas alterações."; |
| +"offline.waiting" = "Sem ligação. A aguardar uma ligação."; |
| +"offline.durable" = "Sem ligação. As alterações estão guardadas neste dispositivo e serão enviadas quando voltar a ligar-se."; |
| +"sync.unsentLost" = "Não foi possível ler as alterações por enviar neste dispositivo e foram removidas."; |
| +"sync.offline" = "Sem ligação · %d pendentes"; |
| +"sync.needsSignIn" = "Inicie sessão para enviar %d pendentes"; |
diff --git a/Sources/RastrilloApple/Resources/pt.lproj/Localizable.stringsdict b/Sources/RastrilloApple/Resources/pt.lproj/Localizable.stringsdict| new file mode 100644 |
| index 0000000..0adbbe8 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/pt.lproj/Localizable.stringsdict |
| @@ -0,0 +1,38 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>sync.needsSignIn</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>one</key> |
| + <string>Inicie sessão para enviar %d pendente</string> |
| + <key>other</key> |
| + <string>Inicie sessão para enviar %d pendentes</string> |
| + </dict> |
| + </dict> |
| + <key>sync.offline</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>one</key> |
| + <string>Sem ligação · %d pendente</string> |
| + <key>other</key> |
| + <string>Sem ligação · %d pendentes</string> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/Sources/RastrilloApple/Resources/ru.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/ru.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..6b4c740 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/ru.lproj/Localizable.strings |
| @@ -0,0 +1,11 @@ |
| +"sync.saving" = "Сохранение…"; |
| +"sync.retrying" = "Не удаётся сохранить · Повторная попытка…"; |
| +"sync.signIn" = "Войти"; |
| +"sync.failed.cannotSave" = "Не удаётся сохранить на этом устройстве."; |
| +"sync.failed.tooManyUnsent" = "Слишком много неотправленных изменений для хранения на этом устройстве. Подключитесь к сети для сохранения."; |
| +"sync.failed.held" = "Не удалось сохранить некоторые изменения."; |
| +"offline.waiting" = "Нет подключения. Ожидание сети."; |
| +"offline.durable" = "Нет подключения. Изменения сохранены на этом устройстве и будут отправлены при подключении к сети."; |
| +"sync.unsentLost" = "Не удалось прочитать неотправленные изменения на этом устройстве, и они были удалены."; |
| +"sync.offline" = "Не в сети · %d ожидают отправки"; |
| +"sync.needsSignIn" = "Войдите, чтобы отправить ожидающие изменения: %d"; |
diff --git a/Sources/RastrilloApple/Resources/ru.lproj/Localizable.stringsdict b/Sources/RastrilloApple/Resources/ru.lproj/Localizable.stringsdict| new file mode 100644 |
| index 0000000..c204339 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/ru.lproj/Localizable.stringsdict |
| @@ -0,0 +1,40 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>sync.needsSignIn</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>Войдите, чтобы отправить ожидающие изменения: %d</string> |
| + </dict> |
| + </dict> |
| + <key>sync.offline</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>few</key> |
| + <string>Не в сети · %d ожидают отправки</string> |
| + <key>many</key> |
| + <string>Не в сети · %d ожидают отправки</string> |
| + <key>one</key> |
| + <string>Не в сети · %d ожидает отправки</string> |
| + <key>other</key> |
| + <string>Не в сети · %d ожидают отправки</string> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/Sources/RastrilloApple/Resources/vi.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/vi.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..d96fa16 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/vi.lproj/Localizable.strings |
| @@ -0,0 +1,11 @@ |
| +"sync.saving" = "Đang lưu…"; |
| +"sync.retrying" = "Gặp sự cố khi lưu · Đang thử lại…"; |
| +"sync.signIn" = "Đăng nhập"; |
| +"sync.failed.cannotSave" = "Không thể lưu trên thiết bị này."; |
| +"sync.failed.tooManyUnsent" = "Có quá nhiều thay đổi chưa gửi để lưu trên thiết bị này. Hãy kết nối để lưu."; |
| +"sync.failed.held" = "Không thể lưu một số thay đổi."; |
| +"offline.waiting" = "Bạn đang ngoại tuyến. Đang chờ kết nối."; |
| +"offline.durable" = "Bạn đang ngoại tuyến. Các thay đổi đã được lưu trên thiết bị này và sẽ được gửi khi bạn kết nối lại."; |
| +"sync.unsentLost" = "Không thể đọc các thay đổi chưa gửi trên thiết bị này nên chúng đã bị xóa."; |
| +"sync.offline" = "Ngoại tuyến · %d đang chờ"; |
| +"sync.needsSignIn" = "Đăng nhập để gửi %d mục đang chờ"; |
diff --git a/Sources/RastrilloApple/Resources/vi.lproj/Localizable.stringsdict b/Sources/RastrilloApple/Resources/vi.lproj/Localizable.stringsdict| new file mode 100644 |
| index 0000000..7f20433 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/vi.lproj/Localizable.stringsdict |
| @@ -0,0 +1,34 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>sync.needsSignIn</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>Đăng nhập để gửi %d mục đang chờ</string> |
| + </dict> |
| + </dict> |
| + <key>sync.offline</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>Ngoại tuyến · %d đang chờ</string> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/Sources/RastrilloApple/Resources/yue.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/yue.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..8222134 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/yue.lproj/Localizable.strings |
| @@ -0,0 +1,11 @@ |
| +"sync.saving" = "儲存中…"; |
| +"sync.retrying" = "儲存遇到問題 · 重試中…"; |
| +"sync.signIn" = "登入"; |
| +"sync.failed.cannotSave" = "無法喺呢部裝置儲存。"; |
| +"sync.failed.tooManyUnsent" = "未傳送嘅更改太多,無法保留喺呢部裝置。請連線儲存。"; |
| +"sync.failed.held" = "部分更改無法儲存。"; |
| +"offline.waiting" = "你已離線。等候連線中。"; |
| +"offline.durable" = "你已離線。更改已儲存喺呢部裝置,重新連線後就會傳送。"; |
| +"sync.unsentLost" = "無法讀取呢部裝置上未傳送嘅更改,已將佢哋移除。"; |
| +"sync.offline" = "離線 · %d 項等候傳送"; |
| +"sync.needsSignIn" = "登入以傳送 %d 項等候中嘅更改"; |
diff --git a/Sources/RastrilloApple/Resources/yue.lproj/Localizable.stringsdict b/Sources/RastrilloApple/Resources/yue.lproj/Localizable.stringsdict| new file mode 100644 |
| index 0000000..afc8745 |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/yue.lproj/Localizable.stringsdict |
| @@ -0,0 +1,34 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>sync.needsSignIn</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>登入以傳送 %d 項等候中嘅更改</string> |
| + </dict> |
| + </dict> |
| + <key>sync.offline</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>離線 · %d 項等候傳送</string> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/Sources/RastrilloApple/Resources/zh-Hans.lproj/Localizable.strings b/Sources/RastrilloApple/Resources/zh-Hans.lproj/Localizable.strings| new file mode 100644 |
| index 0000000..0e15cba |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/zh-Hans.lproj/Localizable.strings |
| @@ -0,0 +1,11 @@ |
| +"sync.saving" = "正在保存…"; |
| +"sync.retrying" = "保存遇到问题 · 正在重试…"; |
| +"sync.signIn" = "登录"; |
| +"sync.failed.cannotSave" = "无法在此设备上保存。"; |
| +"sync.failed.tooManyUnsent" = "未发送的更改过多,无法保存在此设备上。请联网保存。"; |
| +"sync.failed.held" = "部分更改无法保存。"; |
| +"offline.waiting" = "你已离线。正在等待连接。"; |
| +"offline.durable" = "你已离线。更改已保存在此设备上,重新连接后将发送。"; |
| +"sync.unsentLost" = "无法读取此设备上未发送的更改,已将其移除。"; |
| +"sync.offline" = "离线 · %d 项待发送"; |
| +"sync.needsSignIn" = "登录以发送 %d 项待处理的更改"; |
diff --git a/Sources/RastrilloApple/Resources/zh-Hans.lproj/Localizable.stringsdict b/Sources/RastrilloApple/Resources/zh-Hans.lproj/Localizable.stringsdict| new file mode 100644 |
| index 0000000..5345c5f |
| --- /dev/null |
| +++ b/Sources/RastrilloApple/Resources/zh-Hans.lproj/Localizable.stringsdict |
| @@ -0,0 +1,34 @@ |
| +<?xml version="1.0" encoding="UTF-8"?> |
| +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| +<plist version="1.0"> |
| +<dict> |
| + <key>sync.needsSignIn</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>登录以发送 %d 项待处理的更改</string> |
| + </dict> |
| + </dict> |
| + <key>sync.offline</key> |
| + <dict> |
| + <key>NSStringLocalizedFormatKey</key> |
| + <string>%#@n@</string> |
| + <key>n</key> |
| + <dict> |
| + <key>NSStringFormatSpecTypeKey</key> |
| + <string>NSStringPluralRuleType</string> |
| + <key>NSStringFormatValueTypeKey</key> |
| + <string>d</string> |
| + <key>other</key> |
| + <string>离线 · %d 项待发送</string> |
| + </dict> |
| + </dict> |
| +</dict> |
| +</plist> |
diff --git a/Sources/RastrilloNative/BlobStore.swift b/Sources/RastrilloNative/BlobStore.swift| new file mode 100644 |
| index 0000000..04edf31 |
| --- /dev/null |
| +++ b/Sources/RastrilloNative/BlobStore.swift |
| @@ -0,0 +1,38 @@ |
| +import Foundation |
| + |
| +/// Why a store operation failed. The three cases are kept apart because each |
| +/// one asks for a different reaction, and folding them together is how a |
| +/// locked phone ends up deleting the changes it was keeping. |
| +public enum BlobStoreError: Error, Sendable, Equatable { |
| + /// The device is locked, file protection refused the read, or the Keychain |
| + /// would not answer. Retry later (on `.active` or unlock). Never read this |
| + /// as "empty", and never delete anything because of it. |
| + case unavailable |
| + /// The account's key is gone (`errSecItemNotFound`) while its directory |
| + /// still held sealed files. The store has already removed that directory, |
| + /// because nothing in it can ever be read again; the caller reports the |
| + /// loss once and carries on with an empty store. |
| + case keyLost |
| + /// One file failed its authentication tag. The store has removed that |
| + /// file, and only that file; the path is given so it can be reported once. |
| + case corrupt(String) |
| +} |
| + |
| +/// Durable bytes by relative path, for one account. `NativeSealedStore` is the |
| +/// real one; tests use `MemoryBlobStore` and `FaultingBlobStore`. |
| +/// |
| +/// Paths are relative, `/`-separated, and never renamed once written: a |
| +/// sealed file is bound to its path. |
| +public protocol BlobStore: Sendable { |
| + /// Atomic: after a crash the path holds either the old bytes or the new |
| + /// ones. Durable on return means it survives a process kill, not a power |
| + /// loss. |
| + func write(_ data: Data, to path: String) throws |
| + /// nil when there is no such file. |
| + func read(_ path: String) throws -> Data? |
| + /// The names (not paths) of the files directly in `directory`, sorted. An |
| + /// absent directory lists as empty. |
| + func list(_ directory: String) throws -> [String] |
| + /// Removes a file or a directory. Removing what is not there succeeds. |
| + func remove(_ path: String) throws |
| +} |
diff --git a/Sources/RastrilloNative/Outbox.swift b/Sources/RastrilloNative/Outbox.swift| new file mode 100644 |
| index 0000000..bf36eed |
| --- /dev/null |
| +++ b/Sources/RastrilloNative/Outbox.swift |
| @@ -0,0 +1,260 @@ |
| +import Foundation |
| + |
| +/// One queued change, durable on disk until the server has it. |
| +public struct OutboxEntry: Codable, Sendable, Identifiable, Equatable { |
| + /// Stable for the entry's life; apps may send it as an idempotency key. |
| + public let id: UUID |
| + /// Per account and monotonic, recovered as max + 1 at load. It orders a lane. |
| + public let seq: UInt64 |
| + /// The FIFO scope, such as "doc:<key>". It never changes. |
| + public let lane: String |
| + /// App-defined. |
| + public let kind: String |
| + /// Opaque app bytes. |
| + public let payload: Data |
| + public let createdAt: Date |
| + /// Failed sends so far. Counted in memory; written with the next rewrite. |
| + public var attempts: Int |
| + /// nil means sendable. |
| + public var hold: Hold? |
| + |
| + public init(id: UUID, seq: UInt64, lane: String, kind: String, payload: Data, createdAt: Date, attempts: Int = 0, hold: Hold? = nil) { |
| + self.id = id; self.seq = seq; self.lane = lane; self.kind = kind; self.payload = payload |
| + self.createdAt = createdAt; self.attempts = attempts; self.hold = hold |
| + } |
| +} |
| + |
| +/// Why an entry stopped: the person has to decide what happens to it. |
| +public struct Hold: Codable, Sendable, Equatable { |
| + public enum Reason: Codable, Sendable, Equatable { |
| + case conflict(Data) |
| + case rejected(String) |
| + case accessLost |
| + case tooLarge |
| + } |
| + public let reason: Reason |
| + /// Every v1 hold stops its lane. A hold that lets later entries pass |
| + /// arrives with Sheets, whose writes to different cells do not depend on |
| + /// each other; Docs' updates do, so it never needs one. |
| + public var blocksLane: Bool { true } |
| + public init(_ reason: Reason) { self.reason = reason } |
| +} |
| + |
| +/// Per-lane counts, for status and for the app's own caps. |
| +public struct OutboxSummary: Sendable, Equatable { |
| + public struct Lane: Sendable, Equatable { |
| + /// Entries not held, including any in flight. |
| + public var pending = 0 |
| + public var held = 0 |
| + public var inFlight = 0 |
| + /// Payload bytes of every entry in the lane, held or not. |
| + public var bytes = 0 |
| + public var firstHold: Hold.Reason? |
| + public init() {} |
| + } |
| + public var lanes: [String: Lane] = [:] |
| + /// The store could not be read. Counts are the last ones known, which is |
| + /// never the same thing as "nothing pending". |
| + public var unavailable = false |
| + /// Unsent entries were lost to a lost key or a corrupt file. Report it |
| + /// once, then call `Outbox.acknowledgeLoss()`. |
| + public var lostUnsent = false |
| + |
| + public init() {} |
| + |
| + public var pending: Int { lanes.values.reduce(0) { $0 + $1.pending } } |
| + public var held: Int { lanes.values.reduce(0) { $0 + $1.held } } |
| + public var inFlight: Int { lanes.values.reduce(0) { $0 + $1.inFlight } } |
| + /// Lanes with anything in them: the default "N" of "Offline · N pending". |
| + public var pendingLanes: Int { lanes.values.filter { $0.pending + $0.held > 0 }.count } |
| + public var isEmpty: Bool { lanes.values.allSatisfy { $0.pending + $0.held == 0 } } |
| + |
| + /// The same summary, narrowed to one lane. |
| + public func only(_ lane: String) -> OutboxSummary { |
| + var copy = self |
| + copy.lanes = lanes[lane].map { [lane: $0] } ?? [:] |
| + return copy |
| + } |
| +} |
| + |
| +public enum OutboxError: Error, Sendable, Equatable { |
| + /// `replace` was asked to merge an entry that is being sent. The batch's |
| + /// `ack` would otherwise delete the merged file, and with it content whose |
| + /// own files the merge had already removed. |
| + case inFlight |
| + /// `replace` needs a run of entries that are next to each other in one lane. |
| + case notContiguous |
| + /// `replace` never merges a held entry: it is waiting for a person. |
| + case held |
| + case unknownEntry(UUID) |
| + case duplicateEntry(UUID) |
| +} |
| + |
| +/// Serializes disk mutations and send reservations for one account. Memory is |
| +/// changed after each successful disk operation, including partial batch acks. |
| +public actor Outbox { |
| + public nonisolated let account: String |
| + public static let directory = "outbox" |
| + private let store: BlobStore |
| + private var values: [OutboxEntry] = [] |
| + private var flying: Set<UUID> = [] |
| + private var loaded = false |
| + private var unavailable = false |
| + private var lost = false |
| + private var next: UInt64 = 1 |
| + private var subscribers: [UUID: AsyncStream<OutboxSummary>.Continuation] = [:] |
| + public init(store: BlobStore, account: String) { self.store = store; self.account = account } |
| + private func path(_ seq: UInt64) -> String { "outbox/" + String(format: "%016llx", seq) + ".entry" } |
| + public func load() throws { |
| + guard !loaded || unavailable else { return } |
| + var recovered: [OutboxEntry] = [] |
| + do { |
| + let names = try store.list(Self.directory) |
| + for name in names where name.hasSuffix(".entry") { |
| + let file = Self.directory + "/" + name |
| + do { |
| + guard let data = try store.read(file) else { continue } |
| + guard let entry = try? JSONDecoder().decode(OutboxEntry.self, from: data), path(entry.seq) == file, |
| + !recovered.contains(where: { $0.id == entry.id }) else { |
| + try store.remove(file); lost = true; continue |
| + } |
| + recovered.append(entry) |
| + } catch BlobStoreError.corrupt { lost = true } |
| + } |
| + values = recovered.sorted { $0.seq < $1.seq } |
| + next = max(next, (values.last?.seq ?? 0) + 1) |
| + loaded = true; unavailable = false; notify() |
| + } catch BlobStoreError.keyLost { |
| + values = []; flying = []; loaded = true; unavailable = false; lost = true; notify() |
| + } catch { unavailable = true; notify(); throw error } |
| + } |
| + private func mutate<T>(_ body: () throws -> T) throws -> T { |
| + do { let result = try body(); unavailable = false; notify(); return result } |
| + catch { unavailable = true; notify(); throw error } |
| + } |
| + private func persist(_ entry: OutboxEntry) throws { try store.write(JSONEncoder().encode(entry), to: path(entry.seq)) } |
| + @discardableResult |
| + public func enqueue(lane: String, kind: String, payload: Data, id: UUID = UUID()) throws -> OutboxEntry { |
| + try load() |
| + guard !values.contains(where: { $0.id == id }) else { throw OutboxError.duplicateEntry(id) } |
| + return try mutate { |
| + let entry = OutboxEntry(id: id, seq: next, lane: lane, kind: kind, payload: payload, createdAt: Date()) |
| + try persist(entry); values.append(entry); next += 1; return entry |
| + } |
| + } |
| + /// A last-known snapshot, including held entries. On an unavailable store |
| + /// this may be stale, just like summary (whose unavailable flag is set). |
| + /// Use it for display/deduplication hints only; replay must first call load() |
| + /// successfully. Every mutation and beginSend independently require load(). |
| + public func entries(lane: String, excludingInFlight: Bool = false) -> [OutboxEntry] { |
| + try? load() |
| + return values.filter { $0.lane == lane && (!excludingInFlight || !flying.contains($0.id)) } |
| + } |
| + /// Fails closed to an empty batch when loading is unavailable. |
| + public func sendable(lane: String, maxBytes: Int? = nil) -> [OutboxEntry] { |
| + try? load() |
| + guard !unavailable else { return [] } |
| + var result: [OutboxEntry] = []; var bytes = 0 |
| + for entry in values where entry.lane == lane { |
| + if entry.hold != nil { break } |
| + // Compaction may use the suffix after a reserved batch, but beginSend |
| + // below never sends it before the earlier batch finishes. |
| + if flying.contains(entry.id) { continue } |
| + if let maxBytes, !result.isEmpty, bytes + entry.payload.count > maxBytes { break } |
| + result.append(entry); bytes += entry.payload.count |
| + } |
| + return result |
| + } |
| + public func beginSend(lane: String, maxBytes: Int? = nil) throws -> [OutboxEntry] { |
| + try load() |
| + guard flying.isEmpty else { return [] } |
| + let batch = sendable(lane: lane, maxBytes: maxBytes) |
| + if !batch.isEmpty { flying.formUnion(batch.map(\.id)); notify() } |
| + return batch |
| + } |
| + public func endSend(_ ids: [UUID], failed: Bool = true) { |
| + for id in ids { |
| + flying.remove(id) |
| + if failed, let index = values.firstIndex(where: { $0.id == id }) { values[index].attempts += 1 } |
| + } |
| + notify() |
| + } |
| + @discardableResult |
| + public func replace(_ ids: [UUID], with payload: Data) throws -> OutboxEntry { |
| + try load() |
| + guard !ids.isEmpty, Set(ids).count == ids.count else { throw OutboxError.notContiguous } |
| + if ids.contains(where: { flying.contains($0) }) { throw OutboxError.inFlight } |
| + let run = try ids.map { id in |
| + guard let entry = values.first(where: { $0.id == id }) else { throw OutboxError.unknownEntry(id) } |
| + return entry |
| + }.sorted { $0.seq < $1.seq } |
| + let first = run[0] |
| + let lane = values.filter { $0.lane == first.lane } |
| + guard run.allSatisfy({ $0.lane == first.lane && $0.kind == first.kind }), |
| + let start = lane.firstIndex(where: { $0.id == first.id }), |
| + Array(lane.dropFirst(start).prefix(run.count)).map(\.id) == run.map(\.id) else { throw OutboxError.notContiguous } |
| + let sendableIDs = Set(sendable(lane: first.lane).map(\.id)) |
| + guard run.allSatisfy({ $0.hold == nil && sendableIDs.contains($0.id) }) else { throw OutboxError.held } |
| + return try mutate { |
| + let merged = OutboxEntry(id: UUID(), seq: first.seq, lane: first.lane, kind: first.kind, payload: payload, createdAt: first.createdAt) |
| + try persist(merged) |
| + values[values.firstIndex(where: { $0.id == first.id })!] = merged |
| + for entry in run.dropFirst() { |
| + try store.remove(path(entry.seq)); values.removeAll { $0.id == entry.id } |
| + } |
| + return merged |
| + } |
| + } |
| + public func ack(_ ids: [UUID]) throws { |
| + try load() |
| + try mutate { |
| + for id in ids { |
| + guard let entry = values.first(where: { $0.id == id }) else { continue } |
| + try store.remove(path(entry.seq)); values.removeAll { $0.id == id }; flying.remove(id) |
| + } |
| + } |
| + } |
| + public func hold(_ ids: [UUID], _ hold: Hold) throws { |
| + try load() |
| + try mutate { |
| + for id in ids { |
| + guard let index = values.firstIndex(where: { $0.id == id }) else { continue } |
| + var entry = values[index]; entry.hold = hold |
| + try persist(entry); values[index] = entry; flying.remove(id) |
| + } |
| + } |
| + } |
| + public func release(_ id: UUID) throws { |
| + try load() |
| + guard let index = values.firstIndex(where: { $0.id == id }) else { throw OutboxError.unknownEntry(id) } |
| + try mutate { var entry = values[index]; entry.hold = nil; try persist(entry); values[index] = entry } |
| + } |
| + public func discard(lane: String) throws { try load(); try ack(values.filter { $0.lane == lane }.map(\.id)) } |
| + public func purge() throws { |
| + try load() |
| + try mutate { try store.remove(Self.directory); values = []; flying = [] } |
| + } |
| + public func acknowledgeLoss() { lost = false; notify() } |
| + private var currentSummary: OutboxSummary { |
| + var result = OutboxSummary(); result.unavailable = unavailable; result.lostUnsent = lost |
| + for entry in values { |
| + var lane = result.lanes[entry.lane] ?? OutboxSummary.Lane() |
| + if let hold = entry.hold { lane.held += 1; if lane.firstHold == nil { lane.firstHold = hold.reason } } |
| + else { lane.pending += 1 } |
| + if flying.contains(entry.id) { lane.inFlight += 1 } |
| + lane.bytes += entry.payload.count; result.lanes[entry.lane] = lane |
| + } |
| + return result |
| + } |
| + /// Last-known counts; unavailable marks retained counts as non-authoritative. |
| + public var summary: OutboxSummary { try? load(); return currentSummary } |
| + private func notify() { for subscriber in subscribers.values { subscriber.yield(currentSummary) } } |
| + private func unsubscribe(_ id: UUID) { subscribers[id] = nil } |
| + public var changes: AsyncStream<OutboxSummary> { |
| + try? load() |
| + let (stream, continuation) = AsyncStream<OutboxSummary>.makeStream(bufferingPolicy: .bufferingNewest(1)) |
| + let id = UUID(); subscribers[id] = continuation; continuation.yield(currentSummary) |
| + continuation.onTermination = { [weak self] _ in Task { await self?.unsubscribe(id) } } |
| + return stream |
| + } |
| +} |
diff --git a/Sources/RastrilloNative/SyncStatus.swift b/Sources/RastrilloNative/SyncStatus.swift| new file mode 100644 |
| index 0000000..a3cde75 |
| --- /dev/null |
| +++ b/Sources/RastrilloNative/SyncStatus.swift |
| @@ -0,0 +1,59 @@ |
| +import Foundation |
| + |
| +/// A failure the person has to see and act on. |
| +public enum SyncFailure: Sendable, Equatable { |
| + /// An entry is held; the app owns the wording and the action. |
| + case held(Hold.Reason) |
| + /// Enqueueing failed (disk full, or the store is unreadable): input pauses. |
| + case cannotSaveOnDevice |
| + /// The app's per-lane byte ceiling was reached: input stops, and says why. |
| + case tooManyUnsent |
| +} |
| + |
| +/// What the save status says, computed from outbox state and never from HTTP |
| +/// activity. The cases are in priority order: the first that applies wins. |
| +/// |
| +/// "Offline" with nothing pending is `.saved`: status stays silent and the |
| +/// connectivity chrome says offline, so "Offline · 0 pending" never appears. |
| +public enum SyncStatus: Sendable, Equatable { |
| + /// 1. A held entry, or a local failure. Sticky until resolved. |
| + case failed(pending: Int, reason: SyncFailure) |
| + /// 2. The session ended with changes still to send. Sticky until sign-in. |
| + case needsSignIn(pending: Int) |
| + /// 3. Nothing to send and nothing in flight. Silent. |
| + case saved |
| + /// 4. A send is in flight (or about to start). |
| + case saving |
| + /// 5. Changes are waiting for a connection. |
| + case offline(pending: Int) |
| + /// 6. Backing off after a transient failure. |
| + case retrying(pending: Int) |
| + |
| + public enum SaveState: String, Sendable { case saved, saving, retrying, failed } |
| + public var saveState: SaveState { |
| + switch self { |
| + case .saved: return .saved |
| + case .saving: return .saving |
| + case .offline, .retrying: return .retrying |
| + case .failed, .needsSignIn: return .failed |
| + } |
| + } |
| + public var isSticky: Bool { saveState == .failed } |
| + public var isAnnounced: Bool { |
| + switch self { case .offline, .needsSignIn, .failed: return true; default: return false } |
| + } |
| + public init(summary: OutboxSummary, sending: Bool, sessionEnded: Bool, offline: Bool, backingOff: Bool, |
| + localFailure: SyncFailure? = nil, count: (OutboxSummary) -> Int = { $0.pendingLanes }) { |
| + let pending = count(summary) |
| + if let held = summary.lanes.sorted(by: { $0.key < $1.key }).compactMap({ $0.value.firstHold }).first { |
| + self = .failed(pending: pending, reason: .held(held)) |
| + } else if let localFailure { self = .failed(pending: pending, reason: localFailure) } |
| + else if summary.unavailable { self = .failed(pending: pending, reason: .cannotSaveOnDevice) } |
| + else if sessionEnded { self = .needsSignIn(pending: pending) } |
| + else if summary.isEmpty && summary.inFlight == 0 { self = .saved } |
| + else if sending || summary.inFlight > 0 { self = .saving } |
| + else if offline { self = .offline(pending: pending) } |
| + else if backingOff { self = .retrying(pending: pending) } |
| + else { self = .saving } |
| + } |
| +} |
diff --git a/Sources/RastrilloTestSupport/FaultingBlobStore.swift b/Sources/RastrilloTestSupport/FaultingBlobStore.swift| new file mode 100644 |
| index 0000000..ecf5c77 |
| --- /dev/null |
| +++ b/Sources/RastrilloTestSupport/FaultingBlobStore.swift |
| @@ -0,0 +1,98 @@ |
| +import CryptoKit |
| +import Foundation |
| +import RastrilloApple |
| +import RastrilloNative |
| +import Security |
| + |
| +/// Thread-safe in-memory disk. Copies model a new process reading committed bytes. |
| +public final class MemoryBlobStore: BlobStore, @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var files: [String: Data] |
| + public init(_ files: [String: Data] = [:]) { self.files = files } |
| + public var contents: [String: Data] { lock.withLock { files } } |
| + public func write(_ data: Data, to path: String) throws { lock.withLock { files[path] = data } } |
| + public func read(_ path: String) throws -> Data? { lock.withLock { files[path] } } |
| + public func list(_ directory: String) throws -> [String] { |
| + lock.withLock { |
| + let prefix = directory.isEmpty ? "" : directory + "/" |
| + return files.keys.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) } |
| + .filter { !$0.contains("/") }.sorted() |
| + } |
| + } |
| + public func remove(_ path: String) throws { |
| + lock.withLock { files = files.filter { !(path.isEmpty || $0.key == path || $0.key.hasPrefix(path + "/")) } } |
| + } |
| +} |
| +public struct SimulatedCrash: Error, Sendable, Equatable { |
| + public let operation: Int |
| + public init(operation: Int) { self.operation = operation } |
| +} |
| +/// Crash *before* operation N (zero based). Once crashed, the committed view |
| +/// freezes and every later operation throws, just as a dead process cannot write. |
| +public final class FaultingBlobStore: BlobStore, @unchecked Sendable { |
| + public enum Operation: Sendable, Equatable { case write(String), read(String), list(String), remove(String) } |
| + private let lock = NSLock() |
| + private let base: MemoryBlobStore |
| + private var schedule: Int? |
| + private var frozen: [String: Data]? |
| + private var history: [Operation] = [] |
| + private var locked = false |
| + public init(_ base: MemoryBlobStore = MemoryBlobStore(), crashAt: Int? = nil) { self.base = base; schedule = crashAt } |
| + public func crash(after operations: Int) { lock.withLock { schedule = history.count + operations } } |
| + public var unavailable: Bool { get { lock.withLock { locked } } set { lock.withLock { locked = newValue } } } |
| + public var hasCrashed: Bool { lock.withLock { frozen != nil } } |
| + public var operationCount: Int { lock.withLock { history.count } } |
| + public var operations: [Operation] { lock.withLock { history } } |
| + public var committed: MemoryBlobStore { lock.withLock { MemoryBlobStore(frozen ?? base.contents) } } |
| + private func perform<T>(_ operation: Operation, _ body: () throws -> T) throws -> T { |
| + try lock.withLock { |
| + if frozen != nil || schedule == history.count { |
| + if frozen == nil { frozen = base.contents } |
| + throw SimulatedCrash(operation: history.count) |
| + } |
| + if locked { throw BlobStoreError.unavailable } |
| + let value = try body() |
| + history.append(operation) |
| + return value |
| + } |
| + } |
| + public func write(_ data: Data, to path: String) throws { try perform(.write(path)) { try base.write(data, to: path) } } |
| + public func read(_ path: String) throws -> Data? { try perform(.read(path)) { try base.read(path) } } |
| + public func list(_ directory: String) throws -> [String] { try perform(.list(directory)) { try base.list(directory) } } |
| + public func remove(_ path: String) throws { try perform(.remove(path)) { try base.remove(path) } } |
| +} |
| +public final class ScriptedKeyProvider: KeyProvider, @unchecked Sendable { |
| + private let lock = NSLock() |
| + private var key: SymmetricKey? |
| + private var state: StoreKeyState? |
| + private var copyFailure: OSStatus? |
| + private var addFailure: OSStatus? |
| + private var history: [String] = [] |
| + public init(key: SymmetricKey? = nil) { self.key = key; state = key.map { StoreKeyState(current: $0.withUnsafeBytes { Data($0) }) } } |
| + public func failCopies(with status: OSStatus?) { lock.withLock { copyFailure = status } } |
| + public func failAdds(with status: OSStatus?) { lock.withLock { addFailure = status } } |
| + public var storedKey: SymmetricKey? { lock.withLock { key } } |
| + public var log: [String] { lock.withLock { history } } |
| + public func copyKey() -> (OSStatus, SymmetricKey?) { |
| + lock.withLock { history.append("copy"); return copyFailure.map { ($0, nil) } ?? (key == nil ? errSecItemNotFound : errSecSuccess, key) } |
| + } |
| + public func addKey(_ new: SymmetricKey) -> OSStatus { |
| + lock.withLock { |
| + history.append("add") |
| + if let addFailure { return addFailure } |
| + guard key == nil else { return errSecDuplicateItem } |
| + key = new; state = StoreKeyState(current: new.withUnsafeBytes { Data($0) }); return errSecSuccess |
| + } |
| + } |
| + public func deleteKey() -> OSStatus { lock.withLock { history.append("delete"); key = nil; state = nil; return errSecSuccess } } |
| + public func sealState() -> (OSStatus, StoreKeyState?) { |
| + lock.withLock { copyFailure.map { ($0, nil) } ?? (state == nil ? errSecItemNotFound : errSecSuccess, state) } |
| + } |
| + public func saveSealState(_ value: StoreKeyState) -> OSStatus { |
| + lock.withLock { |
| + if let addFailure { return addFailure } |
| + state = value; key = SymmetricKey(data: value.current); return errSecSuccess |
| + } |
| + } |
| + |
| +} |
diff --git a/Tests/RastrilloAppleTests/OutboxDrainerTests.swift b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift| new file mode 100644 |
| index 0000000..cc1d7f4 |
| --- /dev/null |
| +++ b/Tests/RastrilloAppleTests/OutboxDrainerTests.swift |
| @@ -0,0 +1,353 @@ |
| +import Foundation |
| +import RastrilloNative |
| +import RastrilloTestSupport |
| +import XCTest |
| +@testable import RastrilloApple |
| + |
| +@MainActor private func settle(until condition: () async -> Bool, seconds: Double = 3) async { |
| + let deadline = Date().addingTimeInterval(seconds) |
| + while !(await condition()) && Date() < deadline { try? await Task.sleep(nanoseconds: 5_000_000) } |
| +} |
| + |
| +/// A send that POSTs each entry's payload, in order, as one request per batch. |
| +@MainActor private func poster(_ calls: @escaping @MainActor (String, [OutboxEntry]) -> Void = { _, _ in }) -> OutboxDrainer.Send { |
| + { lane, entries, transport in |
| + calls(lane, entries) |
| + let body = entries.map { String(decoding: $0.payload, as: UTF8.self) }.joined(separator: ",") |
| + let reply = try await transport.responseDetails("/\(lane)", method: "POST", body: Data(body.utf8)) |
| + try reply.requireSuccess() |
| + return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, .sent) }) |
| + } |
| +} |
| + |
| +@MainActor final class OutboxDrainerTests: XCTestCase { |
| + private func connectivity(_ status: NativePath.Status = .satisfied) async -> (NativeConnectivity, ScriptedPathSource) { |
| + let source = ScriptedPathSource(status) |
| + let connectivity = NativeConnectivity(source: source) |
| + await settle { connectivity.path?.status == status } |
| + return (connectivity, source) |
| + } |
| + |
| + private func outbox(_ lanes: [(String, String)]) async throws -> Outbox { |
| + let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| + for (lane, text) in lanes { try await outbox.enqueue(lane: lane, kind: "u", payload: Data(text.utf8)) } |
| + return outbox |
| + } |
| + |
| + private func bodies(_ transport: ScriptedTransport) -> [String] { |
| + transport.committed.map { String(decoding: $0.body ?? Data(), as: UTF8.self) } |
| + } |
| + |
| + func testThrowingLaneDoesNotStarveOtherLanesAndEventuallyHolds() async throws { |
| + struct Broken: Error {} |
| + let (connectivity, _) = await connectivity() |
| + let outbox = try await outbox([("a", "broken"), ("b", "good"), ("c", "good")]) |
| + let transport = ScriptedTransport([]) |
| + var calls: [String] = [] |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { lane, entries, _ in |
| + calls.append(lane) |
| + if lane == "a" { throw Broken() } |
| + return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, .sent) }) |
| + } |
| + await drainer.drain() |
| + XCTAssertEqual(calls, ["a", "b", "c"]) |
| + let remaining = await outbox.summary |
| + XCTAssertEqual(remaining.lanes.count, 1) |
| + await drainer.drain(); await drainer.drain() |
| + let held = await outbox.entries(lane: "a") |
| + XCTAssertEqual(held.first?.hold, Hold(.rejected("send"))) |
| + await drainer.drain() |
| + XCTAssertEqual(calls.filter { $0 == "a" }.count, 3, "Three consecutive throws hold the lane instead of spinning") |
| + } |
| + |
| + func testConcurrentDrainWaitsForTheReservedBatch() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let outbox = try await outbox([("doc:a", "1")]) |
| + let transport = ScriptedTransport([]) |
| + var release: CheckedContinuation<Void, Never>? |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { _, entries, _ in |
| + await withCheckedContinuation { release = $0 } |
| + return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, .sent) }) |
| + } |
| + let first = Task { await drainer.drain() } |
| + await settle { release != nil } |
| + var finished = false |
| + let second = Task { let outcome = await drainer.drain(); finished = true; return outcome } |
| + try await Task.sleep(for: .milliseconds(30)) |
| + XCTAssertFalse(finished, "A concurrent close-save cannot report idle before acknowledgement") |
| + release?.resume() |
| + let outcomes = await (first.value, second.value) |
| + XCTAssertEqual(outcomes.0, .idle); XCTAssertEqual(outcomes.1, .idle) |
| + let summary = await outbox.summary |
| + XCTAssertTrue(summary.isEmpty) |
| + } |
| + |
| + func testUnavailableStoreKeepsTheLastKnownStatusUntilUnlock() async throws { |
| + let (connectivity, _) = await connectivity(.unsatisfied) |
| + let disk = FaultingBlobStore() |
| + let outbox = Outbox(store: disk, account: "a") |
| + try await outbox.enqueue(lane: "doc:a", kind: "update", payload: Data([1])) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { ScriptedTransport([]) }, connectivity: connectivity, send: poster()) |
| + await drainer.drain() |
| + XCTAssertEqual(drainer.status(), .offline(pending: 1)) |
| + disk.unavailable = true |
| + // Force an I/O operation even after a successful initial load. |
| + do { try await outbox.enqueue(lane: "doc:a", kind: "update", payload: Data([2])); XCTFail("Locked store must refuse enqueue") } |
| + catch BlobStoreError.unavailable { } |
| + await drainer.drain() |
| + XCTAssertEqual(drainer.status(), .offline(pending: 1), "Unavailable keeps the known status; it is not lost") |
| + XCTAssertEqual(drainer.status(localFailure: .cannotSaveOnDevice), .failed(pending: 1, reason: .cannotSaveOnDevice), "The app still reports undurable input explicitly") |
| + disk.unavailable = false |
| + await drainer.drain() |
| + XCTAssertEqual(drainer.status(), .offline(pending: 1)) |
| + XCTAssertEqual(disk.committed.contents.count, 1) |
| + } |
| + |
| + func testDrainsEachLaneInOrderAndAcknowledges() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)]) |
| + let outbox = try await outbox([("doc:a", "1"), ("doc:b", "x"), ("doc:a", "2")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome = await drainer.drain() |
| + XCTAssertEqual(outcome, .idle) |
| + XCTAssertEqual(transport.committed.map(\.path), ["/doc:a", "/doc:b"]) |
| + XCTAssertEqual(bodies(transport), ["1,2", "x"], "One batch per lane, FIFO") |
| + let summary = await outbox.summary |
| + XCTAssertTrue(summary.isEmpty) |
| + XCTAssertEqual(drainer.status(), .saved) |
| + } |
| + |
| + func testBatchesAreCutByBytes() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)]) |
| + let outbox = try await outbox([("doc:a", "aaaa"), ("doc:a", "bbbb"), ("doc:a", "cccc")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, maxBatchBytes: 8, send: poster()) |
| + await drainer.drain() |
| + XCTAssertEqual(bodies(transport), ["aaaa,bbbb", "cccc"]) |
| + } |
| + |
| + func testAPathDownMakesNoAttemptAndWaits() async throws { |
| + let (connectivity, _) = await connectivity(.unsatisfied) |
| + let transport = ScriptedTransport([]) |
| + let outbox = try await outbox([("doc:a", "1")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome = await drainer.drain() |
| + XCTAssertEqual(outcome, .waitingForNetwork) |
| + XCTAssertEqual(transport.calls.count, 0, "An offline phone makes no attempts") |
| + XCTAssertEqual(drainer.status(), .offline(pending: 1)) |
| + } |
| + |
| + func testOfflineMidSendWithThePathDownWaitsForTheNetwork() async throws { |
| + let (connectivity, source) = await connectivity() |
| + let outbox = try await outbox([("doc:a", "1")]) |
| + let transport = ScriptedTransport(responder: { _ in |
| + source.send(.unsatisfied) |
| + return .offline(.notConnectedToInternet) |
| + }) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + await settle { connectivity.isPathSatisfied } |
| + let first = await drainer.drain() |
| + await settle { !connectivity.isPathSatisfied } |
| + // Whether the path update landed before or after the classification, |
| + // the next drain must not try again while the path is down. |
| + let second = await drainer.drain() |
| + XCTAssertEqual(second, .waitingForNetwork, "first was \(first)") |
| + XCTAssertEqual(transport.calls.count, 1) |
| + } |
| + |
| + /// M2: a lost response or timeout while the path stays up backs off and |
| + /// retries the same batch; it does not wait for a path change. |
| + func testALostResponseOnAWorkingPathBacksOffAndResendsTheSameBatch() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport([.commitThenLoseResponse(), .respond(status: 204)]) |
| + let outbox = try await outbox([("doc:a", "1"), ("doc:a", "2")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let first = await drainer.drain() |
| + guard case .backingOff(let delay) = first else { return XCTFail("Expected a backoff, got \(first)") } |
| + XCTAssertGreaterThan(delay, 0) |
| + XCTAssertEqual(drainer.phase, .backingOff) |
| + XCTAssertEqual(drainer.status(), .retrying(pending: 1)) |
| + let kept = await outbox.entries(lane: "doc:a") |
| + XCTAssertEqual(kept.count, 2, "Nothing is acknowledged that the drainer did not see acknowledged") |
| + let outcome1 = await drainer.drain() |
| + XCTAssertEqual(outcome1, .idle) |
| + XCTAssertEqual(bodies(transport), ["1,2", "1,2"], "The same batch, sent again") |
| + } |
| + |
| + func testAnEndedSessionStopsEveryLaneAndHoldsNothing() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport([.respond(status: 401), .respond(status: 204), .respond(status: 204)]) |
| + let outbox = try await outbox([("doc:a", "1"), ("doc:b", "2")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome2 = await drainer.drain() |
| + XCTAssertEqual(outcome2, .sessionEnded) |
| + XCTAssertEqual(transport.calls.count, 1, "Every lane stops") |
| + let summary = await outbox.summary |
| + XCTAssertEqual(summary.held, 0, "Never hold or drop on 401") |
| + XCTAssertEqual(summary.pending, 2) |
| + XCTAssertEqual(drainer.status(), .needsSignIn(pending: 2)) |
| + let outcome3 = await drainer.drain() |
| + XCTAssertEqual(outcome3, .sessionEnded, "It stays stopped until signed in again") |
| + drainer.sessionRestored() |
| + let outcome4 = await drainer.drain() |
| + XCTAssertEqual(outcome4, .idle) |
| + XCTAssertEqual(transport.calls.count, 3) |
| + } |
| + |
| + func testNoSessionIsAnEndedSession() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let outbox = try await outbox([("doc:a", "1")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { nil }, connectivity: connectivity, send: poster()) |
| + let outcome5 = await drainer.drain() |
| + XCTAssertEqual(outcome5, .sessionEnded) |
| + } |
| + |
| + func testRefusalsHoldTheirLaneAndOtherLanesKeepDraining() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport { call in |
| + switch call.path { |
| + case "/doc:gone": return .respond(status: 403) |
| + case "/doc:big": return .respond(status: 413) |
| + case "/doc:bad": return .respond(status: 400) |
| + default: return .respond(status: 204) |
| + } |
| + } |
| + let outbox = try await outbox([("doc:gone", "1"), ("doc:big", "2"), ("doc:bad", "3"), ("doc:ok", "4")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome6 = await drainer.drain() |
| + XCTAssertEqual(outcome6, .idle) |
| + let summary = await outbox.summary |
| + XCTAssertEqual(summary.lanes["doc:gone"]?.firstHold, .accessLost) |
| + XCTAssertEqual(summary.lanes["doc:big"]?.firstHold, .tooLarge) |
| + XCTAssertEqual(summary.lanes["doc:bad"]?.firstHold, .rejected("400")) |
| + XCTAssertNil(summary.lanes["doc:ok"], "Other lanes are not stopped by a hold") |
| + XCTAssertEqual(transport.calls.count, 4, "A held lane is not retried") |
| + XCTAssertEqual(drainer.status(lane: "doc:gone"), .failed(pending: 1, reason: .held(.accessLost))) |
| + XCTAssertEqual(drainer.status(lane: "doc:ok"), .saved) |
| + } |
| + |
| + func testRateLimitingIsAFloorOnTheDelay() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport([.respond(status: 429, headers: ["retry-after": "30"])]) |
| + let outbox = try await outbox([("doc:a", "1")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome = await drainer.drain() |
| + guard case .rateLimited(let delay) = outcome else { return XCTFail("Expected rate limiting, got \(outcome)") } |
| + XCTAssertGreaterThanOrEqual(delay, 30) |
| + } |
| + |
| + func testServerErrorsBackOff() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let transport = ScriptedTransport([.respond(status: 502)]) |
| + let outbox = try await outbox([("doc:a", "1")]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let backedOff = await drainer.drain() |
| + guard case .backingOff = backedOff else { return XCTFail("Expected a backoff") } |
| + let summary = await outbox.summary |
| + XCTAssertEqual(summary.pending, 1) |
| + XCTAssertEqual(summary.held, 0) |
| + } |
| + |
| + /// H4: the send went through but the store locked before the ack. The |
| + /// batch stays queued and goes again; it is never lost. |
| + func testALockedStoreDuringTheAckIsARetry() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let disk = FaultingBlobStore() |
| + let outbox = Outbox(store: disk, account: "a") |
| + try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data("1".utf8)) |
| + let transport = ScriptedTransport([.respond(status: 204), .respond(status: 204)]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, |
| + send: poster { _, _ in disk.unavailable = true }) |
| + let backedOff = await drainer.drain() |
| + guard case .backingOff = backedOff else { return XCTFail("Expected a retry") } |
| + disk.unavailable = false |
| + let kept = await outbox.entries(lane: "doc:a") |
| + XCTAssertEqual(kept.count, 1) |
| + let again = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome7 = await again.drain() |
| + XCTAssertEqual(outcome7, .idle) |
| + XCTAssertEqual(transport.committed.count, 2) |
| + } |
| + |
| + func testAnUnavailableStoreWaitsAndDeletesNothing() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let disk = FaultingBlobStore() |
| + let setup = Outbox(store: disk, account: "a") |
| + try await setup.enqueue(lane: "doc:a", kind: "u", payload: Data("1".utf8)) |
| + disk.unavailable = true |
| + let outbox = Outbox(store: disk, account: "a") |
| + let transport = ScriptedTransport([]) |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + let outcome = await drainer.drain() |
| + XCTAssertTrue(outcome == .storeUnavailable || outcome == .idle, "\(outcome)") |
| + XCTAssertEqual(transport.calls.count, 0) |
| + disk.unavailable = false |
| + XCTAssertEqual(disk.committed.contents.count, 1) |
| + } |
| + |
| + func testAppDispositionsRetryHoldAndSkip() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let outbox = try await outbox([("doc:a", "1"), ("doc:a", "2"), ("doc:a", "3")]) |
| + let transport = ScriptedTransport([]) |
| + var round = 0 |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { _, entries, _ in |
| + round += 1 |
| + // Round 1: only the first could be sent (a merge fallback); the rest |
| + // are left out and go back without penalty. |
| + if round == 1 { return [entries[0].id: .sent] } |
| + return [entries[0].id: .hold(Hold(.rejected("no")))] |
| + } |
| + let outcome8 = await drainer.drain() |
| + XCTAssertEqual(outcome8, .idle) |
| + XCTAssertEqual(round, 2) |
| + let lane = await outbox.entries(lane: "doc:a") |
| + XCTAssertEqual(lane.map { String(decoding: $0.payload, as: UTF8.self) }, ["2", "3"]) |
| + XCTAssertEqual(lane.first?.hold, Hold(.rejected("no"))) |
| + XCTAssertEqual(lane.last?.attempts, 0, "A left-out entry is not a failed attempt") |
| + } |
| + |
| + /// The background loop: an enqueue kicks it, and a restored path wakes it |
| + /// with no timer running in between. |
| + func testTheLoopSendsOnKickAndOnReconnect() async throws { |
| + let (connectivity, source) = await connectivity() |
| + let transport = ScriptedTransport { _ in .respond(status: 204) } |
| + let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity, send: poster()) |
| + drainer.start() |
| + defer { drainer.stop() } |
| + try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data("1".utf8)) |
| + drainer.kick() |
| + await settle { transport.committed.count == 1 } |
| + XCTAssertEqual(transport.committed.count, 1) |
| + |
| + source.send(.unsatisfied) |
| + await settle { !connectivity.isPathSatisfied } |
| + try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data("2".utf8)) |
| + drainer.kick() |
| + try await Task.sleep(nanoseconds: 100_000_000) |
| + XCTAssertEqual(transport.calls.count, 1, "Nothing is attempted while the path is down") |
| + await settle { drainer.status() == .offline(pending: 1) } |
| + XCTAssertEqual(drainer.status(), .offline(pending: 1)) |
| + source.send(.satisfied) |
| + await settle { transport.committed.count == 2 } |
| + XCTAssertEqual(bodies(transport), ["1", "2"]) |
| + await settle { drainer.summary.isEmpty } |
| + XCTAssertEqual(drainer.status(), .saved) |
| + } |
| + func testAnAppRetryDoesNotStarveOtherLanes() async throws { |
| + let (connectivity, _) = await connectivity() |
| + let outbox = try await outbox([("doc:a-draft", "1"), ("doc:z-ready", "2")]) |
| + let transport = ScriptedTransport([]) |
| + var sent: [String] = [] |
| + let drainer = OutboxDrainer(outbox: outbox, transport: { transport }, connectivity: connectivity) { lane, entries, _ in |
| + sent.append(lane) |
| + return Dictionary(uniqueKeysWithValues: entries.map { ($0.id, lane == "doc:a-draft" ? .retry : .sent) }) |
| + } |
| + let result = await drainer.drain() |
| + guard case .backingOff = result else { return XCTFail("The draft still needs a retry") } |
| + XCTAssertEqual(sent, ["doc:a-draft", "doc:z-ready"]) |
| + let remaining = await outbox.entries(lane: "doc:z-ready") |
| + XCTAssertTrue(remaining.isEmpty, "An unattached draft must not block another document") |
| + } |
| + |
| +} |
diff --git a/Tests/RastrilloAppleTests/SealedStoreTests.swift b/Tests/RastrilloAppleTests/SealedStoreTests.swift| new file mode 100644 |
| index 0000000..bfbd049 |
| --- /dev/null |
| +++ b/Tests/RastrilloAppleTests/SealedStoreTests.swift |
| @@ -0,0 +1,223 @@ |
| +import CryptoKit |
| +import Foundation |
| +import RastrilloNative |
| +import RastrilloTestSupport |
| +import Security |
| +import XCTest |
| +@testable import RastrilloApple |
| + |
| +final class SealedStoreTests: XCTestCase { |
| + var directory: URL! |
| + |
| + override func setUpWithError() throws { |
| + directory = FileManager.default.temporaryDirectory.appendingPathComponent("sealed-\(UUID().uuidString)", isDirectory: true) |
| + } |
| + |
| + override func tearDownWithError() throws { |
| + try? FileManager.default.removeItem(at: directory) |
| + } |
| + |
| + private func store(_ account: String = "account-a", keys: KeyProvider) -> NativeSealedStore { |
| + NativeSealedStore(root: directory.appendingPathComponent(account, isDirectory: true), accountID: account, keys: keys) |
| + } |
| + |
| + private func file(_ account: String, _ path: String) -> URL { |
| + directory.appendingPathComponent(account).appendingPathComponent(path) |
| + } |
| + |
| + func testRoundTripIsSealedWithAFreshNonceAndBoundToItsPath() throws { |
| + let keys = ScriptedKeyProvider() |
| + let sealed = store(keys: keys) |
| + let secret = Data("the quarterly numbers".utf8) |
| + try sealed.write(secret, to: "outbox/0000000000000001.entry") |
| + XCTAssertEqual(try sealed.read("outbox/0000000000000001.entry"), secret) |
| + XCTAssertNotNil(keys.storedKey, "The key is created on first write") |
| + |
| + let onDisk = try Data(contentsOf: file("account-a", "outbox/0000000000000001.entry")) |
| + XCTAssertNil(onDisk.range(of: secret), "Plaintext must never reach the disk") |
| + XCTAssertEqual(onDisk.count, 12 + secret.count + 16, ".combined is nonce, ciphertext, tag") |
| + |
| + try sealed.write(secret, to: "outbox/0000000000000001.entry") |
| + let again = try Data(contentsOf: file("account-a", "outbox/0000000000000001.entry")) |
| + XCTAssertNotEqual(onDisk.prefix(12), again.prefix(12), "Every seal draws a fresh nonce") |
| + |
| + // The associated data is exactly accountID | format version | path. |
| + let box = try AES.GCM.SealedBox(combined: again) |
| + XCTAssertEqual(try AES.GCM.open(box, using: keys.storedKey!, authenticating: Data("account-a|1|outbox/0000000000000001.entry".utf8)), secret) |
| + XCTAssertEqual(try sealed.list("outbox"), ["0000000000000001.entry"]) |
| + XCTAssertNil(try sealed.read("outbox/missing.entry")) |
| + } |
| + |
| + func testATamperedFileFailsAndIsRemovedAlone() throws { |
| + let sealed = store(keys: ScriptedKeyProvider()) |
| + try sealed.write(Data("one".utf8), to: "cache/docs/a/replica") |
| + try sealed.write(Data("two".utf8), to: "cache/docs/b/replica") |
| + var bytes = try Data(contentsOf: file("account-a", "cache/docs/a/replica")) |
| + bytes[bytes.count - 1] ^= 0x01 |
| + try bytes.write(to: file("account-a", "cache/docs/a/replica")) |
| + |
| + XCTAssertThrowsError(try sealed.read("cache/docs/a/replica")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("cache/docs/a/replica")) } |
| + XCTAssertFalse(FileManager.default.fileExists(atPath: file("account-a", "cache/docs/a/replica").path)) |
| + XCTAssertEqual(try sealed.read("cache/docs/b/replica"), Data("two".utf8), "The directory is never removed for one file") |
| + } |
| + |
| + func testARenamedFileFailsToOpen() throws { |
| + let sealed = store(keys: ScriptedKeyProvider()) |
| + try sealed.write(Data("document A".utf8), to: "cache/docs/a/replica") |
| + try sealed.write(Data("document B".utf8), to: "cache/docs/b/replica") |
| + // Swap A's replica in as B's. |
| + try FileManager.default.removeItem(at: file("account-a", "cache/docs/b/replica")) |
| + try FileManager.default.copyItem(at: file("account-a", "cache/docs/a/replica"), to: file("account-a", "cache/docs/b/replica")) |
| + XCTAssertThrowsError(try sealed.read("cache/docs/b/replica")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("cache/docs/b/replica")) } |
| + XCTAssertEqual(try sealed.read("cache/docs/a/replica"), Data("document A".utf8)) |
| + } |
| + |
| + func testAFileFromAnotherAccountFailsToOpenEvenUnderTheSameKey() throws { |
| + let key = SymmetricKey(size: .bits256) |
| + let a = store("account-a", keys: ScriptedKeyProvider(key: key)) |
| + let b = store("account-b", keys: ScriptedKeyProvider(key: key)) |
| + try a.write(Data("A's entry".utf8), to: "outbox/0000000000000001.entry") |
| + try b.write(Data("B's entry".utf8), to: "outbox/0000000000000001.entry") |
| + try FileManager.default.removeItem(at: file("account-b", "outbox/0000000000000001.entry")) |
| + try FileManager.default.copyItem(at: file("account-a", "outbox/0000000000000001.entry"), to: file("account-b", "outbox/0000000000000001.entry")) |
| + XCTAssertThrowsError(try b.read("outbox/0000000000000001.entry")) { XCTAssertEqual($0 as? BlobStoreError, .corrupt("outbox/0000000000000001.entry")) } |
| + } |
| + |
| + func testALockedKeychainIsUnavailableAndDeletesNothing() throws { |
| + let keys = ScriptedKeyProvider() |
| + try store(keys: keys).write(Data("kept".utf8), to: "outbox/0000000000000001.entry") |
| + for status in [errSecInteractionNotAllowed, errSecAuthFailed, errSecNotAvailable, OSStatus(-1)] { |
| + keys.failCopies(with: status) |
| + let relaunched = store(keys: keys) // no cached key: it must ask the Keychain |
| + XCTAssertThrowsError(try relaunched.read("outbox/0000000000000001.entry"), "status \(status)") { |
| + XCTAssertEqual($0 as? BlobStoreError, .unavailable) |
| + } |
| + XCTAssertThrowsError(try relaunched.write(Data("new".utf8), to: "outbox/0000000000000002.entry")) { |
| + XCTAssertEqual($0 as? BlobStoreError, .unavailable) |
| + } |
| + XCTAssertTrue(FileManager.default.fileExists(atPath: file("account-a", "outbox/0000000000000001.entry").path), "status \(status) deleted a file") |
| + XCTAssertFalse(keys.log.contains("delete")) |
| + } |
| + keys.failCopies(with: nil) |
| + XCTAssertEqual(try store(keys: keys).read("outbox/0000000000000001.entry"), Data("kept".utf8), "Unlocking reads everything again") |
| + } |
| + |
| + func testALostKeyWithFilesPresentRemovesTheDirectoryOnce() throws { |
| + let old = store(keys: ScriptedKeyProvider()) |
| + try old.write(Data("sealed under a key that is gone".utf8), to: "outbox/0000000000000001.entry") |
| + try old.write(Data("cache".utf8), to: "cache/docs/a/replica") |
| + let fresh = ScriptedKeyProvider() // errSecItemNotFound |
| + let relaunched = store(keys: fresh) |
| + XCTAssertThrowsError(try relaunched.read("outbox/0000000000000001.entry")) { XCTAssertEqual($0 as? BlobStoreError, .keyLost) } |
| + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("account-a").path)) |
| + // After the loss, the store starts again with a new key. |
| + try relaunched.write(Data("new".utf8), to: "outbox/0000000000000002.entry") |
| + XCTAssertEqual(try relaunched.read("outbox/0000000000000002.entry"), Data("new".utf8)) |
| + XCTAssertNotNil(fresh.storedKey) |
| + } |
| + |
| + func testNoKeyAndNoFilesIsAFirstWriteNotALoss() throws { |
| + let keys = ScriptedKeyProvider() |
| + let sealed = store(keys: keys) |
| + XCTAssertNil(try sealed.read("outbox/0000000000000001.entry")) |
| + XCTAssertEqual(try sealed.list("outbox"), []) |
| + XCTAssertEqual(keys.log, [], "A missing file needs no key") |
| + try sealed.write(Data("first".utf8), to: "outbox/0000000000000001.entry") |
| + XCTAssertEqual(keys.log, ["copy", "add"]) |
| + } |
| + |
| + func testAFailedKeyCreationIsUnavailable() throws { |
| + let keys = ScriptedKeyProvider() |
| + keys.failAdds(with: errSecInteractionNotAllowed) |
| + XCTAssertThrowsError(try store(keys: keys).write(Data("x".utf8), to: "outbox/0000000000000001.entry")) { |
| + XCTAssertEqual($0 as? BlobStoreError, .unavailable) |
| + } |
| + } |
| + |
| + func testTheAccountDirectoryIsExcludedFromBackup() throws { |
| + let sealed = store(keys: ScriptedKeyProvider()) |
| + try sealed.write(Data("x".utf8), to: "outbox/0000000000000001.entry") |
| + let values = try directory.appendingPathComponent("account-a").resourceValues(forKeys: [.isExcludedFromBackupKey]) |
| + XCTAssertEqual(values.isExcludedFromBackup, true) |
| + #if os(iOS) |
| + XCTAssertTrue(NativeSealedStore.writeOptions.contains(.completeFileProtection)) |
| + #endif |
| + XCTAssertTrue(NativeSealedStore.writeOptions.contains(.atomic)) |
| + } |
| + |
| + func testWipeDeletesTheKeyFirstThenTheDirectory() throws { |
| + let keys = ScriptedKeyProvider() |
| + let sealed = store(keys: keys) |
| + try sealed.write(Data("x".utf8), to: "outbox/0000000000000001.entry") |
| + try sealed.wipe() |
| + XCTAssertNil(keys.storedKey) |
| + XCTAssertEqual(keys.log.last, "delete") |
| + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("account-a").path)) |
| + } |
| + |
| + func testPathsCannotEscapeTheAccountDirectory() { |
| + let sealed = store(keys: ScriptedKeyProvider()) |
| + for path in ["../other/outbox/1.entry", "/etc/passwd", "outbox/../../x", ""] { |
| + XCTAssertThrowsError(try sealed.write(Data(), to: path), path) |
| + } |
| + } |
| + |
| + /// The outbox over the real sealed store survives a relaunch, and an |
| + /// entry copied from another account cannot be smuggled into this one. |
| + func testAnOutboxRelaunchesFromSealedFiles() async throws { |
| + let keys = ScriptedKeyProvider() |
| + let outbox = Outbox(store: store(keys: keys), account: "account-a") |
| + let entry = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: Data("typed".utf8)) |
| + let relaunched = Outbox(store: store(keys: keys), account: "account-a") |
| + let lane = await relaunched.entries(lane: "doc:1") |
| + XCTAssertEqual(lane.map(\.id), [entry.id]) |
| + } |
| + func testSealBlocksCacheStateAndSkipUnusedQuotaAfterCrash() throws { |
| + let keys = ScriptedKeyProvider() |
| + var nonces = Set<Data>() |
| + for launch in 1...2 { |
| + let sealed = store(keys: keys) |
| + for index in 0..<19 { |
| + try sealed.write(Data([UInt8(index)]), to: "cache/a") |
| + let bytes = try Data(contentsOf: file("account-a", "cache/a")) |
| + XCTAssertTrue(nonces.insert(Data(bytes.prefix(12))).inserted, "A random 96-bit nonce must not repeat") |
| + XCTAssertEqual(keys.sealState().1?.seals, UInt64(launch * 1024), "Persist the entire block before its first seal; a relaunch skips the unused tail") |
| + XCTAssertEqual(try sealed.read("cache/a"), Data([UInt8(index)])) |
| + } |
| + XCTAssertEqual(keys.log.filter { $0 == "copy" }.count, launch, "Cache the key across reads and writes") |
| + } |
| + } |
| + |
| + func testSealQuotaRotatesAndResealsInPlace() throws { |
| + let keys = ScriptedKeyProvider() |
| + let sealed = store(keys: keys) |
| + sealed.rotationLimit = 8 |
| + for _ in 0..<8 { try sealed.write(Data("A".utf8), to: "cache/a") } |
| + let old = try XCTUnwrap(keys.storedKey) |
| + try sealed.write(Data("B".utf8), to: "cache/b") |
| + XCTAssertNotEqual(keys.storedKey, old) |
| + XCTAssertEqual(try sealed.read("cache/a"), Data("A".utf8)) |
| + XCTAssertEqual(try sealed.read("cache/b"), Data("B".utf8)) |
| + XCTAssertNil(keys.sealState().1?.previous) |
| + XCTAssertEqual(keys.sealState().1?.seals, 8, "Reserve a block, capped at this test key’s rotation limit") |
| + let bytes = try Data(contentsOf: file("account-a", "cache/a")) |
| + XCTAssertThrowsError(try AES.GCM.open(AES.GCM.SealedBox(combined: bytes), using: old, authenticating: Data("account-a|1|cache/a".utf8))) |
| + } |
| + |
| + func testInterruptedRotationReadsBothKeysAndFinishesOnNextWrite() throws { |
| + let keys = ScriptedKeyProvider() |
| + let sealed = store(keys: keys) |
| + try sealed.write(Data("A".utf8), to: "cache/a") |
| + let previous = try XCTUnwrap(keys.sealState().1) |
| + let new = SymmetricKey(size: .bits256) |
| + // Crash just after installing the new key, before any file is resealed. |
| + XCTAssertEqual(keys.saveSealState(StoreKeyState(current: new.withUnsafeBytes { Data($0) }, previous: previous.current)), errSecSuccess) |
| + let reopened = store(keys: keys) |
| + XCTAssertEqual(try reopened.read("cache/a"), Data("A".utf8)) |
| + try reopened.write(Data("B".utf8), to: "cache/b") |
| + XCTAssertNil(keys.sealState().1?.previous) |
| + XCTAssertEqual(try reopened.read("cache/a"), Data("A".utf8)) |
| + XCTAssertEqual(try reopened.read("cache/b"), Data("B".utf8)) |
| + } |
| + |
| +} |
diff --git a/Tests/RastrilloAppleTests/SyncStringsTests.swift b/Tests/RastrilloAppleTests/SyncStringsTests.swift| new file mode 100644 |
| index 0000000..f784e59 |
| --- /dev/null |
| +++ b/Tests/RastrilloAppleTests/SyncStringsTests.swift |
| @@ -0,0 +1,63 @@ |
| +import Foundation |
| +import RastrilloNative |
| +import XCTest |
| +@testable import RastrilloApple |
| + |
| +final class SyncStringsTests: XCTestCase { |
| + static let languages = ["ar", "bn", "en", "es", "ga", "hi", "ja", "pt", "ru", "vi", "yue", "zh-Hans"] |
| + static let keys = ["sync.saving", "sync.retrying", "sync.offline", "sync.needsSignIn", "sync.signIn", |
| + "sync.failed.cannotSave", "sync.failed.tooManyUnsent", "sync.failed.held", |
| + "offline.waiting", "offline.durable", "sync.unsentLost"] |
| + |
| + /// The catalog ships in the same twelve languages as the apps, and no |
| + /// language is missing a string: a missing one would show its key. |
| + func testEveryLanguageHasEveryString() throws { |
| + for language in Self.languages { |
| + let strings = try XCTUnwrap(Bundle.module.path(forResource: "Localizable", ofType: "strings", inDirectory: nil, forLocalization: language), language) |
| + let table = try XCTUnwrap(NSDictionary(contentsOfFile: strings) as? [String: String], language) |
| + XCTAssertEqual(Set(table.keys), Set(Self.keys), language) |
| + for (key, value) in table { XCTAssertFalse(value.isEmpty, "\(language) \(key)") } |
| + let plurals = try XCTUnwrap(Bundle.module.path(forResource: "Localizable", ofType: "stringsdict", inDirectory: nil, forLocalization: language), language) |
| + let rules = try XCTUnwrap(NSDictionary(contentsOfFile: plurals) as? [String: Any], language) |
| + XCTAssertEqual(Set(rules.keys), ["sync.offline", "sync.needsSignIn"], language) |
| + for key in rules.keys { |
| + let rule = try XCTUnwrap((rules[key] as? [String: Any])?["n"] as? [String: String], "\(language) \(key)") |
| + XCTAssertNotNil(rule["other"], "\(language) \(key) needs an other form") |
| + for (category, text) in rule where !category.hasPrefix("NSString") { |
| + XCTAssertTrue(text.contains("%d"), "\(language) \(key) \(category) drops the count") |
| + } |
| + } |
| + } |
| + } |
| + |
| + func testEnglishWording() { |
| + let strings = NativeSyncStrings.kit(localization: "en") |
| + XCTAssertNil(strings.text(for: .saved), "Saved is silent") |
| + XCTAssertEqual(strings.text(for: .saving), "Saving…") |
| + XCTAssertEqual(strings.text(for: .offline(pending: 1)), "Offline · 1 pending") |
| + XCTAssertEqual(strings.text(for: .offline(pending: 3)), "Offline · 3 pending") |
| + XCTAssertEqual(strings.text(for: .retrying(pending: 2)), "Trouble saving · Retrying…") |
| + XCTAssertEqual(strings.text(for: .needsSignIn(pending: 2)), "Sign in to send 2 pending") |
| + XCTAssertEqual(strings.text(for: .failed(pending: 1, reason: .tooManyUnsent)), |
| + "Too many unsent changes to keep on this device. Connect to save.") |
| + XCTAssertEqual(strings.text(for: .failed(pending: 1, reason: .cannotSaveOnDevice)), "Can’t save on this device.") |
| + XCTAssertEqual(strings.offlineDurable, |
| + "You’re offline. Changes are saved on this device and will be sent when you reconnect.") |
| + XCTAssertEqual(strings.offlineWaiting, "You’re offline. Waiting for a connection.") |
| + } |
| + |
| + func testPluralRulesAreApplied() { |
| + let russian = NativeSyncStrings.kit(localization: "ru") |
| + XCTAssertEqual(russian.text(for: .offline(pending: 1)), "Не в сети · 1 ожидает отправки") |
| + XCTAssertEqual(russian.text(for: .offline(pending: 3)), "Не в сети · 3 ожидают отправки") |
| + let spanish = NativeSyncStrings.kit(localization: "es") |
| + XCTAssertEqual(spanish.text(for: .offline(pending: 1)), "Sin conexión · 1 pendiente") |
| + XCTAssertEqual(spanish.text(for: .offline(pending: 2)), "Sin conexión · 2 pendientes") |
| + } |
| + |
| + func testAnAppOverridesWithACopy() { |
| + var strings = NativeSyncStrings.kit(localization: "en") |
| + strings.offline = { "Offline · \($0) cells pending" } |
| + XCTAssertEqual(strings.text(for: .offline(pending: 4)), "Offline · 4 cells pending") |
| + } |
| +} |
diff --git a/Tests/RastrilloNativeTests/OutboxTests.swift b/Tests/RastrilloNativeTests/OutboxTests.swift| new file mode 100644 |
| index 0000000..892b07a |
| --- /dev/null |
| +++ b/Tests/RastrilloNativeTests/OutboxTests.swift |
| @@ -0,0 +1,310 @@ |
| +import Foundation |
| +import RastrilloNative |
| +import RastrilloTestSupport |
| +import XCTest |
| + |
| +private func bytes(_ text: String) -> Data { Data(text.utf8) } |
| + |
| +/// Answers `.corrupt` for chosen paths, removing them, as the sealed store does. |
| +private final class CorruptingStore: BlobStore, @unchecked Sendable { |
| + let base: MemoryBlobStore |
| + let corrupt: Set<String> |
| + init(_ base: MemoryBlobStore, corrupt: Set<String>) { self.base = base; self.corrupt = corrupt } |
| + func write(_ data: Data, to path: String) throws { try base.write(data, to: path) } |
| + func read(_ path: String) throws -> Data? { |
| + if corrupt.contains(path) { try base.remove(path); throw BlobStoreError.corrupt(path) } |
| + return try base.read(path) |
| + } |
| + func list(_ directory: String) throws -> [String] { try base.list(directory) } |
| + func remove(_ path: String) throws { try base.remove(path) } |
| +} |
| + |
| +/// Answers `.keyLost` on the first read and removes everything, as the |
| +/// sealed store does when the Keychain has lost the key. |
| +private final class KeyLosingStore: BlobStore, @unchecked Sendable { |
| + let base: MemoryBlobStore |
| + init(_ base: MemoryBlobStore) { self.base = base } |
| + func write(_ data: Data, to path: String) throws { try base.write(data, to: path) } |
| + func read(_ path: String) throws -> Data? { |
| + guard try base.read(path) != nil else { return nil } |
| + try base.remove("") |
| + throw BlobStoreError.keyLost |
| + } |
| + func list(_ directory: String) throws -> [String] { try base.list(directory) } |
| + func remove(_ path: String) throws { try base.remove(path) } |
| +} |
| + |
| +final class OutboxTests: XCTestCase { |
| + func testEnqueueIsDurableOnReturnAndSeqResumesAfterTheHighest() async throws { |
| + let disk = MemoryBlobStore() |
| + let outbox = Outbox(store: disk, account: "a") |
| + let first = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes("one")) |
| + let second = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes("two")) |
| + XCTAssertEqual(disk.contents.keys.sorted(), ["outbox/0000000000000001.entry", "outbox/0000000000000002.entry"]) |
| + |
| + let relaunched = Outbox(store: MemoryBlobStore(disk.contents), account: "a") |
| + let entries = await relaunched.entries(lane: "doc:1") |
| + XCTAssertEqual(entries.map(\.id), [first.id, second.id]) |
| + XCTAssertEqual(entries.map(\.payload), [bytes("one"), bytes("two")]) |
| + let third = try await relaunched.enqueue(lane: "doc:1", kind: "update", payload: bytes("three")) |
| + XCTAssertEqual(third.seq, 3, "seq is recovered as max + 1") |
| + } |
| + |
| + func testLanesAreFIFOAndIndependentAndBatchesAreCutByBytes() async throws { |
| + let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| + for index in 0..<4 { try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data(repeating: UInt8(index), count: 100)) } |
| + try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("b")) |
| + let batch = await outbox.sendable(lane: "doc:a", maxBytes: 250) |
| + XCTAssertEqual(batch.map(\.seq), [1, 2], "Cut by bytes, in seq order") |
| + let oversized = await outbox.sendable(lane: "doc:a", maxBytes: 10) |
| + XCTAssertEqual(oversized.map(\.seq), [1], "A batch always carries at least one entry") |
| + let other = await outbox.sendable(lane: "doc:b", maxBytes: nil) |
| + XCTAssertEqual(other.map(\.payload), [bytes("b")]) |
| + } |
| + |
| + func testAHoldStopsItsOwnLaneOnly() async throws { |
| + let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| + let first = try await outbox.enqueue(lane: "doc:a", kind: "u", payload: bytes("1")) |
| + try await outbox.enqueue(lane: "doc:a", kind: "u", payload: bytes("2")) |
| + try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("3")) |
| + try await outbox.hold([first.id], Hold(.accessLost)) |
| + let held = await outbox.sendable(lane: "doc:a", maxBytes: nil) |
| + XCTAssertEqual(held, [], "Nothing passes a hold: Docs updates depend on the ones before them") |
| + let other = await outbox.sendable(lane: "doc:b", maxBytes: nil) |
| + XCTAssertEqual(other.count, 1) |
| + let summary = await outbox.summary |
| + XCTAssertEqual(summary.lanes["doc:a"]?.held, 1) |
| + XCTAssertEqual(summary.lanes["doc:a"]?.pending, 1) |
| + XCTAssertEqual(summary.lanes["doc:a"]?.firstHold, .accessLost) |
| + try await outbox.release(first.id) |
| + let released = await outbox.sendable(lane: "doc:a", maxBytes: nil) |
| + XCTAssertEqual(released.count, 2) |
| + } |
| + |
| + func testReplaceKeepsTheOldestSeqAndTakesANewID() async throws { |
| + let disk = MemoryBlobStore() |
| + let outbox = Outbox(store: disk, account: "a") |
| + var ids: [UUID] = [] |
| + for text in ["a", "b", "c"] { ids.append(try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes(text)).id) } |
| + let after = try await outbox.enqueue(lane: "doc:1", kind: "update", payload: bytes("d")) |
| + let merged = try await outbox.replace(Array(ids[0...2]), with: bytes("abc")) |
| + XCTAssertEqual(merged.seq, 1) |
| + XCTAssertFalse(ids.contains(merged.id), "The merged entry gets a new id") |
| + XCTAssertEqual(merged.lane, "doc:1") |
| + XCTAssertEqual(merged.kind, "update") |
| + let lane = await outbox.entries(lane: "doc:1") |
| + XCTAssertEqual(lane.map(\.payload), [bytes("abc"), bytes("d")], "Lane order is unchanged") |
| + XCTAssertEqual(lane.last?.id, after.id) |
| + XCTAssertEqual(disk.contents.keys.sorted(), ["outbox/0000000000000001.entry", "outbox/0000000000000004.entry"]) |
| + } |
| + |
| + func testReplaceRefusesInFlightHeldGappedAndCrossLaneRuns() async throws { |
| + let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| + var a: [UUID] = [] |
| + for index in 0..<4 { |
| + a.append(try await outbox.enqueue(lane: "doc:a", kind: "u", payload: Data(repeating: 1, count: 10)).id) |
| + // Another lane interleaves in seq; that does not break a run of doc:a. |
| + try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("b\(index)")) |
| + } |
| + let b = await outbox.entries(lane: "doc:b").map(\.id) |
| + guard b.count == 4 else { return XCTFail("Expected four durable entries in lane b") } |
| + let inFlight = try await outbox.beginSend(lane: "doc:a", maxBytes: 15) |
| + XCTAssertEqual(inFlight.map(\.id), [a[0]]) |
| + |
| + await assertThrows(OutboxError.inFlight) { try await outbox.replace([a[0], a[1]], with: Data()) } |
| + await assertThrows(OutboxError.notContiguous) { try await outbox.replace([a[1], a[3]], with: Data()) } |
| + await assertThrows(OutboxError.notContiguous) { try await outbox.replace([a[1], b[1]], with: Data()) } |
| + try await outbox.hold([a[3]], Hold(.tooLarge)) |
| + await assertThrows(OutboxError.held) { try await outbox.replace([a[2], a[3]], with: Data()) } |
| + |
| + let merged = try await outbox.replace([a[1], a[2]], with: bytes("merged")) |
| + XCTAssertEqual(merged.seq, 3, "A run of doc:a skips over doc:b's seqs") |
| + try await outbox.ack([a[0]]) |
| + let lane = await outbox.entries(lane: "doc:a") |
| + XCTAssertEqual(lane.map(\.id), [merged.id, a[3]], "Acking the in-flight head never touches the merged entry") |
| + } |
| + |
| + func testACrashBetweenReplaceStepsLeavesDuplicatesNeverAGap() async throws { |
| + let base = MemoryBlobStore() |
| + let setup = Outbox(store: base, account: "a") |
| + var ids: [UUID] = [] |
| + for text in ["a", "b", "c", "d"] { ids.append(try await setup.enqueue(lane: "doc:1", kind: "u", payload: bytes(text)).id) } |
| + |
| + let faulting = FaultingBlobStore(MemoryBlobStore(base.contents)) |
| + let outbox = Outbox(store: faulting, account: "a") |
| + try await outbox.load() |
| + faulting.crash(after: 1) // step 1 (the write) happens; the first remove does not |
| + do { |
| + try await outbox.replace(Array(ids[0...2]), with: bytes("abc")) |
| + XCTFail("Expected the simulated crash") |
| + } catch is SimulatedCrash {} |
| + |
| + let relaunched = Outbox(store: faulting.committed, account: "a") |
| + let lane = await relaunched.entries(lane: "doc:1") |
| + XCTAssertEqual(lane.map(\.payload), [bytes("abc"), bytes("b"), bytes("c"), bytes("d")], |
| + "Duplicates only: the merged head, then what it merged, then the rest in order") |
| + } |
| + |
| + func testAckDiscardAndPurgeRemoveFiles() async throws { |
| + let disk = MemoryBlobStore() |
| + let outbox = Outbox(store: disk, account: "a") |
| + let one = try await outbox.enqueue(lane: "doc:a", kind: "u", payload: bytes("1")) |
| + try await outbox.enqueue(lane: "doc:b", kind: "u", payload: bytes("2")) |
| + try await outbox.enqueue(lane: "doc:c", kind: "u", payload: bytes("3")) |
| + try await outbox.ack([one.id, UUID()]) |
| + try await outbox.discard(lane: "doc:b") |
| + XCTAssertEqual(disk.contents.count, 1) |
| + try await outbox.purge() |
| + XCTAssertEqual(disk.contents.count, 0) |
| + let summary = await outbox.summary |
| + XCTAssertTrue(summary.isEmpty) |
| + } |
| + |
| + func testAnUnavailableStoreIsNeverAnEmptyStore() async throws { |
| + let base = MemoryBlobStore() |
| + try await Outbox(store: base, account: "a").enqueue(lane: "doc:1", kind: "u", payload: bytes("kept")) |
| + let faulting = FaultingBlobStore(base) |
| + faulting.unavailable = true |
| + let outbox = Outbox(store: faulting, account: "a") |
| + await assertThrows(BlobStoreError.unavailable) { try await outbox.load() } |
| + await assertThrows(BlobStoreError.unavailable) { try await outbox.enqueue(lane: "doc:1", kind: "u", payload: Data()) } |
| + let summary = await outbox.summary |
| + XCTAssertTrue(summary.unavailable) |
| + XCTAssertEqual(base.contents.count, 1, "Nothing was deleted while locked") |
| + XCTAssertFalse(faulting.operations.contains { if case .remove = $0 { return true } else { return false } }) |
| + |
| + faulting.unavailable = false |
| + try await outbox.load() |
| + let lane = await outbox.entries(lane: "doc:1") |
| + XCTAssertEqual(lane.map(\.payload), [bytes("kept")]) |
| + let after = await outbox.summary |
| + XCTAssertFalse(after.unavailable) |
| + } |
| + |
| + func testOneCorruptEntryIsRemovedAloneAndReported() async throws { |
| + let base = MemoryBlobStore() |
| + let setup = Outbox(store: base, account: "a") |
| + for text in ["1", "2", "3"] { try await setup.enqueue(lane: "doc:1", kind: "u", payload: bytes(text)) } |
| + let outbox = Outbox(store: CorruptingStore(base, corrupt: ["outbox/0000000000000002.entry"]), account: "a") |
| + let lane = await outbox.entries(lane: "doc:1") |
| + XCTAssertEqual(lane.map(\.payload), [bytes("1"), bytes("3")]) |
| + XCTAssertEqual(base.contents.count, 2, "Only the corrupt file went") |
| + var summary = await outbox.summary |
| + XCTAssertTrue(summary.lostUnsent) |
| + await outbox.acknowledgeLoss() |
| + summary = await outbox.summary |
| + XCTAssertFalse(summary.lostUnsent, "Reported once") |
| + } |
| + |
| + func testALostKeyEmptiesTheOutboxAndSaysSoOnce() async throws { |
| + let base = MemoryBlobStore() |
| + try await Outbox(store: base, account: "a").enqueue(lane: "doc:1", kind: "u", payload: bytes("gone")) |
| + let outbox = Outbox(store: KeyLosingStore(base), account: "a") |
| + try await outbox.load() |
| + let summary = await outbox.summary |
| + XCTAssertTrue(summary.isEmpty) |
| + XCTAssertTrue(summary.lostUnsent) |
| + try await outbox.enqueue(lane: "doc:1", kind: "u", payload: bytes("new")) |
| + let lane = await outbox.entries(lane: "doc:1") |
| + XCTAssertEqual(lane.map(\.payload), [bytes("new")]) |
| + } |
| + |
| + func testChangesDeliverTheCurrentSummaryThenEachChange() async throws { |
| + let outbox = Outbox(store: MemoryBlobStore(), account: "a") |
| + let changes = await outbox.changes |
| + var iterator = changes.makeAsyncIterator() |
| + let first = await iterator.next() |
| + XCTAssertEqual(first?.pending, 0) |
| + try await outbox.enqueue(lane: "doc:1", kind: "u", payload: bytes("x")) |
| + let second = await iterator.next() |
| + XCTAssertEqual(second?.pending, 1) |
| + } |
| + |
| + /// Random enqueues, sends, acks, compactions and crashes. After every |
| + /// relaunch: every entry whose enqueue returned and was never acked is |
| + /// still there, in order, and each lane's content is a |
| + /// duplicates-allowed replay of what was enqueued. |
| + func testRandomCrashesNeverLoseOrReorderAnEntry() async throws { |
| + var generator = SeededGenerator(seed: 0x6b33) |
| + for _ in 0..<150 { |
| + var disk = MemoryBlobStore() |
| + var faulting = FaultingBlobStore(disk) |
| + var outbox = Outbox(store: faulting, account: "a") |
| + var expected: [String: [String]] = [:] // lane -> symbols not yet acked, in order |
| + var acked: Set<String> = [] |
| + var symbol = 0 |
| + for _ in 0..<40 { |
| + let lane = ["doc:a", "doc:b"][Int.random(in: 0...1, using: &generator)] |
| + if Int.random(in: 0..<12, using: &generator) == 0 { faulting.crash(after: Int.random(in: 0...2, using: &generator)) } |
| + do { |
| + switch Int.random(in: 0..<10, using: &generator) { |
| + case 0..<5: |
| + symbol += 1 |
| + let text = "s\(symbol)" |
| + try await outbox.enqueue(lane: lane, kind: "u", payload: bytes(text)) |
| + expected[lane, default: []].append(text) |
| + case 5..<7: |
| + let batch = try await outbox.beginSend(lane: lane, maxBytes: 8) |
| + guard !batch.isEmpty else { continue } |
| + // The server has the batch before the ack starts: a crash part |
| + // way through the ack loses nothing the server lacks. |
| + for entry in batch { |
| + let text = String(decoding: entry.payload, as: UTF8.self) |
| + for part in text.split(separator: "+") { acked.insert(String(part)) } |
| + } |
| + expected[lane] = expected[lane, default: []].filter { !acked.contains($0) } |
| + try await outbox.ack(batch.map(\.id)) |
| + case 7..<9: |
| + let run = await outbox.sendable(lane: lane, maxBytes: nil) |
| + guard run.count >= 2 else { continue } |
| + let take = Array(run.prefix(Int.random(in: 2...run.count, using: &generator))) |
| + let joined = take.map { String(decoding: $0.payload, as: UTF8.self) }.joined(separator: "+") |
| + try await outbox.replace(take.map(\.id), with: bytes(joined)) |
| + default: |
| + let batch = try await outbox.beginSend(lane: lane, maxBytes: nil) |
| + await outbox.endSend(batch.map(\.id)) |
| + } |
| + } catch is SimulatedCrash { |
| + disk = faulting.committed |
| + faulting = FaultingBlobStore(disk) |
| + outbox = Outbox(store: faulting, account: "a") |
| + } |
| + // The invariant, checked against a fresh reading of the disk. |
| + let reread = Outbox(store: MemoryBlobStore(faulting.committed.contents), account: "a") |
| + for (lane, symbols) in expected { |
| + let present = await reread.entries(lane: lane).flatMap { |
| + String(decoding: $0.payload, as: UTF8.self).split(separator: "+").map(String.init) |
| + } |
| + let unacked = present.filter { !acked.contains($0) } |
| + var seen: [String] = [] |
| + for item in unacked where seen.last != item && !seen.contains(item) { seen.append(item) } |
| + XCTAssertEqual(seen, symbols, "lane \(lane): lost or reordered an entry") |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +struct SeededGenerator: RandomNumberGenerator { |
| + private var state: UInt64 |
| + init(seed: UInt64) { state = seed } |
| + mutating func next() -> UInt64 { |
| + state &+= 0x9E3779B97F4A7C15 |
| + var z = state |
| + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 |
| + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB |
| + return z ^ (z >> 31) |
| + } |
| +} |
| + |
| +func assertThrows<E: Error & Equatable>(_ expected: E, file: StaticString = #filePath, line: UInt = #line, |
| + _ body: () async throws -> some Any) async { |
| + do { |
| + _ = try await body() |
| + XCTFail("Expected \(expected)", file: file, line: line) |
| + } catch let error as E { |
| + XCTAssertEqual(error, expected, file: file, line: line) |
| + } catch { |
| + XCTFail("Expected \(expected), got \(error)", file: file, line: line) |
| + } |
| +} |
diff --git a/Tests/RastrilloNativeTests/SyncStatusTests.swift b/Tests/RastrilloNativeTests/SyncStatusTests.swift| new file mode 100644 |
| index 0000000..14021c0 |
| --- /dev/null |
| +++ b/Tests/RastrilloNativeTests/SyncStatusTests.swift |
| @@ -0,0 +1,71 @@ |
| +import Foundation |
| +import RastrilloNative |
| +import XCTest |
| + |
| +private func summary(_ lanes: [String: (pending: Int, held: Int, inFlight: Int)], hold: Hold.Reason? = nil) -> OutboxSummary { |
| + var summary = OutboxSummary() |
| + for (name, counts) in lanes { |
| + var lane = OutboxSummary.Lane() |
| + lane.pending = counts.pending; lane.held = counts.held; lane.inFlight = counts.inFlight |
| + if counts.held > 0 { lane.firstHold = hold ?? .accessLost } |
| + summary.lanes[name] = lane |
| + } |
| + return summary |
| +} |
| + |
| +final class SyncStatusTests: XCTestCase { |
| + func testPriorityOrder() { |
| + let pending = summary(["doc:a": (2, 0, 0), "doc:b": (1, 0, 0)]) |
| + let held = summary(["doc:a": (1, 1, 0)], hold: .tooLarge) |
| + let flying = summary(["doc:a": (2, 0, 1)]) |
| + |
| + // 1. A hold beats everything, even an ended session. |
| + XCTAssertEqual(SyncStatus(summary: held, sending: true, sessionEnded: true, offline: true, backingOff: true), |
| + .failed(pending: 1, reason: .held(.tooLarge))) |
| + // A local failure is failed too. |
| + XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: false, offline: false, backingOff: false, localFailure: .cannotSaveOnDevice), |
| + .failed(pending: 2, reason: .cannotSaveOnDevice)) |
| + // 2. Session ended. |
| + XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: true, offline: true, backingOff: true), .needsSignIn(pending: 2)) |
| + // 3. Nothing to send. |
| + XCTAssertEqual(SyncStatus(summary: OutboxSummary(), sending: false, sessionEnded: false, offline: false, backingOff: false), .saved) |
| + // 4. In flight. |
| + XCTAssertEqual(SyncStatus(summary: flying, sending: true, sessionEnded: false, offline: true, backingOff: false), .saving) |
| + // 5. Offline. |
| + XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: false, offline: true, backingOff: true), .offline(pending: 2)) |
| + // 6. Backing off. |
| + XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: false, offline: false, backingOff: true), .retrying(pending: 2)) |
| + // Sendable, online, not yet started: about to save, never "saved". |
| + XCTAssertEqual(SyncStatus(summary: pending, sending: false, sessionEnded: false, offline: false, backingOff: false), .saving) |
| + } |
| + |
| + func testOfflineWithNothingPendingIsSilentlySaved() { |
| + let status = SyncStatus(summary: OutboxSummary(), sending: false, sessionEnded: false, offline: true, backingOff: false) |
| + XCTAssertEqual(status, .saved, "\"Offline · 0 pending\" must never appear") |
| + XCTAssertEqual(SyncStatus(summary: OutboxSummary(), sending: false, sessionEnded: true, offline: true, backingOff: false), .needsSignIn(pending: 0), |
| + "Session-ended has priority over an empty outbox (spec 3.4)") |
| + } |
| + |
| + func testNCountsLanesByDefaultAndWhatTheAppChooses() { |
| + let lanes = summary(["doc:a": (5, 0, 0), "doc:b": (1, 0, 0)]) |
| + XCTAssertEqual(SyncStatus(summary: lanes, sending: false, sessionEnded: false, offline: true, backingOff: false), .offline(pending: 2)) |
| + XCTAssertEqual(SyncStatus(summary: lanes, sending: false, sessionEnded: false, offline: true, backingOff: false, count: { $0.pending }), |
| + .offline(pending: 6)) |
| + } |
| + |
| + func testSaveStateMappingStickinessAndAnnouncements() { |
| + XCTAssertEqual(SyncStatus.saved.saveState, .saved) |
| + XCTAssertEqual(SyncStatus.saving.saveState, .saving) |
| + XCTAssertEqual(SyncStatus.offline(pending: 1).saveState, .retrying, "The Slides precedent: offline is retrying") |
| + XCTAssertEqual(SyncStatus.retrying(pending: 1).saveState, .retrying) |
| + XCTAssertEqual(SyncStatus.needsSignIn(pending: 1).saveState, .failed) |
| + XCTAssertEqual(SyncStatus.failed(pending: 1, reason: .tooManyUnsent).saveState, .failed) |
| + XCTAssertTrue(SyncStatus.needsSignIn(pending: 1).isSticky) |
| + XCTAssertTrue(SyncStatus.failed(pending: 1, reason: .cannotSaveOnDevice).isSticky) |
| + XCTAssertFalse(SyncStatus.offline(pending: 1).isSticky) |
| + XCTAssertFalse(SyncStatus.saving.isAnnounced, "A saving flicker is never announced") |
| + XCTAssertTrue(SyncStatus.offline(pending: 1).isAnnounced) |
| + XCTAssertTrue(SyncStatus.needsSignIn(pending: 1).isAnnounced) |
| + XCTAssertTrue(SyncStatus.failed(pending: 1, reason: .tooManyUnsent).isAnnounced) |
| + } |
| +} |
diff --git a/Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift b/Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift| new file mode 100644 |
| index 0000000..d420876 |
| --- /dev/null |
| +++ b/Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift |
| @@ -0,0 +1,48 @@ |
| +import Foundation |
| +import RastrilloNative |
| +import RastrilloTestSupport |
| +import XCTest |
| + |
| +final class FaultingBlobStoreTests: XCTestCase { |
| + func testACrashFreezesTheCommittedViewAndEverythingAfterFails() throws { |
| + let store = FaultingBlobStore(crashAt: 2) |
| + try store.write(Data("a".utf8), to: "x/1") |
| + try store.write(Data("b".utf8), to: "x/2") |
| + XCTAssertThrowsError(try store.write(Data("c".utf8), to: "x/3")) { XCTAssertEqual($0 as? SimulatedCrash, SimulatedCrash(operation: 2)) } |
| + XCTAssertThrowsError(try store.read("x/1"), "A dead process does nothing more") { XCTAssertTrue($0 is SimulatedCrash) } |
| + XCTAssertTrue(store.hasCrashed) |
| + XCTAssertEqual(store.committed.contents.keys.sorted(), ["x/1", "x/2"], "The crashed write never happened") |
| + XCTAssertEqual(store.operations, [.write("x/1"), .write("x/2")]) |
| + } |
| + |
| + func testCrashAfterCountsFromNow() throws { |
| + let store = FaultingBlobStore() |
| + try store.write(Data("a".utf8), to: "x/1") |
| + store.crash(after: 1) |
| + try store.remove("x/1") |
| + XCTAssertThrowsError(try store.list("x")) |
| + XCTAssertEqual(store.committed.contents, [:]) |
| + } |
| + |
| + func testUnavailableAnswersEveryOperationAndChangesNothing() throws { |
| + let store = FaultingBlobStore() |
| + try store.write(Data("a".utf8), to: "x/1") |
| + store.unavailable = true |
| + XCTAssertThrowsError(try store.read("x/1")) { XCTAssertEqual($0 as? BlobStoreError, .unavailable) } |
| + XCTAssertThrowsError(try store.remove("x/1")) { XCTAssertEqual($0 as? BlobStoreError, .unavailable) } |
| + XCTAssertThrowsError(try store.list("x")) { XCTAssertEqual($0 as? BlobStoreError, .unavailable) } |
| + store.unavailable = false |
| + XCTAssertEqual(try store.read("x/1"), Data("a".utf8)) |
| + } |
| + |
| + func testMemoryStoreListsOneLevelAndRemovesDirectories() throws { |
| + let store = MemoryBlobStore() |
| + try store.write(Data(), to: "outbox/1.entry") |
| + try store.write(Data(), to: "outbox/2.entry") |
| + try store.write(Data(), to: "cache/docs/a/replica") |
| + XCTAssertEqual(try store.list("outbox"), ["1.entry", "2.entry"]) |
| + XCTAssertEqual(try store.list("cache/docs"), []) |
| + try store.remove("cache") |
| + XCTAssertEqual(store.contents.keys.sorted(), ["outbox/1.entry", "outbox/2.entry"]) |
| + } |
| +} |
diff --git a/docs/testing/k3-evidence.md b/docs/testing/k3-evidence.md| new file mode 100644 |
| index 0000000..a55964f |
| --- /dev/null |
| +++ b/docs/testing/k3-evidence.md |
| @@ -0,0 +1,91 @@ |
| +# K3 validation |
| + |
| +Continuing WIP 68a51d7 against offline-kit spec 6049830 (discussion #5). |
| +The inherited implementations were explicitly no-op stubs, not working code. |
| + |
| +2026-09-24: ran the inherited tests against those stubs before implementation. |
| +`swift test` failed on missing enqueues, sends, replacement ordering, status |
| +mapping and store writes. One inherited outbox test indexed an empty result |
| +and terminated the process. A separate filtered run of `SyncStringsTests`, |
| +`SyncStatusTests`, `FaultingBlobStoreTests`, and `SealedStoreTests` executed 24 |
| +tests with 78 failures (including missing sealed files). Local transcripts: |
| +`/tmp/k3-kit-red.log`, `/tmp/k3-kit-red-rest.log`. |
| + |
| +After implementation, the storage/status/fault suite passed 32 tests and the |
| +drainer/catalog suite passed 18. `make ci` passed, including macOS and iOS |
| +companion builds, Go race tests, and Python bundle tests; transcript: |
| +`/tmp/k3-kit-ci-1.log`. This is intermediate evidence, not final K3 approval. |
| + |
| +The continuation evidence below supersedes the intermediate status. Nothing |
| +has been merged or deployed. |
| + |
| +## Disk stop, 2026-09-24 |
| + |
| +Work stopped when `df` reported 1.2 GiB free (user's stop threshold: 1.5 GiB). |
| +Only session-created Docs build outputs and companion DerivedData were removed. |
| +The inherited `.build` directory in this worktree was preserved. |
| + |
| +Additional focused evidence before the stop: |
| + |
| +- Seal-count rotation and interrupted-rotation tests: both failed with the |
| + rotation call stubbed (`/tmp/k3-rotation-red.log`); all 14 sealed-store tests |
| + passed after restoring/fixing rotation (`/tmp/k3-rotation-green-2.log`). |
| +- Independent-lane retry regression: failed with the first retry starving the |
| + next lane (`/tmp/k3-lane-retry-red.log`); all 15 drainer tests then passed |
| + (`/tmp/k3-lane-retry-green.log`). |
| +- Fixed an inherited undeclared AppleTests → RastrilloTestSupport dependency: |
| + incremental builds could compile tests against a stale support module. |
| +- Fixed an inherited test's unchecked empty-array indexing so stub failures |
| + produce an assertion instead of terminating the test process. |
| + |
| +At that disk stop, the latest changes had not had another full make ci. |
| +The resumed local gate and later focused checks are recorded below. |
| + |
| +Docs adoption is saved on oficina/docs `feat/k3-outbox-lane`, discussion #69. |
| +Its evidence file lists unresolved title/lifecycle issues and remaining gates. |
| + |
| +## Resume: status priority and concurrent close-save |
| + |
| +Spec §3.4 gives session-ended priority 2 and empty priority 3. Pending entries |
| +already produced needsSignIn(n); the empty-session exception is now removed too. |
| +A concurrent drain now awaits the reserved batch's result, so close-save cannot |
| +mistake an in-flight batch for idle. New checks failed on the previous behavior |
| +(2 assertions, /tmp/k3-status-drain-red.log); all 20 status/drainer tests then |
| +passed (/tmp/k3-status-drain-green.log). |
| + |
| +Reservation API rationale: load surfaces unavailable storage rather than an |
| +empty queue; entries includes held entries for offline replay; beginSend makes |
| +selection and reservation one actor operation so compaction cannot delete a |
| +batch in flight; endSend releases a retry/cancelled reservation without ack. |
| +No update or retarget API was added. |
| + |
| +## Final continuation evidence |
| + |
| +- Full local `make ci` passed at 3e93ef916f2bb5469fd09375e0f6253938da8650, |
| + including Swift, macOS/iOS companion builds, Go race and Python bundle checks |
| + (`/tmp/k3-kit-final-ci.log`). |
| +- A locked store must preserve its last known status. The new regression failed |
| + against the previous behavior (`/tmp/k3-unavailable-status-red.log`); all 21 |
| + drainer/status tests passed after the fix (`/tmp/k3-unavailable-status-green.log`). |
| + Explicit app enqueue failures still show cannotSaveOnDevice; unknown storage |
| + never masquerades as a known empty queue. |
| +- The final-head `make ci` rerun is logged at `/tmp/k3-kit-release-ci.log` and |
| + its result/head is recorded on [discussion #9](https://amadan.net/rastrillo/native/discuss/9). |
| +- Hub CI at 3e93ef9 failed before compilation: Sparkle archive extraction could |
| + not find unzip. There is no macOS runner; the local macOS gate is authoritative |
| + for this task, as directed by the user. |
| +- Docs now has real SIGKILL red/green proof and the parked-update port. Its |
| + replica/metadata/Home adoption and crash tests are on oficina/docs #69. |
| + |
| +The only deliberate ordering difference is app-side discard removing the replica |
| +before its lane; exhaustive crash tests prove this prevents stranded unsent text. |
| +The prior empty-session status exception is removed. No merge or deployment. |
| + |
| +## Fable review follow-up (2026-09-24) |
| + |
| +- `testSealBlocksCacheStateAndSkipUnusedQuotaAfterCrash`: failed against the inherited per-seal implementation (38 high-water assertions and two cache assertions); passes with 1024-slot reservations. Two process lifetimes consume 19 seals each, observe durable marks 1024/2048, and produce 38 distinct 96-bit nonces. A restart abandons 1005 unused slots; key rotation still counts reseals and keeps its previous key until completion. Complete file protection remains in every atomic write. |
| +- `testThrowingLaneDoesNotStarveOtherLanesAndEventuallyHolds`: failed against the inherited drainer (only lane a called, three lanes remained, no hold, fourth throw); passes with b/c draining on the first attempt and a held after its third consecutive untyped throw. Returning a disposition or a typed transport error resets the throw streak. |
| +- Full Swift suite: 119 tests passed. Red/green logs: `/tmp/k3-native-review-{red,green}.log` (ephemeral); observed assertions above are the durable record. |
| +- Reservation API addition: `entries(lane:excludingInFlight:)` adds a filter to the existing snapshot, needed to deduplicate held metadata markers without suppressing a successor to a marker already being sent. No new mutation or reservation operation. |
| +- Snapshot invariant is now documented: entries/summary can retain stale display/deduplication hints on unavailable storage; summary flags this, sendable fails closed, and load/beginSend/all mutations enforce availability before replay or changes. |
| +- Corrected unavailable-send and Retry-After comments. The latter remains a deadline that neither connectivity nor Try again bypasses. |