mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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.
74 lines
3.6 KiB
Swift
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
|
|
}
|