From 86fbdd23f1a0c1369c4495e50fe6ad2dac8a3e8d Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 18 May 2026 23:54:42 +0200 Subject: [PATCH] Allow importing account-less legacy instances as sources Legacy v1 instances without an account had no import path after the switch to explicit account review. Surface them in a separate "Sources" section of the legacy import view where they can be added with a single tap (no sign-in) or removed, mirroring the accounts flow. Instances tied to an account stay in the Accounts section and are not duplicated. Detection now gates the entry points and first-launch prompt on any legacy data (accounts or sources), not accounts alone. --- Yattee/Localizable.xcstrings | 40 ++++ .../LegacyDataMigrationService.swift | 144 +++++++++++- .../Services/Migration/LegacyDataModels.swift | 32 +++ Yattee/Views/Settings/AddSourceView.swift | 4 +- .../Views/Settings/AdvancedSettingsView.swift | 2 +- .../Views/Settings/LegacyDataImportView.swift | 217 ++++++++++++++---- 6 files changed, 382 insertions(+), 57 deletions(-) diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 1fe23b90..6d8bdb78 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -5828,6 +5828,46 @@ } } }, + "migration.sources.footer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add these sources from the previous Yattee app. No sign-in is required." + } + } + } + }, + "migration.sources.imported.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source Added" + } + } + } + }, + "migration.sources.remove.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove Legacy Source?" + } + } + } + }, + "migration.sources.section" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sources" + } + } + } + }, "migration.subtitle" : { "extractionState" : "stale", "localizations" : { diff --git a/Yattee/Services/Migration/LegacyDataMigrationService.swift b/Yattee/Services/Migration/LegacyDataMigrationService.swift index fd298c20..95e7929c 100644 --- a/Yattee/Services/Migration/LegacyDataMigrationService.swift +++ b/Yattee/Services/Migration/LegacyDataMigrationService.swift @@ -32,8 +32,8 @@ final class LegacyDataMigrationService { // MARK: - State - /// Changes whenever legacy account defaults are resolved, so SwiftUI views refresh. - private(set) var legacyAccountsRevision = 0 + /// Changes whenever legacy account or instance defaults are resolved, so SwiftUI views refresh. + private(set) var legacyDataRevision = 0 // MARK: - Initialization @@ -57,13 +57,24 @@ final class LegacyDataMigrationService { /// Whether there are legacy accounts left for the user to review. func hasLegacyAccountsToImport() -> Bool { - _ = legacyAccountsRevision + _ = legacyDataRevision return !parseLegacyAccountsForImport().isEmpty } - /// Whether the one-time legacy account prompt should be shown. + /// Whether there are account-less legacy instances (sources) left to import. + func hasLegacyInstancesToImport() -> Bool { + _ = legacyDataRevision + return !parseLegacyInstancesForImport().isEmpty + } + + /// Whether there is any legacy data (accounts or sources) left to import. + func hasLegacyDataToImport() -> Bool { + hasLegacyAccountsToImport() || hasLegacyInstancesToImport() + } + + /// Whether the one-time legacy data prompt should be shown. var shouldShowLegacyAccountsPrompt: Bool { - hasLegacyAccountsToImport() && !UserDefaults.standard.bool(forKey: legacyAccountsPromptShownKey) + hasLegacyDataToImport() && !UserDefaults.standard.bool(forKey: legacyAccountsPromptShownKey) } /// Marks the one-time legacy account prompt as shown. @@ -131,6 +142,52 @@ final class LegacyDataMigrationService { return importItems } + /// Parses account-less legacy instances that can be re-added as sources. + /// Instances tied to an account are handled by `parseLegacyAccountsForImport` + /// and are omitted here; instances already present in v2 are omitted too. + func parseLegacyInstancesForImport() -> [LegacyInstanceImportItem] { + let defaults = UserDefaults.standard + let legacyInstances = parseLegacyInstances(from: defaults) + let accounts = parseLegacyAccounts(from: defaults) + + // Instances that already have an account are covered by the accounts section. + let accountInstanceIDs = Set(accounts.map(\.instanceID)) + let accountHosts = Set(accounts.compactMap { account -> String? in + guard let url = URL(string: account.apiURL) else { return nil } + return Self.splitCredentials(from: url).cleanURL.host + }) + + return legacyInstances.compactMap { instance in + guard let instanceType = instance.instanceType, + instanceType == .invidious || instanceType == .piped, + let url = instance.url + else { + return nil + } + + if accountInstanceIDs.contains(instance.id) { + return nil + } + + let (cleanURL, _) = Self.splitCredentials(from: url) + if let host = cleanURL.host, accountHosts.contains(host) { + return nil + } + + if isLegacyInstanceAlreadyImported(instanceType: instanceType, url: url) { + return nil + } + + return LegacyInstanceImportItem( + legacyInstanceID: instance.id, + instanceType: instanceType, + url: url, + instanceName: instance.name.isEmpty ? nil : instance.name, + proxiesVideos: instance.proxiesVideos + ) + } + } + // MARK: - Import /// Re-creates a legacy account by signing in with fresh credentials. @@ -180,6 +237,39 @@ final class LegacyDataMigrationService { return instance } + /// Re-creates a legacy instance (source) without signing in. + /// The matching source is created if needed; otherwise the existing one is reused. + /// - Parameter item: The legacy instance to import + /// - Returns: The instance that was created or reused + @discardableResult + func importLegacyInstance(_ item: LegacyInstanceImportItem) -> Instance { + let (cleanURL, basicAuthCredentials) = Self.splitCredentials(from: item.url) + + let instance = instancesManager.instances.first { existing in + existing.url.host == cleanURL.host && existing.type == item.instanceType + } ?? Instance( + id: UUID(), + type: item.instanceType, + url: cleanURL, + name: item.instanceName, + isEnabled: true, + proxiesVideos: item.proxiesVideos + ) + + addInstanceIfNeeded(instance) + + if let basicAuthCredentials { + basicAuthCredentialsManager.setCredentials( + username: basicAuthCredentials.username, + password: basicAuthCredentials.password, + for: instance + ) + } + + removeLegacyInstance(item) + return instance + } + private func instanceForAccountImport(_ item: LegacyAccountImportItem) -> Instance { let (cleanURL, _) = Self.splitCredentials(from: item.url) if let existing = instancesManager.instances.first(where: { existing in @@ -225,6 +315,13 @@ final class LegacyDataMigrationService { } } + private func isLegacyInstanceAlreadyImported(instanceType: InstanceType, url: URL) -> Bool { + let (cleanURL, _) = Self.splitCredentials(from: url) + return instancesManager.instances.contains { existing in + existing.url.host == cleanURL.host && existing.type == instanceType + } + } + // MARK: - Credential Splitting /// Splits embedded basic-auth credentials out of a URL. @@ -285,7 +382,42 @@ final class LegacyDataMigrationService { defaults.set(accounts, forKey: legacyAccountsKey) } - legacyAccountsRevision += 1 + legacyDataRevision += 1 + } + + /// Removes one legacy instance after import or explicit user dismissal. + /// If this was the last instance, the instance defaults key is removed. + func removeLegacyInstance(_ item: LegacyInstanceImportItem) { + removeLegacyInstances(withIDs: [item.legacyInstanceID]) + } + + private func removeLegacyInstances(withIDs ids: [String]) { + let defaults = UserDefaults.standard + let ids = Set(ids) + + guard var instances = defaults.array(forKey: legacyInstancesKey) as? [[String: Any]] else { + return + } + + let originalCount = instances.count + instances.removeAll { dictionary in + guard let instanceID = dictionary["id"] as? String else { + return false + } + return ids.contains(instanceID) + } + + guard instances.count != originalCount else { + return + } + + if instances.isEmpty { + defaults.removeObject(forKey: legacyInstancesKey) + } else { + defaults.set(instances, forKey: legacyInstancesKey) + } + + legacyDataRevision += 1 } // MARK: - Prompt State diff --git a/Yattee/Services/Migration/LegacyDataModels.swift b/Yattee/Services/Migration/LegacyDataModels.swift index 9bb27a7c..78a57b2b 100644 --- a/Yattee/Services/Migration/LegacyDataModels.swift +++ b/Yattee/Services/Migration/LegacyDataModels.swift @@ -173,3 +173,35 @@ struct LegacyAccountImportItem: Identifiable, Sendable { return url.host ?? url.absoluteString } } + +// MARK: - Legacy Instance Import Item + +/// Represents a legacy instance (source) that can be re-created without signing in. +/// Used for v1 instances that have no associated account. +struct LegacyInstanceImportItem: Identifiable, Sendable { + /// The original v1 instance ID. + let legacyInstanceID: String + + /// The type of instance. + let instanceType: InstanceType + + /// The instance URL. + let url: URL + + /// User-defined instance name, if any. + let instanceName: String? + + /// Whether this instance proxies videos. + let proxiesVideos: Bool + + /// Stable identifier for SwiftUI lists. + var id: String { legacyInstanceID } + + /// Display name for the source row. + var instanceDisplayName: String { + if let instanceName, !instanceName.isEmpty { + return instanceName + } + return url.host ?? url.absoluteString + } +} diff --git a/Yattee/Views/Settings/AddSourceView.swift b/Yattee/Views/Settings/AddSourceView.swift index ae9c3330..d3eff377 100644 --- a/Yattee/Views/Settings/AddSourceView.swift +++ b/Yattee/Views/Settings/AddSourceView.swift @@ -241,7 +241,7 @@ struct AddSourceView: View { .task { refreshLegacyAccountsVisibility() } - .onChange(of: appEnvironment?.legacyMigrationService.legacyAccountsRevision ?? 0) { + .onChange(of: appEnvironment?.legacyMigrationService.legacyDataRevision ?? 0) { refreshLegacyAccountsVisibility() } } @@ -262,7 +262,7 @@ struct AddSourceView: View { } private func refreshLegacyAccountsVisibility() { - hasLegacyAccountsToImport = appEnvironment?.legacyMigrationService.hasLegacyAccountsToImport() == true + hasLegacyAccountsToImport = appEnvironment?.legacyMigrationService.hasLegacyDataToImport() == true } } diff --git a/Yattee/Views/Settings/AdvancedSettingsView.swift b/Yattee/Views/Settings/AdvancedSettingsView.swift index 7a14f82c..b2762655 100644 --- a/Yattee/Views/Settings/AdvancedSettingsView.swift +++ b/Yattee/Views/Settings/AdvancedSettingsView.swift @@ -416,7 +416,7 @@ struct AdvancedSettingsView: View { } #endif - if appEnvironment?.legacyMigrationService.hasLegacyAccountsToImport() == true { + if appEnvironment?.legacyMigrationService.hasLegacyDataToImport() == true { #if os(tvOS) NavigationLink { LegacyDataImportView() diff --git a/Yattee/Views/Settings/LegacyDataImportView.swift b/Yattee/Views/Settings/LegacyDataImportView.swift index f67e03fd..3561913a 100644 --- a/Yattee/Views/Settings/LegacyDataImportView.swift +++ b/Yattee/Views/Settings/LegacyDataImportView.swift @@ -2,7 +2,7 @@ // LegacyDataImportView.swift // Yattee // -// View for reviewing and re-creating accounts from the legacy v1 app. +// View for reviewing and re-creating accounts and sources from the legacy v1 app. // import SwiftUI @@ -13,10 +13,12 @@ struct LegacyAccountsImportView: View { var showsDoneButton = true - @State private var items: [LegacyAccountImportItem] = [] + @State private var accountItems: [LegacyAccountImportItem] = [] + @State private var instanceItems: [LegacyInstanceImportItem] = [] @State private var rowStates: [String: LegacyAccountRowState] = [:] @State private var isLoading = true - @State private var pendingRemoval: LegacyAccountImportItem? + @State private var pendingRemoval: PendingRemoval? + @State private var importSuccessTitle = "" @State private var importedInstanceName = "" @State private var showingImportSuccess = false @@ -24,16 +26,20 @@ struct LegacyAccountsImportView: View { appEnvironment?.legacyMigrationService } + private var isEmpty: Bool { + accountItems.isEmpty && instanceItems.isEmpty + } + var body: some View { Group { if isLoading { ProgressView() .controlSize(.large) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if items.isEmpty { + } else if isEmpty { emptyState } else { - accountList + contentList } } .navigationTitle(String(localized: "migration.accounts.title")) @@ -52,44 +58,62 @@ struct LegacyAccountsImportView: View { #endif } .task { - loadLegacyAccounts() + loadLegacyData() } .confirmationDialog( - String(localized: "migration.accounts.remove.title"), + pendingRemoval?.confirmationTitle ?? "", item: $pendingRemoval, titleVisibility: .visible - ) { item in + ) { removal in Button(String(localized: "migration.accounts.remove.confirm"), role: .destructive) { - removeLegacyAccount(item) + confirmRemoval(removal) } Button(String(localized: "common.cancel"), role: .cancel) {} - } message: { item in - Text(String(localized: "migration.accounts.remove.message \(item.displayName)")) + } message: { removal in + Text(String(localized: "migration.accounts.remove.message \(removal.displayName)")) } - .alert(String(localized: "migration.accounts.imported.title"), isPresented: $showingImportSuccess) { + .alert(importSuccessTitle, isPresented: $showingImportSuccess) { Button(String(localized: "common.ok"), role: .cancel) {} } message: { Text(importedInstanceName) } } - private var accountList: some View { + private var contentList: some View { Form { - Section { - ForEach(items) { item in - LegacyAccountImportRow( - item: item, - state: stateBinding(for: item) - ) { - importLegacyAccount(item) - } onRemove: { - pendingRemoval = item + if !accountItems.isEmpty { + Section { + ForEach(accountItems) { item in + LegacyAccountImportRow( + item: item, + state: stateBinding(for: item) + ) { + importLegacyAccount(item) + } onRemove: { + pendingRemoval = .account(item) + } } + } header: { + Text(String(localized: "migration.accounts.section")) + } footer: { + Text(String(localized: "migration.accounts.footer")) + } + } + + if !instanceItems.isEmpty { + Section { + ForEach(instanceItems) { item in + LegacyInstanceImportRow(item: item) { + importLegacyInstance(item) + } onRemove: { + pendingRemoval = .instance(item) + } + } + } header: { + Text(String(localized: "migration.sources.section")) + } footer: { + Text(String(localized: "migration.sources.footer")) } - } header: { - Text(String(localized: "migration.accounts.section")) - } footer: { - Text(String(localized: "migration.accounts.footer")) } } #if os(iOS) @@ -106,10 +130,11 @@ struct LegacyAccountsImportView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private func loadLegacyAccounts() { - let loadedItems = legacyMigrationService?.parseLegacyAccountsForImport() ?? [] - items = loadedItems - for item in loadedItems where rowStates[item.legacyAccountID] == nil { + private func loadLegacyData() { + let loadedAccounts = legacyMigrationService?.parseLegacyAccountsForImport() ?? [] + accountItems = loadedAccounts + instanceItems = legacyMigrationService?.parseLegacyInstancesForImport() ?? [] + for item in loadedAccounts where rowStates[item.legacyAccountID] == nil { rowStates[item.legacyAccountID] = LegacyAccountRowState(username: item.username) } isLoading = false @@ -137,9 +162,11 @@ struct LegacyAccountsImportView: View { username: state.username, password: state.password ) + importSuccessTitle = String(localized: "migration.accounts.imported.title") importedInstanceName = importedInstance.displayName showingImportSuccess = true - removeResolvedItem(item) + accountItems.removeAll { $0.legacyAccountID == item.legacyAccountID } + rowStates.removeValue(forKey: item.legacyAccountID) } catch APIError.unauthorized { setImportError(String(localized: "login.error.invalidCredentials"), for: item) } catch { @@ -148,6 +175,15 @@ struct LegacyAccountsImportView: View { } } + private func importLegacyInstance(_ item: LegacyInstanceImportItem) { + guard let service = legacyMigrationService else { return } + let importedInstance = service.importLegacyInstance(item) + importSuccessTitle = String(localized: "migration.sources.imported.title") + importedInstanceName = importedInstance.displayName + showingImportSuccess = true + instanceItems.removeAll { $0.legacyInstanceID == item.legacyInstanceID } + } + private func setImportError(_ message: String, for item: LegacyAccountImportItem) { var state = rowStates[item.legacyAccountID] ?? LegacyAccountRowState(username: item.username) state.isImporting = false @@ -155,17 +191,55 @@ struct LegacyAccountsImportView: View { rowStates[item.legacyAccountID] = state } - private func removeLegacyAccount(_ item: LegacyAccountImportItem) { - legacyMigrationService?.removeLegacyAccount(item) - removeResolvedItem(item) - } - - private func removeResolvedItem(_ item: LegacyAccountImportItem) { - items.removeAll { $0.legacyAccountID == item.legacyAccountID } - rowStates.removeValue(forKey: item.legacyAccountID) + private func confirmRemoval(_ removal: PendingRemoval) { + switch removal { + case .account(let item): + legacyMigrationService?.removeLegacyAccount(item) + accountItems.removeAll { $0.legacyAccountID == item.legacyAccountID } + rowStates.removeValue(forKey: item.legacyAccountID) + case .instance(let item): + legacyMigrationService?.removeLegacyInstance(item) + instanceItems.removeAll { $0.legacyInstanceID == item.legacyInstanceID } + } } } +// MARK: - Pending Removal + +private enum PendingRemoval: Identifiable { + case account(LegacyAccountImportItem) + case instance(LegacyInstanceImportItem) + + var id: String { + switch self { + case .account(let item): + return "account:\(item.id)" + case .instance(let item): + return "instance:\(item.id)" + } + } + + var displayName: String { + switch self { + case .account(let item): + return item.displayName + case .instance(let item): + return item.instanceDisplayName + } + } + + var confirmationTitle: String { + switch self { + case .account: + return String(localized: "migration.accounts.remove.title") + case .instance: + return String(localized: "migration.sources.remove.title") + } + } +} + +// MARK: - Account Row + private struct LegacyAccountImportRow: View { let item: LegacyAccountImportItem @Binding var state: LegacyAccountRowState @@ -179,7 +253,7 @@ private struct LegacyAccountImportRow: View { var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top, spacing: 12) { - Image(systemName: iconName) + Image(systemName: legacyInstanceIcon(for: item.instanceType)) .font(.title2) .foregroundStyle(.secondary) .frame(width: 28) @@ -263,16 +337,63 @@ private struct LegacyAccountImportRow: View { return String(localized: "login.username") } } +} - private var iconName: String { - switch item.instanceType { - case .invidious: - return "server.rack" - case .piped: - return "cloud" - default: - return "globe" +// MARK: - Source Row + +private struct LegacyInstanceImportRow: View { + let item: LegacyInstanceImportItem + let onImport: () -> Void + let onRemove: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: legacyInstanceIcon(for: item.instanceType)) + .font(.title2) + .foregroundStyle(.secondary) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 3) { + Text(item.instanceDisplayName) + .font(.headline) + + Text(item.url.host ?? item.url.absoluteString) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + } + + HStack { + Button(role: .destructive, action: onRemove) { + Text(String(localized: "common.remove")) + } + .foregroundStyle(.red) + .tint(.red) + + Spacer() + + Button(action: onImport) { + Text(String(localized: "migration.import")) + } + .buttonStyle(.borderedProminent) + } } + .padding(.vertical, 8) + } +} + +private func legacyInstanceIcon(for type: InstanceType) -> String { + switch type { + case .invidious: + return "server.rack" + case .piped: + return "cloud" + default: + return "globe" } }