mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +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
@@ -0,0 +1,209 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SharesView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.scheme) private var scheme
|
||||
/// A link opened in this app: the Add form opens with it filled in.
|
||||
@Binding var incomingLink: String?
|
||||
|
||||
@State private var showAdd = false
|
||||
@State private var signingIn = false
|
||||
@State private var browsing = false
|
||||
@State private var repairing: ShareRecord?
|
||||
@State private var removing: ShareRecord?
|
||||
|
||||
var body: some View {
|
||||
TabPage("Shares") {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
if model.shares.isEmpty && model.account.state == nil {
|
||||
EmptyState("No shares yet", "Add a Proton Drive public link to build your library.")
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
AccountCard(onSignIn: { signingIn = true }, onBrowse: { browsing = true })
|
||||
if model.shares.isEmpty {
|
||||
Text("No shares yet. Add a public link, or a folder of your Drive once signed in.")
|
||||
.textStyle(.bodyMedium)
|
||||
.foregroundStyle(scheme.onSurfaceVariant)
|
||||
.padding(16)
|
||||
}
|
||||
ForEach(model.shares, id: \.id) { share in
|
||||
ShareRow(
|
||||
share: share,
|
||||
onRefresh: { model.refreshShare(share.id) },
|
||||
onRepair: { repairing = share },
|
||||
onRemove: { removing = share }
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 96)
|
||||
}
|
||||
}
|
||||
// Where a thumb is, and the one thing this page is for.
|
||||
Button { showAdd = true } label: {
|
||||
Label("Add share", systemImage: "plus")
|
||||
.textStyle(.labelLarge)
|
||||
.padding(.horizontal, 20)
|
||||
.frame(height: 56)
|
||||
.foregroundStyle(scheme.onPrimary)
|
||||
.background(scheme.primary, in: RoundedRectangle(cornerRadius: 16))
|
||||
.shadow(color: .black.opacity(0.3), radius: 6, y: 3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(get: { showAdd || incomingLink != nil }, set: { if !$0 { showAdd = false; incomingLink = nil } })) {
|
||||
AddShareSheet(initialUrl: incomingLink ?? "") { name, url, password in
|
||||
model.addShare(name: name, url: url, password: password)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { signingIn || model.account.state == .secondFactor || model.account.state == .mailboxPassword },
|
||||
set: { shown in
|
||||
if shown { return }
|
||||
signingIn = false
|
||||
// Swiped away mid sign-in: the half-made session is spent.
|
||||
if model.account.state == .secondFactor || model.account.state == .mailboxPassword { model.cancelSignIn() }
|
||||
}
|
||||
)) {
|
||||
SignInSheet { signingIn = false }
|
||||
}
|
||||
.fullScreenCover(isPresented: $browsing) {
|
||||
DriveBrowser()
|
||||
}
|
||||
.sheet(item: $repairing) { share in
|
||||
RepairShareSheet(share: share) { url, password in
|
||||
model.repairShare(id: share.id, url: url, password: password)
|
||||
}
|
||||
}
|
||||
// Asked, because it cannot be undone: the link's secret and its
|
||||
// offline files go. Watch positions stay (`Catalog::remove_share`).
|
||||
.alert(
|
||||
removing.map { "Remove \($0.name)?" } ?? "",
|
||||
isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
|
||||
presenting: removing
|
||||
) { share in
|
||||
Button("Remove", role: .destructive) { model.removeShare(share.id) }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: { _ in
|
||||
Text("Its titles leave the library and its offline episodes are deleted. Watch progress is kept, so adding the link again picks up where you left off.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ShareRecord: Identifiable {}
|
||||
|
||||
/// One share: its name, how it is unlocked, and its actions behind a menu.
|
||||
private struct ShareRow: View {
|
||||
let share: ShareRecord
|
||||
let onRefresh: () -> Void
|
||||
let onRepair: () -> Void
|
||||
let onRemove: () -> Void
|
||||
@Environment(\.scheme) private var scheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: "folder.badge.person.crop").font(.system(size: 20)).foregroundStyle(scheme.primary).frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(share.name).textStyle(.bodyLarge, semibold: true).foregroundStyle(scheme.onSurface)
|
||||
Text(share.fromAccount ? "Folder of your Drive"
|
||||
: share.hasCustomPassword ? "Link and custom password, stored securely" : "Public link")
|
||||
.textStyle(.bodyMedium).foregroundStyle(scheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Menu {
|
||||
// One share, not the library: a link that has just had files
|
||||
// added should not cost a walk of every other one.
|
||||
Button(action: onRefresh) { Label("Refresh", systemImage: "arrow.clockwise") }
|
||||
// Not a remove: a share whose secret has become unreadable
|
||||
// cannot be removed either.
|
||||
if !share.fromAccount {
|
||||
Button(action: onRepair) { Label("Re-enter link", systemImage: "key") }
|
||||
}
|
||||
Button(role: .destructive, action: onRemove) { Label("Remove", systemImage: "trash") }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis").font(.system(size: 20)).frame(width: 48, height: 48)
|
||||
.foregroundStyle(scheme.onSurfaceVariant)
|
||||
}
|
||||
.accessibilityLabel("Actions for \(share.name)")
|
||||
}
|
||||
.padding(.leading, 16)
|
||||
.padding(.trailing, 4)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-enter the link for a share the app can no longer decrypt its secret for.
|
||||
///
|
||||
/// It has to be the *same* link — a different token is a different share.
|
||||
/// Rust enforces that; this only says so.
|
||||
private struct RepairShareSheet: View {
|
||||
let share: ShareRecord
|
||||
let onRepair: (String, String?) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var url = ""
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
DialogSheet("Re-enter link for \(share.name)") {
|
||||
Text("The stored credentials for this share cannot be read — usually after a device restore or a Keychain reset. Entering the same link again restores access without losing the library or anything downloaded.")
|
||||
.textStyle(.bodySmall)
|
||||
Field(label: "Public share URL", text: $url, secret: true)
|
||||
Field(label: "Custom password (optional)", text: $password, secret: true)
|
||||
} buttons: {
|
||||
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||
Button("Restore") {
|
||||
onRepair(url.trimmingCharacters(in: .whitespaces), password)
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.accent)
|
||||
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.secureContent()
|
||||
}
|
||||
}
|
||||
|
||||
private struct AddShareSheet: View {
|
||||
let onAdd: (String, String, String?) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var name = ""
|
||||
@State private var url: String
|
||||
@State private var password = ""
|
||||
|
||||
init(initialUrl: String, onAdd: @escaping (String, String, String?) -> Void) {
|
||||
self.onAdd = onAdd
|
||||
_url = State(initialValue: initialUrl)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
DialogSheet("Add Proton Drive share") {
|
||||
Field(label: "Library name", text: $name)
|
||||
// The URL fragment is a secret, so the field is a secure one: no
|
||||
// keyboard learning, no suggestions, and paste still works.
|
||||
Field(label: "Public share URL", text: $url, secret: true)
|
||||
// There is no share sheet into a sideloaded app, so a link copied
|
||||
// elsewhere comes in from the clipboard.
|
||||
if url.isEmpty {
|
||||
Button {
|
||||
if let text = UIPasteboard.general.string, let link = shareLink(in: text) { url = link }
|
||||
} label: {
|
||||
Label("Paste link", systemImage: "doc.on.clipboard")
|
||||
}
|
||||
.buttonStyle(.quiet)
|
||||
}
|
||||
Field(label: "Custom password (optional)", text: $password, secret: true)
|
||||
} buttons: {
|
||||
Button("Cancel") { dismiss() }.buttonStyle(.tonal)
|
||||
Button("Add") {
|
||||
onAdd(name.trimmingCharacters(in: .whitespaces), url.trimmingCharacters(in: .whitespaces), password)
|
||||
dismiss()
|
||||
}
|
||||
.buttonStyle(.accent)
|
||||
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || url.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
.secureContent()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user