From bde80bfef1ed63dd7eb4a53d0f3512d861bac1b0 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 17 May 2026 13:08:32 +0200 Subject: [PATCH] Add manual legacy account import --- Yattee/Core/AppEnvironment.swift | 4 + Yattee/Localizable.xcstrings | 150 ++++++ .../LegacyDataMigrationService.swift | 224 ++++++++- .../Services/Migration/LegacyDataModels.swift | 102 +++- Yattee/Views/Settings/AddSourceView.swift | 35 ++ .../Views/Settings/AdvancedSettingsView.swift | 6 +- .../Views/Settings/LegacyDataImportView.swift | 464 +++++++++--------- Yattee/YatteeApp.swift | 49 +- 8 files changed, 787 insertions(+), 247 deletions(-) diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index 7aeffd2c..231904b0 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -293,6 +293,10 @@ final class AppEnvironment { self.legacyMigrationService = LegacyDataMigrationService( instancesManager: instances, basicAuthCredentialsManager: basicAuthCreds, + invidiousCredentialsManager: invidiousCreds, + pipedCredentialsManager: pipedCreds, + invidiousAPI: invidiousAPI, + pipedAPI: pipedAPI, httpClient: client ) diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 3a53035b..1fe23b90 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -5532,6 +5532,156 @@ } } }, + "migration.accounts.addSourcesFooter" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Review accounts from the previous Yattee app and sign in again to add their sources." + } + } + } + }, + "migration.accounts.addSourcesLink" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Import Legacy Accounts" + } + } + } + }, + "migration.accounts.empty.description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All legacy accounts have been imported or removed." + } + } + } + }, + "migration.accounts.empty.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Legacy Accounts" + } + } + } + }, + "migration.accounts.footer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter your current password to sign in again. Importing creates or reuses the matching source." + } + } + } + }, + "migration.accounts.imported.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Account Imported" + } + } + } + }, + "migration.accounts.prompt.later" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not Now" + } + } + } + }, + "migration.accounts.prompt.message" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yattee found accounts from the previous app. Review them to sign in again, or remove any accounts you do not want to import." + } + } + } + }, + "migration.accounts.prompt.review" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Review" + } + } + } + }, + "migration.accounts.prompt.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legacy Accounts Found" + } + } + } + }, + "migration.accounts.remove.confirm" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove" + } + } + } + }, + "migration.accounts.remove.message %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This will remove \"%@\" from the legacy import list. It will not be imported." + } + } + } + }, + "migration.accounts.remove.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove Legacy Account?" + } + } + } + }, + "migration.accounts.section" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accounts" + } + } + } + }, + "migration.accounts.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legacy Accounts" + } + } + } + }, "migration.accountsHint" : { "comment" : "Hint about needing to re-add accounts after migration", "localizations" : { diff --git a/Yattee/Services/Migration/LegacyDataMigrationService.swift b/Yattee/Services/Migration/LegacyDataMigrationService.swift index bda4b41a..ecec8ead 100644 --- a/Yattee/Services/Migration/LegacyDataMigrationService.swift +++ b/Yattee/Services/Migration/LegacyDataMigrationService.swift @@ -25,6 +25,10 @@ final class LegacyDataMigrationService { private let instancesManager: InstancesManager private let basicAuthCredentialsManager: BasicAuthCredentialsManager + private let invidiousCredentialsManager: InvidiousCredentialsManager + private let pipedCredentialsManager: PipedCredentialsManager + private let invidiousAPI: InvidiousAPI + private let pipedAPI: PipedAPI private let httpClient: HTTPClient // MARK: - State @@ -35,15 +39,26 @@ final class LegacyDataMigrationService { /// Progress of the current import (0.0 to 1.0) private(set) var importProgress: Double = 0.0 + /// Changes whenever legacy account defaults are resolved, so SwiftUI views refresh. + private(set) var legacyAccountsRevision = 0 + // MARK: - Initialization init( instancesManager: InstancesManager, basicAuthCredentialsManager: BasicAuthCredentialsManager, + invidiousCredentialsManager: InvidiousCredentialsManager, + pipedCredentialsManager: PipedCredentialsManager, + invidiousAPI: InvidiousAPI, + pipedAPI: PipedAPI, httpClient: HTTPClient = HTTPClient() ) { self.instancesManager = instancesManager self.basicAuthCredentialsManager = basicAuthCredentialsManager + self.invidiousCredentialsManager = invidiousCredentialsManager + self.pipedCredentialsManager = pipedCredentialsManager + self.invidiousAPI = invidiousAPI + self.pipedAPI = pipedAPI self.httpClient = httpClient } @@ -56,6 +71,22 @@ final class LegacyDataMigrationService { return !items.isEmpty } + /// Whether there are legacy accounts left for the user to review. + func hasLegacyAccountsToImport() -> Bool { + _ = legacyAccountsRevision + return !parseLegacyAccountsForImport().isEmpty + } + + /// Whether the one-time legacy account prompt should be shown. + var shouldShowLegacyAccountsPrompt: Bool { + hasLegacyAccountsToImport() && !UserDefaults.standard.bool(forKey: legacyAccountsPromptShownKey) + } + + /// Marks the one-time legacy account prompt as shown. + func markLegacyAccountsPromptShown() { + UserDefaults.standard.set(true, forKey: legacyAccountsPromptShownKey) + } + /// Parses legacy v1 data from UserDefaults. /// - Returns: Array of import items, or nil if data is corrupted or doesn't exist func parseLegacyData() -> [LegacyImportItem]? { @@ -108,6 +139,55 @@ final class LegacyDataMigrationService { return array.compactMap { LegacyInstance.parse(from: $0) } } + private func parseLegacyAccounts(from defaults: UserDefaults) -> [LegacyAccount] { + guard let array = defaults.array(forKey: legacyAccountsKey) as? [[String: Any]] else { + return [] + } + + return array.compactMap { LegacyAccount.parse(from: $0) } + } + + /// Parses legacy accounts and matches them with their legacy instances. + /// Accounts without a supported Invidious/Piped instance are omitted. + func parseLegacyAccountsForImport() -> [LegacyAccountImportItem] { + let defaults = UserDefaults.standard + let legacyInstances = parseLegacyInstances(from: defaults) + let instancesByID = legacyInstances.reduce(into: [String: LegacyInstance]()) { result, instance in + result[instance.id] = instance + } + let accounts = parseLegacyAccounts(from: defaults) + + let importItems: [LegacyAccountImportItem] = accounts.compactMap { account in + let matchedInstance = instancesByID[account.instanceID] + ?? legacyInstances.first { $0.apiURL == account.apiURL } + + guard let instance = matchedInstance, + let instanceType = instance.instanceType, + instanceType == .invidious || instanceType == .piped, + let url = instance.url ?? URL(string: account.apiURL) + else { + return nil + } + + if isLegacyAccountAlreadyImported(instanceType: instanceType, url: url) { + return nil + } + + return LegacyAccountImportItem( + legacyAccountID: account.id, + legacyInstanceID: instance.id, + instanceType: instanceType, + url: url, + instanceName: instance.name.isEmpty ? nil : instance.name, + accountName: account.name.isEmpty ? nil : account.name, + username: account.username, + proxiesVideos: instance.proxiesVideos + ) + } + + return importItems + } + // MARK: - Reachability /// Checks if an instance is reachable. @@ -180,6 +260,53 @@ final class LegacyDataMigrationService { ) } + /// Re-creates a legacy account by signing in with fresh credentials. + /// The matching source is created if needed; otherwise the existing matching source is reused. + /// - Parameters: + /// - item: The legacy account to import + /// - username: Username/email to use for the v2 login + /// - password: Password to use for the v2 login + /// - Returns: The instance that now has the imported login credential + @discardableResult + func importLegacyAccount(_ item: LegacyAccountImportItem, username: String, password: String) async throws -> Instance { + let instance = instanceForAccountImport(item) + let (_, basicAuthCredentials) = Self.splitCredentials(from: item.url) + + let credential: String + switch item.instanceType { + case .invidious: + let extraHeaders = basicAuthCredentials.map { + ["Authorization": Self.basicAuthHeader(username: $0.username, password: $0.password)] + } + credential = try await invidiousAPI.login( + email: username, + password: password, + instance: instance, + extraHeaders: extraHeaders + ) + addInstanceIfNeeded(instance) + if let basicAuthCredentials { + basicAuthCredentialsManager.setCredentials( + username: basicAuthCredentials.username, + password: basicAuthCredentials.password, + for: instance + ) + } + invidiousCredentialsManager.setCredential(credential, for: instance) + + case .piped: + credential = try await pipedAPI.login(username: username, password: password, instance: instance) + addInstanceIfNeeded(instance) + pipedCredentialsManager.setCredential(credential, for: instance) + + default: + throw APIError.notSupported + } + + removeLegacyAccount(item) + return instance + } + /// Imports a single item into the v2 system. /// If the legacy URL contains embedded basic-auth credentials /// (e.g. `https://user:pass@host`), they are stripped from the URL @@ -218,6 +345,51 @@ final class LegacyDataMigrationService { return false } + private func instanceForAccountImport(_ item: LegacyAccountImportItem) -> Instance { + let (cleanURL, _) = Self.splitCredentials(from: item.url) + if let existing = instancesManager.instances.first(where: { existing in + existing.url.host == cleanURL.host && existing.type == item.instanceType + }) { + return existing + } + + return Instance( + id: UUID(), + type: item.instanceType, + url: cleanURL, + name: item.instanceName, + isEnabled: true, + proxiesVideos: item.proxiesVideos + ) + } + + private func addInstanceIfNeeded(_ instance: Instance) { + guard !instancesManager.instances.contains(where: { $0.id == instance.id }) else { + return + } + + instancesManager.add(instance) + } + + private func isLegacyAccountAlreadyImported(instanceType: InstanceType, url: URL) -> Bool { + let (cleanURL, _) = Self.splitCredentials(from: url) + + guard let existingInstance = instancesManager.instances.first(where: { existing in + existing.url.host == cleanURL.host && existing.type == instanceType + }) else { + return false + } + + switch instanceType { + case .invidious: + return invidiousCredentialsManager.isLoggedIn(for: existingInstance) + case .piped: + return pipedCredentialsManager.isLoggedIn(for: existingInstance) + default: + return false + } + } + // MARK: - Credential Splitting /// Splits embedded basic-auth credentials out of a URL. @@ -238,15 +410,20 @@ final class LegacyDataMigrationService { return (cleaned, BasicAuthCredential(username: user, password: password)) } + private static func basicAuthHeader(username: String, password: String) -> String { + let value = "\(username):\(password)" + let encoded = Data(value.utf8).base64EncodedString() + return "Basic \(encoded)" + } + // MARK: - Auto-Import /// Silently imports any legacy v1 data on first launch. /// Skips unreachable-checks and UI; just imports everything and deletes the legacy keys. /// Safe to call repeatedly — if there is no legacy data left, this is a no-op. func autoImportIfNeeded() async { - guard let items = parseLegacyData() else { return } - _ = await importItems(items) - deleteLegacyData() + // Silent migration is intentionally disabled. Legacy accounts require + // explicit review because v2 stores fresh per-instance session tokens. } // MARK: - Cleanup @@ -264,4 +441,45 @@ final class LegacyDataMigrationService { // if the user reinstalls v1 or for debugging purposes. // The old Keychain service name is different so there's no conflict. } + + /// Removes one legacy account after successful import or explicit user dismissal. + /// If this was the last account, the account defaults key is removed. + func removeLegacyAccount(_ item: LegacyAccountImportItem) { + removeLegacyAccounts(withIDs: [item.legacyAccountID]) + } + + private func removeLegacyAccounts(withIDs ids: [String]) { + let defaults = UserDefaults.standard + let ids = Set(ids) + + guard var accounts = defaults.array(forKey: legacyAccountsKey) as? [[String: Any]] else { + return + } + + let originalCount = accounts.count + accounts.removeAll { dictionary in + guard let accountID = dictionary["id"] as? String else { + return false + } + return ids.contains(accountID) + } + + guard accounts.count != originalCount else { + return + } + + if accounts.isEmpty { + defaults.removeObject(forKey: legacyAccountsKey) + } else { + defaults.set(accounts, forKey: legacyAccountsKey) + } + + legacyAccountsRevision += 1 + } + + // MARK: - Prompt State + + private var legacyAccountsPromptShownKey: String { + "legacyAccountsImportPromptShown_v1" + } } diff --git a/Yattee/Services/Migration/LegacyDataModels.swift b/Yattee/Services/Migration/LegacyDataModels.swift index 8c2134d4..58253ee3 100644 --- a/Yattee/Services/Migration/LegacyDataModels.swift +++ b/Yattee/Services/Migration/LegacyDataModels.swift @@ -11,7 +11,7 @@ import Foundation /// Represents a v1 Instance stored in UserDefaults under the "instances" key. /// The v1 format used Defaults.Serializable with a bridge that stored instances as [String: String] dictionaries. -struct LegacyInstance { +struct LegacyInstance: Sendable { /// The app type: "invidious", "piped", "peerTube", "local" let app: String @@ -79,6 +79,55 @@ struct LegacyInstance { } } +// MARK: - Legacy Account + +/// Represents a v1 account stored in UserDefaults under the "accounts" key. +/// Credentials moved between UserDefaults and the old Keychain across v1 releases, +/// so v2 only uses this metadata to help the user sign in again. +struct LegacyAccount: Sendable { + /// Legacy account identifier, also used by v1 Keychain keys. + let id: String + + /// Legacy instance identifier this account belonged to. + let instanceID: String + + /// User-facing account name. + let name: String + + /// The account/server URL string. + let apiURL: String + + /// Stored username/email, when present in UserDefaults. + let username: String + + /// Password value from very old defaults exports, if present. + let password: String? + + /// Parses a dictionary from v1 UserDefaults format. + /// - Parameter dictionary: The serialized account dictionary + /// - Returns: A LegacyAccount if parsing succeeds, nil otherwise + static func parse(from dictionary: [String: Any]) -> LegacyAccount? { + guard let id = dictionary["id"] as? String, + let apiURL = dictionary["apiURL"] as? String, + let username = dictionary["username"] as? String else { + return nil + } + + let instanceID = dictionary["instanceID"] as? String ?? "" + let name = dictionary["name"] as? String ?? "" + let password = dictionary["password"] as? String + + return LegacyAccount( + id: id, + instanceID: instanceID, + name: name, + apiURL: apiURL, + username: username, + password: password?.isEmpty == true ? nil : password + ) + } +} + // MARK: - Legacy Import Item /// Represents an instance to be imported from v1 data. @@ -117,6 +166,57 @@ struct LegacyImportItem: Identifiable, Sendable { } } +// MARK: - Legacy Account Import Item + +/// Represents a legacy account that can be re-created by signing in to v2. +struct LegacyAccountImportItem: Identifiable, Sendable { + /// The original v1 account ID. + let legacyAccountID: String + + /// The original v1 instance ID. + let legacyInstanceID: String + + /// The type of instance this account belongs to. + let instanceType: InstanceType + + /// The instance URL. + let url: URL + + /// User-defined instance name, if any. + let instanceName: String? + + /// Legacy account display name, if any. + let accountName: String? + + /// Legacy username/email. + let username: String + + /// Whether this instance proxies videos. + let proxiesVideos: Bool + + /// Stable identifier for SwiftUI lists. + var id: String { legacyAccountID } + + /// Display name for the account row. + var displayName: String { + if let accountName, !accountName.isEmpty { + return accountName + } + if !username.isEmpty { + return username + } + return instanceDisplayName + } + + /// Display name for the associated instance. + var instanceDisplayName: String { + if let instanceName, !instanceName.isEmpty { + return instanceName + } + return url.host ?? url.absoluteString + } +} + // MARK: - Reachability Status /// Status of an instance's reachability check. diff --git a/Yattee/Views/Settings/AddSourceView.swift b/Yattee/Views/Settings/AddSourceView.swift index 57cb7d6c..ae9c3330 100644 --- a/Yattee/Views/Settings/AddSourceView.swift +++ b/Yattee/Views/Settings/AddSourceView.swift @@ -23,6 +23,7 @@ struct AddSourceView: View { @State private var discoveredSMBServer: String? @State private var discoveredName: String? @State private var discoveredAllowInvalidCerts = false + @State private var hasLegacyAccountsToImport = false var body: some View { #if os(tvOS) @@ -101,6 +102,30 @@ struct AddSourceView: View { private var listContent: some View { List { + if hasLegacyAccountsToImport { + Section { + NavigationLink { + #if os(tvOS) + TVSidebarDetailContainer( + systemImage: "person.badge.key", + title: String(localized: "migration.accounts.title") + ) { + LegacyAccountsImportView(showsDoneButton: false) + } + #else + LegacyAccountsImportView(showsDoneButton: false) + #endif + } label: { + Label(String(localized: "migration.accounts.addSourcesLink"), systemImage: "person.badge.key") + #if os(tvOS) + .labelStyle(TVSourceRowLabelStyle()) + #endif + } + } footer: { + Text(String(localized: "migration.accounts.addSourcesFooter")) + } + } + Section { #if !os(tvOS) NavigationLink { @@ -213,6 +238,12 @@ struct AddSourceView: View { Text(String(localized: "sources.footer.discovery")) } } + .task { + refreshLegacyAccountsVisibility() + } + .onChange(of: appEnvironment?.legacyMigrationService.legacyAccountsRevision ?? 0) { + refreshLegacyAccountsVisibility() + } } private func handleSelectedShare(_ share: DiscoveredShare) { @@ -229,6 +260,10 @@ struct AddSourceView: View { navigateToSMB = true } } + + private func refreshLegacyAccountsVisibility() { + hasLegacyAccountsToImport = appEnvironment?.legacyMigrationService.hasLegacyAccountsToImport() == true + } } // MARK: - Preview diff --git a/Yattee/Views/Settings/AdvancedSettingsView.swift b/Yattee/Views/Settings/AdvancedSettingsView.swift index bf1abd22..7a14f82c 100644 --- a/Yattee/Views/Settings/AdvancedSettingsView.swift +++ b/Yattee/Views/Settings/AdvancedSettingsView.swift @@ -416,15 +416,15 @@ struct AdvancedSettingsView: View { } #endif - if appEnvironment?.legacyMigrationService.hasLegacyData() == true { + if appEnvironment?.legacyMigrationService.hasLegacyAccountsToImport() == true { #if os(tvOS) NavigationLink { LegacyDataImportView() } label: { - Label(String(localized: "settings.advanced.data.importLegacy"), systemImage: "arrow.up.doc") + Label(String(localized: "migration.accounts.title"), systemImage: "person.badge.key") } #else - SettingsNavigationRow("settings.advanced.data.importLegacy", systemImage: "arrow.up.doc") { + SettingsNavigationRow("migration.accounts.title", systemImage: "person.badge.key") { LegacyDataImportView() } #endif diff --git a/Yattee/Views/Settings/LegacyDataImportView.swift b/Yattee/Views/Settings/LegacyDataImportView.swift index 917beb8b..9896b56d 100644 --- a/Yattee/Views/Settings/LegacyDataImportView.swift +++ b/Yattee/Views/Settings/LegacyDataImportView.swift @@ -2,300 +2,294 @@ // LegacyDataImportView.swift // Yattee // -// Full-screen view for importing legacy v1 data from Advanced Settings. +// View for reviewing and re-creating accounts from the legacy v1 app. // import SwiftUI -struct LegacyDataImportView: View { +struct LegacyAccountsImportView: View { @Environment(\.dismiss) private var dismiss @Environment(\.appEnvironment) private var appEnvironment - @State private var items: [LegacyImportItem] = [] + var showsDoneButton = true + + @State private var items: [LegacyAccountImportItem] = [] + @State private var rowStates: [String: LegacyAccountRowState] = [:] @State private var isLoading = true - @State private var isImporting = false - @State private var showingResultSheet = false - @State private var lastResult: MigrationResult? - @State private var showingUnreachableAlert = false - @State private var pendingUnreachableItem: LegacyImportItem? + @State private var pendingRemoval: LegacyAccountImportItem? + @State private var importedInstanceName = "" + @State private var showingImportSuccess = false private var legacyMigrationService: LegacyDataMigrationService? { appEnvironment?.legacyMigrationService } - private var selectedCount: Int { - items.filter(\.isSelected).count - } - var body: some View { Group { if isLoading { ProgressView() .controlSize(.large) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else if items.isEmpty { - ContentUnavailableView( - String(localized: "migration.noDataFound"), - systemImage: "doc.questionmark", - description: Text(String(localized: "migration.noDataFoundDescription")) - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + emptyState } else { - importContent + accountList } } - .navigationTitle(String(localized: "settings.advanced.data.importLegacy")) + .navigationTitle(String(localized: "migration.accounts.title")) #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif - .task { - loadLegacyData() - } - .sheet(isPresented: $showingResultSheet) { - resultSheet - } - .alert(String(localized: "migration.unreachableTitle"), isPresented: $showingUnreachableAlert) { - Button(String(localized: "migration.unreachableImport"), role: .destructive) { - // Keep the item selected - } - Button(String(localized: "common.cancel"), role: .cancel) { - if let item = pendingUnreachableItem, - let index = items.firstIndex(where: { $0.id == item.id }) { - items[index].isSelected = false + .toolbar { + #if !os(tvOS) + if showsDoneButton { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "common.done")) { + dismiss() + } } } + #endif + } + .task { + loadLegacyAccounts() + } + .confirmationDialog( + String(localized: "migration.accounts.remove.title"), + item: $pendingRemoval, + titleVisibility: .visible + ) { item in + Button(String(localized: "migration.accounts.remove.confirm"), role: .destructive) { + removeLegacyAccount(item) + } + Button(String(localized: "common.cancel"), role: .cancel) {} + } message: { item in + Text(String(localized: "migration.accounts.remove.message \(item.displayName)")) + } + .alert(String(localized: "migration.accounts.imported.title"), isPresented: $showingImportSuccess) { + Button(String(localized: "common.ok"), role: .cancel) {} } message: { - Text(String(localized: "migration.unreachableMessage")) + Text(importedInstanceName) } } - // MARK: - Import Content - - @ViewBuilder - private var importContent: some View { - VStack(spacing: 0) { - SettingsFormContainer { - SettingsFormSection("migration.selectToImport") { - ForEach(items) { item in - MigrationImportRow(item: item) { - toggleItem(item) - } - } - } footer: { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "migration.accountsHint")) - Text(String(localized: "migration.settingsFooter")) + private var accountList: some View { + Form { + Section { + ForEach(items) { item in + LegacyAccountImportRow( + item: item, + state: stateBinding(for: item) + ) { + importLegacyAccount(item) + } onRemove: { + pendingRemoval = item } } + } header: { + Text(String(localized: "migration.accounts.section")) + } footer: { + Text(String(localized: "migration.accounts.footer")) + } + } + #if os(iOS) + .scrollDismissesKeyboard(.interactively) + #endif + } + + private var emptyState: some View { + ContentUnavailableView( + String(localized: "migration.accounts.empty.title"), + systemImage: "person.badge.key", + description: Text(String(localized: "migration.accounts.empty.description")) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func loadLegacyAccounts() { + let loadedItems = legacyMigrationService?.parseLegacyAccountsForImport() ?? [] + items = loadedItems + for item in loadedItems where rowStates[item.legacyAccountID] == nil { + rowStates[item.legacyAccountID] = LegacyAccountRowState(username: item.username) + } + isLoading = false + } + + private func stateBinding(for item: LegacyAccountImportItem) -> Binding { + Binding { + rowStates[item.legacyAccountID] ?? LegacyAccountRowState(username: item.username) + } set: { newValue in + rowStates[item.legacyAccountID] = newValue + } + } + + private func importLegacyAccount(_ item: LegacyAccountImportItem) { + guard let service = legacyMigrationService else { return } + var state = rowStates[item.legacyAccountID] ?? LegacyAccountRowState(username: item.username) + state.isImporting = true + state.errorMessage = nil + rowStates[item.legacyAccountID] = state + + Task { + do { + let importedInstance = try await service.importLegacyAccount( + item, + username: state.username, + password: state.password + ) + appEnvironment?.toastManager.showSuccess( + String(localized: "migration.accounts.imported.title"), + subtitle: importedInstance.displayName + ) + importedInstanceName = importedInstance.displayName + showingImportSuccess = true + removeResolvedItem(item) + } catch APIError.unauthorized { + setImportError(String(localized: "login.error.invalidCredentials"), for: item) + } catch { + setImportError(error.localizedDescription, for: item) + } + } + } + + private func setImportError(_ message: String, for item: LegacyAccountImportItem) { + var state = rowStates[item.legacyAccountID] ?? LegacyAccountRowState(username: item.username) + state.isImporting = false + state.errorMessage = message + 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 struct LegacyAccountImportRow: View { + let item: LegacyAccountImportItem + @Binding var state: LegacyAccountRowState + let onImport: () -> Void + let onRemove: () -> Void + + private var canImport: Bool { + !state.username.isEmpty && !state.password.isEmpty && !state.isImporting + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: iconName) + .font(.title2) + .foregroundStyle(.secondary) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 3) { + Text(item.displayName) + .font(.headline) + + Text(item.instanceDisplayName) + .font(.subheadline) + .foregroundStyle(.secondary) + + Text(item.url.host ?? item.url.absoluteString) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() } - // Bottom bar with import button - VStack(spacing: 12) { - Divider() + credentialsFields - Button(action: performImport) { - if isImporting { - HStack(spacing: 8) { + if let errorMessage = state.errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.red) + } + + HStack { + Button(role: .destructive, action: onRemove) { + Text(String(localized: "common.remove")) + } + .disabled(state.isImporting) + .foregroundStyle(.red) + .tint(.red) + + Spacer() + + Button(action: onImport) { + if state.isImporting { + HStack(spacing: 6) { ProgressView() .controlSize(.small) Text(String(localized: "migration.importing")) } - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(Color.accentColor) - .foregroundStyle(.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) } else { Text(String(localized: "migration.import")) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(selectedCount > 0 ? Color.accentColor : Color.gray) - .foregroundStyle(.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) } } - .disabled(selectedCount == 0 || isImporting) - .padding(.horizontal) - .padding(.bottom, 8) + .buttonStyle(.borderedProminent) + .disabled(!canImport) } - #if os(tvOS) - .background(Color(.systemGray).opacity(0.2)) - #elseif os(macOS) - .background(Color(nsColor: .controlBackgroundColor)) - #else - .background(Color(uiColor: .systemBackground)) - #endif } + .padding(.vertical, 8) } - // MARK: - Result Sheet - @ViewBuilder - private var resultSheet: some View { - NavigationStack { - VStack(spacing: 24) { - if let result = lastResult { - Spacer() + private var credentialsFields: some View { + #if os(tvOS) + TVSettingsTextField(title: usernameLabel, text: $state.username) + TVSettingsTextField(title: String(localized: "login.password"), text: $state.password, isSecure: true) + #else + TextField(usernameLabel, text: $state.username) + .textContentType(.username) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .autocorrectionDisabled() - Image(systemName: result.isFullSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") - .font(.system(size: 60)) - .foregroundStyle(result.isFullSuccess ? .green : .orange) - - Text(String(localized: "migration.partialTitle")) - .font(.title2) - .fontWeight(.bold) - - Text(String( - format: NSLocalizedString("migration.partialMessage %lld %lld", comment: "Import result count"), - result.succeeded.count, - result.totalProcessed - )) - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - - if !result.failed.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "migration.failedItems")) - .font(.subheadline) - .fontWeight(.medium) - - ForEach(result.failed, id: \.item.id) { failure in - HStack { - Text(failure.item.displayName) - .font(.caption) - Spacer() - Text(failure.error.localizedDescription) - .font(.caption) - .foregroundStyle(.red) - } - } - } - .padding() - #if os(tvOS) - .background(Color(.systemGray).opacity(0.2)) - #elseif os(macOS) - .background(Color(nsColor: .controlBackgroundColor)) - #else - .background(Color(uiColor: .secondarySystemBackground)) - #endif - .clipShape(RoundedRectangle(cornerRadius: 8)) - .padding(.horizontal) - } - - Spacer() - - VStack(spacing: 12) { - if !result.failed.isEmpty { - Button(action: retryFailed) { - Text(String(localized: "migration.retry")) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(Color.accentColor) - .foregroundStyle(.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - } - - Button(action: finishImport) { - Text(String(localized: "migration.continue")) - .font(.headline) - .frame(maxWidth: .infinity) - .padding() - .background(result.failed.isEmpty ? Color.accentColor : Color.secondary) - .foregroundStyle(.white) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - } - .padding(.horizontal) - .padding(.bottom, 20) - } - } - .padding() - .interactiveDismissDisabled() - } - #if os(macOS) - .frame(minWidth: 500, minHeight: 450) + SecureField(String(localized: "login.password"), text: $state.password) + .textContentType(.password) #endif } - // MARK: - Actions - - private func loadLegacyData() { - items = legacyMigrationService?.parseLegacyData() ?? [] - isLoading = false - } - - private func toggleItem(_ item: LegacyImportItem) { - guard let index = items.firstIndex(where: { $0.id == item.id }) else { return } - - let wasSelected = items[index].isSelected - items[index].isSelected.toggle() - - if !wasSelected && items[index].reachabilityStatus == .unknown { - checkReachability(for: items[index]) + private var usernameLabel: String { + switch item.instanceType { + case .invidious: + return String(localized: "login.email") + default: + return String(localized: "login.username") } } - private func checkReachability(for item: LegacyImportItem) { - guard let index = items.firstIndex(where: { $0.id == item.id }) else { return } - - items[index].reachabilityStatus = .checking - - Task { - let isReachable = await legacyMigrationService?.checkReachability(for: item) ?? false - - guard let currentIndex = items.firstIndex(where: { $0.id == item.id }) else { return } - items[currentIndex].reachabilityStatus = isReachable ? .reachable : .unreachable - - if !isReachable && items[currentIndex].isSelected { - pendingUnreachableItem = items[currentIndex] - showingUnreachableAlert = true - } + private var iconName: String { + switch item.instanceType { + case .invidious: + return "server.rack" + case .piped: + return "cloud" + default: + return "globe" } } +} - private func performImport() { - guard let service = legacyMigrationService else { return } +private struct LegacyAccountRowState: Equatable { + var username: String + var password = "" + var errorMessage: String? + var isImporting = false +} - isImporting = true - - Task { - let result = await service.importItems(items) - lastResult = result - - isImporting = false - - if result.isFullSuccess { - dismiss() - } else { - showingResultSheet = true - } - } - } - - private func retryFailed() { - guard let result = lastResult else { return } - - for index in items.indices { - let isFailed = result.failed.contains(where: { $0.item.id == items[index].id }) - items[index].isSelected = isFailed - } - - showingResultSheet = false - - Task { - try? await Task.sleep(for: .milliseconds(300)) - performImport() - } - } - - private func finishImport() { - showingResultSheet = false - dismiss() +struct LegacyDataImportView: View { + var body: some View { + LegacyAccountsImportView() } } @@ -303,7 +297,7 @@ struct LegacyDataImportView: View { #Preview { NavigationStack { - LegacyDataImportView() + LegacyAccountsImportView() } .appEnvironment(.preview) } diff --git a/Yattee/YatteeApp.swift b/Yattee/YatteeApp.swift index b66021ad..c98cdfe1 100644 --- a/Yattee/YatteeApp.swift +++ b/Yattee/YatteeApp.swift @@ -46,6 +46,8 @@ struct YatteeApp: App { // First-launch state @State private var showingICloudAlert = false @State private var showingICloudProgress = false + @State private var showingLegacyAccountsAlert = false + @State private var showingLegacyAccountsImport = false #if os(iOS) @State private var showingSettings = false #endif @@ -152,11 +154,22 @@ struct YatteeApp: App { enableICloudAndWait() } Button(String(localized: "common.cancel"), role: .cancel) { - appEnvironment.settingsManager.onboardingCompleted = true + finishFirstLaunchFlow() } } message: { Text(String(localized: "settings.icloud.enable.confirmation.message")) } + .alert( + String(localized: "migration.accounts.prompt.title"), + isPresented: $showingLegacyAccountsAlert + ) { + Button(String(localized: "migration.accounts.prompt.review")) { + showingLegacyAccountsImport = true + } + Button(String(localized: "migration.accounts.prompt.later"), role: .cancel) {} + } message: { + Text(String(localized: "migration.accounts.prompt.message")) + } #if os(macOS) .sheet(isPresented: $showingICloudProgress) { ICloudSyncProgressView() @@ -169,6 +182,15 @@ struct YatteeApp: App { .appEnvironment(appEnvironment) } #endif + .sheet(isPresented: $showingLegacyAccountsImport) { + NavigationStack { + LegacyAccountsImportView() + .appEnvironment(appEnvironment) + } + #if os(macOS) + .frame(minWidth: 560, minHeight: 560) + #endif + } #if os(iOS) .sheet(isPresented: $showingSettings) { SettingsView() @@ -377,10 +399,9 @@ struct YatteeApp: App { // Auto-delete old history entries based on retention setting performHistoryCleanup() - // Run first-launch tasks: silently import v1 data, then offer iCloud sync. + // Run first-launch tasks: offer iCloud sync, then optionally ask about legacy accounts. if !appEnvironment.settingsManager.onboardingCompleted { Task { - await appEnvironment.legacyMigrationService.autoImportIfNeeded() await appEnvironment.cloudKitSync.refreshAccountStatus() if appEnvironment.cloudKitSync.accountStatus == .available { @@ -388,9 +409,11 @@ struct YatteeApp: App { try? await Task.sleep(nanoseconds: 500_000_000) showingICloudAlert = true } else { - appEnvironment.settingsManager.onboardingCompleted = true + finishFirstLaunchFlow() } } + } else { + presentLegacyAccountsPromptIfNeeded() } } @@ -412,8 +435,24 @@ struct YatteeApp: App { appEnvironment.instancesManager.replaceWithiCloudData() appEnvironment.mediaSourcesManager.replaceWithiCloudData() - settings.onboardingCompleted = true showingICloudProgress = false + finishFirstLaunchFlow() + } + } + + /// Completes first-launch bookkeeping and then shows the one-time legacy account prompt if needed. + private func finishFirstLaunchFlow() { + appEnvironment.settingsManager.onboardingCompleted = true + presentLegacyAccountsPromptIfNeeded() + } + + /// Shows a one-time prompt for unresolved v1 accounts. + private func presentLegacyAccountsPromptIfNeeded() { + guard appEnvironment.legacyMigrationService.shouldShowLegacyAccountsPrompt else { return } + + appEnvironment.legacyMigrationService.markLegacyAccountsPromptShown() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + showingLegacyAccountsAlert = true } }