Files
yattee/Yattee/Views/Settings/InstanceLoginView.swift
Arkadiusz Fal b3c4216013 Let every request reach an instance the same way
A handful of call sites built their own InvidiousAPI or PipedAPI over the
shared HTTP client, which quietly opted them out of everything an instance
may require of a request: the reverse-proxy basic auth, the certificate
policy, and the User-Agent some instances whitelist and answer 401 without.
The feed on an instance behind a proxy could load in one place and fail in
another for no reason the user could see. They now all go through the
service that already configures a client per instance.

Login was the site that had noticed. It reproduced the basic-auth handling
itself, including the header Invidious' login needs passed explicitly
because that POST runs on its own session to read the SID out of a cookie.
That logic moves into the service verbatim rather than being dropped: the
view now asks for a credential and is told nothing about how it was
obtained.

Paging is the one thing the service could not already express. Invidious
feeds have pages and Piped feeds do not, so the paged variant says so in
its return value instead of leaving each caller to know.

The browse view's subscriptions arrive as channels now rather than as raw
Invidious rows, so the avatar enrichment works over channels — a fetched
or cached avatar still wins over whatever the listing carried, which is
the precedence the conversion applied before.
2026-09-02 23:53:33 +02:00

234 lines
8.6 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,
/// and reports whether it was stored.
///
/// 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.
///
/// The return value matters because signing in successfully is not the same
/// as having somewhere to keep the result: dismissing on a Keychain failure
/// would throw away what the user typed and make them do it all again.
let onLoginSuccess: (_ credential: String, _ username: String) -> Bool
@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
// Which Apple identity this login is being made under. Credentials are
// filed per identity, and the round trip below leaves room for the
// scope to move — storing afterwards would file the account obtained
// here in somebody else's namespace, and sync it to their Keychain.
let scopeAtStart = appEnvironment.credentialIdentityScope.current
Task {
do {
let credential = try await performLogin(appEnvironment: appEnvironment)
await MainActor.run {
// Only a move between two *named* identities is a mismatch.
// Unresolved at either end is left to `storeLogin`, which
// already refuses to write under a scope it cannot name —
// rejecting here as well would turn an ordinary sign-in
// that began before iCloud settled, or a tokenless scope
// revalidating on activation, into a failed login.
if let scopeAtStart,
let scopeNow = appEnvironment.credentialIdentityScope.current,
scopeNow != scopeAtStart {
errorMessage = String(localized: "accounts.signIn.identityChanged")
isLoading = false
return
}
guard onLoginSuccess(credential, username) else {
// The failure is reported by a toast from the storing
// side; staying open keeps the form filled in so the
// user can retry once it can succeed.
isLoading = false
return
}
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 {
// The service owns the per-instance configuration a login needs — the
// reverse-proxy basic auth (without which the POST 401s before reaching
// the upstream login endpoint), the certificate policy, and the
// User-Agent instances may insist on.
try await appEnvironment.contentService.login(
username: username,
password: password,
instance: instance
)
}
}
// MARK: - Preview
#Preview("Invidious") {
InstanceLoginView(
instance: Instance(type: .invidious, url: URL(string: "https://invidious.example.com")!)
) { _, _ in true }
.appEnvironment(.preview)
}
#Preview("Piped") {
InstanceLoginView(
instance: Instance(type: .piped, url: URL(string: "https://piped.example.com")!)
) { _, _ in true }
.appEnvironment(.preview)
}