diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index 231904b0..ad56fcfa 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -296,8 +296,7 @@ final class AppEnvironment { invidiousCredentialsManager: invidiousCreds, pipedCredentialsManager: pipedCreds, invidiousAPI: invidiousAPI, - pipedAPI: pipedAPI, - httpClient: client + pipedAPI: pipedAPI ) // Initialize Sources Settings diff --git a/Yattee/Services/Migration/LegacyDataMigrationService.swift b/Yattee/Services/Migration/LegacyDataMigrationService.swift index ecec8ead..fd298c20 100644 --- a/Yattee/Services/Migration/LegacyDataMigrationService.swift +++ b/Yattee/Services/Migration/LegacyDataMigrationService.swift @@ -29,16 +29,9 @@ final class LegacyDataMigrationService { private let pipedCredentialsManager: PipedCredentialsManager private let invidiousAPI: InvidiousAPI private let pipedAPI: PipedAPI - private let httpClient: HTTPClient // MARK: - State - /// Whether an import is currently in progress - private(set) var isImporting = false - - /// 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 @@ -50,8 +43,7 @@ final class LegacyDataMigrationService { invidiousCredentialsManager: InvidiousCredentialsManager, pipedCredentialsManager: PipedCredentialsManager, invidiousAPI: InvidiousAPI, - pipedAPI: PipedAPI, - httpClient: HTTPClient = HTTPClient() + pipedAPI: PipedAPI ) { self.instancesManager = instancesManager self.basicAuthCredentialsManager = basicAuthCredentialsManager @@ -59,18 +51,10 @@ final class LegacyDataMigrationService { self.pipedCredentialsManager = pipedCredentialsManager self.invidiousAPI = invidiousAPI self.pipedAPI = pipedAPI - self.httpClient = httpClient } // MARK: - Detection - /// Checks if there is legacy v1 data available for migration. - /// - Returns: true if v1 data exists and can be parsed - func hasLegacyData() -> Bool { - guard let items = parseLegacyData() else { return false } - return !items.isEmpty - } - /// Whether there are legacy accounts left for the user to review. func hasLegacyAccountsToImport() -> Bool { _ = legacyAccountsRevision @@ -87,47 +71,6 @@ final class LegacyDataMigrationService { 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]? { - let defaults = UserDefaults.standard - - // Check if legacy data exists - guard defaults.object(forKey: legacyInstancesKey) != nil || - defaults.object(forKey: legacyAccountsKey) != nil else { - return nil - } - - // Parse instances only (credentials are not imported) - let legacyInstances = parseLegacyInstances(from: defaults) - - // Build import items - one per unique instance - var items: [LegacyImportItem] = [] - - for instance in legacyInstances { - // Skip PeerTube (not supported in migration) - guard let instanceType = instance.instanceType, - instanceType != .peertube else { - continue - } - - guard let url = instance.url else { continue } - - let item = LegacyImportItem( - id: UUID(), - legacyInstanceID: instance.id, - instanceType: instanceType, - url: url, - name: instance.name.isEmpty ? nil : instance.name, - proxiesVideos: instance.proxiesVideos - ) - items.append(item) - } - - // Return nil if no valid items were found (treat as no data) - return items.isEmpty ? nil : items - } - // MARK: - Parsing Helpers private func parseLegacyInstances(from defaults: UserDefaults) -> [LegacyInstance] { @@ -188,78 +131,8 @@ final class LegacyDataMigrationService { return importItems } - // MARK: - Reachability - - /// Checks if an instance is reachable. - /// - Parameter item: The import item to check - /// - Returns: true if the instance responds, false otherwise - func checkReachability(for item: LegacyImportItem) async -> Bool { - // Build the appropriate health check endpoint based on instance type - let endpoint: GenericEndpoint - switch item.instanceType { - case .invidious: - endpoint = GenericEndpoint(path: "/api/v1/stats", timeout: 10) - case .piped: - endpoint = GenericEndpoint(path: "/healthcheck", timeout: 10) - default: - return false - } - - do { - _ = try await httpClient.fetchData(endpoint, baseURL: item.url) - return true - } catch { - return false - } - } - // MARK: - Import - /// Imports the selected items into the v2 system. - /// - Parameter items: The items to import (only selected items will be processed) - /// - Returns: The result of the import operation - func importItems(_ items: [LegacyImportItem]) async -> MigrationResult { - isImporting = true - importProgress = 0.0 - - let selectedItems = items.filter(\.isSelected) - var succeeded: [LegacyImportItem] = [] - var failed: [(item: LegacyImportItem, error: MigrationError)] = [] - var skippedDuplicates: [LegacyImportItem] = [] - - let total = selectedItems.count - - for (index, item) in selectedItems.enumerated() { - // Update progress - importProgress = Double(index) / Double(max(total, 1)) - - // Check for duplicates - if isDuplicate(item) { - skippedDuplicates.append(item) - continue - } - - // Perform import - do { - try importItem(item) - succeeded.append(item) - } catch let error as MigrationError { - failed.append((item, error)) - } catch { - failed.append((item, .unknown(error.localizedDescription))) - } - } - - importProgress = 1.0 - isImporting = false - - return MigrationResult( - succeeded: succeeded, - failed: failed, - skippedDuplicates: skippedDuplicates - ) - } - /// 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: @@ -307,44 +180,6 @@ final class LegacyDataMigrationService { 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 - /// and stored in the Keychain via `BasicAuthCredentialsManager`. - private func importItem(_ item: LegacyImportItem) throws { - let (cleanURL, credentials) = Self.splitCredentials(from: item.url) - - let instance = Instance( - id: UUID(), - type: item.instanceType, - url: cleanURL, - name: item.name, - isEnabled: true, - proxiesVideos: item.proxiesVideos - ) - - instancesManager.add(instance) - - if let credentials { - basicAuthCredentialsManager.setCredentials( - username: credentials.username, - password: credentials.password, - for: instance - ) - } - } - - /// Checks if an import item would be a duplicate of an existing instance. - private func isDuplicate(_ item: LegacyImportItem) -> Bool { - let (cleanURL, _) = Self.splitCredentials(from: item.url) - for existing in instancesManager.instances { - if existing.url.host == cleanURL.host && existing.type == item.instanceType { - return true - } - } - return false - } - private func instanceForAccountImport(_ item: LegacyAccountImportItem) -> Instance { let (cleanURL, _) = Self.splitCredentials(from: item.url) if let existing = instancesManager.instances.first(where: { existing in @@ -416,32 +251,8 @@ final class LegacyDataMigrationService { 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 { - // Silent migration is intentionally disabled. Legacy accounts require - // explicit review because v2 stores fresh per-instance session tokens. - } - // MARK: - Cleanup - /// Deletes the legacy v1 data from UserDefaults. - /// Call this after a successful import or when the user confirms they don't want to import. - func deleteLegacyData() { - let defaults = UserDefaults.standard - - // Remove legacy keys - defaults.removeObject(forKey: legacyInstancesKey) - defaults.removeObject(forKey: legacyAccountsKey) - - // Note: We don't delete the old Keychain items as they may be needed - // 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) { diff --git a/Yattee/Services/Migration/LegacyDataModels.swift b/Yattee/Services/Migration/LegacyDataModels.swift index 58253ee3..9bb27a7c 100644 --- a/Yattee/Services/Migration/LegacyDataModels.swift +++ b/Yattee/Services/Migration/LegacyDataModels.swift @@ -100,9 +100,6 @@ struct LegacyAccount: Sendable { /// 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 @@ -115,57 +112,17 @@ struct LegacyAccount: Sendable { 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 + username: username ) } } -// MARK: - Legacy Import Item - -/// Represents an instance to be imported from v1 data. -/// Only instances are imported - users need to re-add their accounts after import. -struct LegacyImportItem: Identifiable, Sendable { - /// New UUID for UI identification - let id: UUID - - /// The original v1 instance ID - let legacyInstanceID: String - - /// The type of instance (Invidious or Piped) - let instanceType: InstanceType - - /// The instance URL - let url: URL - - /// User-defined name for the instance - let name: String? - - /// Whether this instance proxies videos - var proxiesVideos: Bool = false - - /// Whether this item is selected for import - var isSelected: Bool = true - - /// Current reachability status - var reachabilityStatus: ReachabilityStatus = .unknown - - /// Display name for the UI - var displayName: String { - if let name, !name.isEmpty { - return name - } - return url.host ?? url.absoluteString - } -} - // MARK: - Legacy Account Import Item /// Represents a legacy account that can be re-created by signing in to v2. @@ -216,58 +173,3 @@ struct LegacyAccountImportItem: Identifiable, Sendable { return url.host ?? url.absoluteString } } - -// MARK: - Reachability Status - -/// Status of an instance's reachability check. -enum ReachabilityStatus: Sendable { - /// Not yet checked - case unknown - /// Currently checking - case checking - /// Instance is reachable - case reachable - /// Instance is unreachable - case unreachable -} - -// MARK: - Migration Result - -/// Result of an import operation. -struct MigrationResult { - /// Items that were successfully imported - let succeeded: [LegacyImportItem] - - /// Items that failed to import with their errors - let failed: [(item: LegacyImportItem, error: MigrationError)] - - /// Items that were skipped because they already exist - let skippedDuplicates: [LegacyImportItem] - - /// Whether all selected items were successfully imported - var isFullSuccess: Bool { - failed.isEmpty - } - - /// Total number of items processed - var totalProcessed: Int { - succeeded.count + failed.count + skippedDuplicates.count - } -} - -// MARK: - Migration Error - -/// Errors that can occur during migration. -enum MigrationError: LocalizedError, Sendable { - case invalidURL - case unknown(String) - - var errorDescription: String? { - switch self { - case .invalidURL: - return String(localized: "migration.error.invalidURL") - case .unknown(let message): - return message - } - } -} diff --git a/Yattee/Views/Settings/MigrationImportRow.swift b/Yattee/Views/Settings/MigrationImportRow.swift deleted file mode 100644 index 4f340a2f..00000000 --- a/Yattee/Views/Settings/MigrationImportRow.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// MigrationImportRow.swift -// Yattee -// -// Reusable row component for displaying a legacy import item. -// - -import SwiftUI - -struct MigrationImportRow: View { - let item: LegacyImportItem - let onToggle: () -> Void - - var body: some View { - Button(action: onToggle) { - HStack(spacing: 12) { - // Checkbox - Image(systemName: item.isSelected ? "checkmark.circle.fill" : "circle") - .font(.title2) - .foregroundStyle(item.isSelected ? Color.accentColor : .secondary) - - // Instance type icon - instanceIcon - .font(.title2) - .foregroundStyle(.secondary) - .frame(width: 28) - - // Instance details - VStack(alignment: .leading, spacing: 2) { - Text(item.displayName) - .font(.body) - .foregroundStyle(.primary) - - Text(item.url.host ?? item.url.absoluteString) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - - // Reachability indicator - reachabilityIndicator - } - .padding(.vertical, 8) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - // MARK: - Subviews - - @ViewBuilder - private var instanceIcon: some View { - switch item.instanceType { - case .invidious: - Image(systemName: "server.rack") - case .piped: - Image(systemName: "cloud") - default: - Image(systemName: "globe") - } - } - - @ViewBuilder - private var reachabilityIndicator: some View { - switch item.reachabilityStatus { - case .unknown: - EmptyView() - case .checking: - ProgressView() - .controlSize(.small) - case .reachable: - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - case .unreachable: - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } - } -} - -// MARK: - Preview - -#Preview { - List { - MigrationImportRow( - item: LegacyImportItem( - id: UUID(), - legacyInstanceID: "test", - instanceType: .invidious, - url: URL(string: "https://invidious.example.com")!, - name: "My Invidious", - isSelected: true, - reachabilityStatus: .reachable - ), - onToggle: {} - ) - - MigrationImportRow( - item: LegacyImportItem( - id: UUID(), - legacyInstanceID: "test2", - instanceType: .piped, - url: URL(string: "https://piped.example.com")!, - name: nil, - isSelected: false, - reachabilityStatus: .unreachable - ), - onToggle: {} - ) - - MigrationImportRow( - item: LegacyImportItem( - id: UUID(), - legacyInstanceID: "test3", - instanceType: .invidious, - url: URL(string: "https://another.invidious.com")!, - name: "Another Instance", - isSelected: true, - reachabilityStatus: .checking - ), - onToggle: {} - ) - } -}