mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
feat(ios): port the Android client to SwiftUI
The iOS client is now a screen-for-screen port of the Android app over the same pstr-android UniFFI surface, with no Rust changes: Library, title page, History, Shares (public links and Drive folders), Downloads and Settings, with the same strings, undo snackbars and two-pane layout on wide screens. - Theme from the engine's palette (Material role names, Inter, the same type ramp and corners), repainted live from the appearance settings. - Account sign-in with two-factor code, mailbox password and the Proton CAPTCHA in a non-persistent WKWebView, the Drive folder browser, and watch-history sync every five minutes, after sign-in, on player close and when the app leaves the screen. - Player over libmpv: resume, progress saved every 5 s, autoplay with the up-next card, skip and auto-skip from the chapter plan, track, speed, volume and episode pickers, landscape lock, mini transport, and lock-screen controls with interruption and route-change handling. - Offline downloads with the Android record format, free-space floor, retries, transfer rate and the Wi-Fi-only policy; interrupted transfers resume at next launch. - Config, data and download state are excluded from backup, as Android's allowBackup=false: a restore would bring shares back without their Keychain secrets and give two devices one sync identity. Picture-in-picture, the share sheet and background downloads are not possible the same way on iOS; docs/IOS.md lists why and what replaces them. Adds XCTest ports of the Android unit tests, a `test` command to scripts/build-ios.sh, the app icon and its render script, and an iOS workflow on macos-15 that runs the tests and builds the unsigned .ipa.
This commit is contained in:
1 parent
da16ab0575
commit
ed05a1e723
38 files changed
+7546
-208
No files matched your search
+85
@@ -0,0 +1,85 @@
|
||||
# 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](https://github.com/mpvkit/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:
|
||||
|
||||
```bash
|
||||
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`.
|
||||
|
||||
```bash
|
||||
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. It is not yet part of `release.yml`: add it there, and the `ios`
|
||||
artifact to `publish`, once it has passed on `main`.
|
||||
|
||||
`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.
|
||||
- **Watch history syncs 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 restored `sync.json` would give two devices one sync
|
||||
identity.
|
||||
Reference in new issue
Block a user