Files
yattee/Yattee/Views/Settings/EditSourceView.swift
Arkadiusz Fal ff78cc0121 Make synced accounts usable the moment they arrive
A fresh device never adopted the synced context. The local value is a
default stamped when the manager is built, so it is always newer than a
choice made on another device, and ordering against it discarded that
choice. With no local decision to protect, the first synced context is
simply adopted.

An account arriving mid-session sat at "sign in required" until the app
was relaunched: nothing recomputed its status once the metadata — or its
instance — turned up, even when iCloud Keychain had already delivered
the credential. Both arrivals now recompute statuses and re-run the
legacy mirror, which was similarly stuck whenever the instance had not
arrived yet.

Signing in again keeps the same account and the same context, so nothing
downstream saw a change — but the credential may now belong to somebody
else, and their subscriptions and feed stayed on screen. The provider
and the account's caches are discarded on every credential replacement.

The deletion repair also cancels a context upload the deleted choice had
queued; sending it would have published the fallback under that choice's
timestamp, which not stamping it "now" exists to prevent.

Piped subscribe/unsubscribe and Invidious unsubscribe now report their
outcome like every other authenticated call, so a credential rejected by
one of them stops claiming to be signed in. Removing the last account
updates the source screen, which kept offering authenticated imports
that could only fail.
2026-08-25 23:59:38 +02:00

803 lines
32 KiB
Swift

