mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
Sync carried only watch positions, so a second device started empty. The per-installation history file now also carries per-key registers (pstr-core::profile): every share with its link and custom password, the playback, appearance and metadata settings, the TMDB key, hand-picked matches and per-title track choices. Newer updated_at wins per key, a removal travels as an empty register, and a first sync stamps at 0 so a fresh install takes the library the other devices have instead of overwriting it with defaults. Volume, mute and the device-only settings (cache budget, Wi-Fi only, background audio, hardware decoding) stay out. The secrets live only in the end-to-end encrypted Drive file, never in shares.json. FORMAT_VERSION stays 1; an older build ignores the registers. Adding a share stored it before opening the link, so a first failure left a broken entry and every retry said "already configured". add_verified opens the link first and stores nothing on failure, and only the new share is crawled afterwards, so another broken share cannot fail the add. Android drops picture-in-picture: the window sized the video wrongly and background audio already covers leaving the app. iOS sizes the Metal drawable on every layout, so the picture no longer stretches after a rotation or a Split View resize. Settings show a save at once rather than after the match run, and a failed save reverts and says why. Both phone clients gain an activity banner naming what is running, and the add-share, metadata and change-match forms show progress and errors in place.
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, your shares, settings and watch history are kept in your own Drive, so every device shows the same library and 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 "Did not sync: \(error)" }
|
|
guard let at = account.syncedAt else { return "Library, settings and watch history sync through 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)
|
|
}
|
|
}
|