Files
yattee/Yattee/Views/Settings/InstanceLoginView.swift
Arkadiusz Fal 66f721e3ac Add Account model, AccountsManager and multi-login UI
Yattee 2 could hold at most one login per instance: a Keychain item keyed by
the instance UUID and a Bool on the instance. This adds the account layer
the rest of the revamp builds on.

DataContext (.local or .account) replaces SubscriptionAccount as the thing
that selects a data set. The legacy value maps onto it as an identity
mapping, because migrated accounts deliberately reuse their instance UUID.
Contexts carry an app-managed changedAt, and a migrated one is stamped at
the epoch so any explicit choice on any device outranks it whatever order
they arrive in.

AccountCapabilities makes what a backend supports a single switch in
InstanceType.accountCapabilities, verified against each API surface -
Invidious has history and playlist privacy, Piped has neither. Call sites
ask for capabilities instead of switching on instance type, which is the
seam the planned per-user Yattee Server accounts land on.

AccountsManager owns accounts and the selected context, stored per identity
scope so a switch of Apple Account cannot show or upload the previous one's
list. Legacy single-logins become accounts through a repeatable
reconciliation rather than a one-shot at launch, because instances arrive
over iCloud whenever they arrive; a permanent marker set is what makes
re-running safe, since usesAccountLogin stays true on the instance forever
and would otherwise recreate a deliberately deleted account. A dirty-flag
outbox records what still needs uploading so an offline change survives a
crash. The legacy subscriptionAccount selection is migrated one way,
reading local defaults first and then the raw KVS value - the previous
release published that key to iCloud but never read it back, so on a fresh
install it exists only there.

DataContextValidator replaces SubscriptionAccountValidator, which corrected
any selection it could not immediately verify. That was safe when a context
was just "an instance I am logged into", but with credentials arriving
asynchronously it would discard the user's choice during a slow Keychain
sync. Validity is now tri-state: anything that might still resolve keeps
the selection and falls back to local for reads only, with server mutations
disabled rather than silently retargeted at local data; only an actual
deletion corrects the stored context.

EditSourceView's single log in/out pair becomes a list of accounts with
per-row status, sign-in-again and sign-out-and-remove, plus the explicit
"use saved login from this device" action - the one-tap adoption that
replaced automatic adoption after the Keychain provenance measurement. On
tvOS a credential-less account offers a local sign-in instead, since Apple
never syncs app Keychain items there and the state will not resolve on its
own.

InstanceLoginView now returns the identifier alongside the credential so
the caller can file it in the Keychain beside the secret, keeping login
identifiers out of synced metadata.
2026-08-24 23:52:01 +02:00

226 lines
8.0 KiB
Swift

