feat(ios): a sideloadable iOS client over the shared Rust engine

A SwiftUI app that drives the same UniFFI engine as Android: Rust owns the
Proton sessions, SQLite and every decrypted byte, and Swift asks it questions
off the main thread. Playback goes through MPVKit's libmpv, with the pstr://
stream protocol ported from the Android JNI half, and secrets live in the
Keychain.

scripts/build-ios.sh builds the static library for aarch64-apple-ios, writes
the Swift bindings (module PstrBridge), generates the Xcode project with
XcodeGen and packages an unsigned .ipa for SideStore or AltStore, which
re-sign it with the viewer's own Apple ID. The generated project, Info.plist
and build output are ignored.
This commit is contained in:
nvrl committed 2026-10-02 23:34:17 +02:00
1 parent 4d44db0949
commit 5d079b05c9
11 files changed
+821

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
import Foundation
import Observation
/// The app's one Rust engine and the screen state read from it.
///
/// 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.
@MainActor
@Observable
final class AppModel {
let engine: AndroidEngine?
private(set) var startupError: String?
var titles: [TitleRecord] = []
var shares: [ShareRecord] = []
var search = ""
var crawling = false
var error: String?
init() {
do {
engine = try AndroidEngine(paths: Self.paths(), secrets: KeychainSecretStore())
} catch {
engine = nil
startupError = String(describing: error)
}
}
/// 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
}
func report(_ error: Error) {
self.error = String(describing: error)
}
func reload() async {
let needle = search.trimmingCharacters(in: .whitespaces)
do {
let (titles, shares) = try await run { engine in
(try engine.library(search: needle.isEmpty ? nil : needle), try engine.shares())
}
self.titles = titles
self.shares = shares
} catch {
report(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)
}
}
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)
}
}
func removeShare(id: String) async {
do {
try await run { try $0.removeShare(shareId: id) }
} catch {
report(error)
}
await reload()
}
/// 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 }
}
}
@@ -0,0 +1,63 @@
import Foundation
import Security
/// The share URL fragment, custom passwords and metadata API keys, held in the
/// iOS Keychain. The counterpart of Android's `KeystoreSecretStore`.
///
/// Anything thrown from here reaches Rust as `UnexpectedUniFFICallbackError`,
/// which the bridge turns into an ordinary `BridgeError` rather than a panic on
/// whichever tokio worker asked (B18).
final class KeychainSecretStore: AndroidSecretStore, @unchecked Sendable {
private let service = "io.narl.protonstream.secrets"
struct KeychainError: Error, CustomStringConvertible {
let status: OSStatus
let action: String
var description: String {
let message = SecCopyErrorMessageString(status, nil) as String? ?? "status \(status)"
return "keychain \(action): \(message)"
}
}
private func query(_ key: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
}
func set(key: String, value: String) throws {
let data = Data(value.utf8)
let update = SecItemUpdate(query(key) as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if update == errSecSuccess { return }
guard update == errSecItemNotFound else { throw KeychainError(status: update, action: "update") }
var item = query(key)
item[kSecValueData as String] = data
// Readable by the refresh that runs while the phone is locked in a
// pocket, and never restored onto another device from a backup.
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let add = SecItemAdd(item as CFDictionary, nil)
guard add == errSecSuccess else { throw KeychainError(status: add, action: "add") }
}
func get(key: String) throws -> String? {
var item = query(key)
item[kSecReturnData as String] = true
item[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(item as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw KeychainError(status: status, action: "read")
}
return String(data: data, encoding: .utf8)
}
func delete(key: String) throws {
let status = SecItemDelete(query(key) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError(status: status, action: "delete")
}
}
}
@@ -0,0 +1,49 @@
import AVFoundation
import SwiftUI
@main
struct ProtonStreamApp: App {
@State private var model = AppModel()
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)
}
var body: some Scene {
WindowGroup {
RootView()
.environment(model)
.preferredColorScheme(.dark)
}
}
}
struct RootView: View {
@Environment(AppModel.self) private var model
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") }
}
.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 ?? "")
}
}
}
}