mirror of
https://github.com/yattee/yattee.git
synced 2026-07-21 06:42:01 +00:00
The manual account-import flow made the old silent instance-import pipeline unreachable. Drop the orphaned parsing, reachability checks, batch import, result/error types, and the old import row view, plus the httpClient dependency they were the only consumer of. Also remove the LegacyAccount.password field, which was parsed but never read.
297 lines
11 KiB
Swift
297 lines
11 KiB
Swift
//
|
|
// 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 basicAuthCredentialsManager: BasicAuthCredentialsManager
|
|
private let invidiousCredentialsManager: InvidiousCredentialsManager
|
|
private let pipedCredentialsManager: PipedCredentialsManager
|
|
private let invidiousAPI: InvidiousAPI
|
|
private let pipedAPI: PipedAPI
|
|
|
|
// MARK: - State
|
|
|
|
/// 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
|
|
) {
|
|
self.instancesManager = instancesManager
|
|
self.basicAuthCredentialsManager = basicAuthCredentialsManager
|
|
self.invidiousCredentialsManager = invidiousCredentialsManager
|
|
self.pipedCredentialsManager = pipedCredentialsManager
|
|
self.invidiousAPI = invidiousAPI
|
|
self.pipedAPI = pipedAPI
|
|
}
|
|
|
|
// MARK: - Detection
|
|
|
|
/// 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)
|
|
}
|
|
|
|
// 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) }
|
|
}
|
|
|
|
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: - Import
|
|
|
|
/// 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
|
|
}
|
|
|
|
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.
|
|
/// v1 supported credentials embedded directly in the URL (e.g. `https://user:pass@host`);
|
|
/// v2 stores them separately in the Keychain.
|
|
/// - Returns: The cleaned URL (no user/password) and the extracted credentials, if any.
|
|
static func splitCredentials(from url: URL) -> (cleanURL: URL, credentials: BasicAuthCredential?) {
|
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
|
let user = components.user, !user.isEmpty else {
|
|
return (url, nil)
|
|
}
|
|
|
|
let password = components.password ?? ""
|
|
components.user = nil
|
|
components.password = nil
|
|
|
|
let cleaned = components.url ?? url
|
|
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: - Cleanup
|
|
|
|
/// 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"
|
|
}
|
|
}
|