Files
proton-stream/ios/ProtonStream/UI/AccountUI.swift
T
nvrl ed05a1e723 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.
2026-10-07 00:32:03 +02:00

390 lines
17 KiB
Swift

import SwiftUI
import WebKit
/// The Proton account, at the top of the shares page: why one would sign in,
/// or who is signed in and how watch-history sync is doing.
struct AccountCard: View {
let onSignIn: () -> Void
let onBrowse: () -> Void
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
var body: some View {
if let state = model.account.state {
Group {
if case let .signedIn(username) = state {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 16) {
Image(systemName: "person.crop.circle.fill").font(.system(size: 22)).foregroundStyle(scheme.primary)
VStack(alignment: .leading, spacing: 2) {
Text(username).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface)
TimelineView(.periodic(from: .now, by: 30)) { context in
Text(syncLine(model.account, now: context.date))
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
}
}
Spacer(minLength: 0)
Menu {
Button(action: model.syncNow) { Label("Sync now", systemImage: "arrow.triangle.2.circlepath") }
Button(action: model.signOut) { Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") }
} label: {
Image(systemName: "ellipsis").font(.system(size: 20)).frame(width: 48, height: 48)
.foregroundStyle(scheme.onSurfaceVariant)
}
.accessibilityLabel("Account actions")
}
.padding(.leading, 16)
.padding(.trailing, 4)
.padding(.vertical, 8)
Button(action: onBrowse) { Label("Add from your Drive", systemImage: "folder") }
.buttonStyle(.tonal)
.padding(.horizontal, 16)
.padding(.bottom, 12)
}
} else {
VStack(alignment: .leading, spacing: 8) {
Text("Proton account").textStyle(.titleMedium).foregroundStyle(scheme.onSurface)
Text("Optional. Signed in, watch history is kept in your own Drive so every device resumes where another left off, and folders of your Drive can join the library.")
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
Button("Sign in", action: onSignIn).buttonStyle(.tonal)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.card()
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
}
/// "Synced 3 min ago · 2 from other devices", or why it did not.
func syncLine(_ account: AccountUiState, now: Date) -> String {
if let error = account.syncError { return "Watch history did not sync: \(error)" }
guard let at = account.syncedAt else { return "Watch history syncs with your Drive" }
let minutes = Int(max(now.timeIntervalSince(at), 0) / 60)
let ago = minutes < 1 ? "just now" : minutes < 60 ? "\(minutes) min ago" : "\(minutes / 60) h ago"
let applied = account.applied > 0 ? " · \(account.applied) from other devices" : ""
return "Synced \(ago)\(applied)"
}
/// Security keys need WebAuthn, which the sign-in here does not speak. An
/// account with nothing else is refused before this form (`AccountStore::sign_in`).
private let securityKeyNote = "Security keys are not supported here — use the code from your authenticator app."
/// Signing in, one step at a time: address and password, then a second-factor
/// code or the mailbox password if the account has them, with Proton's CAPTCHA
/// over the top when it asks for one.
struct SignInSheet: View {
let onDismiss: () -> Void
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
@State private var username = ""
@State private var password = ""
@State private var code = ""
@State private var mailbox = ""
var body: some View {
let account = model.account
DialogSheet("Sign in to Proton") {
switch account.state {
case .secondFactor:
Field(label: "Code from your authenticator app", text: $code, keyboard: .numberPad, content: .oneTimeCode)
Text(securityKeyNote).textStyle(.bodySmall)
case .mailboxPassword:
Text("This account has a separate mailbox password. It unlocks your files.").textStyle(.bodySmall)
Field(label: "Mailbox password", text: $mailbox, secret: true, content: .password)
default:
Field(label: "Address", text: $username, keyboard: .emailAddress, content: .username)
Field(label: "Password", text: $password, secret: true, content: .password)
}
if let error = account.error {
Text(error).foregroundStyle(scheme.error)
}
if account.busy {
ProgressView().tint(scheme.primary).padding(.top, 4)
}
} buttons: {
Button("Cancel", action: dismiss).buttonStyle(.tonal)
switch account.state {
case .secondFactor:
Button("Continue") { model.submitSecondFactor(code.trimmingCharacters(in: .whitespaces)) }
.buttonStyle(.accent)
.disabled(code.trimmingCharacters(in: .whitespaces).isEmpty || account.busy)
case .mailboxPassword:
Button("Unlock") { model.submitMailboxPassword(mailbox) }
.buttonStyle(.accent)
.disabled(mailbox.isEmpty || account.busy)
default:
Button("Sign in") { model.signIn(username.trimmingCharacters(in: .whitespaces), password) }
.buttonStyle(.accent)
.disabled(username.trimmingCharacters(in: .whitespaces).isEmpty || password.isEmpty || account.busy)
}
}
.secureContent()
.interactiveDismissDisabled(account.busy)
.onChange(of: account.state) { _, state in
if case .signedIn = state { onDismiss() }
}
.fullScreenCover(isPresented: Binding(
get: { account.verificationUrl != nil },
set: { if !$0 { model.dismissVerification() } }
)) {
if let url = account.verificationUrl {
VerificationView(url: url, onToken: { token in
model.signIn(username.trimmingCharacters(in: .whitespaces), password, verificationToken: token)
}, onDismiss: model.dismissVerification)
}
}
}
private func dismiss() {
if model.account.state == .secondFactor || model.account.state == .mailboxPassword { model.cancelSignIn() }
onDismiss()
}
}
/// Proton's hosted CAPTCHA, which it puts in front of a sign-in it does not
/// recognise — a new network, a VPN exit.
///
/// In a web view rather than Safari because the page reports success by
/// posting a message to its host, not by redirecting. The page targets
/// `window.parent`, which for a top-level page is the page itself, so a
/// listener on its own window hears it.
private struct VerificationView: View {
let url: String
let onToken: (String) -> Void
let onDismiss: () -> Void
@Environment(\.scheme) private var scheme
var body: some View {
VStack(spacing: 0) {
HStack {
Text("Confirm you are human").textStyle(.titleMedium).foregroundStyle(scheme.onSurface).padding(.leading, 16)
Spacer()
IconButton("xmark", "Cancel", action: onDismiss)
}
.frame(height: 56)
VerificationWebView(url: url, onToken: onToken)
}
.background(scheme.surfaceContainerHigh)
}
}
/// Installed once per page, at start and again at finish in case the first ran
/// too early.
private let verificationBridge = """
if (!window.__pstrVerification) {
window.__pstrVerification = true;
window.addEventListener('message', function (event) {
try {
window.webkit.messageHandlers.pstrVerification.postMessage(
typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
} catch (e) {}
});
}
"""
/// Whether a page is Proton's own, over HTTPS.
func isProtonPage(scheme: String?, host: String?) -> Bool {
guard scheme == "https", let host else { return false }
return host == "proton.me" || host.hasSuffix(".proton.me")
}
private struct VerificationWebView: UIViewRepresentable {
let url: String
let onToken: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onToken: onToken) }
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
let content = configuration.userContentController
for time in [WKUserScriptInjectionTime.atDocumentStart, .atDocumentEnd] {
content.addUserScript(WKUserScript(source: verificationBridge, injectionTime: time, forMainFrameOnly: true))
}
content.add(WeakHandler(context.coordinator), name: "pstrVerification")
let view = WKWebView(frame: .zero, configuration: configuration)
view.navigationDelegate = context.coordinator
if let address = URL(string: url) { view.load(URLRequest(url: address)) }
return view
}
func updateUIView(_: WKWebView, context _: Context) {}
static func dismantleUIView(_ view: WKWebView, coordinator _: Coordinator) {
view.configuration.userContentController.removeScriptMessageHandler(forName: "pstrVerification")
}
@MainActor
final class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
let onToken: (String) -> Void
private var answered = false
init(onToken: @escaping (String) -> Void) {
self.onToken = onToken
}
/// Every message the page posts lands here, most of them its own chatter.
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard !answered, let text = message.body as? String, let token = verificationToken(message: text) else { return }
answered = true
onToken(token)
}
/// The bridge is reachable by whatever the frame shows, so the frame
/// stays on Proton.
func webView(_: WKWebView, decidePolicyFor action: WKNavigationAction) async -> WKNavigationActionPolicy {
guard action.targetFrame?.isMainFrame ?? true else { return .allow }
return isProtonPage(scheme: action.request.url?.scheme, host: action.request.url?.host) ? .allow : .cancel
}
}
/// `WKUserContentController` holds its handlers strongly.
@MainActor
private final class WeakHandler: NSObject, WKScriptMessageHandler {
weak var target: WKScriptMessageHandler?
init(_ target: WKScriptMessageHandler) {
self.target = target
}
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
target?.userContentController(controller, didReceive: message)
}
}
}
/// Walk the account's Drive and add a folder of it to the library.
///
/// Full screen, because a Drive is deep. Listings are asked for here rather
/// than through the model: they are this view's alone, and gone when it closes.
struct DriveBrowser: View {
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
@Environment(\.dismiss) private var dismiss
@State private var trail: [(name: String, place: DrivePlaceRecord)] = []
@State private var places: [DrivePlaceRecord]?
@State private var entries: [DriveEntryRecord]?
@State private var error: String?
var body: some View {
let current = trail.last?.place
VStack(spacing: 0) {
HStack(spacing: 0) {
IconButton(trail.isEmpty ? "xmark" : "chevron.backward", trail.isEmpty ? "Close" : "Up a folder", tint: scheme.onSurface) {
if trail.isEmpty { dismiss() } else { trail.removeLast() }
}
Text(trail.last?.name ?? "Your Drive").textStyle(.titleLarge).foregroundStyle(scheme.onSurface).lineLimit(1)
Spacer()
}
.frame(height: 56)
Group {
let loading = error == nil && (current == nil ? places == nil : entries == nil)
if let error {
EmptyState("Could not list this", error)
} else if loading {
ProgressView().tint(scheme.primary).frame(maxWidth: .infinity, maxHeight: .infinity)
} else if current == nil {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(places ?? [], id: \.linkId) { place in
DriveRow(icon: place.kind.icon, name: place.name, detail: nil) {
trail.append((place.name, place))
}
}
}
.padding(.vertical, 8)
}
} else if (entries ?? []).isEmpty {
EmptyState("Empty folder", "Nothing to add from here.")
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(entries ?? [], id: \.linkId) { entry in
DriveRow(
icon: entry.isFolder ? "folder.fill" : entry.isVideo ? "film" : "doc",
name: entry.name,
detail: entry.size.map(formatBytes),
open: entry.isFolder ? {
trail.append((entry.name, DrivePlaceRecord(kind: .myFiles, name: entry.name, volumeId: entry.volumeId, linkId: entry.linkId)))
} : nil
)
}
}
.padding(.vertical, 8)
}
}
}
.frame(maxHeight: .infinity)
// A folder already in the library is refused by the engine, with a
// message saying so.
if let folder = current, let name = trail.last?.name {
HStack {
Spacer()
Button("Add this folder") {
model.addAccountFolder(name: name, volumeId: folder.volumeId, linkId: folder.linkId)
dismiss()
}
.buttonStyle(.accent)
}
.padding(16)
}
}
.background(scheme.background)
.environment(\.colorScheme, scheme.colorScheme)
.task(id: current?.linkId) {
error = nil
do {
if let current {
entries = nil
entries = try await NativeRuntime.engine().driveFolder(volumeId: current.volumeId, linkId: current.linkId)
} else if places == nil {
places = try await NativeRuntime.engine().drivePlaces()
}
} catch {
self.error = errorMessage(error)
}
}
}
}
private extension PlaceType {
var icon: String {
switch self {
case .myFiles: "externaldrive"
case .device: "desktopcomputer"
case .sharedWithMe: "folder.badge.person.crop"
}
}
}
private struct DriveRow: View {
let icon: String
let name: String
let detail: String?
var open: (() -> Void)?
@Environment(\.scheme) private var scheme
var body: some View {
Button { open?() } label: {
HStack(spacing: 16) {
Image(systemName: icon).font(.system(size: 20))
.foregroundStyle(open != nil ? scheme.primary : scheme.onSurfaceVariant).frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(name).textStyle(.bodyLarge).foregroundStyle(scheme.onSurface).lineLimit(2)
if let detail { Text(detail).textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant) }
}
Spacer(minLength: 0)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(open == nil)
}
}