diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index 359dd94d..b613c93a 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -333,6 +333,10 @@ final class AppEnvironment { // Set up circular dependencies after all properties are initialized 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 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 @MainActor diff --git a/Yattee/Core/InstancesManager.swift b/Yattee/Core/InstancesManager.swift index e712d3d6..1d26607a 100644 --- a/Yattee/Core/InstancesManager.swift +++ b/Yattee/Core/InstancesManager.swift @@ -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. func setPrimary(_ instance: Instance) { LoggingService.shared.debug("[InstancesManager] setPrimary called for: \(instance.displayName)", category: .general) diff --git a/Yattee/Models/Instance.swift b/Yattee/Models/Instance.swift index 55e2c4e0..0d531cd4 100644 --- a/Yattee/Models/Instance.swift +++ b/Yattee/Models/Instance.swift @@ -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. 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 init( @@ -87,7 +97,9 @@ struct Instance: Identifiable, Codable, Hashable, Sendable { dateAdded: Date = Date(), apiKey: String? = nil, allowInvalidCertificates: Bool = false, - proxiesVideos: Bool = false + proxiesVideos: Bool = false, + usesBasicAuth: Bool = false, + usesAccountLogin: Bool = false ) { self.id = id self.type = type @@ -98,6 +110,8 @@ struct Instance: Identifiable, Codable, Hashable, Sendable { self.apiKey = apiKey self.allowInvalidCertificates = allowInvalidCertificates self.proxiesVideos = proxiesVideos + self.usesBasicAuth = usesBasicAuth + self.usesAccountLogin = usesAccountLogin } init(from decoder: Decoder) throws { @@ -111,6 +125,8 @@ struct Instance: Identifiable, Codable, Hashable, Sendable { apiKey = try container.decodeIfPresent(String.self, forKey: .apiKey) allowInvalidCertificates = try container.decode(Bool.self, forKey: .allowInvalidCertificates) 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 diff --git a/Yattee/Services/Migration/LegacyDataMigrationService.swift b/Yattee/Services/Migration/LegacyDataMigrationService.swift index 95e7929c..817e720a 100644 --- a/Yattee/Services/Migration/LegacyDataMigrationService.swift +++ b/Yattee/Services/Migration/LegacyDataMigrationService.swift @@ -221,13 +221,16 @@ final class LegacyDataMigrationService { password: basicAuthCredentials.password, for: instance ) + instancesManager.setUsesBasicAuth(true, for: instance) } invidiousCredentialsManager.setCredential(credential, for: instance) + instancesManager.setUsesAccountLogin(true, for: instance) case .piped: credential = try await pipedAPI.login(username: username, password: password, instance: instance) addInstanceIfNeeded(instance) pipedCredentialsManager.setCredential(credential, for: instance) + instancesManager.setUsesAccountLogin(true, for: instance) default: throw APIError.notSupported @@ -264,6 +267,7 @@ final class LegacyDataMigrationService { password: basicAuthCredentials.password, for: instance ) + instancesManager.setUsesBasicAuth(true, for: instance) } removeLegacyInstance(item) diff --git a/Yattee/Views/MediaBrowser/MediaSourcesView.swift b/Yattee/Views/MediaBrowser/MediaSourcesView.swift index e2977874..e7de6ead 100644 --- a/Yattee/Views/MediaBrowser/MediaSourcesView.swift +++ b/Yattee/Views/MediaBrowser/MediaSourcesView.swift @@ -40,6 +40,12 @@ struct MediaSourcesView: View { 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 { Group { #if os(tvOS) @@ -517,6 +523,12 @@ struct MediaSourcesView: View { Text("\(instance.type.displayName) - \(instance.url.host ?? instance.url.absoluteString)") .font(.caption) .foregroundStyle(.secondary) + + if needsCredentials(instance) { + Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill") + .font(.caption2) + .foregroundStyle(.orange) + } } Spacer() diff --git a/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift b/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift index 6e1199e2..7a121734 100644 --- a/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift +++ b/Yattee/Views/Settings/AddSource/AddRemoteServerView.swift @@ -753,7 +753,8 @@ struct AddRemoteServerView: View { type: type, url: url, name: name.isEmpty ? nil : name, - allowInvalidCertificates: allowInvalidCertificates + allowInvalidCertificates: allowInvalidCertificates, + usesBasicAuth: true ) appEnvironment.basicAuthCredentialsManager.setCredentials( @@ -783,14 +784,16 @@ struct AddRemoteServerView: View { // For other instance types: optionally store HTTP Basic Auth credentials // (used when the instance is fronted by a reverse proxy that requires basic auth). + let hasBasicAuth = !basicAuthUsername.isEmpty && !basicAuthPassword.isEmpty let instance = Instance( type: type, url: url, name: name.isEmpty ? nil : name, - allowInvalidCertificates: allowInvalidCertificates + allowInvalidCertificates: allowInvalidCertificates, + usesBasicAuth: hasBasicAuth ) - if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty { + if hasBasicAuth { appEnvironment.basicAuthCredentialsManager.setCredentials( username: basicAuthUsername, password: basicAuthPassword, diff --git a/Yattee/Views/Settings/EditSourceView.swift b/Yattee/Views/Settings/EditSourceView.swift index a3e804af..b5165168 100644 --- a/Yattee/Views/Settings/EditSourceView.swift +++ b/Yattee/Views/Settings/EditSourceView.swift @@ -345,6 +345,7 @@ private struct EditRemoteServerContent: View { .fullScreenCover(isPresented: $showLoginSheet) { InstanceLoginView(instance: instance) { credential in appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance) + appEnvironment?.instancesManager.setUsesAccountLogin(true, for: instance) isLoggedIn = true } } @@ -352,6 +353,7 @@ private struct EditRemoteServerContent: View { .sheet(isPresented: $showLoginSheet) { InstanceLoginView(instance: instance) { credential in appEnvironment?.credentialsManager(for: instance)?.setCredential(credential, for: instance) + appEnvironment?.instancesManager.setUsesAccountLogin(true, for: instance) isLoggedIn = true } } @@ -409,6 +411,7 @@ private struct EditRemoteServerContent: View { private func logout() { appEnvironment?.credentialsManager(for: instance)?.deleteCredential(for: instance) + appEnvironment?.instancesManager.setUsesAccountLogin(false, for: instance) isLoggedIn = false } @@ -445,7 +448,9 @@ private struct EditRemoteServerContent: View { } 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.isEnabled = isEnabled updated.allowInvalidCertificates = allowInvalidCertificates @@ -458,14 +463,17 @@ private struct EditRemoteServerContent: View { if hadStoredBasicAuth { appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance) } + updated.usesBasicAuth = false } else if !basicAuthUsername.isEmpty, !basicAuthPassword.isEmpty { appEnvironment?.basicAuthCredentialsManager.setCredentials( username: basicAuthUsername, password: basicAuthPassword, for: instance ) + updated.usesBasicAuth = true } else if hadStoredBasicAuth, instance.type != .yatteeServer { appEnvironment?.basicAuthCredentialsManager.deleteCredentials(for: instance) + updated.usesBasicAuth = false } appEnvironment?.instancesManager.update(updated) diff --git a/Yattee/Views/Settings/SourceRow.swift b/Yattee/Views/Settings/SourceRow.swift index aad2a42f..1840262f 100644 --- a/Yattee/Views/Settings/SourceRow.swift +++ b/Yattee/Views/Settings/SourceRow.swift @@ -32,6 +32,13 @@ struct SourceRow: View { 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 { #if os(tvOS) Button(action: onEdit) { @@ -92,7 +99,7 @@ struct SourceRow: View { @ViewBuilder private var statusView: some View { - if needsPassword { + if needsPassword || needsCredentials { Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill") .font(.caption2) .foregroundStyle(.orange) diff --git a/Yattee/Views/Settings/SourcesListView.swift b/Yattee/Views/Settings/SourcesListView.swift index 358af3c0..03111d5d 100644 --- a/Yattee/Views/Settings/SourcesListView.swift +++ b/Yattee/Views/Settings/SourcesListView.swift @@ -403,9 +403,19 @@ struct SourcesListView: View { .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 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 { case .authFailed: Label(String(localized: "sources.status.authFailed"), systemImage: "exclamationmark.triangle.fill")