//
// EditSourceView.swift
// Yattee
//
// Unified sheet for editing any source type.
//
import SwiftUI
struct EditSourceView: View {
let source: UnifiedSource
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
var body: some View {
switch source {
case .remoteServer(let instance):
EditRemoteServerContent(instance: instance)
case .fileSource(let mediaSource):
EditFileSourceContent(source: mediaSource)
}
}
}
// MARK: - Remote Server Content
private struct EditRemoteServerContent: View {
let instance: Instance
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
@State private var name: String
@State private var isEnabled: Bool
@State private var allowInvalidCertificates: Bool
@State private var proxiesVideos: Bool
// HTTP Basic Auth credentials (for any instance type behind a reverse proxy;
// required for Yattee Server, optional for Invidious/Piped/PeerTube).
@State private var basicAuthUsername: String = ""
@State private var basicAuthPassword: String = ""
/// Tracks whether credentials existed when the view loaded, so we can detect
/// "user cleared the fields" and delete the stored credentials on save.
@State private var hadStoredBasicAuth: Bool = false
// Account login state. `loginTarget` is non-nil while the sheet is up and
// carries the account being signed into — nil inside it means "create one".
@State private var loginTarget: AccountLoginTarget?
@State private var isLoggedIn = false
// Yattee Server validation
@State private var showingYatteeServerWarning = false
// Delete confirmation
@State private var showingDeleteConfirmation = false
// Yattee Server info
@State private var serverInfo: InstanceDetectorModels.YatteeServerFullInfo?
@State private var isLoadingServerInfo = false
@State private var serverInfoError: String?
init(instance: Instance) {
self.instance = instance
_name = State(initialValue: instance.name ?? "")
_isEnabled = State(initialValue: instance.isEnabled)
_allowInvalidCertificates = State(initialValue: instance.allowInvalidCertificates)
_proxiesVideos = State(initialValue: instance.proxiesVideos)
}
var body: some View {
#if os(tvOS)
formContent
.accessibilityIdentifier("editSource.view")
#else
NavigationStack {
formContent
.navigationTitle(String(localized: "sources.editSource"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "common.save")) {
saveChanges()
}
}
}
.accessibilityIdentifier("editSource.view")
}
#if os(macOS)
.frame(minWidth: 500, minHeight: 450)
#endif
#endif
}
private var formContent: some View {
Form {
Section {
LabeledContent(String(localized: "sources.field.type"), value: instance.type.displayName)
LabeledContent(String(localized: "sources.field.url"), value: instance.displayURL)
}
Section {
#if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name)
TVSettingsToggle(title: String(localized: "sources.field.enabled"), isOn: $isEnabled)
#elseif os(macOS)
LabeledContent(String(localized: "sources.field.name")) {
TextField("", text: $name)
}
Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled)
#else
TextField(String(localized: "sources.field.name"), text: $name)
Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled)
#endif
}
if instance.supportsHTTPBasicAuthProxy {
Section {
#if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $basicAuthUsername)
TVSettingsTextField(title: String(localized: "sources.field.password"), text: $basicAuthPassword, isSecure: true)
#elseif os(macOS)
LabeledContent(String(localized: "sources.field.username")) {
TextField("", text: $basicAuthUsername)
.textContentType(.username)
.autocorrectionDisabled()
}
LabeledContent(String(localized: "sources.field.password")) {
SecureField("", text: $basicAuthPassword)
.textContentType(.password)
}
#else
TextField(String(localized: "sources.field.username"), text: $basicAuthUsername)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
SecureField(String(localized: "sources.field.password"), text: $basicAuthPassword)
.textContentType(.password)
#endif
} header: {
Text(String(localized: instance.type == .yatteeServer ? "sources.header.auth" : "sources.header.basicAuth"))
} footer: {
Text(String(localized: instance.type == .yatteeServer ? "sources.footer.yatteeServerAuth" : "sources.footer.basicAuth"))
}
}
if instance.type == .yatteeServer {
// Server Info Section
Section {
if isLoadingServerInfo {
HStack {
ProgressView()
.controlSize(.small)
Text(String(localized: "sources.serverInfo.loading"))
.foregroundStyle(.secondary)
}
} else if let info = serverInfo {
LabeledContent(String(localized: "sources.field.serverVersion"), value: info.version ?? "—")
LabeledContent(String(localized: "sources.field.ytdlp"), value: info.dependencies?.ytDlp ?? "—")
LabeledContent(String(localized: "sources.field.ffmpeg"), value: info.dependencies?.ffmpeg ?? "—")
LabeledContent(String(localized: "sources.field.invidiousInstance"), value: invidiousDisplayValue(info))
// Extractors section
if info.config?.allowAllSitesForExtraction == true {
LabeledContent(String(localized: "sources.field.extractors"), value: String(localized: "sources.serverInfo.allSitesSupported"))
} else if let sites = info.sites, !sites.isEmpty {
#if os(tvOS)
LabeledContent(String(localized: "sources.field.extractors"), value: "\(sites.count)")
#else
DisclosureGroup(String(localized: "sources.serverInfo.enabledExtractors \(sites.count)")) {
ForEach(sites, id: \.name) { site in
Text(site.name)
.foregroundStyle(.secondary)
}
}
#endif
} else {
LabeledContent(String(localized: "sources.field.extractors"), value: String(localized: "sources.serverInfo.noneConfigured"))
}
} else if let error = serverInfoError {
Label(error, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
} header: {
Text(String(localized: "sources.header.serverInfo"))
}
}
if instance.supportsAuthentication {
InstanceAccountsSection(instance: instance, loginTarget: $loginTarget)
// Import section - show when logged in for Invidious and Piped instances
if isLoggedIn && (instance.type == .invidious || instance.type == .piped) {
Section {
NavigationLink {
#if os(tvOS)
TVSidebarDetailContainer(
systemImage: "person.2",
title: String(localized: "sources.import.subscriptions"),
showsDismissButton: true
) {
ImportSubscriptionsView(instance: instance)
}
#else
ImportSubscriptionsView(instance: instance)
#endif
} label: {
Label(String(localized: "sources.import.subscriptions"), systemImage: "person.2")
}
.accessibilityIdentifier("sources.import.subscriptions")
NavigationLink {
#if os(tvOS)
TVSidebarDetailContainer(
systemImage: "list.bullet.rectangle",
title: String(localized: "sources.import.playlists"),
showsDismissButton: true
) {
ImportPlaylistsView(instance: instance)
}
#else
ImportPlaylistsView(instance: instance)
#endif
} label: {
Label(String(localized: "sources.import.playlists"), systemImage: "list.bullet.rectangle")
}
.accessibilityIdentifier("sources.import.playlists")
} header: {
Text(String(localized: "sources.header.import"))
}
}
}
Section {
#if os(tvOS)
TVSettingsToggle(
title: String(localized: "sources.field.allowInvalidCertificates"),
isOn: $allowInvalidCertificates
)
#else
Toggle(String(localized: "sources.field.allowInvalidCertificates"), isOn: $allowInvalidCertificates)
#endif
} header: {
Text(String(localized: "sources.header.security"))
} footer: {
Text(String(localized: "sources.footer.allowInvalidCertificates"))
}
if instance.supportsVideoProxying {
Section {
#if os(tvOS)
TVSettingsToggle(
title: String(localized: "sources.field.proxiesVideos"),
isOn: $proxiesVideos
)
#else
Toggle(String(localized: "sources.field.proxiesVideos"), isOn: $proxiesVideos)
#endif
} header: {
Text(String(localized: "sources.header.proxy"))
} footer: {
Text(String(localized: "sources.footer.proxiesVideos"))
}
}
#if os(tvOS)
Section {
Button {
saveChanges()
} label: {
Label(String(localized: "common.save"), systemImage: "checkmark.circle")
}
.buttonStyle(TVSettingsButtonStyle())
}
#endif
Section {
Button(role: .destructive) {
showingDeleteConfirmation = true
} label: {
Label(String(localized: "sources.deleteSource"), systemImage: "trash")
}
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
}
}
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
#if os(macOS)
.formStyle(.grouped)
#endif
.opaqueSettingsFormBackground()
.confirmationDialog(
String(localized: "sources.delete.confirmation.single \(instance.displayName)"),
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "common.delete"), role: .destructive) {
appEnvironment?.removeInstance(instance)
dismiss()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
}
.presentationCompactAdaptation(.sheet)
#if os(tvOS)
.fullScreenCover(item: $loginTarget) { target in
InstanceLoginView(instance: instance) { credential, username in
storeLogin(credential: credential, username: username, target: target)
}
}
#else
.sheet(item: $loginTarget) { target in
InstanceLoginView(instance: instance) { credential, username in
storeLogin(credential: credential, username: username, target: target)
}
}
#endif
// Signing out happens in the accounts section below, which cannot reach
// this state directly. Without this the authenticated import actions
// stay on screen after the last account is removed, and then fail.
.onReceive(NotificationCenter.default.publisher(for: .accountsDidChange)) { _ in
isLoggedIn = appEnvironment?.isSignedIn(to: instance) ?? false
}
.onAppear {
isLoggedIn = appEnvironment?.isSignedIn(to: instance) ?? false
// Load existing HTTP Basic Auth credentials (works for any instance type)
if let credentials = appEnvironment?.basicAuthCredentialsManager.credentials(for: instance) {
basicAuthUsername = credentials.username
basicAuthPassword = credentials.password
hadStoredBasicAuth = true
}
}
.task {
await loadServerInfo()
}
.confirmationDialog(
String(localized: "sources.yatteeServer.warning.title"),
isPresented: $showingYatteeServerWarning,
titleVisibility: .visible
) {
Button(String(localized: "sources.yatteeServer.warning.disableOthers"), role: .destructive) {
appEnvironment?.instancesManager.disableOtherYatteeServerInstances(except: instance.id)
performSave()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: {
Text(String(localized: "sources.yatteeServer.warning.message"))
}
.presentationCompactAdaptation(.sheet)
}
// MARK: - Computed Properties
private var hasYatteeServer: Bool {
appEnvironment?.instancesManager.hasYatteeServerInstances ?? false
}
private func invidiousDisplayValue(_ info: InstanceDetectorModels.YatteeServerFullInfo) -> String {
guard let invidiousInstance = info.config?.invidiousInstance else {
return String(localized: "sources.serverInfo.notConfigured")
}
if invidiousInstance == "not configured" || invidiousInstance.isEmpty {
return String(localized: "sources.serverInfo.notConfigured")
}
// Extract just the host from the URL for cleaner display
if let url = URL(string: invidiousInstance), let host = url.host {
return host
}
return invidiousInstance
}
// MARK: - Actions
/// Files a successful login against an account, creating one if this was a
/// fresh sign-in.
///
/// The identifier is stored beside the credential in the Keychain rather
/// than on the account record, so it never travels as synced metadata.
private func storeLogin(credential: String, username: String, target: AccountLoginTarget) {
guard let appEnvironment else { return }
// Credential storage refuses to write under an unresolved identity
// scope, so creating the account first would leave one with no
// credential — and in a namespace that is discarded the moment the real
// scope arrives. Fail visibly instead.
guard appEnvironment.credentialIdentityScope.isResolved else {
appEnvironment.toastManager.showError(
String(localized: "accounts.signIn.identityUnresolved")
)
return
}
let account = target.account
?? appEnvironment.accountsManager.addAccount(instanceID: instance.id)
guard let credentials = appEnvironment.accountCredentialsManager(for: account) else { return }
credentials.setCredential(credential, for: account)
// Confirm the write landed before reporting success: a Keychain failure
// would otherwise leave an account that looks signed in but has nothing
// behind it.
guard credentials.credential(for: account) != nil else {
if target.account == nil {
appEnvironment.accountsManager.removeAccount(account.id)
}
appEnvironment.toastManager.showError(
String(localized: "accounts.signIn.storeFailed")
)
return
}
if !username.isEmpty {
credentials.setUsername(username, for: account)
}
appEnvironment.accountsManager.setStatus(.signedIn, for: account.id)
appEnvironment.instancesManager.setUsesAccountLogin(true, for: instance)
// Signing in again keeps the same account, so nothing downstream sees a
// change of context — but the credential behind it may now belong to
// somebody else, and their data must not survive the swap.
appEnvironment.accountCredentialDidChange(for: account)
isLoggedIn = true
}
private func loadServerInfo() async {
guard instance.type == .yatteeServer, let appEnvironment else { return }
isLoadingServerInfo = true
serverInfoError = nil
do {
serverInfo = try await appEnvironment.contentService.yatteeServerInfo(for: instance)
} catch {
serverInfoError = String(localized: "sources.serverInfo.loadError")
}
isLoadingServerInfo = false
}
private func saveChanges() {
// Check if we're enabling a Yattee Server instance
let wasDisabled = !instance.isEnabled
let willBeEnabled = isEnabled
let isYatteeServer = instance.type == .yatteeServer
if isYatteeServer && wasDisabled && willBeEnabled {
let otherEnabled = appEnvironment?.instancesManager.enabledYatteeServerInstances ?? []
if !otherEnabled.isEmpty {
showingYatteeServerWarning = true
return
}
}
performSave()
}
private func performSave() {
// 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
updated.proxiesVideos = proxiesVideos
// Save / clear HTTP Basic Auth credentials. Yattee Server never auto-clears
// (credentials are required and the user re-enters to overwrite). Piped never
// persists them — its session token reuses the same Authorization header.
if !instance.supportsHTTPBasicAuthProxy {
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)
dismiss()
}
}
// MARK: - File Source Content
private struct EditFileSourceContent: View {
let source: MediaSource
@Environment(\.dismiss) private var dismiss
@Environment(\.appEnvironment) private var appEnvironment
@State private var name: String
@State private var isEnabled: Bool
@State private var username: String
@State private var password: String
@State private var allowInvalidCertificates: Bool
@State private var smbProtocolVersion: SMBProtocol = .auto
@State private var hasExistingPassword = false
@State private var showingDeleteConfirmation = false
init(source: MediaSource) {
self.source = source
_name = State(initialValue: source.name)
_isEnabled = State(initialValue: source.isEnabled)
_username = State(initialValue: source.username ?? "")
_password = State(initialValue: "")
_allowInvalidCertificates = State(initialValue: source.allowInvalidCertificates)
}
var body: some View {
#if os(tvOS)
formContent
.onAppear {
hasExistingPassword = appEnvironment?.mediaSourcesManager.password(for: source) != nil
smbProtocolVersion = source.smbProtocolVersion ?? .auto
}
#else
NavigationStack {
formContent
.navigationTitle(String(localized: "sources.editSource"))
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(String(localized: "common.cancel")) {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button(String(localized: "common.save")) {
saveChanges()
}
.disabled(name.isEmpty)
}
}
.onAppear {
hasExistingPassword = appEnvironment?.mediaSourcesManager.password(for: source) != nil
smbProtocolVersion = source.smbProtocolVersion ?? .auto
}
}
#if os(macOS)
.frame(minWidth: 500, minHeight: 450)
#endif
#endif
}
private var formContent: some View {
Form {
Section {
#if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.name"), text: $name)
TVSettingsToggle(title: String(localized: "sources.field.enabled"), isOn: $isEnabled)
#elseif os(macOS)
LabeledContent(String(localized: "sources.field.name")) {
TextField("", text: $name)
}
Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled)
#else
TextField(String(localized: "sources.field.name"), text: $name)
Toggle(String(localized: "sources.field.enabled"), isOn: $isEnabled)
#endif
} header: {
Text(String(localized: "sources.header.general"))
}
Section {
HStack {
Text(String(localized: "sources.field.type"))
Spacer()
Label(source.type.displayName, systemImage: source.type.systemImage)
.foregroundStyle(.secondary)
}
#if !os(tvOS)
.alignmentGuide(.listRowSeparatorLeading) { d in d[.leading] }
#endif
#if os(macOS)
HStack {
Text(String(localized: "sources.field.url"))
Spacer()
Text(source.url.absoluteString)
.foregroundStyle(.secondary)
.lineLimit(1)
}
#else
if source.type != .localFolder {
HStack {
Text(String(localized: "sources.field.url"))
Spacer()
Text(source.url.absoluteString)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
#endif
}
if source.type == .webdav || source.type == .smb {
Section {
#if os(tvOS)
TVSettingsTextField(title: String(localized: "sources.field.username"), text: $username)
TVSettingsTextField(
title: hasExistingPassword
? String(localized: "sources.field.passwordKeep")
: String(localized: "sources.field.passwordRequired"),
text: $password,
isSecure: true
)
#elseif os(macOS)
LabeledContent(String(localized: "sources.field.username")) {
TextField("", text: $username)
.textContentType(.username)
.autocorrectionDisabled()
}
LabeledContent(String(localized: "sources.field.password")) {
SecureField(
hasExistingPassword
? String(localized: "sources.field.passwordKeep")
: String(localized: "sources.field.passwordRequired"),
text: $password
)
.textContentType(.password)
}
#else
TextField(String(localized: "sources.field.username"), text: $username)
.textContentType(.username)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
.autocorrectionDisabled()
SecureField(
hasExistingPassword
? String(localized: "sources.field.passwordKeep")
: String(localized: "sources.field.passwordRequired"),
text: $password
)
.textContentType(.password)
#endif
} header: {
Text(String(localized: "sources.header.auth"))
}
}
if source.type == .smb {
Section {
Picker(String(localized: "sources.field.smbProtocol"), selection: $smbProtocolVersion) {
ForEach(SMBProtocol.allCases, id: \.self) { proto in
Text(proto.displayName).tag(proto)
}
}
} header: {
Text(String(localized: "sources.header.advanced"))
} footer: {
Text(String(localized: "sources.footer.smbProtocol"))
}
}
if source.type == .webdav {
Section {
#if os(tvOS)
TVSettingsToggle(
title: String(localized: "sources.field.allowInvalidCertificates"),
isOn: $allowInvalidCertificates
)
#else
Toggle(String(localized: "sources.field.allowInvalidCertificates"), isOn: $allowInvalidCertificates)
#endif
} header: {
Text(String(localized: "sources.header.security"))
} footer: {
Text(String(localized: "sources.footer.allowInvalidCertificates"))
}
}
#if os(tvOS)
Section {
Button {
saveChanges()
} label: {
Label(String(localized: "common.save"), systemImage: "checkmark.circle")
}
.disabled(name.isEmpty)
.buttonStyle(TVSettingsButtonStyle())
}
#endif
Section {
Button(role: .destructive) {
showingDeleteConfirmation = true
} label: {
Label(String(localized: "sources.deleteSource"), systemImage: "trash")
.foregroundStyle(.red)
}
#if os(tvOS)
.buttonStyle(TVSettingsButtonStyle())
#endif
}
}
#if os(iOS)
.scrollDismissesKeyboard(.interactively)
#endif
#if os(macOS)
.formStyle(.grouped)
#endif
.opaqueSettingsFormBackground()
.confirmationDialog(
String(localized: "sources.delete.confirmation.single \(source.name)"),
isPresented: $showingDeleteConfirmation,
titleVisibility: .visible
) {
Button(String(localized: "common.delete"), role: .destructive) {
deleteSource()
}
Button(String(localized: "common.cancel"), role: .cancel) {}
}
.presentationCompactAdaptation(.sheet)
}
private func saveChanges() {
guard let appEnvironment else { return }
var updatedSource = source
updatedSource.name = name
updatedSource.isEnabled = isEnabled
if source.type == .webdav || source.type == .smb {
updatedSource.username = username.isEmpty ? nil : username
if !password.isEmpty {
appEnvironment.mediaSourcesManager.setPassword(password, for: source)
}
}
if source.type == .webdav {
updatedSource.allowInvalidCertificates = allowInvalidCertificates
}
if source.type == .smb {
updatedSource.smbProtocolVersion = smbProtocolVersion
// Clear SMB cache if credentials or protocol changed
let credentialsChanged = (source.username != updatedSource.username) || !password.isEmpty
let protocolChanged = source.smbProtocolVersion != smbProtocolVersion
if credentialsChanged || protocolChanged {
Task {
await appEnvironment.smbClient.clearCache(for: source)
}
}
}
appEnvironment.mediaSourcesManager.update(updatedSource)
dismiss()
}
private func deleteSource() {
guard let appEnvironment else { return }
appEnvironment.mediaSourcesManager.remove(source)
dismiss()
}
}
// MARK: - Preview
#Preview("Remote Server") {
EditSourceView(
source: .remoteServer(Instance(type: .invidious, url: URL(string: "https://invidious.example.com")!))
)
.appEnvironment(.preview)
}
#Preview("WebDAV") {
EditSourceView(
source: .fileSource(.webdav(name: "My NAS", url: URL(string: "https://nas.local:5006")!, username: "user"))
)
.appEnvironment(.preview)
}