Show missing-credentials indicator for remote server sources

After reinstalling the app and importing sources from iCloud, remote
server instances with Keychain-only credentials showed no indication
that login is needed, unlike WebDAV/SMB sources.

- Show the orange key icon for Yattee Server instances without stored
  basic auth credentials (auth is always required for this type)
- Persist usesBasicAuth/usesAccountLogin flags on Instance (synced via
  iCloud) so missing Invidious/Piped/PeerTube credentials are detectable
  too; maintained on credential save/delete, login/logout, and legacy
  migration
- Centralize the check in AppEnvironment.needsCredentials(for:) used by
  SourcesListView, MediaSourcesView, and SourceRow
- Backfill the flags at launch from credentials currently in the
  Keychain so existing setups publish them without a re-login
This commit is contained in:
Arkadiusz Fal
2026-07-23 18:19:38 +02:00
parent c3e39c955d
commit 6994b9353a
9 changed files with 132 additions and 7 deletions

View File

@@ -333,6 +333,10 @@ final class AppEnvironment {
// Set up circular dependencies after all properties are initialized // Set up circular dependencies after all properties are initialized
bgRefreshManager.setAppEnvironment(self) bgRefreshManager.setAppEnvironment(self)
// Backfill credential flags for instances created before the flags
// existed, so existing setups publish them to iCloud without a re-login
backfillCredentialFlags()
// Log device capabilities on startup for debugging // Log device capabilities on startup for debugging
HardwareCapabilities.shared.logCapabilities() HardwareCapabilities.shared.logCapabilities()
@@ -424,6 +428,47 @@ final class AppEnvironment {
} }
} }
/// Backfills `usesBasicAuth`/`usesAccountLogin` on instances that were created
/// before these flags existed, based on credentials currently in the Keychain.
/// Only ever sets the flags a missing Keychain entry (e.g. right after a
/// reinstall) must not clear a previously recorded flag.
private func backfillCredentialFlags() {
for instance in instancesManager.instances {
if !instance.usesBasicAuth,
instance.supportsHTTPBasicAuthProxy,
basicAuthCredentialsManager.hasCredentials(for: instance) {
instancesManager.setUsesBasicAuth(true, for: instance)
}
if !instance.usesAccountLogin,
instance.supportsAuthentication,
let manager = credentialsManager(for: instance),
manager.isLoggedIn(for: instance) {
instancesManager.setUsesAccountLogin(true, for: instance)
}
}
}
/// Whether an instance is known to require credentials that are missing from
/// the Keychain (e.g. after reinstalling the app and importing sources from iCloud).
/// - Yattee Server always requires basic auth; other types require it when
/// `usesBasicAuth` was recorded.
/// - Invidious/Piped account logins are checked when `usesAccountLogin` was recorded.
func needsCredentials(for instance: Instance) -> Bool {
if instance.type == .yatteeServer || instance.usesBasicAuth,
!basicAuthCredentialsManager.hasCredentials(for: instance) {
return true
}
if instance.usesAccountLogin,
let manager = credentialsManager(for: instance),
!manager.isLoggedIn(for: instance) {
return true
}
return false
}
// MARK: - Preview/Testing Support // MARK: - Preview/Testing Support
@MainActor @MainActor

View File

