mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
4d44db0949
commit
5d079b05c9
11 files changed
+821
No files matched your search
@@ -20,3 +20,9 @@
|
|||||||
/packaging/pkg/
|
/packaging/pkg/
|
||||||
/packaging/src/
|
/packaging/src/
|
||||||
/packaging/*.pkg.tar.zst
|
/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
|
||||||
@@ -2,3 +2,9 @@
|
|||||||
# UniFFI's default JVM cleaner references java.lang.ref.Cleaner (API 33).
|
# 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.
|
# Android uses the JNA cleaner, which is available on our minSdk 31 runtime.
|
||||||
disable_java_cleaner = true
|
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"
|
||||||
@@ -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 ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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 <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
/// 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);
|
||||||
@@ -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://<handle>`. 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<CChar>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import Foundation
|
||||||
|
import Libmpv
|
||||||
|
|
||||||
|
// libmpv's `pstr://<handle>` 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<StreamCookie>.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<CChar>?,
|
||||||
|
_ info: UnsafeMutablePointer<mpv_stream_cb_info>?
|
||||||
|
) -> 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<StreamCookie>.fromOpaque(opaque).takeRetainedValue()
|
||||||
|
pstr_android_stream_release(stream.handle)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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-<version>.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
|
||||||
Reference in new issue
Block a user