diff --git a/.gitignore b/.gitignore index 269396b..d277063 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,9 @@ /packaging/pkg/ /packaging/src/ /packaging/*.pkg.tar.zst + +# iOS generated state: XcodeGen owns the project and the Info.plist, and +# scripts/build-ios.sh writes the bindings and DerivedData under ios/build. +/ios/build/ +/ios/*.xcodeproj/ +/ios/ProtonStream/Info.plist diff --git a/crates/pstr-android/uniffi.toml b/crates/pstr-android/uniffi.toml index 4ac9cf6..8e25975 100644 --- a/crates/pstr-android/uniffi.toml +++ b/crates/pstr-android/uniffi.toml @@ -2,3 +2,9 @@ # UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33). # Android uses the JNA cleaner, which is available on our minSdk 31 runtime. disable_java_cleaner = true + +[bindings.swift] +# The iOS client (`ios/`) compiles the generated file straight into the app +# target and reaches the C side through its bridging header, so the names are +# what that header and `ios/project.yml` expect. +module_name = "PstrBridge" diff --git a/ios/ProtonStream/App/AppModel.swift b/ios/ProtonStream/App/AppModel.swift new file mode 100644 index 0000000..b20388f --- /dev/null +++ b/ios/ProtonStream/App/AppModel.swift @@ -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(_ 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 } + } +} diff --git a/ios/ProtonStream/App/KeychainSecretStore.swift b/ios/ProtonStream/App/KeychainSecretStore.swift new file mode 100644 index 0000000..1264660 --- /dev/null +++ b/ios/ProtonStream/App/KeychainSecretStore.swift @@ -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") + } + } +} diff --git a/ios/ProtonStream/App/ProtonStreamApp.swift b/ios/ProtonStream/App/ProtonStreamApp.swift new file mode 100644 index 0000000..3d4038c --- /dev/null +++ b/ios/ProtonStream/App/ProtonStreamApp.swift @@ -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 ?? "") + } + } + } +} diff --git a/ios/ProtonStream/Bridge/ProtonStream-Bridging-Header.h b/ios/ProtonStream/Bridge/ProtonStream-Bridging-Header.h new file mode 100644 index 0000000..29b3645 --- /dev/null +++ b/ios/ProtonStream/Bridge/ProtonStream-Bridging-Header.h @@ -0,0 +1,4 @@ +// C declarations Swift sees without a module import: the UniFFI scaffolding +// generated beside PstrBridge.swift, and the stream ABI UniFFI does not cover. +#include "PstrBridgeFFI.h" +#include "pstr_stream.h" diff --git a/ios/ProtonStream/Bridge/pstr_stream.h b/ios/ProtonStream/Bridge/pstr_stream.h new file mode 100644 index 0000000..8401647 --- /dev/null +++ b/ios/ProtonStream/Bridge/pstr_stream.h @@ -0,0 +1,21 @@ +// The blocking stream ABI exported by crates/pstr-android (`lib.rs`, +// `pstr_android_stream_*`). It is not part of the UniFFI interface on purpose: +// decrypted bytes go straight from Rust into libmpv's buffer and never pass +// through Swift values. +#pragma once + +#include +#include + +/// Read up to `length` bytes at `offset` into `buffer`. Returns the byte count, +/// 0 at end of file, or -1 on failure or cancellation. +int64_t pstr_android_stream_read(uint64_t handle, uint64_t offset, void *buffer, size_t length); + +/// The stream's size in bytes, or -1 for an unknown handle. +int64_t pstr_android_stream_size(uint64_t handle); + +/// Interrupt the read in flight for this stream, if any. +void pstr_android_stream_cancel(uint64_t handle); + +/// Drop the registry's reference. Call exactly once per published handle. +void pstr_android_stream_release(uint64_t handle); diff --git a/ios/ProtonStream/Player/MpvPlayer.swift b/ios/ProtonStream/Player/MpvPlayer.swift new file mode 100644 index 0000000..6eb1a1a --- /dev/null +++ b/ios/ProtonStream/Player/MpvPlayer.swift @@ -0,0 +1,298 @@ +import Foundation +import Libmpv +import Observation +import UIKit + +/// MoltenVK can set the drawable to 1x1 to force a presentation through, which +/// flickers and can stick. MPVKit's demo carries the same guard +/// (mpv-player/mpv#13651). +final class MpvMetalLayer: CAMetalLayer { + override var drawableSize: CGSize { + get { super.drawableSize } + set { + if Int(newValue.width) > 1, Int(newValue.height) > 1 { + super.drawableSize = newValue + } + } + } +} + +struct MpvTrack: Identifiable, Hashable { + let id: Int64 + let type: String + let language: String + let title: String + let selected: Bool + + var label: String { + let parts = [title, language.uppercased()].filter { !$0.isEmpty } + return parts.isEmpty ? "Track \(id)" : parts.joined(separator: " ยท ") + } +} + +/// One libmpv core rendering into a Metal layer. Every public method runs on +/// the main thread; mpv's events are read on a thread of their own and applied +/// back on the main thread. +@MainActor +@Observable +final class MpvPlayer { + enum EndReason { case eof, stopped, failed } + + private(set) var position: Double = 0 + private(set) var duration: Double = 0 + private(set) var paused = false + private(set) var buffering = false + private(set) var tracks: [MpvTrack] = [] + private(set) var chapters: [ChapterRecord] = [] + private(set) var failure: String? + /// Between `loadfile` and mpv's `FILE_LOADED`. Everything mpv reports in + /// that window still describes the *outgoing* file, including the + /// `END_FILE(stop)` stopping it emits. + private(set) var loading = false + + /// Called once per file that ends, never for the one a load replaced. + @ObservationIgnored var onEnded: ((EndReason) -> Void)? + + @ObservationIgnored let layer = MpvMetalLayer() + @ObservationIgnored private var mpv: OpaquePointer? + + init() { + layer.framebufferOnly = true + layer.backgroundColor = UIColor.black.cgColor + guard let mpv = mpv_create() else { + failure = "libmpv did not start" + return + } + var wid = layer + mpv_set_option(mpv, "wid", MPV_FORMAT_INT64, &wid) + for (name, value) in [ + ("config", "no"), + ("vo", "gpu-next"), + ("gpu-api", "vulkan"), + ("gpu-context", "moltenvk"), + ("hwdec", "videotoolbox"), + ("video-rotate", "no"), + ("keep-open", "no"), + ("cache", "yes"), + ("cache-secs", "30"), + ("demuxer-readahead-secs", "30"), + ("demuxer-max-bytes", "50331648"), + ("subs-fallback", "yes"), + ] { + mpv_set_option_string(mpv, name, value) + } + guard registerStreamProtocol(mpv), mpv_initialize(mpv) >= 0 else { + mpv_terminate_destroy(mpv) + failure = "libmpv did not initialize" + return + } + mpv_observe_property(mpv, 1, "time-pos", MPV_FORMAT_DOUBLE) + mpv_observe_property(mpv, 2, "duration", MPV_FORMAT_DOUBLE) + mpv_observe_property(mpv, 3, "pause", MPV_FORMAT_FLAG) + mpv_observe_property(mpv, 4, "paused-for-cache", MPV_FORMAT_FLAG) + mpv_observe_property(mpv, 5, "track-list/count", MPV_FORMAT_INT64) + mpv_observe_property(mpv, 6, "chapter-list/count", MPV_FORMAT_INT64) + mpv_request_log_messages(mpv, "error") + self.mpv = mpv + + let thread = Thread { [weak self] in Self.eventLoop(mpv) { event in self?.apply(event) } } + thread.name = "mpv-events" + thread.start() + } + + // MARK: Commands + + /// Load `pstr://`. On `false` nothing was issued, and the caller + /// still owns the handle's release. + func load(handle: UInt64, start: Double, audio: String?, subtitle: String?, subtitles: Bool) -> Bool { + guard mpv != nil else { return false } + setString("start", start > 0 ? String(format: "%.3f", start) : "none") + setString("alang", audio ?? "") + setString("slang", subtitle ?? "") + setString("sid", subtitles ? "auto" : "no") + position = 0 + duration = 0 + tracks = [] + chapters = [] + failure = nil + loading = true + if command(["loadfile", "pstr://\(handle)", "replace"]) < 0 { + loading = false + return false + } + return true + } + + func setPaused(_ value: Bool) { setFlag("pause", value) } + func seek(to seconds: Double) { setDouble("time-pos", max(0, seconds)) } + func seek(by seconds: Double) { _ = command(["seek", String(seconds), "relative"]) } + func setSpeed(_ value: Double) { setDouble("speed", min(max(value, 0.25), 4)) } + func setVolume(_ value: Double) { setDouble("volume", min(max(value, 0), 100)) } + func setMuted(_ value: Bool) { setFlag("mute", value) } + + func select(audio: Bool, track: MpvTrack?) { + let property = audio ? "aid" : "sid" + if let track { setString(property, String(track.id)) } else { setString(property, "no") } + refreshTracks() + } + + /// Showing no picture while backgrounded keeps the audio playing and avoids + /// the black frame MoltenVK otherwise leaves on return. + func setVideoEnabled(_ enabled: Bool) { setString("vid", enabled ? "auto" : "no") } + + /// Stop and tear the core down. `cancel_fn` interrupts a read parked in a + /// block fetch, and the event thread destroys the core once it is down. + func shutdown() { + guard let mpv else { return } + self.mpv = nil + mpv_command_string(mpv, "quit") + } + + // MARK: Events + + private enum Event { + case double(UInt64, Double) + case flag(UInt64, Bool) + case count(UInt64) + case loaded + case ended(Int32) + case log(String) + } + + /// Runs until the core shuts down, then destroys it. Nothing else calls + /// `mpv_terminate_destroy`, so no main-thread call can race it. + private nonisolated static func eventLoop(_ mpv: OpaquePointer, _ deliver: @escaping @MainActor (Event) -> Void) { + while true { + guard let event = mpv_wait_event(mpv, -1)?.pointee else { continue } + var decoded: Event? + switch event.event_id { + case MPV_EVENT_SHUTDOWN: + mpv_terminate_destroy(mpv) + return + case MPV_EVENT_PROPERTY_CHANGE: + guard let property = event.data?.assumingMemoryBound(to: mpv_event_property.self).pointee, + let data = property.data else { break } + switch property.format { + case MPV_FORMAT_DOUBLE: decoded = .double(event.reply_userdata, data.load(as: Double.self)) + case MPV_FORMAT_FLAG: decoded = .flag(event.reply_userdata, data.load(as: Int32.self) != 0) + case MPV_FORMAT_INT64: decoded = .count(event.reply_userdata) + default: break + } + case MPV_EVENT_FILE_LOADED: + decoded = .loaded + case MPV_EVENT_END_FILE: + if let end = event.data?.assumingMemoryBound(to: mpv_event_end_file.self).pointee { + decoded = .ended(end.reason.rawValue) + } + case MPV_EVENT_LOG_MESSAGE: + if let message = event.data?.assumingMemoryBound(to: mpv_event_log_message.self).pointee, + let text = message.text { + decoded = .log(String(cString: text).trimmingCharacters(in: .whitespacesAndNewlines)) + } + default: + break + } + if let decoded { + DispatchQueue.main.async { deliver(decoded) } + } + } + } + + private func apply(_ event: Event) { + switch event { + case let .double(id, value): + if loading { return } + if id == 1 { position = value } else if id == 2 { duration = value } + case let .flag(id, value): + if id == 3 { paused = value } else if id == 4 { buffering = value } + case let .count(id): + if loading { return } + if id == 5 { refreshTracks() } else if id == 6 { refreshChapters() } + case .loaded: + loading = false + refreshTracks() + refreshChapters() + case let .ended(reason): + // The outgoing file's END_FILE arrives after the next load began. + if loading { return } + switch reason { + case MPV_END_FILE_REASON_EOF.rawValue: onEnded?(.eof) + case MPV_END_FILE_REASON_ERROR.rawValue: + failure = failure ?? "The file could not be played" + onEnded?(.failed) + default: onEnded?(.stopped) + } + case let .log(text): + if !text.isEmpty { failure = text } + } + } + + private func refreshTracks() { + let count = getInt("track-list/count") + tracks = (0 ..< max(0, count)).map { index in + let base = "track-list/\(index)/" + return MpvTrack( + id: getInt(base + "id"), + type: getString(base + "type") ?? "", + language: getString(base + "lang") ?? "", + title: getString(base + "title") ?? "", + selected: getString(base + "selected") == "yes" + ) + } + } + + private func refreshChapters() { + let count = getInt("chapter-list/count") + chapters = (0 ..< max(0, count)).map { index in + let base = "chapter-list/\(index)/" + return ChapterRecord(index: index, title: getString(base + "title"), start: getDouble(base + "time")) + } + } + + // MARK: Property plumbing + + private func command(_ args: [String]) -> Int32 { + guard let mpv else { return -1 } + var pointers = args.map { UnsafePointer(strdup($0)) } + pointers.append(nil) + defer { pointers.forEach { free(UnsafeMutablePointer(mutating: $0)) } } + return mpv_command(mpv, &pointers) + } + + private func setString(_ name: String, _ value: String) { + guard let mpv else { return } + mpv_set_property_string(mpv, name, value) + } + + private func setDouble(_ name: String, _ value: Double) { + guard let mpv else { return } + var value = value + mpv_set_property(mpv, name, MPV_FORMAT_DOUBLE, &value) + } + + private func setFlag(_ name: String, _ value: Bool) { + guard let mpv else { return } + var flag: Int32 = value ? 1 : 0 + mpv_set_property(mpv, name, MPV_FORMAT_FLAG, &flag) + } + + private func getInt(_ name: String) -> Int64 { + guard let mpv else { return 0 } + var value: Int64 = 0 + mpv_get_property(mpv, name, MPV_FORMAT_INT64, &value) + return value + } + + private func getDouble(_ name: String) -> Double { + guard let mpv else { return 0 } + var value = 0.0 + mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &value) + return value + } + + private func getString(_ name: String) -> String? { + guard let mpv, let raw = mpv_get_property_string(mpv, name) else { return nil } + defer { mpv_free(raw) } + return String(cString: raw) + } +} diff --git a/ios/ProtonStream/Player/StreamProtocol.swift b/ios/ProtonStream/Player/StreamProtocol.swift new file mode 100644 index 0000000..bdf24b8 --- /dev/null +++ b/ios/ProtonStream/Player/StreamProtocol.swift @@ -0,0 +1,86 @@ +import Foundation +import Libmpv + +// libmpv's `pstr://` protocol, backed by the Rust stream registry. The +// Swift port of the stream half of android/app/src/main/cpp/pstr_mpv.cpp. + +/// One open file on mpv's demuxer thread. `position` is only ever touched from +/// that thread; `cancelled` is also written from whichever thread cancels. +private final class StreamCookie { + let handle: UInt64 + let size: Int64 + var position: Int64 = 0 + private let lock = NSLock() + private var _cancelled = false + + init(handle: UInt64, size: Int64) { + self.handle = handle + self.size = size + } + + var cancelled: Bool { + get { lock.withLock { _cancelled } } + set { lock.withLock { _cancelled = newValue } } + } +} + +private func cookie(_ opaque: UnsafeMutableRawPointer?) -> StreamCookie? { + opaque.map { Unmanaged.fromOpaque($0).takeUnretainedValue() } +} + +/// Register the protocol on a core that has not been initialized yet. +func registerStreamProtocol(_ mpv: OpaquePointer) -> Bool { + mpv_stream_cb_add_ro(mpv, "pstr", nil, streamOpen) >= 0 +} + +private func streamOpen( + _: UnsafeMutableRawPointer?, + _ uri: UnsafeMutablePointer?, + _ info: UnsafeMutablePointer? +) -> Int32 { + let failed = MPV_ERROR_LOADING_FAILED.rawValue + guard let uri, let info else { return failed } + let value = String(cString: uri) + guard value.hasPrefix("pstr://"), let handle = UInt64(value.dropFirst("pstr://".count)) else { return failed } + let size = pstr_android_stream_size(handle) + guard size >= 0 else { return failed } + + info.pointee.cookie = Unmanaged.passRetained(StreamCookie(handle: handle, size: size)).toOpaque() + info.pointee.read_fn = { opaque, buffer, length in + guard let stream = cookie(opaque), let buffer else { return -1 } + if stream.cancelled { return -1 } + let remaining = stream.size - stream.position + if remaining <= 0 { return 0 } + let requested = Int(min(length, UInt64(remaining))) + let read = pstr_android_stream_read(stream.handle, UInt64(stream.position), buffer, requested) + if read > 0 { stream.position += read } + return read + } + info.pointee.seek_fn = { opaque, offset in + guard let stream = cookie(opaque), offset >= 0, offset <= stream.size else { + return Int64(MPV_ERROR_GENERIC.rawValue) + } + stream.position = offset + stream.cancelled = false + return offset + } + info.pointee.size_fn = { opaque in + cookie(opaque)?.size ?? Int64(MPV_ERROR_GENERIC.rawValue) + } + // The flag stops the next read; the one already parked in a 4 MiB block + // fetch has to be interrupted in Rust, or `mpv_terminate_destroy` waits + // out the whole fetch while joining the demuxer thread (B16). + info.pointee.cancel_fn = { opaque in + guard let stream = cookie(opaque) else { return } + stream.cancelled = true + pstr_android_stream_cancel(stream.handle) + } + // The one moment defined to be after mpv's last read of this file, so the + // Rust stream is released here and nowhere else (B15/B28). + info.pointee.close_fn = { opaque in + guard let opaque else { return } + let stream = Unmanaged.fromOpaque(opaque).takeRetainedValue() + pstr_android_stream_release(stream.handle) + } + return 0 +} diff --git a/ios/project.yml b/ios/project.yml new file mode 100644 index 0000000..b50f969 --- /dev/null +++ b/ios/project.yml @@ -0,0 +1,57 @@ +# XcodeGen spec for the iOS client. The .xcodeproj and Info.plist are generated +# from this by scripts/build-ios.sh and are not committed. +name: ProtonStream +options: + bundleIdPrefix: io.narl + deploymentTarget: + iOS: "17.0" + +packages: + MPVKit: + url: https://github.com/mpvkit/MPVKit + exactVersion: 1.0.0 + +targets: + ProtonStream: + type: application + platform: iOS + sources: + - path: ProtonStream + # UniFFI output, written by `scripts/build-ios.sh bindings`. + - path: build/generated/PstrBridge.swift + dependencies: + # The GPL build is the one with libass and every demuxer; the app is + # sideloaded, never distributed through the App Store. + - package: MPVKit + product: MPVKit-GPL + - sdk: Security.framework + - sdk: SystemConfiguration.framework + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: io.narl.protonstream + MARKETING_VERSION: ${PSTR_VERSION} + CURRENT_PROJECT_VERSION: ${PSTR_BUILD} + SWIFT_VERSION: "5.0" + TARGETED_DEVICE_FAMILY: "1,2" + SWIFT_OBJC_BRIDGING_HEADER: ProtonStream/Bridge/ProtonStream-Bridging-Header.h + HEADER_SEARCH_PATHS: $(PROJECT_DIR)/build/generated + LIBRARY_SEARCH_PATHS: $(PROJECT_DIR)/../target/aarch64-apple-ios/release + OTHER_LDFLAGS: -lpstr_android + info: + path: ProtonStream/Info.plist + properties: + CFBundleDisplayName: Proton Stream + LSRequiresIPhoneOS: true + ITSAppUsesNonExemptEncryption: false + UIBackgroundModes: [audio] + UILaunchScreen: {} + UIRequiresFullScreen: false + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight diff --git a/scripts/build-ios.sh b/scripts/build-ios.sh new file mode 100644 index 0000000..2afa1f1 --- /dev/null +++ b/scripts/build-ios.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# Build the iOS client as an unsigned .ipa for SideStore / AltStore. +# +# SideStore re-signs whatever it installs with the viewer's own Apple ID, so +# nothing here touches a certificate or a provisioning profile. Needs macOS with +# Xcode, XcodeGen and the aarch64-apple-ios Rust target. +set -euo pipefail + +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "$script_dir/.." && pwd)" +ios_dir="$repo_root/ios" +generated="$ios_dir/build/generated" +target=aarch64-apple-ios + +usage() { + echo "usage: $0 {rust|bindings|ipa|all}" + echo + echo " rust static libpstr_android.a for $target" + echo " bindings Swift bindings into ios/build/generated" + echo " ipa xcodegen + xcodebuild, then dist/ProtonStream-.ipa" + echo " all all three, in that order" +} + +die() { printf '\033[1;31merror:\033[0m %s\n' "$*" >&2; exit 1; } +log() { printf '\033[1;34m==>\033[0m %s\n' "$*"; } + +[[ "$(uname -s)" == Darwin ]] || die "the iOS build needs macOS and Xcode" + +# Same guard as scripts/build.sh: without the sibling checkout the resolve fails +# a hundred lines deep in cargo output. +if grep -q '^\[patch\.crates-io\]' "$repo_root/Cargo.toml" && [[ ! -d "$repo_root/../proton-sdk-rs" ]]; then + die "Cargo.toml still carries [patch.crates-io] -> ../proton-sdk-rs, but that checkout is missing." +fi + +version="$(awk '/^\[workspace.package\]/{found=1; next} found && /^\[/{exit} found && /^version = /{gsub(/[" ]/, "", $3); print $3; exit}' "$repo_root/Cargo.toml")" +[[ -n "$version" ]] || die "no [workspace.package] version in Cargo.toml" + +build_rust() { + log "cargo rustc -p pstr-android --target $target (staticlib)" + # The crate stays `cdylib, rlib` for Android; the archive iOS links is asked + # for here instead of being built on every other platform too. + ( cd "$repo_root" && IPHONEOS_DEPLOYMENT_TARGET=17.0 cargo rustc -p pstr-android --lib \ + --release --locked --target "$target" --crate-type staticlib ) +} + +build_bindings() { + log "uniffi-bindgen --language swift" + # Library mode reads the interface out of a built library, and the host + # dylib is the one the bindgen binary can open. + ( cd "$repo_root" && cargo build -p pstr-android --lib --locked ) + rm -rf "$generated" + ( cd "$repo_root" && cargo run -p pstr-android --features bindgen --bin uniffi-bindgen --locked -- \ + generate --library target/debug/libpstr_android.dylib --language swift \ + --no-format --out-dir "$generated" ) + [[ -f "$generated/PstrBridge.swift" ]] || die "bindgen wrote no PstrBridge.swift" +} + +build_ipa() { + command -v xcodegen >/dev/null || die "xcodegen is required (brew install xcodegen)" + [[ -f "$repo_root/target/$target/release/libpstr_android.a" ]] || die "run '$0 rust' first" + [[ -f "$generated/PstrBridge.swift" ]] || die "run '$0 bindings' first" + + log "xcodegen ($version)" + ( cd "$ios_dir" && PSTR_VERSION="$version" PSTR_BUILD="${PSTR_BUILD:-1}" xcodegen generate --quiet ) + + log "xcodebuild (unsigned)" + xcodebuild -project "$ios_dir/ProtonStream.xcodeproj" -scheme ProtonStream \ + -configuration Release -sdk iphoneos -destination 'generic/platform=iOS' \ + -derivedDataPath "$ios_dir/build/DerivedData" \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY= \ + build + + local app="$ios_dir/build/DerivedData/Build/Products/Release-iphoneos/ProtonStream.app" + [[ -d "$app" ]] || die "xcodebuild produced no ProtonStream.app" + local stage="$ios_dir/build/ipa" + rm -rf "$stage" + mkdir -p "$stage/Payload" "$repo_root/dist" + cp -R "$app" "$stage/Payload/" + local ipa="$repo_root/dist/ProtonStream-$version.ipa" + rm -f "$ipa" + ( cd "$stage" && zip -qry "$ipa" Payload ) + log "wrote $ipa" +} + +case "${1:-}" in + rust) build_rust ;; + bindings) build_bindings ;; + ipa) build_ipa ;; + all) build_rust; build_bindings; build_ipa ;; + *) usage >&2; exit 2 ;; +esac