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.
This commit is contained in:
Arkadiusz Fal
2026-04-14 01:16:52 +02:00
parent f4605e7390
commit 9e95a91284
3 changed files with 98 additions and 1 deletions

View File

@@ -116,9 +116,26 @@ enum SettingsKey: String, CaseIterable {
case onboardingCompleted case onboardingCompleted
/// Whether this key should have platform-specific prefixes. /// 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 { var isPlatformSpecific: Bool {
switch self { 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 return true
default: default:
return false return false

View File

@@ -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<SettingsKey> = [
.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")
}
}

View File

@@ -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) // 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. // This ensures local defaults have the latest iCloud values before any reads.
// While sync is pending, suppress iCloud writes from set() to prevent stale // While sync is pending, suppress iCloud writes from set() to prevent stale