mirror of
https://github.com/yattee/yattee.git
synced 2026-07-31 04:12:03 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user