//
// InstanceLoginView.swift
// Yattee
//
// Shared login sheet for Invidious and Piped accounts.
//
import SwiftUI
struct InstanceLoginView: View {
let instance: Instance
/// Receives the credential **and** the identifier the user signed in with.
///
/// The identifier is handed back rather than stored here because it belongs
/// beside the credential in the Keychain, not in synced account metadata —
/// so only the caller, which knows which account this login is for, can
/// file it correctly.
let onLoginSuccess: (_ credential: String, _ username: String) -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
@State private var username = ""
@State private var password = ""
@State private var isLoading = false
@State private var errorMessage: String?
var body: some View {
#if os(tvOS)
TVSidebarDetailContainer(systemImage: "person.badge.key", title: String(localized: "login.title")) {
formContent
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.black.ignoresSafeArea())
.accessibilityIdentifier("instance.login.view")
#else
NavigationStack {
formContent
.navigationTitle(String(localized: "login.title"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
dismiss()
}
}
}
.accessibilityIdentifier("instance.login.view")
}
#if os(macOS)
.frame(minWidth: 450, minHeight: 350)
#endif
#endif
}
private var formContent: some View {
Form {
Section {
#if os(tvOS)
TVSettingsTextField(title: usernameFieldLabel, text: $username)
TVSettingsTextField(title: String(localized: "login.password"), text: $password, isSecure: true)
#else
TextField(usernameFieldLabel, text: $username)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
.accessibilityIdentifier("instance.login.usernameField")
SecureField(String(localized: "login.password"), text: $password)
.textContentType(.password)
.accessibilityIdentifier("instance.login.passwordField")
#endif
} header: {
Text(String(localized: "login.header.credentials"))
} footer: {
Text(footerText)
}
if let error = errorMessage {
Section {
Label(error, systemImage: "exclamationmark.triangle")
.foregroundStyle(.red)
.accessibilityIdentifier("instance.login.error")
}
}
Section {
Button {
login()
} label: {
if isLoading {
HStack {
ProgressView()
.controlSize(.small)
Text(String(localized: "login.loggingIn"))
}
} else {
Text(String(localized: "login.signIn"))
}
}
.disabled(username.isEmpty || password.isEmpty || isLoading)
.accessibilityIdentifier("instance.login.submitButton")
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
}
}
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
}
// MARK: - Computed Properties
/// Returns the appropriate label for the username field based on instance type.
private var usernameFieldLabel: String {
switch instance.type {
case .piped:
return String(localized: "login.username")
case .invidious:
return String(localized: "login.email")
default:
return String(localized: "login.username")
}
}
/// Returns the appropriate footer text based on instance type.
private var footerText: String {
switch instance.type {
case .piped:
return String(localized: "login.footer.pipedAccount \(instance.displayName)")
case .invidious:
return String(localized: "login.footer.invidiousAccount \(instance.displayName)")
default:
return ""
}
}
// MARK: - Actions
private func login() {
guard let appEnvironment else { return }
isLoading = true
errorMessage = nil
Task {
do {
let credential = try await performLogin(appEnvironment: appEnvironment)
await MainActor.run {
onLoginSuccess(credential, username)
dismiss()
}
} catch APIError.unauthorized {
await MainActor.run {
errorMessage = String(localized: "login.error.invalidCredentials")
isLoading = false
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isLoading = false
}
}
}
}
/// Performs the login based on instance type.
/// - Returns: The credential (SID for Invidious, token for Piped)
private func performLogin(appEnvironment: AppEnvironment) async throws -> String {
// If the instance sits behind an HTTP Basic Auth reverse proxy, the login
// POST must carry that proxy's Authorization header too — otherwise the
// request 401s before reaching the upstream login endpoint. Bake the
// header into a fresh per-instance HTTPClient.
let basicAuthHeader = appEnvironment.basicAuthCredentialsManager.basicAuthHeader(for: instance)
let extraHeaders: [String: String]? = basicAuthHeader.map { ["Authorization": $0] }
let httpClient: HTTPClient
if let basicAuthHeader {
httpClient = appEnvironment.httpClientFactory.createClient(for: instance)
await httpClient.setDefaultHeaders(["Authorization": basicAuthHeader])
} else {
httpClient = appEnvironment.httpClient
}
switch instance.type {
case .invidious:
// InvidiousAPI.login uses its own URLSession (to handle redirect/Set-Cookie),
// so it doesn't inherit defaultHeaders from the injected HTTPClient. Pass
// the basic-auth header explicitly so the login POST passes the proxy.
let api = InvidiousAPI(httpClient: httpClient)
return try await api.login(email: username, password: password, instance: instance, extraHeaders: extraHeaders)
case .piped:
// PipedAPI.login uses httpClient.fetch which DOES inherit defaultHeaders,
// so the basic-auth header on the per-instance client is sufficient.
let api = PipedAPI(httpClient: httpClient)
return try await api.login(username: username, password: password, instance: instance)
default:
throw APIError.notSupported
}
}
}
// MARK: - Preview
#Preview("Invidious") {
InstanceLoginView(
instance: Instance(type: .invidious, url: URL(string: "https://invidious.example.com")!)
) { _, _ in }
.appEnvironment(.preview)
}
#Preview("Piped") {
InstanceLoginView(
instance: Instance(type: .piped, url: URL(string: "https://piped.example.com")!)
) { _, _ in }
.appEnvironment(.preview)
}