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/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
|
||||
@@ -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"
|
||||
@@ -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