mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 16:57:00 +01:00
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.
This commit is contained in:
1 parent
da16ab0575
commit
ed05a1e723
38 files changed
+7546
-208
No files matched your search
@@ -0,0 +1,73 @@
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user