@@ -174,6 +174,26 @@ final class InstancesManager {
} }
} }
/// Records whether an instance sits behind HTTP Basic Auth.
/// Persisted (and synced to iCloud) so missing Keychain credentials can be
/// detected after a reinstall.
func setUsesBasicAuth(_ value: Bool, for instance: Instance) {
guard let index = instances.firstIndex(where: { $0.id == instance.id }),
instances[index].usesBasicAuth != value else { return }
instances[index].usesBasicAuth = value
saveInstances()
}
/// Records whether the user has logged into an account on an instance.
/// Persisted (and synced to iCloud) so missing Keychain credentials can be
/// detected after a reinstall.
func setUsesAccountLogin(_ value: Bool, for instance: Instance) {
guard let index = instances.firstIndex(where: { $0.id == instance.id }),
instances[index].usesAccountLogin != value else { return }
instances[index].usesAccountLogin = value
saveInstances()
}
/// Sets the given instance as the primary (first) instance. /// Sets the given instance as the primary (first) instance.
func setPrimary(_ instance: Instance) { func setPrimary(_ instance: Instance) {
LoggingService.shared.debug("[InstancesManager] setPrimary called for: \(instance.displayName)", category: .general) LoggingService.shared.debug("[InstancesManager] setPrimary called for: \(instance.displayName)", category: .general)

View File

@@ -76,6 +76,16 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
/// Whether to route video streams through this instance instead of connecting directly to YouTube CDN. /// Whether to route video streams through this instance instead of connecting directly to YouTube CDN.
var proxiesVideos: Bool var proxiesVideos: Bool
/// Whether this instance sits behind an HTTP Basic Auth reverse proxy.
/// Set when basic auth credentials are stored, so a missing Keychain entry
/// (e.g. after reinstalling and importing sources from iCloud) can be detected.
/// Yattee Server always requires basic auth regardless of this flag.
var usesBasicAuth: Bool
/// Whether the user has logged into an account on this instance (Invidious/Piped).
/// Set on login, so a missing Keychain credential can be detected after reinstall.
var usesAccountLogin: Bool
// MARK: - Initialization // MARK: - Initialization
init( init(
@@ -87,7 +97,9 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
dateAdded: Date = Date(), dateAdded: Date = Date(),
apiKey: String? = nil, apiKey: String? = nil,
allowInvalidCertificates: Bool = false, allowInvalidCertificates: Bool = false,
proxiesVideos: Bool = false proxiesVideos: Bool = false,
usesBasicAuth: Bool = false,
usesAccountLogin: Bool = false
) { ) {
self.id = id self.id = id
self.type = type self.type = type
@@ -98,6 +110,8 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
self.apiKey = apiKey self.apiKey = apiKey
self.allowInvalidCertificates = allowInvalidCertificates self.allowInvalidCertificates = allowInvalidCertificates
self.proxiesVideos = proxiesVideos self.proxiesVideos = proxiesVideos
self.usesBasicAuth = usesBasicAuth
self.usesAccountLogin = usesAccountLogin
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
@@ -111,6 +125,8 @@ struct Instance: Identifiable, Codable, Hashable, Sendable {
apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey) apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey)
allowInvalidCertificates = try container.decode(Bool.self, forKey: .allowInvalidCertificates) allowInvalidCertificates = try container.decode(Bool.self, forKey: .allowInvalidCertificates)
proxiesVideos = try container.decodeIfPresent(Bool.self, forKey: .proxiesVideos) ?? false proxiesVideos = try container.decodeIfPresent(Bool.self, forKey: .proxiesVideos) ?? false
usesBasicAuth = try container.decodeIfPresent(Bool.self, forKey: .usesBasicAuth) ?? false
usesAccountLogin = try container.decodeIfPresent(Bool.self, forKey: .usesAccountLogin) ?? false
} }
// MARK: - Computed Properties // MARK: - Computed Properties

View File

@@ -221,13 +221,16 @@ final class LegacyDataMigrationService {
password: basicAuthCredentials.password, password: basicAuthCredentials.password,
for: instance for: instance
) )
instancesManager.setUsesBasicAuth(true, for: instance)
} }
invidiousCredentialsManager.setCredential(credential, for: instance) invidiousCredentialsManager.setCredential(credential, for: instance)
instancesManager.setUsesAccountLogin(true, for: instance)
case .piped: case .piped:
credential = try await pipedAPI.login(username: username, password: password, instance: instance) credential = try await pipedAPI.login(username: username, password: password, instance: instance)
addInstanceIfNeeded(instance) addInstanceIfNeeded(instance)
pipedCredentialsManager.setCredential(credential, for: instance) pipedCredentialsManager.setCredential(credential, for: instance)
instancesManager.setUsesAccountLogin(true, for: instance)
default: default:
throw APIError.notSupported throw APIError.notSupported
@@ -264,6 +267,7 @@ final class LegacyDataMigrationService {
password: basicAuthCredentials.password, password: basicAuthCredentials.password,
for: instance for: instance
) )
instancesManager.setUsesBasicAuth(true, for: instance)
} }
removeLegacyInstance(item) removeLegacyInstance(item)

View File

