Files
nvrl ed05a1e723 feat(ios): port the Android client to SwiftUI
The iOS client is now a screen-for-screen port of the Android app over the
same pstr-android UniFFI surface, with no Rust changes: Library, title page,
History, Shares (public links and Drive folders), Downloads and Settings,
with the same strings, undo snackbars and two-pane layout on wide screens.

- Theme from the engine's palette (Material role names, Inter, the same
  type ramp and corners), repainted live from the appearance settings.
- Account sign-in with two-factor code, mailbox password and the Proton
  CAPTCHA in a non-persistent WKWebView, the Drive folder browser, and
  watch-history sync every five minutes, after sign-in, on player close and
  when the app leaves the screen.
- Player over libmpv: resume, progress saved every 5 s, autoplay with the
  up-next card, skip and auto-skip from the chapter plan, track, speed,
  volume and episode pickers, landscape lock, mini transport, and
  lock-screen controls with interruption and route-change handling.
- Offline downloads with the Android record format, free-space floor,
  retries, transfer rate and the Wi-Fi-only policy; interrupted transfers
  resume at next launch.
- Config, data and download state are excluded from backup, as Android's
  allowBackup=false: a restore would bring shares back without their
  Keychain secrets and give two devices one sync identity.

Picture-in-picture, the share sheet and background downloads are not
possible the same way on iOS; docs/IOS.md lists why and what replaces them.

Adds XCTest ports of the Android unit tests, a `test` command to
scripts/build-ios.sh, the app icon and its render script, and an iOS
workflow on macos-15 that runs the tests and builds the unsigned .ipa.
2026-10-07 00:32:03 +02:00

74 lines
3.6 KiB
Swift

import Foundation
/// Process-wide owner of the Rust engine; records and decrypted media remain
/// app-private. The counterpart of Android's `NativeRuntime`.
enum NativeRuntime {
private static let lock = NSLock()
private static var instance: AndroidEngine?
/// The engine, built on first use off the main thread: construction opens
/// SQLite, spins a Tokio runtime and reads the Keychain.
static func engine() async throws -> AndroidEngine {
try await Task.detached(priority: .userInitiated) { try blockingEngine() }.value
}
/// The engine from a thread that may block — a bridge callback, a
/// background task. Never call this on the main thread.
static func blockingEngine() throws -> AndroidEngine {
try lock.withLock {
if let instance { return instance }
let engine = try AndroidEngine(paths: try paths(), secrets: KeychainSecretStore())
// The budget is this app's setting, so the engine starts on the
// default and is told the chosen one before anything streams.
engine.setStreamCacheBudget(bytes: SettingsStore.gibToBytes(SettingsStore().cacheBudgetGib))
instance = engine
return engine
}
}
/// The stored palette, without building the engine.
///
/// Callable from the main thread, and meant to be: it is the only palette
/// read early enough to decide what the first frame is painted in, and
/// `engine()` is not. This reads one small JSON file and resolves it.
static func initialPalette() -> PaletteRecord? {
guard let paths = try? paths() else { return nil }
return storedPalette(paths: paths)
}
/// Application Support for the catalog and the configuration, Caches for
/// the block cache, which iOS may purge.
///
/// Neither the catalog nor the configuration is backed up, as on Android
/// (`allowBackup="false"`): the share secrets live in the Keychain as
/// this-device-only items, so a restored share list would arrive without
/// them, and a restored `sync.json` would give two phones one sync identity
/// writing one file — the case `pstr-core::sync` exists to prevent.
private static func paths() throws -> AndroidPaths {
let files = FileManager.default
let support = try files.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let caches = try files.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
var config = support.appendingPathComponent("config", isDirectory: true)
var data = support.appendingPathComponent("data", isDirectory: true)
let cache = caches.appendingPathComponent("stream", isDirectory: true)
for directory in [config, data, cache] {
try files.createDirectory(at: directory, withIntermediateDirectories: true)
}
var excluded = URLResourceValues()
excluded.isExcludedFromBackup = true
try config.setResourceValues(excluded)
try data.setResourceValues(excluded)
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
}
}
/// What a bridge failure says, without Swift's type name around it.
///
/// `BridgeError`'s own description is `String(reflecting:)`, which reads
/// `PstrBridge.BridgeError.Failure(reason: "…")` in a snackbar.
func errorMessage(_ error: Error) -> String {
if case let BridgeError.Failure(reason) = error { return reason }
let text = error.localizedDescription
return text.isEmpty ? "Unexpected error" : text
}