mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
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.
429 lines
16 KiB
Swift
429 lines
16 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 or instance defaults are resolved, so SwiftUI views refresh.
|
|
private(set) var legacyDataRevision = 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 {
|
|
_ = legacyDataRevision
|
|
return !parseLegacyAccountsForImport().isEmpty
|
|
}
|
|
|
|
/// 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 {
|
|
hasLegacyDataToImport() && !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
|
|
}
|
|
|
|
/// 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.
|
|
/// 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
|
|
}
|
|
|
|
/// 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
|
|
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
|
|
}
|
|
}
|
|
|
|
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.
|
|
/// 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)
|
|
}
|
|
|
|
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
|
|
|
|
private var legacyAccountsPromptShownKey: String {
|
|
"legacyAccountsImportPromptShown_v1"
|
|
}
|
|
}
|