Files
nvrl 5d079b05c9 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.
2026-10-02 23:34:17 +02:00

87 lines
3.3 KiB
Swift

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
}