rastrillo / native Public

Merge branch 'feat/k3-outbox' into main

Merge K3: NativeSealedStore, KeyProvider, Outbox, OutboxDrainer, SyncStatus
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 529f356f476e43174882361511032e0bdefa5317 parent b8098cdparent 0ff1de9
41 files changed, +2816 −11
  • Package.swift +4 −4
  • Sources/RastrilloApple/NativeOfflineBanner.swift +15 −7
  • Sources/RastrilloApple/NativeSealedStore.swift +277 −0
  • Sources/RastrilloApple/NativeSyncStatusView.swift +43 −0
  • Sources/RastrilloApple/NativeSyncStrings.swift +63 −0
  • Sources/RastrilloApple/OutboxDrainer.swift +246 −0
  • Sources/RastrilloApple/Resources/ar.lproj/Localizable.strings +11 −0
  • Sources/RastrilloApple/Resources/ar.lproj/Localizable.stringsdict +34 −0
  • Sources/RastrilloApple/Resources/bn.lproj/Localizable.strings +11 −0
  • Sources/RastrilloApple/Resources/bn.lproj/Localizable.stringsdict +34 −0
  • Sources/RastrilloApple/Resources/en.lproj/Localizable.strings +11 −0
  • Sources/RastrilloApple/Resources/en.lproj/Localizable.stringsdict +34 −0
  • Sources/RastrilloApple/Resources/es.lproj/Localizable.strings +11 −0
  • Sources/RastrilloApple/Resources/es.lproj/Localizable.stringsdict +38 −0
  • Sources/RastrilloApple/Resources/ga.lproj/Localizable.strings +11 −0
  • Sources/RastrilloApple/Resources/ga.lproj/Localizable.stringsdict +34 −0
  • Sources/RastrilloApple/Resources/hi.lproj/Localizable.strings +11 −0
  • Sources/RastrilloApple/Resources/hi.lproj/Localizable.stringsdict +34 −0
  • Sources/RastrilloApple/Resources/ja.lproj/Localizable.strings +11 −0
  • Sources/RastrilloApple/Resources/ja.lproj/Localizable.stringsdict +34 −0
  • Sources/RastrilloApple/Resources/pt.lproj/Localizable.strings +11 −0
  • Sources/RastrilloApple/Resources/pt.lproj/Localizable.stringsdict +38 −0
  • Sources/RastrilloApple/Resources/ru.lproj/Localizable.strings +11 −0
  • Sources/RastrilloApple/Resources/ru.lproj/Localizable.stringsdict +40 −0
  • Sources/RastrilloApple/Resources/vi.lproj/Localizable.strings +11 −0
  • Sources/RastrilloApple/Resources/vi.lproj/Localizable.stringsdict +34 −0
  • Sources/RastrilloApple/Resources/yue.lproj/Localizable.strings +11 −0
  • Sources/RastrilloApple/Resources/yue.lproj/Localizable.stringsdict +34 −0
  • Sources/RastrilloApple/Resources/zh-Hans.lproj/Localizable.strings +11 −0
  • Sources/RastrilloApple/Resources/zh-Hans.lproj/Localizable.stringsdict +34 −0
  • Sources/RastrilloNative/BlobStore.swift +38 −0
  • Sources/RastrilloNative/Outbox.swift +260 −0
  • Sources/RastrilloNative/SyncStatus.swift +59 −0
  • Sources/RastrilloTestSupport/FaultingBlobStore.swift +98 −0
  • Tests/RastrilloAppleTests/OutboxDrainerTests.swift +353 −0
  • Tests/RastrilloAppleTests/SealedStoreTests.swift +223 −0
  • Tests/RastrilloAppleTests/SyncStringsTests.swift +63 −0
  • Tests/RastrilloNativeTests/OutboxTests.swift +310 −0
  • Tests/RastrilloNativeTests/SyncStatusTests.swift +71 −0
  • Tests/RastrilloTestSupportTests/FaultingBlobStoreTests.swift +48 −0
  • docs/testing/k3-evidence.md +91 −0

CI failed — run details

landed from feat/k3-outbox

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.