Allow importing account-less legacy instances as sources

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.
This commit is contained in:
Arkadiusz Fal
2026-05-18 23:54:42 +02:00
parent de9b06072d
commit 86fbdd23f1
6 changed files with 382 additions and 57 deletions

View File

@@ -5828,6 +5828,46 @@
} }
} }
}, },
"migration.sources.footer" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add these sources from the previous Yattee app. No sign-in is required."
}
}
}
},
"migration.sources.imported.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Source Added"
}
}
}
},
"migration.sources.remove.title" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Remove Legacy Source?"
}
}
}
},
"migration.sources.section" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sources"
}
}
}
},
"migration.subtitle" : { "migration.subtitle" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {

View File

@@ -32,8 +32,8 @@ final class LegacyDataMigrationService {
// MARK: - State // MARK: - State
/// Changes whenever legacy account defaults are resolved, so SwiftUI views refresh. /// Changes whenever legacy account or instance defaults are resolved, so SwiftUI views refresh.
private(set) var legacyAccountsRevision = 0 private(set) var legacyDataRevision = 0
// MARK: - Initialization // MARK: - Initialization
@@ -57,13 +57,24 @@ final class LegacyDataMigrationService {
/// Whether there are legacy accounts left for the user to review. /// Whether there are legacy accounts left for the user to review.
func hasLegacyAccountsToImport() -> Bool { func hasLegacyAccountsToImport() -> Bool {
_ = legacyAccountsRevision _ = legacyDataRevision
return !parseLegacyAccountsForImport().isEmpty return !parseLegacyAccountsForImport().isEmpty
} }
/// Whether the one-time legacy account prompt should be shown. /// 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 { var shouldShowLegacyAccountsPrompt: Bool {
hasLegacyAccountsToImport() && !UserDefaults.standard.bool(forKey: legacyAccountsPromptShownKey) hasLegacyDataToImport() && !UserDefaults.standard.bool(forKey: legacyAccountsPromptShownKey)
} }
/// Marks the one-time legacy account prompt as shown. /// Marks the one-time legacy account prompt as shown.
@@ -131,6 +142,52 @@ final class LegacyDataMigrationService {
return importItems 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 // MARK: - Import
/// Re-creates a legacy account by signing in with fresh credentials. /// Re-creates a legacy account by signing in with fresh credentials.
@@ -180,6 +237,39 @@ final class LegacyDataMigrationService {
return instance 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 { private func instanceForAccountImport(_ item: LegacyAccountImportItem) -> Instance {
let (cleanURL, _) = Self.splitCredentials(from: item.url) let (cleanURL, _) = Self.splitCredentials(from: item.url)
if let existing = instancesManager.instances.first(where: { existing in if let existing = instancesManager.instances.first(where: { existing in
@@ -225,6 +315,13 @@ final class LegacyDataMigrationService {
} }
} }
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 // MARK: - Credential Splitting
/// Splits embedded basic-auth credentials out of a URL. /// Splits embedded basic-auth credentials out of a URL.
@@ -285,7 +382,42 @@ final class LegacyDataMigrationService {
defaults.set(accounts, forKey: legacyAccountsKey) defaults.set(accounts, forKey: legacyAccountsKey)
} }
legacyAccountsRevision += 1 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 // MARK: - Prompt State

View File

@@ -173,3 +173,35 @@ struct LegacyAccountImportItem: Identifiable, Sendable {
return url.host ?? url.absoluteString return url.host ?? url.absoluteString
} }
} }
// MARK: - Legacy Instance Import Item
/// Represents a legacy instance (source) that can be re-created without signing in.
/// Used for v1 instances that have no associated account.
struct LegacyInstanceImportItem: Identifiable, Sendable {
/// The original v1 instance ID.
let legacyInstanceID: String
/// The type of instance.
let instanceType: InstanceType
/// The instance URL.
let url: URL
/// User-defined instance name, if any.
let instanceName: String?
/// Whether this instance proxies videos.
let proxiesVideos: Bool
/// Stable identifier for SwiftUI lists.
var id: String { legacyInstanceID }
/// Display name for the source row.
var instanceDisplayName: String {
if let instanceName, !instanceName.isEmpty {
return instanceName
}
return url.host ?? url.absoluteString
}
}

View File

@@ -241,7 +241,7 @@ struct AddSourceView: View {
.task { .task {
refreshLegacyAccountsVisibility() refreshLegacyAccountsVisibility()
} }
.onChange(of: appEnvironment?.legacyMigrationService.legacyAccountsRevision ?? 0) { .onChange(of: appEnvironment?.legacyMigrationService.legacyDataRevision ?? 0) {
refreshLegacyAccountsVisibility() refreshLegacyAccountsVisibility()
} }
} }
@@ -262,7 +262,7 @@ struct AddSourceView: View {
} }
private func refreshLegacyAccountsVisibility() { private func refreshLegacyAccountsVisibility() {
hasLegacyAccountsToImport = appEnvironment?.legacyMigrationService.hasLegacyAccountsToImport() == true hasLegacyAccountsToImport = appEnvironment?.legacyMigrationService.hasLegacyDataToImport() == true
} }
} }

