diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index 5d7dfe8a..60d1dfed 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -116,9 +116,26 @@ enum SettingsKey: String, CaseIterable { case onboardingCompleted /// Whether this key should have platform-specific prefixes. + /// Platform-specific keys are stored under a `iOS.` / `macOS.` / `tvOS.` prefix + /// in both UserDefaults and iCloud, so each platform family syncs independently. var isPlatformSpecific: Bool { switch self { - case .preferredQuality, .cellularQuality, .macPlayerMode, .listStyle: + case .preferredQuality, .cellularQuality, .macPlayerMode, .listStyle, + // Home layout — different UI paradigms per platform + .homeShortcutOrder, .homeShortcutVisibility, .homeShortcutLayout, + .homeSectionOrder, .homeSectionVisibility, .homeSectionItemsLimit, + // Tab bar (compact size class) layout + .tabBarItemOrder, .tabBarItemVisibility, .tabBarStartupTab, + // Sidebar layout/selection + .sidebarMainItemOrder, .sidebarMainItemVisibility, .sidebarStartupTab, + .sidebarSourcesEnabled, .sidebarSourceSort, .sidebarSourcesLimitEnabled, .sidebarMaxSources, + .sidebarChannelsEnabled, .sidebarMaxChannels, .sidebarChannelSort, .sidebarChannelsLimitEnabled, + .sidebarPlaylistsEnabled, .sidebarMaxPlaylists, .sidebarPlaylistSort, .sidebarPlaylistsLimitEnabled, + // Player details panel — iOS/iPadOS only, different on other platforms + .floatingDetailsPanelSide, .floatingDetailsPanelWidth, + .landscapeDetailsPanelVisible, .landscapeDetailsPanelPinned, + // Video swipe actions — touch-gesture feature + .videoSwipeActionOrder, .videoSwipeActionVisibility: return true default: return false diff --git a/Yattee/Core/Settings/SettingsManager+Migration.swift b/Yattee/Core/Settings/SettingsManager+Migration.swift new file mode 100644 index 00000000..4a0d8bd5 --- /dev/null +++ b/Yattee/Core/Settings/SettingsManager+Migration.swift @@ -0,0 +1,74 @@ +// +// SettingsManager+Migration.swift +// Yattee +// +// One-shot migrations that move legacy unprefixed values under +// platform-specific keys when `SettingsKey.isPlatformSpecific` flips to true. +// + +import Foundation + +extension SettingsManager { + private static let migrationFlagKey = "didMigratePlatformSpecificLayoutKeys_v1" + + // Mirrors `SettingsManager+CloudSync.protectedVisibilityKeys`. Kept in sync manually + // because that collection is fileprivate; the set is small and rarely changes. + private static let protectedKeysForMigration: Set = [ + .homeShortcutVisibility, + .homeSectionVisibility, + .homeShortcutOrder, + .homeSectionOrder + ] + + /// Copies any legacy unprefixed values for keys that became platform-specific into their + /// new `iOS.` / `macOS.` / `tvOS.` slots, both locally and (if iCloud sync is on) in iCloud. + /// Leaves the legacy unprefixed keys in place so older builds on other devices still work. + func migrateLayoutKeysToPlatformPrefixed() { + guard !localDefaults.bool(forKey: Self.migrationFlagKey) else { return } + + let keysNeedingMigration = SettingsKey.allCases.filter { $0.isPlatformSpecific } + let pushToCloud = iCloudSyncEnabled && syncSettings + + for key in keysNeedingMigration { + let pKey = platformKey(key) + let legacyKey = key.rawValue + + // Skip if the prefixed form already exists or if the legacy key is the same as the prefixed + // key (e.g. on a platform where `platformKey` didn't rewrite it, though that shouldn't happen + // for isPlatformSpecific keys). + guard pKey != legacyKey, + localDefaults.object(forKey: pKey) == nil, + let legacyValue = localDefaults.object(forKey: legacyKey) + else { continue } + + localDefaults.set(legacyValue, forKey: pKey) + + if Self.protectedKeysForMigration.contains(key) { + let legacyTimestampKey = "\(legacyKey)_modifiedAt" + let newTimestampKey = modifiedAtKey(for: key) + let legacyTimestamp = localDefaults.double(forKey: legacyTimestampKey) + if legacyTimestamp > 0, localDefaults.double(forKey: newTimestampKey) == 0 { + localDefaults.set(legacyTimestamp, forKey: newTimestampKey) + } + } + + if pushToCloud { + ubiquitousStore.set(legacyValue, forKey: pKey) + if Self.protectedKeysForMigration.contains(key) { + let newTimestampKey = modifiedAtKey(for: key) + let timestamp = localDefaults.double(forKey: newTimestampKey) + if timestamp > 0 { + ubiquitousStore.set(timestamp, forKey: newTimestampKey) + } + } + } + } + + if pushToCloud { + ubiquitousStore.synchronize() + } + + localDefaults.set(true, forKey: Self.migrationFlagKey) + LoggingService.shared.logCloudKit("Migrated legacy layout keys to platform-prefixed storage") + } +} diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index 941926da..f625fe59 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -252,6 +252,12 @@ final class SettingsManager { } } + // One-shot migration: move legacy unprefixed values for keys that became + // platform-specific into their new iOS./macOS./tvOS. slots. Must run before + // the initial iCloud refresh so it can seed the prefixed iCloud slot from the + // current local value before any remote data is read. + migrateLayoutKeysToPlatformPrefixed() + // Initial sync from iCloud to local storage (async to avoid blocking app launch) // This ensures local defaults have the latest iCloud values before any reads. // While sync is pending, suppress iCloud writes from set() to prevent stale