Files
proton-stream/docs/IOS.md
T
nvrl 73973841c2 feat(core,app,android,ios,cli): look up new titles after every crawl, show presses and work on iOS
Metadata was only fetched after adding a share (mobile) or from the
Settings buttons (desktop), so a show or episode that turned up in an
existing share stayed bare. Every crawl is now followed by a match run
when enrichment is on; `plan` makes no requests for a library that is
already matched. A stored episode guide is fetched again when a file
newer than it has no named entry, and daily for two weeks while an
aired episode is still unnamed.

Removing a share now carries its watch state to any other share holding
the same file, `pstr remove` drops the share's catalog rows, and the app
forgets catalog rows of shares no longer configured — an older `pstr
remove` left them, and every episode showed twice.

iOS, for a slow iPad:
- swipe back on a settings page uses its own edge strip; on the whole
  page it also pressed the row under the finger and opened the metadata
  sheet
- "Change match" from the tile menu opens once the menu has dismissed,
  not after a guessed 350 ms that a slow device missed
- the activity banner tracks each running line by itself (also Android),
  so overlapping work no longer leaves it spinning forever
- buttons, tiles and rows draw their press; marking watched shows at
  once; remove/repair share, sign out and deletions show the banner;
  the player says "Opening…" while the stream opens
- posters are read into a reserved buffer instead of a byte at a time
2026-10-12 18:36:33 +02:00

4.8 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 AVPlayerLayer or AVSampleBufferDisplayLayer. 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 with https swapped for protonstream — 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 URLSession task, 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 .part file 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.
  • Presses are drawn by hand. Compose's ripple shows every press; SwiftUI's .plain button style and onTapGesture show none, which on a slow iPad reads as a tap that missed. PressableStyle and tapAndHold (UI/Theme/Accent.swift) stand in for the ripple, and a settings page goes back by an edge strip of its own, as a title page does, where Android has the system back.
  • 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 restored sync.json would give two devices one sync identity.