@@ -40,6 +40,12 @@ struct MediaSourcesView: View {
appEnvironment?.settingsManager.listStyle ?? .inset appEnvironment?.settingsManager.listStyle ?? .inset
} }
/// Whether a remote server instance requires credentials that are missing from
/// the Keychain (e.g. after reinstalling and importing sources from iCloud).
private func needsCredentials(_ instance: Instance) -> Bool {
appEnvironment?.needsCredentials(for: instance) ?? false
}
var body: some View { var body: some View {
Group { Group {
#if os(tvOS) #if os(tvOS)
@@ -517,6 +523,12 @@ struct MediaSourcesView: View {
Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)") Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if needsCredentials(instance) {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2)
.foregroundStyle(.orange)
}
} }
Spacer() Spacer()

View File

@@ -753,7 +753,8 @@ struct AddRemoteServerView: View {
type: type, type: type,
url: url, url: url,
name: name.isEmpty ? nil : name, name: name.isEmpty ? nil : name,
allowInvalidCertificates: allowInvalidCertificates allowInvalidCertificates: allowInvalidCertificates,
usesBasicAuth: true
) )
appEnvironment.basicAuthCredentialsManager.setCredentials( appEnvironment.basicAuthCredentialsManager.setCredentials(
@@ -783,14 +784,16 @@ struct AddRemoteServerView: View {
// For other instance types: optionally store HTTP Basic Auth credentials // For other instance types: optionally store HTTP Basic Auth credentials
// (used when the instance is fronted by a reverse proxy that requires basic auth). // (used when the instance is fronted by a reverse proxy that requires basic auth).
let hasBasicAuth = !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty
let instance = Instance( let instance = Instance(
type: type, type: type,
url: url, url: url,
name: name.isEmpty ? nil : name, name: name.isEmpty ? nil : name,
allowInvalidCertificates: allowInvalidCertificates allowInvalidCertificates: allowInvalidCertificates,
usesBasicAuth: hasBasicAuth
) )
if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty { if hasBasicAuth {
appEnvironment.basicAuthCredentialsManager.setCredentials( appEnvironment.basicAuthCredentialsManager.setCredentials(
username: basicAuthUsername, username: basicAuthUsername,
password: basicAuthPassword, password: basicAuthPassword,

View File

@@ -345,6 +345,7 @@ private struct EditRemoteServerContent: View {
.fullScreenCover(isPresented: $showLoginSheet) { .fullScreenCover(isPresented: $showLoginSheet) {
InstanceLoginView(instance: instance) { credential in InstanceLoginView(instance: instance) { credential in
appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance) appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance)
appEnvironment?.instancesManager.setUsesAccountLogin(true, for: instance)
isLoggedIn = true isLoggedIn = true
} }
} }
@@ -352,6 +353,7 @@ private struct EditRemoteServerContent: View {
.sheet(isPresented: $showLoginSheet) { .sheet(isPresented: $showLoginSheet) {
InstanceLoginView(instance: instance) { credential in InstanceLoginView(instance: instance) { credential in
appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance) appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance)
appEnvironment?.instancesManager.setUsesAccountLogin(true, for: instance)
isLoggedIn = true isLoggedIn = true
} }
} }
@@ -409,6 +411,7 @@ private struct EditRemoteServerContent: View {
private func logout() { private func logout() {
appEnvironment?.credentialsManager(for: instance)?.deleteCredential(for: instance) appEnvironment?.credentialsManager(for: instance)?.deleteCredential(for: instance)
appEnvironment?.instancesManager.setUsesAccountLogin(false, for: instance)
isLoggedIn = false isLoggedIn = false
} }
@@ -445,7 +448,9 @@ private struct EditRemoteServerContent: View {
} }
private func performSave() { private func performSave() {
var updated = instance // Start from the manager's latest copy so flags updated while this sheet
// was open (e.g. usesAccountLogin set by the login sheet) are preserved.
var updated = appEnvironment?.instancesManager.instances.first { $0.id == instance.id } ?? instance
updated.name = name.isEmpty ? nil : name updated.name = name.isEmpty ? nil : name
updated.isEnabled = isEnabled updated.isEnabled = isEnabled
updated.allowInvalidCertificates = allowInvalidCertificates updated.allowInvalidCertificates = allowInvalidCertificates
@@ -458,14 +463,17 @@ private struct EditRemoteServerContent: View {
if hadStoredBasicAuth { if hadStoredBasicAuth {
appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance) appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance)
} }
updated.usesBasicAuth = false
} else if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty { } else if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty {
appEnvironment?.basicAuthCredentialsManager.setCredentials( appEnvironment?.basicAuthCredentialsManager.setCredentials(
username: basicAuthUsername, username: basicAuthUsername,
password: basicAuthPassword, password: basicAuthPassword,
for: instance for: instance
) )
updated.usesBasicAuth = true
} else if hadStoredBasicAuth, instance.type != .yatteeServer { } else if hadStoredBasicAuth, instance.type != .yatteeServer {
appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance) appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance)
updated.usesBasicAuth = false
} }
appEnvironment?.instancesManager.update(updated) appEnvironment?.instancesManager.update(updated)

View File

@@ -32,6 +32,13 @@ struct SourceRow: View {
return status == .authFailed || status == .authRequired return status == .authFailed || status == .authRequired
} }
/// Whether a remote server instance requires credentials that are missing from
/// the Keychain (e.g. after reinstalling and importing sources from iCloud).
private var needsCredentials: Bool {
guard case .remoteServer(let instance) = source else { return false }
return appEnvironment?.needsCredentials(for: instance) ?? false
}
var body: some View { var body: some View {
#if os(tvOS) #if os(tvOS)
Button(action: onEdit) { Button(action: onEdit) {
@@ -92,7 +99,7 @@ struct SourceRow: View {
@ViewBuilder @ViewBuilder
private var statusView: some View { private var statusView: some View {
if needsPassword { if needsPassword || needsCredentials {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill") Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2) .font(.caption2)
.foregroundStyle(.orange) .foregroundStyle(.orange)

View File

@@ -403,9 +403,19 @@ struct SourcesListView: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
/// Whether a remote server instance requires credentials that are missing from
/// the Keychain (e.g. after reinstalling and importing sources from iCloud).
private func needsCredentials(_ instance: Instance) -> Bool {
appEnvironment?.needsCredentials(for: instance) ?? false
}
@ViewBuilder @ViewBuilder
private func instanceStatusView(for instance: Instance) -> some View { private func instanceStatusView(for instance: Instance) -> some View {
if let status = instancesManager?.status(for: instance) { if needsCredentials(instance) {
Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill")
.font(.caption2)
.foregroundStyle(.orange)
} else if let status = instancesManager?.status(for: instance) {
switch status { switch status {
case .authFailed: case .authFailed:
Label(String(localized: "sources.status.authFailed"), systemImage: "exclamationmark.triangle.fill") Label(String(localized: "sources.status.authFailed"), systemImage: "exclamationmark.triangle.fill")