mirror of
https://github.com/yattee/yattee.git
synced 2026-07-21 14:52:03 +00:00
Add manual legacy account import
This commit is contained in:
@@ -293,6 +293,10 @@ final class AppEnvironment {
|
|||||||
self.legacyMigrationService = LegacyDataMigrationService(
|
self.legacyMigrationService = LegacyDataMigrationService(
|
||||||
instancesManager: instances,
|
instancesManager: instances,
|
||||||
basicAuthCredentialsManager: basicAuthCreds,
|
basicAuthCredentialsManager: basicAuthCreds,
|
||||||
|
invidiousCredentialsManager: invidiousCreds,
|
||||||
|
pipedCredentialsManager: pipedCreds,
|
||||||
|
invidiousAPI: invidiousAPI,
|
||||||
|
pipedAPI: pipedAPI,
|
||||||
httpClient: client
|
httpClient: client
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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" : {
|
"migration.accountsHint" : {
|
||||||
"comment" : "Hint about needing to re-add accounts after migration",
|
"comment" : "Hint about needing to re-add accounts after migration",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ final class LegacyDataMigrationService {
|
|||||||
|
|
||||||
private let instancesManager: InstancesManager
|
private let instancesManager: InstancesManager
|
||||||
private let basicAuthCredentialsManager: BasicAuthCredentialsManager
|
private let basicAuthCredentialsManager: BasicAuthCredentialsManager
|
||||||
|
private let invidiousCredentialsManager: InvidiousCredentialsManager
|
||||||
|
private let pipedCredentialsManager: PipedCredentialsManager
|
||||||
|
private let invidiousAPI: InvidiousAPI
|
||||||
|
private let pipedAPI: PipedAPI
|
||||||
private let httpClient: HTTPClient
|
private let httpClient: HTTPClient
|
||||||
|
|
||||||
// MARK: - State
|
// MARK: - State
|
||||||
@@ -35,15 +39,26 @@ final class LegacyDataMigrationService {
|
|||||||
/// Progress of the current import (0.0 to 1.0)
|
/// Progress of the current import (0.0 to 1.0)
|
||||||
private(set) var importProgress: Double = 0.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
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(
|
init(
|
||||||
instancesManager: InstancesManager,
|
instancesManager: InstancesManager,
|
||||||
basicAuthCredentialsManager: BasicAuthCredentialsManager,
|
basicAuthCredentialsManager: BasicAuthCredentialsManager,
|
||||||
|
invidiousCredentialsManager: InvidiousCredentialsManager,
|
||||||
|
pipedCredentialsManager: PipedCredentialsManager,
|
||||||
|
invidiousAPI: InvidiousAPI,
|
||||||
|
pipedAPI: PipedAPI,
|
||||||
httpClient: HTTPClient = HTTPClient()
|
httpClient: HTTPClient = HTTPClient()
|
||||||
) {
|
) {
|
||||||
self.instancesManager = instancesManager
|
self.instancesManager = instancesManager
|
||||||
self.basicAuthCredentialsManager = basicAuthCredentialsManager
|
self.basicAuthCredentialsManager = basicAuthCredentialsManager
|
||||||
|
self.invidiousCredentialsManager = invidiousCredentialsManager
|
||||||
|
self.pipedCredentialsManager = pipedCredentialsManager
|
||||||
|
self.invidiousAPI = invidiousAPI
|
||||||
|
self.pipedAPI = pipedAPI
|
||||||
self.httpClient = httpClient
|
self.httpClient = httpClient
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +71,22 @@ final class LegacyDataMigrationService {
|
|||||||
return !items.isEmpty
|
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.
|
/// Parses legacy v1 data from UserDefaults.
|
||||||
/// - Returns: Array of import items, or nil if data is corrupted or doesn't exist
|
/// - Returns: Array of import items, or nil if data is corrupted or doesn't exist
|
||||||
func parseLegacyData() -> [LegacyImportItem]? {
|
func parseLegacyData() -> [LegacyImportItem]? {
|
||||||
@@ -108,6 +139,55 @@ final class LegacyDataMigrationService {
|
|||||||
return array.compactMap { LegacyInstance.parse(from: $0) }
|
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
|
// MARK: - Reachability
|
||||||
|
|
||||||
/// Checks if an instance is reachable.
|
/// 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.
|
/// Imports a single item into the v2 system.
|
||||||
/// If the legacy URL contains embedded basic-auth credentials
|
/// If the legacy URL contains embedded basic-auth credentials
|
||||||
/// (e.g. `https://user:pass@host`), they are stripped from the URL
|
/// (e.g. `https://user:pass@host`), they are stripped from the URL
|
||||||
@@ -218,6 +345,51 @@ final class LegacyDataMigrationService {
|
|||||||
return false
|
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
|
// MARK: - Credential Splitting
|
||||||
|
|
||||||
/// Splits embedded basic-auth credentials out of a URL.
|
/// Splits embedded basic-auth credentials out of a URL.
|
||||||
@@ -238,15 +410,20 @@ final class LegacyDataMigrationService {
|
|||||||
return (cleaned, BasicAuthCredential(username: user, password: password))
|
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
|
// MARK: - Auto-Import
|
||||||
|
|
||||||
/// Silently imports any legacy v1 data on first launch.
|
/// Silently imports any legacy v1 data on first launch.
|
||||||
/// Skips unreachable-checks and UI; just imports everything and deletes the legacy keys.
|
/// 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.
|
/// Safe to call repeatedly — if there is no legacy data left, this is a no-op.
|
||||||
func autoImportIfNeeded() async {
|
func autoImportIfNeeded() async {
|
||||||
guard let items = parseLegacyData() else { return }
|
// Silent migration is intentionally disabled. Legacy accounts require
|
||||||
_ = await importItems(items)
|
// explicit review because v2 stores fresh per-instance session tokens.
|
||||||
deleteLegacyData()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cleanup
|
// MARK: - Cleanup
|
||||||
@@ -264,4 +441,45 @@ final class LegacyDataMigrationService {
|
|||||||
// if the user reinstalls v1 or for debugging purposes.
|
// if the user reinstalls v1 or for debugging purposes.
|
||||||
// The old Keychain service name is different so there's no conflict.
|
// 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import Foundation
|
|||||||
|
|
||||||
/// Represents a v1 Instance stored in UserDefaults under the "instances" key.
|
/// 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.
|
/// 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"
|
/// The app type: "invidious", "piped", "peerTube", "local"
|
||||||
let app: String
|
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
|
// MARK: - Legacy Import Item
|
||||||
|
|
||||||
/// Represents an instance to be imported from v1 data.
|
/// 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
|
// MARK: - Reachability Status
|
||||||
|
|
||||||
/// Status of an instance's reachability check.
|
/// Status of an instance's reachability check.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ struct AddSourceView: View {
|
|||||||
@State private var discoveredSMBServer: String?
|
@State private var discoveredSMBServer: String?
|
||||||
@State private var discoveredName: String?
|
@State private var discoveredName: String?
|
||||||
@State private var discoveredAllowInvalidCerts = false
|
@State private var discoveredAllowInvalidCerts = false
|
||||||
|
@State private var hasLegacyAccountsToImport = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
@@ -101,6 +102,30 @@ struct AddSourceView: View {
|
|||||||
|
|
||||||
private var listContent: some View {
|
private var listContent: some View {
|
||||||
List {
|
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 {
|
Section {
|
||||||
#if !os(tvOS)
|
#if !os(tvOS)
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
@@ -213,6 +238,12 @@ struct AddSourceView: View {
|
|||||||
Text(String(localized: "sources.footer.discovery"))
|
Text(String(localized: "sources.footer.discovery"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.task {
|
||||||
|
refreshLegacyAccountsVisibility()
|
||||||
|
}
|
||||||
|
.onChange(of: appEnvironment?.legacyMigrationService.legacyAccountsRevision ?? 0) {
|
||||||
|
refreshLegacyAccountsVisibility()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSelectedShare(_ share: DiscoveredShare) {
|
private func handleSelectedShare(_ share: DiscoveredShare) {
|
||||||
@@ -229,6 +260,10 @@ struct AddSourceView: View {
|
|||||||
navigateToSMB = true
|
navigateToSMB = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func refreshLegacyAccountsVisibility() {
|
||||||
|
hasLegacyAccountsToImport = appEnvironment?.legacyMigrationService.hasLegacyAccountsToImport() == true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Preview
|
// MARK: - Preview
|
||||||
|
|||||||
@@ -416,15 +416,15 @@ struct AdvancedSettingsView: View {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
if appEnvironment?.legacyMigrationService.hasLegacyData() == true {
|
if appEnvironment?.legacyMigrationService.hasLegacyAccountsToImport() == true {
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
LegacyDataImportView()
|
LegacyDataImportView()
|
||||||
} label: {
|
} label: {
|
||||||
Label(String(localized: "settings.advanced.data.importLegacy"), systemImage: "arrow.up.doc")
|
Label(String(localized: "migration.accounts.title"), systemImage: "person.badge.key")
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
SettingsNavigationRow("settings.advanced.data.importLegacy", systemImage: "arrow.up.doc") {
|
SettingsNavigationRow("migration.accounts.title", systemImage: "person.badge.key") {
|
||||||
LegacyDataImportView()
|
LegacyDataImportView()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -2,300 +2,294 @@
|
|||||||
// LegacyDataImportView.swift
|
// LegacyDataImportView.swift
|
||||||
// Yattee
|
// 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
|
import SwiftUI
|
||||||
|
|
||||||
struct LegacyDataImportView: View {
|
struct LegacyAccountsImportView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.appEnvironment) private var appEnvironment
|
@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 isLoading = true
|
||||||
@State private var isImporting = false
|
@State private var pendingRemoval: LegacyAccountImportItem?
|
||||||
@State private var showingResultSheet = false
|
@State private var importedInstanceName = ""
|
||||||
@State private var lastResult: MigrationResult?
|
@State private var showingImportSuccess = false
|
||||||
@State private var showingUnreachableAlert = false
|
|
||||||
@State private var pendingUnreachableItem: LegacyImportItem?
|
|
||||||
|
|
||||||
private var legacyMigrationService: LegacyDataMigrationService? {
|
private var legacyMigrationService: LegacyDataMigrationService? {
|
||||||
appEnvironment?.legacyMigrationService
|
appEnvironment?.legacyMigrationService
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectedCount: Int {
|
|
||||||
items.filter(\.isSelected).count
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if isLoading {
|
if isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.controlSize(.large)
|
.controlSize(.large)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else if items.isEmpty {
|
} else if items.isEmpty {
|
||||||
ContentUnavailableView(
|
emptyState
|
||||||
String(localized: "migration.noDataFound"),
|
|
||||||
systemImage: "doc.questionmark",
|
|
||||||
description: Text(String(localized: "migration.noDataFoundDescription"))
|
|
||||||
)
|
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
||||||
} else {
|
} else {
|
||||||
importContent
|
accountList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(String(localized: "settings.advanced.data.importLegacy"))
|
.navigationTitle(String(localized: "migration.accounts.title"))
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
.task {
|
.toolbar {
|
||||||
loadLegacyData()
|
#if !os(tvOS)
|
||||||
}
|
if showsDoneButton {
|
||||||
.sheet(isPresented: $showingResultSheet) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
resultSheet
|
Button(String(localized: "common.done")) {
|
||||||
}
|
dismiss()
|
||||||
.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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#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: {
|
} message: {
|
||||||
Text(String(localized: "migration.unreachableMessage"))
|
Text(importedInstanceName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Import Content
|
private var accountList: some View {
|
||||||
|
Form {
|
||||||
@ViewBuilder
|
Section {
|
||||||
private var importContent: some View {
|
ForEach(items) { item in
|
||||||
VStack(spacing: 0) {
|
LegacyAccountImportRow(
|
||||||
SettingsFormContainer {
|
item: item,
|
||||||
SettingsFormSection("migration.selectToImport") {
|
state: stateBinding(for: item)
|
||||||
ForEach(items) { item in
|
) {
|
||||||
MigrationImportRow(item: item) {
|
importLegacyAccount(item)
|
||||||
toggleItem(item)
|
} onRemove: {
|
||||||
}
|
pendingRemoval = item
|
||||||
}
|
|
||||||
} footer: {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text(String(localized: "migration.accountsHint"))
|
|
||||||
Text(String(localized: "migration.settingsFooter"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} 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<LegacyAccountRowState> {
|
||||||
|
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
|
credentialsFields
|
||||||
VStack(spacing: 12) {
|
|
||||||
Divider()
|
|
||||||
|
|
||||||
Button(action: performImport) {
|
if let errorMessage = state.errorMessage {
|
||||||
if isImporting {
|
Label(errorMessage, systemImage: "exclamationmark.triangle")
|
||||||
HStack(spacing: 8) {
|
.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()
|
ProgressView()
|
||||||
.controlSize(.small)
|
.controlSize(.small)
|
||||||
Text(String(localized: "migration.importing"))
|
Text(String(localized: "migration.importing"))
|
||||||
}
|
}
|
||||||
.font(.headline)
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding()
|
|
||||||
.background(Color.accentColor)
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
} else {
|
} else {
|
||||||
Text(String(localized: "migration.import"))
|
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)
|
.buttonStyle(.borderedProminent)
|
||||||
.padding(.horizontal)
|
.disabled(!canImport)
|
||||||
.padding(.bottom, 8)
|
|
||||||
}
|
}
|
||||||
#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
|
@ViewBuilder
|
||||||
private var resultSheet: some View {
|
private var credentialsFields: some View {
|
||||||
NavigationStack {
|
#if os(tvOS)
|
||||||
VStack(spacing: 24) {
|
TVSettingsTextField(title: usernameLabel, text: $state.username)
|
||||||
if let result = lastResult {
|
TVSettingsTextField(title: String(localized: "login.password"), text: $state.password, isSecure: true)
|
||||||
Spacer()
|
#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")
|
SecureField(String(localized: "login.password"), text: $state.password)
|
||||||
.font(.system(size: 60))
|
.textContentType(.password)
|
||||||
.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)
|
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
private var usernameLabel: String {
|
||||||
|
switch item.instanceType {
|
||||||
private func loadLegacyData() {
|
case .invidious:
|
||||||
items = legacyMigrationService?.parseLegacyData() ?? []
|
return String(localized: "login.email")
|
||||||
isLoading = false
|
default:
|
||||||
}
|
return String(localized: "login.username")
|
||||||
|
|
||||||
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 func checkReachability(for item: LegacyImportItem) {
|
private var iconName: String {
|
||||||
guard let index = items.firstIndex(where: { $0.id == item.id }) else { return }
|
switch item.instanceType {
|
||||||
|
case .invidious:
|
||||||
items[index].reachabilityStatus = .checking
|
return "server.rack"
|
||||||
|
case .piped:
|
||||||
Task {
|
return "cloud"
|
||||||
let isReachable = await legacyMigrationService?.checkReachability(for: item) ?? false
|
default:
|
||||||
|
return "globe"
|
||||||
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 func performImport() {
|
private struct LegacyAccountRowState: Equatable {
|
||||||
guard let service = legacyMigrationService else { return }
|
var username: String
|
||||||
|
var password = ""
|
||||||
|
var errorMessage: String?
|
||||||
|
var isImporting = false
|
||||||
|
}
|
||||||
|
|
||||||
isImporting = true
|
struct LegacyDataImportView: View {
|
||||||
|
var body: some View {
|
||||||
Task {
|
LegacyAccountsImportView()
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +297,7 @@ struct LegacyDataImportView: View {
|
|||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
LegacyDataImportView()
|
LegacyAccountsImportView()
|
||||||
}
|
}
|
||||||
.appEnvironment(.preview)
|
.appEnvironment(.preview)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ struct YatteeApp: App {
|
|||||||
// First-launch state
|
// First-launch state
|
||||||
@State private var showingICloudAlert = false
|
@State private var showingICloudAlert = false
|
||||||
@State private var showingICloudProgress = false
|
@State private var showingICloudProgress = false
|
||||||
|
@State private var showingLegacyAccountsAlert = false
|
||||||
|
@State private var showingLegacyAccountsImport = false
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
#endif
|
#endif
|
||||||
@@ -152,11 +154,22 @@ struct YatteeApp: App {
|
|||||||
enableICloudAndWait()
|
enableICloudAndWait()
|
||||||
}
|
}
|
||||||
Button(String(localized: "common.cancel"), role: .cancel) {
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
||||||
appEnvironment.settingsManager.onboardingCompleted = true
|
finishFirstLaunchFlow()
|
||||||
}
|
}
|
||||||
} message: {
|
} message: {
|
||||||
Text(String(localized: "settings.icloud.enable.confirmation.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)
|
#if os(macOS)
|
||||||
.sheet(isPresented: $showingICloudProgress) {
|
.sheet(isPresented: $showingICloudProgress) {
|
||||||
ICloudSyncProgressView()
|
ICloudSyncProgressView()
|
||||||
@@ -169,6 +182,15 @@ struct YatteeApp: App {
|
|||||||
.appEnvironment(appEnvironment)
|
.appEnvironment(appEnvironment)
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
.sheet(isPresented: $showingLegacyAccountsImport) {
|
||||||
|
NavigationStack {
|
||||||
|
LegacyAccountsImportView()
|
||||||
|
.appEnvironment(appEnvironment)
|
||||||
|
}
|
||||||
|
#if os(macOS)
|
||||||
|
.frame(minWidth: 560, minHeight: 560)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.sheet(isPresented: $showingSettings) {
|
.sheet(isPresented: $showingSettings) {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
@@ -377,10 +399,9 @@ struct YatteeApp: App {
|
|||||||
// Auto-delete old history entries based on retention setting
|
// Auto-delete old history entries based on retention setting
|
||||||
performHistoryCleanup()
|
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 {
|
if !appEnvironment.settingsManager.onboardingCompleted {
|
||||||
Task {
|
Task {
|
||||||
await appEnvironment.legacyMigrationService.autoImportIfNeeded()
|
|
||||||
await appEnvironment.cloudKitSync.refreshAccountStatus()
|
await appEnvironment.cloudKitSync.refreshAccountStatus()
|
||||||
|
|
||||||
if appEnvironment.cloudKitSync.accountStatus == .available {
|
if appEnvironment.cloudKitSync.accountStatus == .available {
|
||||||
@@ -388,9 +409,11 @@ struct YatteeApp: App {
|
|||||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
showingICloudAlert = true
|
showingICloudAlert = true
|
||||||
} else {
|
} else {
|
||||||
appEnvironment.settingsManager.onboardingCompleted = true
|
finishFirstLaunchFlow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
presentLegacyAccountsPromptIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,8 +435,24 @@ struct YatteeApp: App {
|
|||||||
appEnvironment.instancesManager.replaceWithiCloudData()
|
appEnvironment.instancesManager.replaceWithiCloudData()
|
||||||
appEnvironment.mediaSourcesManager.replaceWithiCloudData()
|
appEnvironment.mediaSourcesManager.replaceWithiCloudData()
|
||||||
|
|
||||||
settings.onboardingCompleted = true
|
|
||||||
showingICloudProgress = false
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user