View File

@@ -416,7 +416,7 @@ struct AdvancedSettingsView: View {
} }
#endif #endif
if appEnvironment?.legacyMigrationService.hasLegacyAccountsToImport() == true { if appEnvironment?.legacyMigrationService.hasLegacyDataToImport() == true {
#if os(tvOS) #if os(tvOS)
NavigationLink { NavigationLink {
LegacyDataImportView() LegacyDataImportView()

View File

@@ -2,7 +2,7 @@
// LegacyDataImportView.swift // LegacyDataImportView.swift
// Yattee // Yattee
// //
// View for reviewing and re-creating accounts from the legacy v1 app. // View for reviewing and re-creating accounts and sources from the legacy v1 app.
// //
import SwiftUI import SwiftUI
@@ -13,10 +13,12 @@ struct LegacyAccountsImportView: View {
var showsDoneButton = true var showsDoneButton = true
@State private var items: [LegacyAccountImportItem] = [] @State private var accountItems: [LegacyAccountImportItem] = []
@State private var instanceItems: [LegacyInstanceImportItem] = []
@State private var rowStates: [String: LegacyAccountRowState] = [:] @State private var rowStates: [String: LegacyAccountRowState] = [:]
@State private var isLoading = true @State private var isLoading = true
@State private var pendingRemoval: LegacyAccountImportItem? @State private var pendingRemoval: PendingRemoval?
@State private var importSuccessTitle = ""
@State private var importedInstanceName = "" @State private var importedInstanceName = ""
@State private var showingImportSuccess = false @State private var showingImportSuccess = false
@@ -24,16 +26,20 @@ struct LegacyAccountsImportView: View {
appEnvironment?.legacyMigrationService appEnvironment?.legacyMigrationService
} }
private var isEmpty: Bool {
accountItems.isEmpty && instanceItems.isEmpty
}
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) .frame(maxWidth: .infinity, maxHeight: .infinity)
} else if items.isEmpty { } else if isEmpty {
emptyState emptyState
} else { } else {
accountList contentList
} }
} }
.navigationTitle(String(localized: "migration.accounts.title")) .navigationTitle(String(localized: "migration.accounts.title"))
@@ -52,44 +58,62 @@ struct LegacyAccountsImportView: View {
#endif #endif
} }
.task { .task {
loadLegacyAccounts() loadLegacyData()
} }
.confirmationDialog( .confirmationDialog(
String(localized: "migration.accounts.remove.title"), pendingRemoval?.confirmationTitle ?? "",
item: $pendingRemoval, item: $pendingRemoval,
titleVisibility: .visible titleVisibility: .visible
) { item in ) { removal in
Button(String(localized: "migration.accounts.remove.confirm"), role: .destructive) { Button(String(localized: "migration.accounts.remove.confirm"), role: .destructive) {
removeLegacyAccount(item) confirmRemoval(removal)
} }
Button(String(localized: "common.cancel"), role: .cancel) {} Button(String(localized: "common.cancel"), role: .cancel) {}
} message: { item in } message: { removal in
Text(String(localized: "migration.accounts.remove.message \(item.displayName)")) Text(String(localized: "migration.accounts.remove.message \(removal.displayName)"))
} }
.alert(String(localized: "migration.accounts.imported.title"), isPresented: $showingImportSuccess) { .alert(importSuccessTitle, isPresented: $showingImportSuccess) {
Button(String(localized: "common.ok"), role: .cancel) {} Button(String(localized: "common.ok"), role: .cancel) {}
} message: { } message: {
Text(importedInstanceName) Text(importedInstanceName)
} }
} }
private var accountList: some View { private var contentList: some View {
Form { Form {
Section { if !accountItems.isEmpty {
ForEach(items) { item in Section {
LegacyAccountImportRow( ForEach(accountItems) { item in
item: item, LegacyAccountImportRow(
state: stateBinding(for: item) item: item,
) { state: stateBinding(for: item)
importLegacyAccount(item) ) {
} onRemove: { importLegacyAccount(item)
pendingRemoval = item } onRemove: {
pendingRemoval = .account(item)
}
} }
} header: {
Text(String(localized: "migration.accounts.section"))
} footer: {
Text(String(localized: "migration.accounts.footer"))
}
}
if !instanceItems.isEmpty {
Section {
ForEach(instanceItems) { item in
LegacyInstanceImportRow(item: item) {
importLegacyInstance(item)
} onRemove: {
pendingRemoval = .instance(item)
}
}
} header: {
Text(String(localized: "migration.sources.section"))
} footer: {
Text(String(localized: "migration.sources.footer"))
} }
} header: {
Text(String(localized: "migration.accounts.section"))
} footer: {
Text(String(localized: "migration.accounts.footer"))
} }
} }
#if os(iOS) #if os(iOS)
@@ -106,10 +130,11 @@ struct LegacyAccountsImportView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
private func loadLegacyAccounts() { private func loadLegacyData() {
let loadedItems = legacyMigrationService?.parseLegacyAccountsForImport() ?? [] let loadedAccounts = legacyMigrationService?.parseLegacyAccountsForImport() ?? []
items = loadedItems accountItems = loadedAccounts
for item in loadedItems where rowStates[item.legacyAccountID] == nil { instanceItems = legacyMigrationService?.parseLegacyInstancesForImport() ?? []
for item in loadedAccounts where rowStates[item.legacyAccountID] == nil {
rowStates[item.legacyAccountID] = LegacyAccountRowState(username: item.username) rowStates[item.legacyAccountID] = LegacyAccountRowState(username: item.username)
} }
isLoading = false isLoading = false
@@ -137,9 +162,11 @@ struct LegacyAccountsImportView: View {
username: state.username, username: state.username,
password: state.password password: state.password
) )
importSuccessTitle = String(localized: "migration.accounts.imported.title")
importedInstanceName = importedInstance.displayName importedInstanceName = importedInstance.displayName
showingImportSuccess = true showingImportSuccess = true
removeResolvedItem(item) accountItems.removeAll { $0.legacyAccountID == item.legacyAccountID }
rowStates.removeValue(forKey: item.legacyAccountID)
} catch APIError.unauthorized { } catch APIError.unauthorized {
setImportError(String(localized: "login.error.invalidCredentials"), for: item) setImportError(String(localized: "login.error.invalidCredentials"), for: item)
} catch { } catch {
@@ -148,6 +175,15 @@ struct LegacyAccountsImportView: View {
} }
} }
private func importLegacyInstance(_ item: LegacyInstanceImportItem) {
guard let service = legacyMigrationService else { return }
let importedInstance = service.importLegacyInstance(item)
importSuccessTitle = String(localized: "migration.sources.imported.title")
importedInstanceName = importedInstance.displayName
showingImportSuccess = true
instanceItems.removeAll { $0.legacyInstanceID == item.legacyInstanceID }
}
private func setImportError(_ message: String, for item: LegacyAccountImportItem) { private func setImportError(_ message: String, for item: LegacyAccountImportItem) {
var state = rowStates[item.legacyAccountID] ?? LegacyAccountRowState(username: item.username) var state = rowStates[item.legacyAccountID] ?? LegacyAccountRowState(username: item.username)
state.isImporting = false state.isImporting = false
@@ -155,17 +191,55 @@ struct LegacyAccountsImportView: View {
rowStates[item.legacyAccountID] = state rowStates[item.legacyAccountID] = state
} }
private func removeLegacyAccount(_ item: LegacyAccountImportItem) { private func confirmRemoval(_ removal: PendingRemoval) {
legacyMigrationService?.removeLegacyAccount(item) switch removal {
removeResolvedItem(item) case .account(let item):
} legacyMigrationService?.removeLegacyAccount(item)
accountItems.removeAll { $0.legacyAccountID == item.legacyAccountID }
private func removeResolvedItem(_ item: LegacyAccountImportItem) { rowStates.removeValue(forKey: item.legacyAccountID)
items.removeAll { $0.legacyAccountID == item.legacyAccountID } case .instance(let item):
rowStates.removeValue(forKey: item.legacyAccountID) legacyMigrationService?.removeLegacyInstance(item)
instanceItems.removeAll { $0.legacyInstanceID == item.legacyInstanceID }
}
} }
} }
// MARK: - Pending Removal
private enum PendingRemoval: Identifiable {
case account(LegacyAccountImportItem)
case instance(LegacyInstanceImportItem)
var id: String {
switch self {
case .account(let item):
return "account:\(item.id)"
case .instance(let item):
return "instance:\(item.id)"
}
}
var displayName: String {
switch self {
case .account(let item):
return item.displayName
case .instance(let item):
return item.instanceDisplayName
}
}
var confirmationTitle: String {
switch self {
case .account:
return String(localized: "migration.accounts.remove.title")
case .instance:
return String(localized: "migration.sources.remove.title")
}
}
}
// MARK: - Account Row
private struct LegacyAccountImportRow: View { private struct LegacyAccountImportRow: View {
let item: LegacyAccountImportItem let item: LegacyAccountImportItem
@Binding var state: LegacyAccountRowState @Binding var state: LegacyAccountRowState
@@ -179,7 +253,7 @@ private struct LegacyAccountImportRow: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Image(systemName: iconName) Image(systemName: legacyInstanceIcon(for: item.instanceType))
.font(.title2) .font(.title2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 28) .frame(width: 28)
@@ -263,16 +337,63 @@ private struct LegacyAccountImportRow: View {
return String(localized: "login.username") return String(localized: "login.username")
} }
} }
}
private var iconName: String { // MARK: - Source Row
switch item.instanceType {
case .invidious: private struct LegacyInstanceImportRow: View {
return "server.rack" let item: LegacyInstanceImportItem
case .piped: let onImport: () -> Void
return "cloud" let onRemove: () -> Void
default:
return "globe" var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: legacyInstanceIcon(for: item.instanceType))
.font(.title2)
.foregroundStyle(.secondary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
Text(item.instanceDisplayName)
.font(.headline)
Text(item.url.host ?? item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
}
HStack {
Button(role: .destructive, action: onRemove) {
Text(String(localized: "common.remove"))
}
.foregroundStyle(.red)
.tint(.red)
Spacer()
Button(action: onImport) {
Text(String(localized: "migration.import"))
}
.buttonStyle(.borderedProminent)
}
} }
.padding(.vertical, 8)
}
}
private func legacyInstanceIcon(for type: InstanceType) -> String {
switch type {
case .invidious:
return "server.rack"
case .piped:
return "cloud"
default:
return "globe"
} }
} }