mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
220
Yattee/Services/Migration/LegacyDataMigrationService.swift
Normal file
220
Yattee/Services/Migration/LegacyDataMigrationService.swift
Normal file
@@ -0,0 +1,220 @@
|
||||
//
|
||||
// LegacyDataMigrationService.swift
|
||||
// Yattee
|
||||
//
|
||||
// Service for detecting, parsing, and importing v1 data during migration.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Service that handles migration of v1 Yattee data to the v2 format.
|
||||
/// Detects legacy instances stored in UserDefaults and imports them into the new system.
|
||||
/// Credentials are not imported - users need to sign in again after import.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LegacyDataMigrationService {
|
||||
// MARK: - Constants
|
||||
|
||||
/// UserDefaults key where v1 stored instances
|
||||
private let legacyInstancesKey = "instances"
|
||||
|
||||
/// UserDefaults key where v1 stored accounts
|
||||
private let legacyAccountsKey = "accounts"
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let instancesManager: InstancesManager
|
||||
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
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
instancesManager: InstancesManager,
|
||||
httpClient: HTTPClient = HTTPClient()
|
||||
) {
|
||||
self.instancesManager = instancesManager
|
||||
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
|
||||
}
|
||||
|
||||
/// 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
|
||||
)
|
||||
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] {
|
||||
// v1 used Defaults library which stores arrays of dictionaries
|
||||
guard let array = defaults.array(forKey: legacyInstancesKey) as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
|
||||
return array.compactMap { LegacyInstance.parse(from: $0) }
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
/// Imports a single item into the v2 system.
|
||||
private func importItem(_ item: LegacyImportItem) throws {
|
||||
// Create the new Instance (without credentials - user needs to sign in again)
|
||||
let instance = Instance(
|
||||
id: UUID(),
|
||||
type: item.instanceType,
|
||||
url: item.url,
|
||||
name: item.name,
|
||||
isEnabled: true
|
||||
)
|
||||
|
||||
// Add to instances manager
|
||||
instancesManager.add(instance)
|
||||
}
|
||||
|
||||
/// Checks if an import item would be a duplicate of an existing instance.
|
||||
private func isDuplicate(_ item: LegacyImportItem) -> Bool {
|
||||
// Check if an instance with the same URL and type already exists
|
||||
for existing in instancesManager.instances {
|
||||
if existing.url.host == item.url.host && existing.type == item.instanceType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
170
Yattee/Services/Migration/LegacyDataModels.swift
Normal file
170
Yattee/Services/Migration/LegacyDataModels.swift
Normal file
@@ -0,0 +1,170 @@
|
||||
//
|
||||
// LegacyDataModels.swift
|
||||
// Yattee
|
||||
//
|
||||
// Data models for parsing v1 UserDefaults format during migration.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Legacy Instance
|
||||
|
||||
/// 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 {
|
||||
/// The app type: "invidious", "piped", "peerTube", "local"
|
||||
let app: String
|
||||
|
||||
/// UUID string identifier
|
||||
let id: String
|
||||
|
||||
/// User-defined name for the instance
|
||||
let name: String
|
||||
|
||||
/// The API URL string (e.g., "https://invidious.example.com")
|
||||
let apiURL: String
|
||||
|
||||
/// Optional frontend URL (used by Piped)
|
||||
let frontendURL: String?
|
||||
|
||||
/// Whether the instance proxies videos
|
||||
let proxiesVideos: Bool
|
||||
|
||||
/// Whether to use Invidious Companion
|
||||
let invidiousCompanion: Bool
|
||||
|
||||
/// Parses a dictionary from v1 UserDefaults format.
|
||||
/// - Parameter dictionary: The serialized instance dictionary
|
||||
/// - Returns: A LegacyInstance if parsing succeeds, nil otherwise
|
||||
static func parse(from dictionary: [String: Any]) -> LegacyInstance? {
|
||||
guard let app = dictionary["app"] as? String,
|
||||
let id = dictionary["id"] as? String,
|
||||
let apiURL = dictionary["apiURL"] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = dictionary["name"] as? String ?? ""
|
||||
let frontendURL = dictionary["frontendURL"] as? String
|
||||
let proxiesVideos = (dictionary["proxiesVideos"] as? String) == "true"
|
||||
let invidiousCompanion = (dictionary["invidiousCompanion"] as? String) == "true"
|
||||
|
||||
return LegacyInstance(
|
||||
app: app,
|
||||
id: id,
|
||||
name: name,
|
||||
apiURL: apiURL,
|
||||
frontendURL: frontendURL?.isEmpty == true ? nil : frontendURL,
|
||||
proxiesVideos: proxiesVideos,
|
||||
invidiousCompanion: invidiousCompanion
|
||||
)
|
||||
}
|
||||
|
||||
/// Converts the legacy app type string to the v2 InstanceType.
|
||||
var instanceType: InstanceType? {
|
||||
switch app.lowercased() {
|
||||
case "invidious":
|
||||
return .invidious
|
||||
case "piped":
|
||||
return .piped
|
||||
case "peertube":
|
||||
return .peertube
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The URL object for this instance.
|
||||
var url: URL? {
|
||||
URL(string: apiURL)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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: - 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user