Sync carried only watch positions, so a second device started empty. The per-installation history file now also carries per-key registers (pstr-core::profile): every share with its link and custom password, the playback, appearance and metadata settings, the TMDB key, hand-picked matches and per-title track choices. Newer updated_at wins per key, a removal travels as an empty register, and a first sync stamps at 0 so a fresh install takes the library the other devices have instead of overwriting it with defaults. Volume, mute and the device-only settings (cache budget, Wi-Fi only, background audio, hardware decoding) stay out. The secrets live only in the end-to-end encrypted Drive file, never in shares.json. FORMAT_VERSION stays 1; an older build ignores the registers. Adding a share stored it before opening the link, so a first failure left a broken entry and every retry said "already configured". add_verified opens the link first and stores nothing on failure, and only the new share is crawled afterwards, so another broken share cannot fail the add. Android drops picture-in-picture: the window sized the video wrongly and background audio already covers leaving the app. iOS sizes the Metal drawable on every layout, so the picture no longer stretches after a rotation or a Split View resize. Settings show a save at once rather than after the match run, and a failed save reverts and says why. Both phone clients gain an activity banner naming what is running, and the add-share, metadata and change-match forms show progress and errors in place.
4.5 KiB
iOS Development
The iOS client lives in ios/. It is a SwiftUI port of the Android client, screen
for screen and string for string, over the same Rust engine: crates/pstr-android
builds unchanged for aarch64-apple-ios as a static library, and UniFFI writes its
Swift bindings (module PstrBridge). It targets iOS 17 and newer on iPhone and
iPad. Keep reusable behaviour in the Rust crates; when Android changes, the iOS
file of the same name is the one to change with it.
| Android | iOS |
|---|---|
ui/AppViewModel.kt |
App/AppModel.swift |
ui/ProtonStreamApp.kt |
App/ProtonStreamApp.swift (RootView) |
native/NativeRuntime.kt, native/KeystoreSecretStore.kt |
App/NativeRuntime.swift, App/KeychainSecretStore.swift |
settings/SettingsStore.kt |
App/SettingsStore.swift (same keys, in UserDefaults) |
ui/*Screen.kt, ui/AccountUi.kt, ui/LanguagePicker.kt |
UI/*View.swift, UI/AccountUI.swift, UI/LanguagePicker.swift |
ui/RemoteArtwork.kt, ui/theme/* |
UI/RemoteArtwork.swift, UI/Theme/* |
download/* |
Download/* |
playback/PlayerScreen.kt, NativeMpvHost.kt, PlaybackService.kt, StreamSession.kt |
Player/PlayerView.swift, Player/PlayerHost.swift |
cpp/pstr_mpv.cpp |
Player/MpvPlayer.swift, Player/StreamProtocol.swift |
Playback is libmpv from MPVKit's GPL build,
rendering through MoltenVK into a CAMetalLayer, with the pstr:// stream protocol
reading decrypted blocks straight out of Rust as on Android.
Toolchain
macOS with Xcode 16 or newer, XcodeGen, and Rust 1.96 with the iOS targets:
brew install xcodegen
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
The Xcode project and Info.plist are generated from ios/project.yml and are not
committed, nor are the bindings under ios/build/generated.
scripts/build-ios.sh test # simulator static library, bindings, unit tests
scripts/build-ios.sh all # device static library, bindings, dist/proton-stream-<version>-ios.ipa
all is rust, bindings and ipa in order; each can be run on its own. After
bindings and an xcodegen generate in ios/, the project opens in Xcode as usual,
where a run on a device needs a development team set on the target.
The .ipa is unsigned. SideStore and AltStore re-sign what they install with the
viewer's own Apple ID, which is also why the app has no extensions: a free Apple ID
gets few app IDs, and each extension costs one.
.github/workflows/ios.yml runs the tests and builds the .ipa on macos-15 for
every push to main and every pull request that touches the iOS sources or the Rust
crates under them. release.yml calls it too, and publishes the .ipa next to the
other artifacts.
scripts/render-ios-icon.sh renders ios/ProtonStream/Resources/AppIcon.svg, which
is Android's adaptive launcher icon with both layers on one canvas, into the asset
catalog.
Where iOS differs
Each of these is the platform's doing, not a choice to diverge.
- No Picture-in-Picture. libmpv draws into a Metal layer, and AVKit's
Picture-in-Picture only takes frames from
AVPlayerLayerorAVSampleBufferDisplayLayer. Background audio and the lock-screen transport work as on Android. - No share sheet. A share extension would cost a second app ID. A link reaches
the app through its URL scheme —
protonstream://add?url=<link>, or the link withhttpsswapped forprotonstream— or through Paste link in the Add share form, which finds a link anywhere in the clipboard's text. - Downloads run while the app does. A transfer is a Rust task, not a
URLSessiontask, so iOS has nothing to hand it to in the background. A download keeps going for the grace iOS gives a backgrounded app, then stops at a block boundary and is queued again; it resumes from its.partfile when the app next comes forward. "Download on Wi-Fi only" stops transfers on a cellular or otherwise expensive network rather than holding them back. - Sync runs when the app leaves the screen, inside the same grace, in
place of Android's
WatchSyncWorker, and every five minutes while it is open. - Background audio off pauses the episode when the app leaves the screen, where Android stops it.
- Nothing is backed up, as on Android (
allowBackup="false"). The share secrets are this-device-only Keychain items, so a restored share list would arrive without them, and a restoredsync.jsonwould give two devices one sync identity.