mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
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.
390 lines
17 KiB
Swift
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)
|
|
}
|
|
}
|