From 9e95a912843e6fdb217d7d309bd9716a4dc9dead Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Tue, 14 Apr 2026 01:16:52 +0200 Subject: [PATCH] Sync sidebar/tab bar/home layout per-platform via iCloud Extends `SettingsKey.isPlatformSpecific` to cover home, tab bar, and sidebar layout, plus player details panel and video swipe actions, so iOS devices sync these with other iOS devices (and tvOS with tvOS, macOS with macOS) instead of overwriting each other via the shared iCloud key. Adds a one-shot migration that copies legacy unprefixed values into the new platform-prefixed slots locally and in iCloud, preserving protected-key timestamps. --- Yattee/Core/Settings/SettingsKey.swift | 19 ++++- .../Settings/SettingsManager+Migration.swift | 74 +++++++++++++++++++ Yattee/Core/SettingsManager.swift | 6 ++ 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 Yattee/Core/Settings/SettingsManager+Migration.swift 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