mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Round two of review on the accounts layer. The settings picker mapped its legacy instance-keyed selection straight onto DataContext(legacy:), which turns an instance UUID into an account UUID. That identity only holds for migrated accounts - a login created after the revamp has a random ID - so selecting a freshly added account named one that does not exist and left the provider nil. Selections now resolve through AccountsManager to an actual account on that instance, preferring a signed-in one. The picker still lists one entry per instance and so cannot show a second login; the Phase 3 switcher replaces it. BackgroundFeedRefresher was missed in the previous sweep because it resolves credentials differently: it routed on the legacy setting and looked up "first enabled Invidious instance" with an instance-keyed credential. After any new-style login that silently found nothing and skipped the refresh. It now routes on the selected context and takes the credential from that account. Subscription reads used the raw stored context, so an account waiting on its instance or credential rendered empty instead of falling back to local data as the tri-state design specifies. Reads go through the validator's effective context; writes deliberately do not, so a subscribe fails visibly rather than landing in local data the user did not choose. Account statuses are cleared when the identity namespace reloads, but nothing recomputed them once a deferred scope resolution succeeded - a synced account kept showing "sign in required" until relaunch. The scope change now triggers a refresh, deferred by one turn so it cannot depend on observer registration order relative to the store's own reload.
525 lines
18 KiB
Swift
525 lines
18 KiB
Swift
//
|
|
// SubscriptionsSettingsView.swift
|
|
// Yattee
|
|
//
|
|
// Settings for importing and exporting subscriptions.
|
|
//
|
|
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
#if !os(tvOS)
|
|
|
|
// MARK: - Export File Wrapper
|
|
|
|
private struct ExportFile: Identifiable {
|
|
let id = UUID()
|
|
let url: URL
|
|
}
|
|
|
|
struct SubscriptionsSettingsView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
// Account selection state
|
|
@State private var pendingAccountChange: SubscriptionAccount?
|
|
@State private var showingAccountSwitchConfirmation = false
|
|
|
|
// Import state
|
|
@State private var showingImportPicker = false
|
|
@State private var isImporting = false
|
|
@State private var importResult: (imported: Int, skipped: Int, format: String)?
|
|
@State private var showingImportResult = false
|
|
@State private var importError: String?
|
|
@State private var showingImportError = false
|
|
|
|
// Export state
|
|
@State private var selectedExportFormat: SubscriptionExportFormat = .json
|
|
@State private var exportFile: ExportFile?
|
|
@State private var isExporting = false
|
|
|
|
// Account subscription count (provider-scoped, fetched for server accounts)
|
|
@State private var accountSubscriptionCount: Int?
|
|
|
|
// Local data deletion state
|
|
@State private var showingDeleteLocalConfirmation = false
|
|
|
|
private var dataManager: DataManager? {
|
|
appEnvironment?.dataManager
|
|
}
|
|
|
|
private var settingsManager: SettingsManager? {
|
|
appEnvironment?.settingsManager
|
|
}
|
|
|
|
private var validator: SubscriptionAccountValidator? {
|
|
appEnvironment?.subscriptionAccountValidator
|
|
}
|
|
|
|
private var subscriptionService: SubscriptionService? {
|
|
appEnvironment?.subscriptionService
|
|
}
|
|
|
|
/// Subscription count of the active account (local store or server).
|
|
private var subscriptionCount: Int {
|
|
accountSubscriptionCount ?? subscriptionService?.cachedSubscriptionCount ?? 0
|
|
}
|
|
|
|
/// Count of subscriptions in the local store, shown in the local-data section.
|
|
private var localSubscriptionCount: Int {
|
|
dataManager?.subscriptionCount ?? 0
|
|
}
|
|
|
|
private var currentAccount: SubscriptionAccount {
|
|
settingsManager?.subscriptionAccount ?? .local
|
|
}
|
|
|
|
var body: some View {
|
|
List {
|
|
accountSection
|
|
if validator?.hasAvailableAccounts == true {
|
|
importSection
|
|
exportSection
|
|
localDataSection
|
|
}
|
|
}
|
|
.task(id: currentAccount) {
|
|
await refreshAccountSubscriptionCount()
|
|
}
|
|
.navigationTitle(String(localized: "settings.subscriptions.title"))
|
|
#if os(iOS)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.sheet(isPresented: $showingImportPicker) {
|
|
SubscriptionFilePickerView { url in
|
|
handleImportedFile(url)
|
|
}
|
|
}
|
|
.sheet(item: $exportFile) { file in
|
|
ShareSheet(items: [file.url])
|
|
}
|
|
#endif
|
|
.alert(
|
|
String(localized: "settings.subscriptions.import.success.title"),
|
|
isPresented: $showingImportResult
|
|
) {
|
|
Button(String(localized: "common.ok")) {}
|
|
} message: {
|
|
if let result = importResult {
|
|
Text(String(localized: "settings.subscriptions.import.success.message \(result.imported) \(result.skipped) \(result.format)"))
|
|
}
|
|
}
|
|
.alert(
|
|
String(localized: "settings.subscriptions.import.error.title"),
|
|
isPresented: $showingImportError
|
|
) {
|
|
Button(String(localized: "common.ok")) {}
|
|
} message: {
|
|
if let error = importError {
|
|
Text(error)
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
String(localized: "settings.subscriptions.account.switch.title"),
|
|
isPresented: $showingAccountSwitchConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "settings.subscriptions.account.switch.action")) {
|
|
confirmAccountSwitch()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
|
cancelAccountSwitch()
|
|
}
|
|
} message: {
|
|
Text(String(localized: "settings.subscriptions.account.switch.message"))
|
|
}
|
|
.confirmationDialog(
|
|
String(localized: "settings.subscriptions.localData.delete.confirmation.title \(localSubscriptionCount)"),
|
|
isPresented: $showingDeleteLocalConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button(String(localized: "settings.subscriptions.localData.delete.confirmation.action"), role: .destructive) {
|
|
deleteLocalSubscriptions()
|
|
}
|
|
} message: {
|
|
Text(String(localized: "settings.subscriptions.localData.delete.confirmation.message"))
|
|
}
|
|
.presentationCompactAdaptation(.sheet)
|
|
}
|
|
|
|
// MARK: - Account Section
|
|
|
|
@ViewBuilder
|
|
private var accountSection: some View {
|
|
Section {
|
|
if let validator, validator.hasAvailableAccounts {
|
|
Picker(
|
|
String(localized: "settings.subscriptions.account.label"),
|
|
selection: Binding(
|
|
get: { currentAccount },
|
|
set: { newAccount in
|
|
if newAccount != currentAccount {
|
|
pendingAccountChange = newAccount
|
|
showingAccountSwitchConfirmation = true
|
|
}
|
|
}
|
|
)
|
|
) {
|
|
ForEach(validator.availableAccounts, id: \.self) { account in
|
|
Text(validator.displayName(for: account))
|
|
.tag(account)
|
|
}
|
|
}
|
|
} else {
|
|
// No accounts available - show setup prompt
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Label(
|
|
String(localized: "settings.subscriptions.account.noAccounts.title"),
|
|
systemImage: "exclamationmark.triangle"
|
|
)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(String(localized: "settings.subscriptions.account.noAccounts.message"))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Button(String(localized: "settings.subscriptions.account.noAccounts.action")) {
|
|
appEnvironment?.navigationCoordinator.navigate(to: .settings)
|
|
}
|
|
.font(.caption)
|
|
.padding(.top, 4)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
} footer: {
|
|
if validator?.hasAvailableAccounts == true {
|
|
Text(String(localized: "settings.subscriptions.account.footer"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Account Switch Actions
|
|
|
|
private func confirmAccountSwitch() {
|
|
guard let newAccount = pendingAccountChange else { return }
|
|
|
|
// Both stores are written: the context is what the data layer reads,
|
|
// the legacy setting is kept in step until the Phase 3 switcher removes
|
|
// the last readers of it. Letting them diverge would show one account
|
|
// in the UI while serving another's data.
|
|
settingsManager?.subscriptionAccount = newAccount
|
|
appEnvironment?.accountsManager.setContext(
|
|
appEnvironment?.accountsManager.context(forLegacyInstanceID: newAccount.instanceID) ?? .local
|
|
)
|
|
SubscriptionFeedCache.shared.handleAccountChange()
|
|
|
|
// Trigger feed refresh in background
|
|
Task {
|
|
guard let appEnvironment else { return }
|
|
await SubscriptionFeedCache.shared.refresh(using: appEnvironment)
|
|
}
|
|
|
|
pendingAccountChange = nil
|
|
LoggingService.shared.logSubscriptions("Switched subscription account to: \(String(describing: newAccount.type))")
|
|
}
|
|
|
|
private func cancelAccountSwitch() {
|
|
pendingAccountChange = nil
|
|
}
|
|
|
|
// MARK: - Import Section
|
|
|
|
@ViewBuilder
|
|
private var importSection: some View {
|
|
Section {
|
|
Button {
|
|
showImportPicker()
|
|
} label: {
|
|
HStack {
|
|
Label(String(localized: "settings.subscriptions.import.button"), systemImage: "square.and.arrow.down")
|
|
Spacer()
|
|
if isImporting {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
.disabled(isImporting)
|
|
} header: {
|
|
Text(String(localized: "settings.subscriptions.import.title"))
|
|
} footer: {
|
|
Text(String(localized: "settings.subscriptions.import.footer"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Export Section
|
|
|
|
@ViewBuilder
|
|
private var exportSection: some View {
|
|
Section {
|
|
Picker(String(localized: "settings.subscriptions.export.format"), selection: $selectedExportFormat) {
|
|
ForEach(SubscriptionExportFormat.allCases) { format in
|
|
Text(format.rawValue).tag(format)
|
|
}
|
|
}
|
|
|
|
Button {
|
|
exportSubscriptions()
|
|
} label: {
|
|
HStack {
|
|
Label(String(localized: "settings.subscriptions.export.button"), systemImage: "square.and.arrow.up")
|
|
Spacer()
|
|
if isExporting {
|
|
ProgressView()
|
|
}
|
|
}
|
|
}
|
|
.disabled(subscriptionCount == 0 || isExporting)
|
|
} header: {
|
|
Text(String(localized: "settings.subscriptions.export.title"))
|
|
} footer: {
|
|
Text(String(localized: "settings.subscriptions.export.footer \(subscriptionCount)"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Local Data Section
|
|
|
|
/// Lets the user delete locally stored subscriptions while a server account
|
|
/// is active. Hidden in local mode, where the account IS the local store.
|
|
@ViewBuilder
|
|
private var localDataSection: some View {
|
|
if currentAccount.type != .local && localSubscriptionCount > 0 {
|
|
Section {
|
|
Button(role: .destructive) {
|
|
showingDeleteLocalConfirmation = true
|
|
} label: {
|
|
Label(String(localized: "settings.subscriptions.localData.delete.button"), systemImage: "trash")
|
|
}
|
|
} header: {
|
|
Text(String(localized: "settings.subscriptions.localData.title"))
|
|
} footer: {
|
|
Text(String(localized: "settings.subscriptions.localData.footer \(localSubscriptionCount)"))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
private func showImportPicker() {
|
|
#if os(iOS)
|
|
showingImportPicker = true
|
|
#elseif os(macOS)
|
|
showMacOSImportPanel()
|
|
#endif
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func showMacOSImportPanel() {
|
|
let panel = NSOpenPanel()
|
|
panel.allowedContentTypes = [
|
|
UTType.commaSeparatedText,
|
|
UTType(filenameExtension: "opml") ?? .xml,
|
|
UTType.xml
|
|
]
|
|
panel.canChooseFiles = true
|
|
panel.canChooseDirectories = false
|
|
panel.allowsMultipleSelection = false
|
|
panel.message = String(localized: "settings.subscriptions.import.panel.message")
|
|
|
|
if panel.runModal() == .OK, let url = panel.url {
|
|
handleImportedFile(url)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private func handleImportedFile(_ url: URL) {
|
|
isImporting = true
|
|
|
|
Task {
|
|
do {
|
|
// Read file data
|
|
let data: Data
|
|
if url.startAccessingSecurityScopedResource() {
|
|
defer { url.stopAccessingSecurityScopedResource() }
|
|
data = try Data(contentsOf: url)
|
|
} else {
|
|
data = try Data(contentsOf: url)
|
|
}
|
|
|
|
// Parse subscriptions
|
|
let parseResult = try SubscriptionImportExport.parseAuto(data)
|
|
|
|
// Import into the active account (local store or server)
|
|
guard let subscriptionService else {
|
|
throw SubscriptionImportError.invalidData
|
|
}
|
|
|
|
let importStats = await subscriptionService.importSubscriptions(parseResult.channels)
|
|
|
|
await MainActor.run {
|
|
isImporting = false
|
|
importResult = (imported: importStats.imported, skipped: importStats.skipped, format: parseResult.format)
|
|
showingImportResult = true
|
|
LoggingService.shared.logSubscriptions("Import completed: \(importStats.imported) imported, \(importStats.skipped) skipped")
|
|
}
|
|
await refreshAccountSubscriptionCount()
|
|
} catch {
|
|
await MainActor.run {
|
|
isImporting = false
|
|
importError = error.localizedDescription
|
|
showingImportError = true
|
|
LoggingService.shared.logSubscriptionsError("Import failed", error: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func exportSubscriptions() {
|
|
isExporting = true
|
|
Task {
|
|
await performExport()
|
|
isExporting = false
|
|
}
|
|
}
|
|
|
|
/// Exports subscriptions of the active account: the local store in local
|
|
/// mode, or the server's list for Invidious/Piped accounts.
|
|
private func performExport() async {
|
|
let subscriptions: [Subscription]
|
|
if currentAccount.type == .local {
|
|
guard let dataManager else { return }
|
|
subscriptions = dataManager.allSubscriptions
|
|
} else {
|
|
guard let subscriptionService else { return }
|
|
do {
|
|
let channels = try await subscriptionService.fetchSubscriptions()
|
|
accountSubscriptionCount = channels.count
|
|
// Detached model instances used purely as export carriers
|
|
subscriptions = channels.map { Subscription.from(channel: $0) }
|
|
} catch {
|
|
LoggingService.shared.logSubscriptionsError("Failed to fetch account subscriptions for export", error: error)
|
|
appEnvironment?.toastManager.showError(
|
|
String(localized: "settings.subscriptions.export.error.title"),
|
|
subtitle: error.localizedDescription
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
let data: Data?
|
|
switch selectedExportFormat {
|
|
case .json:
|
|
data = SubscriptionImportExport.exportToJSON(subscriptions)
|
|
case .opml:
|
|
data = SubscriptionImportExport.exportToOPML(subscriptions)
|
|
}
|
|
|
|
guard let exportData = data else {
|
|
LoggingService.shared.logSubscriptionsError("Failed to generate export data")
|
|
return
|
|
}
|
|
|
|
let filename = SubscriptionImportExport.generateExportFilename(format: selectedExportFormat)
|
|
|
|
#if os(iOS)
|
|
// Write to temp file and share
|
|
let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
|
|
do {
|
|
try exportData.write(to: tempURL)
|
|
exportFile = ExportFile(url: tempURL)
|
|
} catch {
|
|
LoggingService.shared.logSubscriptionsError("Failed to write export file", error: error)
|
|
}
|
|
#elseif os(macOS)
|
|
showMacOSSavePanel(data: exportData, filename: filename)
|
|
#endif
|
|
}
|
|
|
|
/// Refreshes the account-scoped subscription count shown in the export footer.
|
|
private func refreshAccountSubscriptionCount() async {
|
|
guard let subscriptionService else { return }
|
|
|
|
if let cached = subscriptionService.cachedSubscriptionCount {
|
|
accountSubscriptionCount = cached
|
|
}
|
|
if !subscriptionService.currentContext.isLocal {
|
|
if let channels = try? await subscriptionService.fetchSubscriptions() {
|
|
accountSubscriptionCount = channels.count
|
|
}
|
|
}
|
|
}
|
|
|
|
private func deleteLocalSubscriptions() {
|
|
dataManager?.deleteAllSubscriptions()
|
|
appEnvironment?.toastManager.showSuccess(String(localized: "settings.subscriptions.localData.deleted.title"))
|
|
Task {
|
|
await refreshAccountSubscriptionCount()
|
|
}
|
|
}
|
|
|
|
#if os(macOS)
|
|
private func showMacOSSavePanel(data: Data, filename: String) {
|
|
let panel = NSSavePanel()
|
|
panel.nameFieldStringValue = filename
|
|
panel.allowedContentTypes = selectedExportFormat == .json ? [.json] : [UTType(filenameExtension: "opml") ?? .xml]
|
|
|
|
if panel.runModal() == .OK, let url = panel.url {
|
|
do {
|
|
try data.write(to: url)
|
|
LoggingService.shared.logSubscriptions("Exported subscriptions to \(url.path)")
|
|
} catch {
|
|
LoggingService.shared.logSubscriptionsError("Failed to save export", error: error)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - File Picker (iOS)
|
|
|
|
#if os(iOS)
|
|
struct SubscriptionFilePickerView: UIViewControllerRepresentable {
|
|
let onSelect: (URL) -> Void
|
|
|
|
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
|
|
let supportedTypes: [UTType] = [
|
|
.commaSeparatedText,
|
|
UTType(filenameExtension: "opml") ?? .xml,
|
|
.xml,
|
|
.text
|
|
]
|
|
|
|
let picker = UIDocumentPickerViewController(forOpeningContentTypes: supportedTypes)
|
|
picker.delegate = context.coordinator
|
|
picker.allowsMultipleSelection = false
|
|
return picker
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(onSelect: onSelect)
|
|
}
|
|
|
|
class Coordinator: NSObject, UIDocumentPickerDelegate {
|
|
let onSelect: (URL) -> Void
|
|
|
|
init(onSelect: @escaping (URL) -> Void) {
|
|
self.onSelect = onSelect
|
|
}
|
|
|
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
|
guard let url = urls.first else { return }
|
|
onSelect(url)
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
SubscriptionsSettingsView()
|
|
}
|
|
.appEnvironment(.preview)
|
|
}
|
|
#endif
|