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:
nvrl committed 2026-10-07 00:32:03 +02:00
1 parent da16ab0575
commit ed05a1e723
38 files changed
+7546 -208

No files matched your search

+623 -107
View File
@@ -1,140 +1,656 @@
import Foundation
import Observation
import UIKit
/// The app's one Rust engine and the screen state read from it.
/// The Proton account: where a sign-in stands, and how sync is doing.
struct AccountUiState: Equatable {
/// Nil until the bridge has said.
var state: AccountState?
/// A sign-in step is out and not answered yet.
var busy = false
/// Why the last sign-in step was refused.
var error: String?
/// The CAPTCHA page Proton wants solved before it accepts the sign-in.
var verificationUrl: String?
/// When watch history last synced.
var syncedAt: Date?
/// Positions the last sync took from other devices.
var applied = 0
/// Why the last sync failed, until one succeeds.
var syncError: String?
}
/// One episode's watch state as it was before a change the viewer can undo.
/// Nil `state` means it had never been played.
struct WatchSnapshot: Equatable {
let shareId: String
let linkId: String
let state: WatchStateRecord?
}
/// The app's screen state and every action on it: Android's `AppViewModel`,
/// field for field and message for message.
///
/// Rust owns the Proton sessions, SQLite and every decrypted byte; this only
/// asks it questions off the main thread and keeps the answers for SwiftUI.
/// Rust owns the Proton sessions, SQLite and every decrypted byte; this asks it
/// questions off the main thread and keeps the answers for SwiftUI.
@MainActor
@Observable
final class AppModel {
let engine: AndroidEngine?
private(set) var startupError: String?
var loading = true
var refreshing = false
private(set) var query = ""
var titles: [TitleRecord] = []
/// The grid: `titles` ordered, each franchise folded into one tile when
/// `grouped`, and the shelves above it. Nil until the bridge has answered,
/// when the grid falls back to `titles` as they are.
var arrangement: ArrangementRecord?
var sort: LibrarySort = .name
var grouped = true
var shares: [ShareRecord] = []
var search = ""
var crawling = false
var error: String?
var offline: [OfflineRecord] = []
var metadataSettings = MetadataSettingsRecord(enabled: false, provider: .aniList, language: "en", ready: true)
var storage = StorageUsageRecord(offlineBytes: 0, offlineCount: 0, partialBytes: 0, cacheBytes: 0)
/// What the snackbar says.
var message: String?
/// What Undo on `message` puts back, when the message offers one.
var undo: [WatchSnapshot]?
var account = AccountUiState()
init() {
do {
engine = try AndroidEngine(paths: Self.paths(), secrets: KeychainSecretStore())
} catch {
engine = nil
startupError = String(describing: error)
@ObservationIgnored private var searchTask: Task<Void, Never>?
@ObservationIgnored private var started = false
/// Five minutes, as on Android: cheap when nothing changed — one folder
/// listing, and no upload.
private static let syncInterval: Duration = .seconds(5 * 60)
func start() {
if started { return }
started = true
reload()
DownloadCoordinator.shared.onFinished = { [weak self] in self?.reload() }
DownloadCoordinator.shared.resumeQueued()
Task {
let state = (try? await run { $0.accountState() }) ?? .signedOut
account.state = state
// While the app is alive, on top of after sign-in and whenever the
// player closes.
while !Task.isCancelled {
await syncWatchHistory()
try? await Task.sleep(for: Self.syncInterval)
}
}
}
/// Application Support survives updates and is backed up, which is what
/// the watch history wants. The block cache goes under Caches, which iOS
/// may purge and never backs up.
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)
let config = support.appendingPathComponent("config", isDirectory: true)
let data = support.appendingPathComponent("data", isDirectory: true)
let cache = caches.appendingPathComponent("proton-stream", isDirectory: true)
for directory in [config, data, cache] {
try files.createDirectory(at: directory, withIntermediateDirectories: true)
}
return AndroidPaths(config: config.path, data: data.path, cache: cache.path)
}
struct NoEngine: Error, CustomStringConvertible {
var description: String { "the Rust engine did not start" }
}
/// Run a blocking bridge call off the main thread. The synchronous exports
/// take the catalog lock and touch SQLite, which a scroll must not wait on.
func run<T>(_ work: @escaping (AndroidEngine) throws -> T) async throws -> T {
guard let engine else { throw NoEngine() }
return try await Task.detached(priority: .userInitiated) { try work(engine) }.value
nonisolated func run<T: Sendable>(_ work: @escaping @Sendable (AndroidEngine) throws -> T) async throws -> T {
try await Task.detached(priority: .userInitiated) {
try work(try NativeRuntime.blockingEngine())
}.value
}
func report(_ error: Error) {
self.error = String(describing: error)
// MARK: - Library
/// Reorder the grid, or fold or unfold its franchises.
func arrange(_ sort: LibrarySort, _ grouped: Bool) {
self.sort = sort
self.grouped = grouped
Task { await reloadLibrary(query) }
}
func reload() async {
let needle = search.trimmingCharacters(in: .whitespaces)
func search(_ query: String) {
self.query = query
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
if Task.isCancelled { return }
await reloadLibrary(query)
}
}
func refresh() {
crawl(nil)
}
/// Recrawl one share rather than the whole library: a share whose link has
/// expired makes a whole-library refresh fail, and must not leave no way to
/// refresh the shares that still work.
func refreshShare(_ id: String) {
crawl(id)
}
private func crawl(_ shareId: String?) {
Task { await crawlNow(shareId) }
}
/// The crawl itself, for pull to refresh, which holds its spinner until
/// this returns.
func crawlNow(_ shareId: String?) async {
refreshing = true
message = nil
do {
let (titles, shares) = try await run { engine in
(try engine.library(search: needle.isEmpty ? nil : needle), try engine.shares())
try await NativeRuntime.engine().crawl(shareId: shareId)
} catch {
message = errorMessage(error)
}
refreshing = false
reload()
}
/// Run enrichment again. With `force` every title is looked up afresh,
/// including ones that already matched.
func matchTitles(force: Bool) {
Task {
refreshing = true
message = nil
do {
let summary = try await NativeRuntime.engine().matchTitles(force: force)
message = Self.describe(summary)
} catch {
message = errorMessage(error)
}
refreshing = false
reload()
}
}
// MARK: - Shares
func addShare(name: String, url: String, password: String?) {
Task {
do {
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
_ = try await run { try $0.addShare(name: name, url: url, customPassword: password) }
try await NativeRuntime.engine().crawl(shareId: nil)
} catch {
message = errorMessage(error)
}
reload()
}
}
/// Re-supply the link behind a share whose stored secret cannot be read.
/// Re-entering the link rewrites the secret and leaves the catalog and the
/// offline files alone.
func repairShare(id: String, url: String, password: String?) {
Task {
do {
let password = password.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
try await NativeRuntime.engine().crawl(shareId: id)
} catch {
reportError(error)
}
reload()
}
}
func removeShare(_ id: String) {
Task {
do {
// Wait for the transfers to stop before Rust removes catalog
// and files. Rust also rejects any late publication.
await DownloadCoordinator.shared.cancelShare(id)
let store = DownloadStateStore.shared
let engine = try await NativeRuntime.engine()
for download in store.records() where download.shareId == id {
try await engine.removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
}
store.removeShare(id)
try await run { try $0.removeShare(shareId: id) }
} catch {
message = errorMessage(error)
}
reload()
}
}
func dismissMessage() {
message = nil
undo = nil
}
// MARK: - Account
/// Start signing in, or start again with `verificationToken` once the
/// CAPTCHA from a first try is solved.
func signIn(_ username: String, _ password: String, verificationToken: String? = nil) {
signInStep { try await $0.signIn(username: username, password: password, verificationToken: verificationToken) }
}
func submitSecondFactor(_ code: String) {
signInStep { try await $0.submitSecondFactor(code: code) }
}
func submitMailboxPassword(_ password: String) {
signInStep(keepStepOnFailure: true) { try await $0.submitMailboxPassword(password: password) }
}
func cancelSignIn() {
account = AccountUiState(state: .signedOut)
Task { try? await run { $0.cancelSignIn() } }
}
/// The CAPTCHA page closed without a token.
func dismissVerification() {
account.verificationUrl = nil
account.busy = false
}
private func signInStep(keepStepOnFailure: Bool = false, _ step: @escaping (AndroidEngine) async throws -> SignInOutcome) {
account.busy = true
account.error = nil
account.verificationUrl = nil
Task {
do {
let outcome = try await step(NativeRuntime.engine())
switch outcome {
case let .done(username):
account = AccountUiState(state: .signedIn(username: username))
message = "Signed in as \(username)"
await syncWatchHistory()
reload()
case .secondFactor:
account.state = .secondFactor
account.busy = false
case .mailboxPassword:
account.state = .mailboxPassword
account.busy = false
case let .humanVerification(url):
account.verificationUrl = url
}
} catch {
account.busy = false
account.error = errorMessage(error)
// A refused code spends the half-made session.
if !keepStepOnFailure { account.state = .signedOut }
}
}
}
func signOut() {
Task {
do {
try await NativeRuntime.engine().signOut()
} catch {
reportError(error)
}
account = AccountUiState(state: .signedOut)
reload()
}
}
/// Sync now, from a tap: says so when it is done.
func syncNow() {
Task {
guard await syncWatchHistory() else { return }
let applied = account.applied
message = applied > 0 ? "Watch history synced, \(applied) from other devices" : "Watch history is up to date"
}
}
/// Pull other devices' positions in and push this one's out. Quiet: it runs
/// on a timer, and a failure is shown on the account card rather than as a
/// snackbar every five minutes of an offline evening. Returns whether it
/// synced.
@discardableResult
func syncWatchHistory() async -> Bool {
guard case .signedIn = account.state else { return false }
do {
guard let report = try await NativeRuntime.engine().syncWatchHistory() else { return false }
account.syncedAt = Date()
account.applied = Int(report.applied)
account.syncError = nil
if report.applied > 0 { reload() }
return true
} catch {
let state = try? await run { $0.accountState() }
if let state { account.state = state }
account.syncError = errorMessage(error)
// Signed out by the engine: the session ended, and that is worth
// interrupting for, unlike a dropped connection.
if state == .signedOut { reportError(error) }
return false
}
}
/// Leaving the app: push where playback stopped, inside the grace iOS
/// gives a backgrounded app — what `WatchSyncWorker` does on Android.
func syncInBackground() {
guard case .signedIn = account.state else { return }
let task = BackgroundTask(name: "watch-history-sync")
Task {
await syncWatchHistory()
task.end()
}
}
/// After the player closes: where it stopped is what another device wants next.
func playerClosed() {
reload()
Task { await syncWatchHistory() }
}
func addAccountFolder(name: String, volumeId: String, linkId: String) {
Task {
refreshing = true
do {
let share = try await run { try $0.addAccountFolder(name: name, volumeId: volumeId, linkId: linkId) }
try await NativeRuntime.engine().crawl(shareId: share.id)
} catch {
reportError(error)
}
refreshing = false
reload()
}
}
// MARK: - Watch state
/// Every episode of a title watched or unwatched, with an Undo: a whole
/// title's positions are what this throws away, and a mis-tap on a tile's
/// menu should not cost them.
func setTitleWatched(_ title: TitleRecord, _ watched: Bool) {
changeWatch(title.playlist, "\(title.displayName) marked \(watched ? "watched" : "unwatched")") { engine, episode, before in
let duration = before?.durationSecs
try engine.saveWatchState(
shareId: episode.shareId,
linkId: episode.linkId,
positionSecs: watched ? duration ?? 0 : 0,
durationSecs: duration,
watched: watched
)
}
}
/// Off Continue watching by forgetting where the episode stopped — what the
/// desktop does — with an Undo that puts the position back.
func forgetPosition(_ title: TitleRecord, _ episode: EpisodeRecord) {
changeWatch([episode], "\(title.displayName) removed from Continue watching") { engine, target, before in
try engine.saveWatchState(shareId: target.shareId, linkId: target.linkId, positionSecs: 0, durationSecs: before?.durationSecs, watched: false)
}
}
/// Off the history page: the position and the watched mark both go, which
/// is "never played", with an Undo.
func removeFromHistory(_ title: TitleRecord, _ episode: EpisodeRecord) {
changeWatch([episode], "\(episode.label) of \(title.displayName) removed from history") { engine, target, before in
try engine.saveWatchState(shareId: target.shareId, linkId: target.linkId, positionSecs: 0, durationSecs: before?.durationSecs, watched: false)
}
}
/// Put back what the last undoable change replaced.
func performUndo() {
guard let snapshots = undo else { return }
message = nil
undo = nil
Task {
do {
try await run { engine in
for snapshot in snapshots {
try engine.saveWatchState(
shareId: snapshot.shareId,
linkId: snapshot.linkId,
positionSecs: snapshot.state?.positionSecs ?? 0,
durationSecs: snapshot.state?.durationSecs,
watched: snapshot.state?.watched ?? false
)
}
}
} catch {
reportError(error)
}
reload()
}
}
private func changeWatch(
_ episodes: [EpisodeRecord],
_ text: String,
_ change: @escaping @Sendable (AndroidEngine, EpisodeRecord, WatchStateRecord?) throws -> Void
) {
Task {
do {
let snapshots = try await run { engine in
try episodes.map { episode in
let before = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)
try change(engine, episode, before)
return WatchSnapshot(shareId: episode.shareId, linkId: episode.linkId, state: before)
}
}
message = text
undo = snapshots
} catch {
reportError(error)
}
reload()
}
}
func reportError(_ error: Error) {
message = errorMessage(error)
}
func report(_ text: String) {
message = text
}
// MARK: - Metadata
func saveMetadataSettings(enabled: Bool, provider: MetadataProvider, language: String, apiKey: String) {
Task {
do {
let engine = try await NativeRuntime.engine()
try await run { engine in
if provider == .tmdb, !apiKey.trimmingCharacters(in: .whitespaces).isEmpty {
try engine.setMetadataApiKey(provider: provider, key: apiKey)
}
try engine.setMetadataSettings(settings: MetadataSettingsRecord(enabled: enabled, provider: provider, language: language, ready: true))
}
if enabled {
message = Self.describe(try await engine.matchTitles(force: false))
}
} catch {
reportError(error)
}
reload()
}
}
func reloadAfterMetadataChange() { reload() }
/// What an enrichment pass did, in one line. Failures are reported even
/// when most titles matched.
static func describe(_ summary: MatchSummary) -> String {
if summary.matched == 0, summary.unmatched == 0, summary.failed == 0 {
return "Everything is already matched"
}
var parts = ["\(summary.matched) matched"]
if summary.unmatched > 0 { parts.append("\(summary.unmatched) not found") }
if summary.episodes > 0 { parts.append("\(summary.episodes) episodes named") }
if summary.failed > 0 { parts.append("\(summary.failed) failed") }
return parts.joined(separator: ", ")
}
// MARK: - Offline
func removeOffline(_ file: OfflineRecord) {
Task {
do {
try await NativeRuntime.engine().removeOfflineEpisode(shareId: file.shareId, linkId: file.linkId)
} catch {
message = errorMessage(error)
}
reload()
}
}
func pauseDownload(_ download: RetainedDownload) { DownloadCoordinator.shared.pause(download) }
func resumeDownload(_ download: RetainedDownload) { DownloadCoordinator.shared.resume(download) }
func deletePartial(_ download: RetainedDownload) {
Task {
await DownloadCoordinator.shared.cancelAndWait(download)
do {
try await NativeRuntime.engine().removeOfflineEpisode(shareId: download.shareId, linkId: download.linkId)
DownloadStateStore.shared.remove(download.shareId, download.linkId)
} catch {
reportError(error)
}
reload()
}
}
func removeAllOffline() {
Task {
await DownloadCoordinator.shared.cancelAll()
do {
try await NativeRuntime.engine().removeAllOffline()
DownloadStateStore.shared.clear()
} catch {
reportError(error)
}
reload()
}
}
func clearBlockCache() {
Task {
do {
let reclaimed = try await run { try $0.clearBlockCache() }
message = "Reclaimed \(formatBytes(reclaimed)) of cache"
} catch {
reportError(error)
}
reload()
}
}
// MARK: - Progress
/// Persist where an episode was left. Runs on its own task rather than the
/// player's, so a save issued on the way out is not cancelled by the
/// player going away.
func saveProgress(_ episode: EpisodeRecord, position: Double, duration: Double, watched: Bool) {
Task {
do {
try await run { engine in
try engine.saveWatchState(
shareId: episode.shareId,
linkId: episode.linkId,
positionSecs: min(max(position, 0), duration),
durationSecs: duration,
watched: watched
)
}
} catch {
reportError(error)
}
}
}
/// Mark an episode seen, or unseen, without playing it.
///
/// Unwatching rewinds, matching the desktop client. Marking one seen puts
/// the position at its duration where one is known, so the progress bar
/// agrees with the tick.
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) {
Task {
do {
try await run { engine in
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs
try engine.saveWatchState(
shareId: episode.shareId,
linkId: episode.linkId,
positionSecs: watched ? duration ?? 0 : 0,
durationSecs: duration,
watched: watched
)
}
} catch {
reportError(error)
}
reload()
}
}
// MARK: - Reload
func reload() {
let query = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : self.query
let sort = sort
let grouped = grouped
Task {
do {
let result = try await run { engine in
// Here rather than inside `library()`, which is read-only
// and cached: a full reload is the one place that has
// already paid for a walk of the offline files.
_ = try engine.pruneOfflineFiles()
return Reloaded(
shares: try engine.shares(),
titles: try engine.library(search: query),
arrangement: try engine.arrangement(search: query, sort: sort, grouped: grouped),
offline: try engine.offlineFiles(),
metadataSettings: try engine.metadataSettings(),
storage: try engine.storageUsage()
)
}
loading = false
shares = result.shares
titles = result.titles
arrangement = result.arrangement
offline = result.offline
metadataSettings = result.metadataSettings
storage = result.storage
} catch {
loading = false
message = errorMessage(error)
}
}
}
private func reloadLibrary(_ query: String) async {
let search = query.trimmingCharacters(in: .whitespaces).isEmpty ? nil : query
let sort = sort
let grouped = grouped
do {
let (titles, arrangement) = try await run { engine in
(try engine.library(search: search), try engine.arrangement(search: search, sort: sort, grouped: grouped))
}
self.titles = titles
self.shares = shares
self.arrangement = arrangement
} catch {
report(error)
message = errorMessage(error)
}
}
}
func crawl(shareId: String? = nil) async {
guard let engine, !crawling else { return }
crawling = true
defer { crawling = false }
do {
try await engine.crawl(shareId: shareId)
} catch {
report(error)
}
await reload()
}
func addShare(name: String, url: String, password: String?) async {
do {
let share = try await run { try $0.addShare(name: name, url: url, customPassword: password) }
await crawl(shareId: share.id)
} catch {
report(error)
}
}
/// The grace iOS gives an app leaving the screen, ended exactly once —
/// whether by the work finishing or by the time running out.
@MainActor
final class BackgroundTask {
private var identifier = UIBackgroundTaskIdentifier.invalid
func repairShare(id: String, url: String, password: String?) async {
do {
_ = try await run { try $0.repairShare(shareId: id, url: url, customPassword: password) }
await crawl(shareId: id)
} catch {
report(error)
init(name: String) {
identifier = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in
MainActor.assumeIsolated { self?.end() }
}
}
func removeShare(id: String) async {
do {
try await run { try $0.removeShare(shareId: id) }
} catch {
report(error)
}
await reload()
func end() {
guard identifier != .invalid else { return }
UIApplication.shared.endBackgroundTask(identifier)
identifier = .invalid
}
}
/// Marking seen puts the position at the duration where one is known, and
/// unwatching rewinds, as on desktop and Android.
func setWatched(_ episode: EpisodeRecord, _ watched: Bool) async {
do {
try await run { engine in
let duration = try engine.watchState(shareId: episode.shareId, linkId: episode.linkId)?.durationSecs
try engine.saveWatchState(
shareId: episode.shareId, linkId: episode.linkId,
positionSecs: watched ? duration ?? 0 : 0,
durationSecs: duration, watched: watched
)
}
} catch {
report(error)
}
await reload()
}
/// Episodes started but not finished, most recent first.
var continueWatching: [(title: TitleRecord, episode: EpisodeRecord)] {
titles
.flatMap { title in
title.seasons.flatMap(\.episodes)
.filter { $0.resumeAt != nil && !$0.watched }
.map { (title: title, episode: $0) }
}
.sorted { $0.episode.lastPlayed > $1.episode.lastPlayed }
}
private struct Reloaded: @unchecked Sendable {
let shares: [ShareRecord]
let titles: [TitleRecord]
let arrangement: ArrangementRecord
let offline: [OfflineRecord]
let metadataSettings: MetadataSettingsRecord
let storage: StorageUsageRecord
}
@@ -0,0 +1,40 @@
import Foundation
/// A Proton Drive public link, wherever in a piece of text it sits.
///
/// The fragment is kept: it is the share's secret, and a link without it cannot
/// be opened. Matching stops at whitespace and at the quotes and brackets a
/// message app wraps a link in, which never occur in a link itself.
private let shareLinkPattern = #"https://drive\.proton\.me/urls/[^\s"'<>()\[\]]+"#
/// The first share link in `text`, if any — for the clipboard.
func shareLink(in text: String) -> String? {
text.range(of: shareLinkPattern, options: .regularExpression).map { String(text[$0]) }
}
/// The share link a URL opened in this app carries, if any.
///
/// iOS hands an app no `https://drive.proton.me` link — Universal Links belong
/// to the domain's owner — so a link arrives through the app's own scheme:
/// `protonstream://add?url=<link>`, or the link itself with its scheme swapped,
/// `protonstream://drive.proton.me/urls/…#…`.
func shareLink(from url: URL) -> String? {
guard url.scheme?.lowercased() == "protonstream" else { return nil }
if url.host == "add" {
let value = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?.first { $0.name == "url" }?.value
return value.flatMap(wholeShareLink)
}
let text = url.absoluteString
let rest = text[text.index(text.startIndex, offsetBy: "protonstream://".count)...]
return wholeShareLink("https://\(rest)")
}
/// `value` when the whole of it is a share link — what `ACTION_VIEW` accepts on
/// Android — rather than a link somewhere inside it.
private func wholeShareLink(_ value: String) -> String? {
guard let range = value.range(of: shareLinkPattern, options: .regularExpression),
range == value.startIndex ..< value.endIndex
else { return nil }
return value
}
+73
View File
@@ -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
}
+309 -19
View File
@@ -1,48 +1,338 @@
import AVFoundation
import SwiftUI
import UIKit
@main
struct ProtonStreamApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@State private var model = AppModel()
@State private var appearance = AppearanceState.shared
init() {
// `.playback` keeps the audio going with the screen locked or the app
// in the background, which `UIBackgroundModes: audio` then permits.
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
// The palette read before the first frame, so the first frame is
// already painted in it.
AppearanceState.shared.seed(NativeRuntime.initialPalette())
}
var body: some Scene {
WindowGroup {
let scheme = appearance.scheme
RootView()
.environment(model)
.preferredColorScheme(.dark)
.environment(\.scheme, scheme)
.preferredColorScheme(scheme.colorScheme)
.tint(scheme.primary)
.font(.inter(.bodyLarge))
.background(scheme.background.ignoresSafeArea())
.task { await appearance.load() }
}
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
MainActor.assumeIsolated { OrientationLock.mask }
}
}
/// From this width the library and an open title share the window.
private let twoPaneWidth: CGFloat = 840
/// From this width the destinations are a rail down the side, not a bar.
private let railWidth: CGFloat = 600
/// How much room the floating mini transport needs at the foot of a page.
private let miniTransportInset: CGFloat = 88
enum Destination: String, CaseIterable {
case library, history, shares, downloads, settings
var label: String {
switch self {
case .library: "Library"
case .history: "History"
case .shares: "Shares"
case .downloads: "Downloads"
case .settings: "Settings"
}
}
var icon: String {
switch self {
case .library: "play.rectangle.on.rectangle.fill"
case .history: "clock.arrow.circlepath"
case .shares: "square.and.arrow.up"
case .downloads: "arrow.down.circle"
case .settings: "gearshape"
}
}
}
struct RootView: View {
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
@Environment(\.scenePhase) private var phase
// Keys, not records: what has to survive is *which* title, and a record is
// no longer current after the library reloads. Everything else is derived,
// so a reload that renames a season updates the open screen.
@SceneStorage("destination") private var destination: Destination = .library
@SceneStorage("selectedTitle") private var selectedTitleKey: String?
@State private var incomingLink: String?
@State private var host = PlayerHost.shared
var body: some View {
if let failure = model.startupError {
ContentUnavailableView("Could not start", systemImage: "exclamationmark.triangle", description: Text(failure))
} else {
TabView {
NavigationStack { LibraryView() }
.tabItem { Label("Library", systemImage: "play.rectangle.on.rectangle") }
NavigationStack { SharesView() }
.tabItem { Label("Shares", systemImage: "link") }
NavigationStack { SettingsView() }
.tabItem { Label("Settings", systemImage: "gearshape") }
GeometryReader { geometry in
let wide = geometry.size.width >= railWidth
ZStack(alignment: .bottom) {
HStack(spacing: 0) {
if wide { NavigationRail(destination: $destination, onSelect: select) }
page(width: geometry.size.width)
.safeAreaInset(edge: .bottom, spacing: 0) {
VStack(spacing: 8) {
if host.isOpen && host.minimized {
MiniTransport(host: host)
}
if !wide { NavigationBar(destination: $destination, onSelect: select) }
}
}
}
Snackbar()
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
}
.task { await model.reload() }
.alert("Something went wrong", isPresented: .init(
get: { model.error != nil },
set: { if !$0 { model.error = nil } }
)) {
Button("OK", role: .cancel) {}
} message: {
Text(model.error ?? "")
.background(scheme.background.ignoresSafeArea())
}
.overlay {
// Over the shell rather than instead of it: the page behind stays
// where it was, so leaving the player finds the title page as it was.
if host.isOpen && !host.minimized {
Group {
if let player = host.player {
PlayerView(host: host, player: player)
} else {
ZStack(alignment: .topTrailing) {
Color.black.ignoresSafeArea()
Text(host.playbackError ?? "Playback is unavailable: libmpv did not start.")
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
IconButton("xmark", "Close", tint: .white, action: host.close)
}
}
}
.transition(.move(edge: .bottom))
}
}
.animation(.easeInOut(duration: 0.25), value: host.isOpen && !host.minimized)
.onAppear {
model.start()
host.onSaveProgress = { [model] episode, position, duration, watched in
model.saveProgress(episode, position: position, duration: duration, watched: watched)
}
host.onClosed = { [model] in model.playerClosed() }
}
.onChange(of: model.titles) { _, titles in host.update(titles: titles) }
.onChange(of: phase) { _, phase in
switch phase {
case .background:
host.enteredBackground()
model.syncInBackground()
case .active:
host.enteredForeground()
DownloadCoordinator.shared.resumeQueued()
default:
break
}
}
// A link opened in this app: show the Add form with it filled in. A
// playing episode keeps going in the mini transport rather than
// covering the form.
.onOpenURL { url in
guard let link = shareLink(from: url) else { return }
incomingLink = link
destination = .shares
selectedTitleKey = nil
if host.isOpen { host.minimized = true }
}
}
private func select(_ target: Destination) {
destination = target
selectedTitleKey = nil
}
private func play(_ title: TitleRecord, _ index: Int) {
host.play(title, at: index)
}
@ViewBuilder private func page(width: CGFloat) -> some View {
switch destination {
case .library:
libraryPage(width: width)
case .history:
HistoryView(onPlay: play, onTitle: { title in
destination = .library
selectedTitleKey = title.key
})
case .shares:
SharesView(incomingLink: $incomingLink)
case .downloads:
DownloadsView()
case .settings:
SettingsView()
}
}
/// Side by side where there is room for both: on a tablet the library
/// stays in view while a title is open, so moving between titles is one
/// tap rather than back and in again.
@ViewBuilder private func libraryPage(width: CGFloat) -> some View {
let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } }
let library = LibraryView(onResume: play, onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } })
if width >= twoPaneWidth {
HStack(spacing: 0) {
library.frame(width: width * 0.45)
Rectangle().fill(scheme.outlineVariant).frame(width: 1).ignoresSafeArea()
Group {
if let selected { titlePane(selected) } else { EmptyState("Choose a title", "Its seasons and episodes open here.") }
}
.frame(maxWidth: .infinity)
}
} else {
// The library stays underneath the title page rather than being
// rebuilt, so going back finds the grid scrolled where it was.
ZStack {
library
.opacity(selected == nil ? 1 : 0)
.allowsHitTesting(selected == nil)
.accessibilityHidden(selected != nil)
if let selected {
titlePane(selected)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity.combined(with: .scale(scale: 0.9))))
.zIndex(1)
}
}
}
}
private func titlePane(_ selected: TitleRecord) -> some View {
TitleView(
title: selected,
onPlay: { _, index in host.play(selected, at: index) },
onBack: { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } },
franchise: selected.franchise.compactMap { key in model.titles.first { $0.key == key } },
onOpenTitle: { selectedTitleKey = $0.key }
)
.id(selected.key)
}
}
/// The destinations along the foot of a phone, the selected one in an accent
/// pill — the desktop's rule is that the accent marks the thing you are on.
private struct NavigationBar: View {
@Binding var destination: Destination
let onSelect: (Destination) -> Void
@Environment(\.scheme) private var scheme
var body: some View {
HStack(spacing: 0) {
ForEach(Destination.allCases, id: \.self) { item in
let selected = item == destination
Button { onSelect(item) } label: {
VStack(spacing: 4) {
Image(systemName: item.icon)
.font(.system(size: 18))
.foregroundStyle(selected ? scheme.onPrimary : scheme.onSurfaceVariant)
.frame(width: 56, height: 30)
.background(selected ? scheme.primary : .clear, in: Capsule())
Text(item.label)
.textStyle(.labelMedium, semibold: selected)
.foregroundStyle(selected ? scheme.onSurface : scheme.onSurfaceVariant)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.top, 10)
.padding(.bottom, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
.background(scheme.surfaceContainer.ignoresSafeArea(edges: .bottom))
}
}
/// The same destinations down the side of a wide window.
private struct NavigationRail: View {
@Binding var destination: Destination
let onSelect: (Destination) -> Void
@Environment(\.scheme) private var scheme
var body: some View {
VStack(spacing: 12) {
ForEach(Destination.allCases, id: \.self) { item in
let selected = item == destination
Button { onSelect(item) } label: {
VStack(spacing: 4) {
Image(systemName: item.icon)
.font(.system(size: 18))
.foregroundStyle(selected ? scheme.onPrimary : scheme.onSurfaceVariant)
.frame(width: 56, height: 32)
.background(selected ? scheme.primary : .clear, in: Capsule())
Text(item.label)
.textStyle(.labelMedium, semibold: selected)
.foregroundStyle(selected ? scheme.onSurface : scheme.onSurfaceVariant)
}
.frame(width: 80)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : [])
}
Spacer()
}
.padding(.top, 24)
.background(scheme.surface.ignoresSafeArea())
}
}
/// The app's one line of feedback: what just happened, with Undo where it can
/// be taken back.
private struct Snackbar: View {
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
var body: some View {
if let message = model.message {
HStack(spacing: 8) {
Text(message)
.textStyle(.bodyMedium)
.foregroundStyle(scheme.inverseOnSurface)
.frame(maxWidth: .infinity, alignment: .leading)
if model.undo != nil {
Button("Undo", action: model.performUndo)
.textStyle(.labelLarge)
.foregroundStyle(scheme.inversePrimary)
.buttonStyle(.plain)
.padding(.horizontal, 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(scheme.inverseSurface, in: RoundedRectangle(cornerRadius: Corner.tight))
.padding(.horizontal, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
.onTapGesture(perform: model.dismissMessage)
// Long with an Undo to reach for, short without — Material's two
// snackbar durations.
.task(id: message) {
try? await Task.sleep(for: .seconds(model.undo != nil ? 10 : 4))
if !Task.isCancelled, model.message == message { model.dismissMessage() }
}
}
}
+55
View File
@@ -0,0 +1,55 @@
import Foundation
/// The settings that are this front end's alone.
///
/// Playback preferences — volume, languages, autoplay, auto-skip — deliberately
/// do *not* live here: they are `pstr_core::prefs`, reached over the bridge, so
/// there is one definition of them rather than one per front end. Keys and
/// defaults are Android's `SettingsStore`.
struct SettingsStore {
private let defaults = UserDefaults.standard
var wifiOnly: Bool {
get { bool(Key.wifiOnly, default: true) }
nonmutating set { defaults.set(newValue, forKey: Key.wifiOnly) }
}
var backgroundAudio: Bool {
get { bool(Key.backgroundAudio, default: true) }
nonmutating set { defaults.set(newValue, forKey: Key.backgroundAudio) }
}
/// Whether mpv may decode in hardware. Off is the way out for a file
/// VideoToolbox shows green or torn frames for; it costs battery, so it is
/// on unless the viewer turns it off. Read at each load, so a change applies
/// from the next file.
var hardwareDecoding: Bool {
get { bool(Key.hardwareDecoding, default: true) }
nonmutating set { defaults.set(newValue, forKey: Key.hardwareDecoding) }
}
/// The streaming cache's budget, in GiB. See `cacheBudgetChoices`.
var cacheBudgetGib: Int {
get { defaults.object(forKey: Key.cacheBudgetGib) as? Int ?? Self.defaultCacheBudgetGib }
nonmutating set { defaults.set(newValue, forKey: Key.cacheBudgetGib) }
}
/// The desktop's range, in the steps a phone's storage is bought in.
static let cacheBudgetChoices = [1, 2, 4, 8, 16]
/// `DiskCacheConfig::DEFAULT_BUDGET_BYTES`, so an unset choice changes nothing.
static let defaultCacheBudgetGib = 4
static func gibToBytes(_ gib: Int) -> UInt64 { UInt64(gib) * 1024 * 1024 * 1024 }
private func bool(_ key: String, default value: Bool) -> Bool {
defaults.object(forKey: key) as? Bool ?? value
}
private enum Key {
static let wifiOnly = "wifi_only"
static let backgroundAudio = "background_audio"
static let hardwareDecoding = "hardware_decoding"
static let cacheBudgetGib = "cache_budget_gib"
}
}