diff --git a/Documentation/Screenshots/en-dark.png b/Documentation/Screenshots/en-dark.png index 46cbfa6..86d99a1 100644 Binary files a/Documentation/Screenshots/en-dark.png and b/Documentation/Screenshots/en-dark.png differ diff --git a/Documentation/Screenshots/en-light.png b/Documentation/Screenshots/en-light.png index 4c024b2..ad2f4a3 100644 Binary files a/Documentation/Screenshots/en-light.png and b/Documentation/Screenshots/en-light.png differ diff --git a/Documentation/Screenshots/raw/en-dark-main.png b/Documentation/Screenshots/raw/en-dark-main.png index a1d8a8b..f75d6a1 100644 Binary files a/Documentation/Screenshots/raw/en-dark-main.png and b/Documentation/Screenshots/raw/en-dark-main.png differ diff --git a/Documentation/Screenshots/raw/en-dark-strategy.png b/Documentation/Screenshots/raw/en-dark-strategy.png index c15d1c0..7f634b7 100644 Binary files a/Documentation/Screenshots/raw/en-dark-strategy.png and b/Documentation/Screenshots/raw/en-dark-strategy.png differ diff --git a/Documentation/Screenshots/raw/en-light-main.png b/Documentation/Screenshots/raw/en-light-main.png index fc10967..fca4ec7 100644 Binary files a/Documentation/Screenshots/raw/en-light-main.png and b/Documentation/Screenshots/raw/en-light-main.png differ diff --git a/Documentation/Screenshots/raw/en-light-strategy.png b/Documentation/Screenshots/raw/en-light-strategy.png index eb4a559..1051055 100644 Binary files a/Documentation/Screenshots/raw/en-light-strategy.png and b/Documentation/Screenshots/raw/en-light-strategy.png differ diff --git a/Documentation/Screenshots/raw/zh-Hans-dark-main.png b/Documentation/Screenshots/raw/zh-Hans-dark-main.png index 54edff4..a9fc8b1 100644 Binary files a/Documentation/Screenshots/raw/zh-Hans-dark-main.png and b/Documentation/Screenshots/raw/zh-Hans-dark-main.png differ diff --git a/Documentation/Screenshots/raw/zh-Hans-dark-strategy.png b/Documentation/Screenshots/raw/zh-Hans-dark-strategy.png index 692d7e8..87ee30a 100644 Binary files a/Documentation/Screenshots/raw/zh-Hans-dark-strategy.png and b/Documentation/Screenshots/raw/zh-Hans-dark-strategy.png differ diff --git a/Documentation/Screenshots/raw/zh-Hans-light-main.png b/Documentation/Screenshots/raw/zh-Hans-light-main.png index f8ae454..d0ae759 100644 Binary files a/Documentation/Screenshots/raw/zh-Hans-light-main.png and b/Documentation/Screenshots/raw/zh-Hans-light-main.png differ diff --git a/Documentation/Screenshots/raw/zh-Hans-light-strategy.png b/Documentation/Screenshots/raw/zh-Hans-light-strategy.png index 2c0c834..7cf65c3 100644 Binary files a/Documentation/Screenshots/raw/zh-Hans-light-strategy.png and b/Documentation/Screenshots/raw/zh-Hans-light-strategy.png differ diff --git a/Documentation/Screenshots/zh-Hans-dark.png b/Documentation/Screenshots/zh-Hans-dark.png index fbd5af4..500e9aa 100644 Binary files a/Documentation/Screenshots/zh-Hans-dark.png and b/Documentation/Screenshots/zh-Hans-dark.png differ diff --git a/Documentation/Screenshots/zh-Hans-light.png b/Documentation/Screenshots/zh-Hans-light.png index 195f9bd..6dc8fe3 100644 Binary files a/Documentation/Screenshots/zh-Hans-light.png and b/Documentation/Screenshots/zh-Hans-light.png differ diff --git a/TypeSwitch/Sources/App/AppFeature.swift b/TypeSwitch/Sources/App/AppFeature.swift index 2871549..af71794 100644 --- a/TypeSwitch/Sources/App/AppFeature.swift +++ b/TypeSwitch/Sources/App/AppFeature.swift @@ -108,7 +108,10 @@ struct AppFeature { case frontmostApplicationLoaded(AppInfo?) case inputMethods([InputMethod]) case launchAtLoginLoaded(LaunchAtLoginStatus) - case legacyRulesLoaded([String: AppRuleRecord]) + case legacyRulesLoaded( + [String: AppRuleRecord], + didCompleteLegacyMigration: Bool + ) case programmaticSwitchFinished(bundleId: String, inputMethodId: String, didSwitch: Bool) case runningApps([AppInfo]) } @@ -131,6 +134,7 @@ struct AppFeature { private enum CancelID { case inputMethodAvailability case inputMethodSelection + case programmaticSwitch case workspaceEvents } @@ -234,10 +238,11 @@ struct AppFeature { state.launchAtLoginStatus = status return .none - case .response(.legacyRulesLoaded(let legacyRules)): + case let .response(.legacyRulesLoaded(legacyRules, didCompleteLegacyMigration)): let mergedRules = AppRulesStoreMigration.merge( currentRules: state.appRules, - legacyRules: legacyRules + legacyRules: legacyRules, + didCompleteLegacyMigration: didCompleteLegacyMigration ) guard mergedRules != state.appRules else { return markLegacyMigrationCompletedEffect() @@ -266,6 +271,8 @@ struct AppFeature { return .none case .view(.ignoreAppTapped(let appInfo)): + let shouldCancelProgrammaticSwitch = state.currentFrontmostBundleId == appInfo.bundleId + || state.pendingProgrammaticSwitch?.bundleId == appInfo.bundleId let updateDate = now state.$appRulesStore.withLock { store in let currentRule = store.rules[appInfo.bundleId] ?? AppRuleRecord( @@ -285,7 +292,9 @@ struct AppFeature { updatedRule.updatedAt = updateDate store.rules[appInfo.bundleId] = updatedRule } - return .none + guard shouldCancelProgrammaticSwitch else { return .none } + state.pendingProgrammaticSwitch = nil + return .cancel(id: CancelID.programmaticSwitch) case .view(.removeMissingInputMethodRulesTapped): let updateDate = now @@ -389,10 +398,20 @@ struct AppFeature { return refreshRunningAppsEffect() case .system(.workspaceEvent(.terminated(let bundleId))): - if state.currentFrontmostBundleId == bundleId { + let wasCurrentApp = state.currentFrontmostBundleId == bundleId + if wasCurrentApp { state.currentFrontmostBundleId = nil } - return refreshRunningAppsEffect() + let shouldCancelProgrammaticSwitch = wasCurrentApp + || state.pendingProgrammaticSwitch?.bundleId == bundleId + guard shouldCancelProgrammaticSwitch else { + return refreshRunningAppsEffect() + } + state.pendingProgrammaticSwitch = nil + return .merge( + .cancel(id: CancelID.programmaticSwitch), + refreshRunningAppsEffect() + ) case .system(.workspaceEvent(.activated(let appInfo))): state.currentFrontmostBundleId = appInfo.bundleId @@ -400,7 +419,7 @@ struct AppFeature { guard let inputMethodId = targetInputMethodId(for: appInfo.bundleId, state: state) else { state.pendingProgrammaticSwitch = nil - return .none + return .cancel(id: CancelID.programmaticSwitch) } state.pendingProgrammaticSwitch = .init( @@ -411,19 +430,23 @@ struct AppFeature { return .run { send in var didSwitch = false if (try? await inputMethodClient.currentInputMethodId()) != inputMethodId { + guard !Task.isCancelled else { return } do { try await inputMethodClient.switchToInputMethod(inputMethodId) didSwitch = true } catch { + guard !Task.isCancelled else { return } didSwitch = false } } + guard !Task.isCancelled else { return } await send(.response(.programmaticSwitchFinished( bundleId: appInfo.bundleId, inputMethodId: inputMethodId, didSwitch: didSwitch ))) } + .cancellable(id: CancelID.programmaticSwitch, cancelInFlight: true) } } } @@ -435,7 +458,11 @@ struct AppFeature { } let legacyRules = await legacyDefaultsMigrationClient.loadRules(now) - await send(.response(.legacyRulesLoaded(legacyRules))) + let didCompleteLegacyMigration = await legacyDefaultsMigrationClient.didCompleteLegacyMigration() + await send(.response(.legacyRulesLoaded( + legacyRules, + didCompleteLegacyMigration: didCompleteLegacyMigration + ))) } } diff --git a/TypeSwitch/Sources/Migrations/AppRulesStoreMigration.swift b/TypeSwitch/Sources/Migrations/AppRulesStoreMigration.swift index bc41133..76f4884 100644 --- a/TypeSwitch/Sources/Migrations/AppRulesStoreMigration.swift +++ b/TypeSwitch/Sources/Migrations/AppRulesStoreMigration.swift @@ -4,21 +4,37 @@ import Sharing enum AppRulesStoreMigration { static func merge( currentRules: [String: AppRuleRecord], - legacyRules: [String: AppRuleRecord] + legacyRules: [String: AppRuleRecord], + didCompleteLegacyMigration: Bool ) -> [String: AppRuleRecord] { - currentRules.merging(legacyRules) { currentRule, legacyRule in - guard currentRule.strategy == .none else { - return currentRule + guard !didCompleteLegacyMigration || currentRules.isEmpty else { + return currentRules + } + + return legacyRules.reduce(into: currentRules) { rules, entry in + guard let currentRule = rules[entry.key] else { + rules[entry.key] = entry.value + return + } + + guard currentRule.strategy == .none, + currentRule.createdAt == currentRule.updatedAt + else { + return } var recoveredRule = currentRule - if legacyRule.lastKnownPath != nil { - recoveredRule.lastKnownPath = legacyRule.lastKnownPath - recoveredRule.lastKnownName = legacyRule.lastKnownName + recoveredRule.strategy = entry.value.strategy + recoveredRule.updatedAt = entry.value.updatedAt + if recoveredRule.lastKnownPath == nil { + recoveredRule.lastKnownPath = entry.value.lastKnownPath + } + if recoveredRule.lastKnownName == recoveredRule.bundleId, + entry.value.lastKnownName != entry.value.bundleId + { + recoveredRule.lastKnownName = entry.value.lastKnownName } - recoveredRule.strategy = legacyRule.strategy - recoveredRule.updatedAt = legacyRule.updatedAt - return recoveredRule + rules[entry.key] = recoveredRule } } } diff --git a/TypeSwitch/Sources/Migrations/LegacyDefaultsMigration.swift b/TypeSwitch/Sources/Migrations/LegacyDefaultsMigration.swift index 2be580a..c78d5ce 100644 --- a/TypeSwitch/Sources/Migrations/LegacyDefaultsMigration.swift +++ b/TypeSwitch/Sources/Migrations/LegacyDefaultsMigration.swift @@ -4,12 +4,17 @@ import Foundation // Migration: legacy defaults -> app rules store enum LegacyDefaultsMigration { static let currentVersion = 2 + static let legacyCompletionKey = "didMigrateLegacyAppRules" static let versionKey = "legacyAppRulesMigrationVersion" static func completedVersion(in defaults: UserDefaults) -> Int { defaults.integer(forKey: versionKey) } + static func didCompleteLegacyMigration(in defaults: UserDefaults) -> Bool { + defaults.bool(forKey: legacyCompletionKey) + } + static func makeRules( legacyMappings: [String: String], matchedApplications: [String: AppInfo], @@ -32,6 +37,7 @@ enum LegacyDefaultsMigration { struct LegacyDefaultsMigrationClient { var completedVersion: @Sendable () async -> Int + var didCompleteLegacyMigration: @Sendable () async -> Bool var loadRules: @Sendable (_ migrationDate: Date) async -> [String: AppRuleRecord] var markCompleted: @Sendable (_ version: Int) async -> Void } @@ -41,6 +47,9 @@ extension LegacyDefaultsMigrationClient: DependencyKey { completedVersion: { LegacyDefaultsMigration.completedVersion(in: .standard) }, + didCompleteLegacyMigration: { + LegacyDefaultsMigration.didCompleteLegacyMigration(in: .standard) + }, loadRules: { migrationDate in let defaults = UserDefaults(suiteName: "group.top.ygsgdbd.TypeSwitch") ?? .standard let legacyMappings = defaults.dictionary(forKey: "appInputMethodSettings")? @@ -64,6 +73,7 @@ extension LegacyDefaultsMigrationClient: DependencyKey { static let testValue = Self( completedVersion: { LegacyDefaultsMigration.currentVersion }, + didCompleteLegacyMigration: { false }, loadRules: { _ in [:] }, markCompleted: { _ in } ) diff --git a/TypeSwitchTests/AppFeatureTests.swift b/TypeSwitchTests/AppFeatureTests.swift index 86ce88c..c8a05da 100644 --- a/TypeSwitchTests/AppFeatureTests.swift +++ b/TypeSwitchTests/AppFeatureTests.swift @@ -452,6 +452,250 @@ final class AppFeatureTests: XCTestCase { XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) } + func testIgnoringCurrentAppCancelsPendingProgrammaticSwitch() async { + let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") + let targetInputMethod = "ime.en" + let updateDate = Date(timeIntervalSince1970: 20) + let lookupGate = InputMethodLookupGate(firstValue: "ime.other") + let recorder = SwitchRecorder() + + var initialState = AppFeature.State() + initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .fixed(inputMethodId: targetInputMethod), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(updateDate) + store.dependencies.inputMethodClient.currentInputMethodId = { + await lookupGate.value() + } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + } + + await store.send(.system(.workspaceEvent(.activated(app)))) { + $0.currentFrontmostBundleId = app.bundleId + $0.pendingProgrammaticSwitch = .init(bundleId: app.bundleId, inputMethodId: targetInputMethod) + } + await lookupGate.waitForFirstCall() + + await store.send(.view(.ignoreAppTapped(app))) { + $0.pendingProgrammaticSwitch = nil + $0.$appRulesStore.withLock { + guard var rule = $0.rules[app.bundleId] else { return } + rule.strategy = .ignored + rule.updatedAt = updateDate + $0.rules[app.bundleId] = rule + } + } + + await lookupGate.resumeFirst() + await store.finish() + + let switchedInputMethods = await recorder.values + XCTAssertTrue(switchedInputMethods.isEmpty) + XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) + } + + func testActivatingIgnoredAppCancelsPreviousProgrammaticSwitch() async { + let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") + let ignoredApp = AppInfo(bundleId: "com.test.ignored", name: "Ignored", path: "/Applications/Ignored.app") + let targetInputMethod = "ime.en" + let lookupGate = InputMethodLookupGate(firstValue: "ime.other") + let recorder = SwitchRecorder() + + var initialState = AppFeature.State() + initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] + initialState.$appRulesStore.withLock { + $0.rules[firstApp.bundleId] = AppRuleRecord( + bundleId: firstApp.bundleId, + lastKnownPath: firstApp.path, + lastKnownName: firstApp.name, + strategy: .fixed(inputMethodId: targetInputMethod), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + $0.rules[ignoredApp.bundleId] = AppRuleRecord( + bundleId: ignoredApp.bundleId, + lastKnownPath: ignoredApp.path, + lastKnownName: ignoredApp.name, + strategy: .ignored, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.inputMethodClient.currentInputMethodId = { + await lookupGate.value() + } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + } + + await store.send(.system(.workspaceEvent(.activated(firstApp)))) { + $0.currentFrontmostBundleId = firstApp.bundleId + $0.pendingProgrammaticSwitch = .init(bundleId: firstApp.bundleId, inputMethodId: targetInputMethod) + } + await lookupGate.waitForFirstCall() + + await store.send(.system(.workspaceEvent(.activated(ignoredApp)))) { + $0.currentFrontmostBundleId = ignoredApp.bundleId + $0.pendingProgrammaticSwitch = nil + } + + await lookupGate.resumeFirst() + await store.finish() + + let switchedInputMethods = await recorder.values + XCTAssertTrue(switchedInputMethods.isEmpty) + XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) + } + + func testConsecutiveActivationsOnlyCompleteLatestProgrammaticSwitch() async { + let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") + let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") + let firstInputMethod = "ime.first" + let secondInputMethod = "ime.second" + let lookupGate = InputMethodLookupGate( + firstValue: "ime.other", + subsequentValue: "ime.other" + ) + let recorder = SwitchRecorder() + + var initialState = AppFeature.State() + initialState.inputMethods = [ + InputMethod(id: firstInputMethod, name: "First"), + InputMethod(id: secondInputMethod, name: "Second"), + ] + initialState.$appRulesStore.withLock { + $0.rules[firstApp.bundleId] = AppRuleRecord( + bundleId: firstApp.bundleId, + lastKnownPath: firstApp.path, + lastKnownName: firstApp.name, + strategy: .fixed(inputMethodId: firstInputMethod), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + $0.rules[secondApp.bundleId] = AppRuleRecord( + bundleId: secondApp.bundleId, + lastKnownPath: secondApp.path, + lastKnownName: secondApp.name, + strategy: .fixed(inputMethodId: secondInputMethod), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.inputMethodClient.currentInputMethodId = { + await lookupGate.value() + } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + } + + await store.send(.system(.workspaceEvent(.activated(firstApp)))) { + $0.currentFrontmostBundleId = firstApp.bundleId + $0.pendingProgrammaticSwitch = .init(bundleId: firstApp.bundleId, inputMethodId: firstInputMethod) + } + await lookupGate.waitForFirstCall() + + await store.send(.system(.workspaceEvent(.activated(secondApp)))) { + $0.currentFrontmostBundleId = secondApp.bundleId + $0.pendingProgrammaticSwitch = .init(bundleId: secondApp.bundleId, inputMethodId: secondInputMethod) + } + await store.receive(.response(.programmaticSwitchFinished( + bundleId: secondApp.bundleId, + inputMethodId: secondInputMethod, + didSwitch: true + ))) { + $0.pendingProgrammaticSwitch = nil + $0.$appSwitchStatisticsStore.withLock { + $0.counts[secondApp.bundleId] = 1 + } + } + + await lookupGate.resumeFirst() + await store.finish() + + let switchedInputMethods = await recorder.values + XCTAssertEqual(switchedInputMethods, [secondInputMethod]) + XCTAssertNil(store.state.appSwitchStatisticsStore.counts[firstApp.bundleId]) + XCTAssertEqual(store.state.appSwitchStatisticsStore.counts[secondApp.bundleId], 1) + } + + func testTerminatingCurrentAppCancelsSwitchAfterSelectionNotificationClearsPending() async { + let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") + let targetInputMethod = "ime.en" + let switchGate = InputMethodSwitchGate() + let recorder = SwitchRecorder() + + var initialState = AppFeature.State() + initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] + initialState.runningApps = [app] + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .fixed(inputMethodId: targetInputMethod), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + await switchGate.wait() + } + store.dependencies.workspaceClient.runningApplications = { [] } + + await store.send(.system(.workspaceEvent(.activated(app)))) { + $0.currentFrontmostBundleId = app.bundleId + $0.pendingProgrammaticSwitch = .init(bundleId: app.bundleId, inputMethodId: targetInputMethod) + } + await switchGate.waitUntilStarted() + + await store.send(.system(.inputMethodSelectedChanged(targetInputMethod))) { + $0.pendingProgrammaticSwitch = nil + } + await store.send(.system(.workspaceEvent(.terminated(bundleId: app.bundleId)))) { + $0.currentFrontmostBundleId = nil + } + await store.receive(.response(.runningApps([]))) { + $0.runningApps = [] + } + + await switchGate.resume() + await store.finish() + + let switchedInputMethods = await recorder.values + XCTAssertEqual(switchedInputMethods, [targetInputMethod]) + XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) + } + func testActivatedAppUsesFallbackRuleWhenAppRuleIsMissing() async { let now = Date(timeIntervalSince1970: 10) let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") @@ -1646,7 +1890,10 @@ final class AppFeatureTests: XCTestCase { await migrationTracker.record(.markCompleted(version)) } - await store.send(.response(.legacyRulesLoaded([legacyRule.bundleId: legacyRule]))) { + await store.send(.response(.legacyRulesLoaded( + [legacyRule.bundleId: legacyRule], + didCompleteLegacyMigration: false + ))) { $0.$appRulesStore.withLock { $0.rules[legacyRule.bundleId] = expectedRule } @@ -1679,7 +1926,10 @@ final class AppFeatureTests: XCTestCase { await migrationTracker.record(.markCompleted(version)) } - await store.send(.response(.legacyRulesLoaded([legacyRule.bundleId: legacyRule]))) { + await store.send(.response(.legacyRulesLoaded( + [legacyRule.bundleId: legacyRule], + didCompleteLegacyMigration: false + ))) { $0.$appRulesStore.withLock { $0.rules[legacyRule.bundleId] = legacyRule } @@ -1715,7 +1965,10 @@ final class AppFeatureTests: XCTestCase { await migrationTracker.record(.markCompleted(version)) } - await store.send(.response(.legacyRulesLoaded([:]))) + await store.send(.response(.legacyRulesLoaded( + [:], + didCompleteLegacyMigration: true + ))) await store.finish() let events = await migrationTracker.events @@ -1762,6 +2015,7 @@ final class AppFeatureTests: XCTestCase { store.dependencies.legacyDefaultsMigrationClient.completedVersion = { await migrationTracker.completedVersion } + store.dependencies.legacyDefaultsMigrationClient.didCompleteLegacyMigration = { true } store.dependencies.legacyDefaultsMigrationClient.loadRules = { receivedDate in await migrationTracker.record(.loadRules(receivedDate)) return [legacyRule.bundleId: legacyRule] @@ -1781,7 +2035,10 @@ final class AppFeatureTests: XCTestCase { store.dependencies.inputMethodClient.selectionChanges = finishedStream await store.send(.task) - await store.receive(.response(.legacyRulesLoaded([legacyRule.bundleId: legacyRule]))) { + await store.receive(.response(.legacyRulesLoaded( + [legacyRule.bundleId: legacyRule], + didCompleteLegacyMigration: true + ))) { $0.$appRulesStore.withLock { $0.rules[legacyRule.bundleId] = legacyRule } @@ -1842,6 +2099,71 @@ private actor SwitchRecorder { } } +private actor InputMethodLookupGate { + private let firstValue: String + private let subsequentValue: String + private var callCount = 0 + private var firstContinuation: CheckedContinuation? + private var firstStartedContinuation: CheckedContinuation? + private var hasStartedFirstCall = false + + init(firstValue: String, subsequentValue: String = "") { + self.firstValue = firstValue + self.subsequentValue = subsequentValue + } + + func value() async -> String { + callCount += 1 + guard callCount == 1 else { return subsequentValue } + + return await withCheckedContinuation { continuation in + firstContinuation = continuation + hasStartedFirstCall = true + firstStartedContinuation?.resume() + firstStartedContinuation = nil + } + } + + func waitForFirstCall() async { + guard !hasStartedFirstCall else { return } + await withCheckedContinuation { continuation in + firstStartedContinuation = continuation + } + } + + func resumeFirst() { + firstContinuation?.resume(returning: firstValue) + firstContinuation = nil + } +} + +private actor InputMethodSwitchGate { + private var continuation: CheckedContinuation? + private var startedContinuation: CheckedContinuation? + private var hasStarted = false + + func wait() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + hasStarted = true + startedContinuation?.resume() + startedContinuation = nil + } + } + + func waitUntilStarted() async { + guard !hasStarted else { return } + await withCheckedContinuation { continuation in + startedContinuation = continuation + } + } + + func resume() { + continuation?.resume() + continuation = nil + } +} + private enum TestError: Error { case failed } diff --git a/TypeSwitchTests/AppRulesStoreMigrationTests.swift b/TypeSwitchTests/AppRulesStoreMigrationTests.swift index 04d1c0f..f15eac9 100644 --- a/TypeSwitchTests/AppRulesStoreMigrationTests.swift +++ b/TypeSwitchTests/AppRulesStoreMigrationTests.swift @@ -153,43 +153,124 @@ final class AppRulesStoreMigrationTests: XCTestCase { )) } - func testMergeAddsMissingLegacyRulesWithoutOverwritingExplicitStrategies() { + func testMergeKeepsCurrentRulesWhenLegacyMigrationPreviouslyCompleted() { let currentDate = Date(timeIntervalSince1970: 100) let legacyDate = Date(timeIntervalSince1970: 200) let currentRules = [ - "fixed": makeRule(bundleId: "fixed", strategy: .fixed(inputMethodId: "current.fixed"), date: currentDate), - "followLast": makeRule( - bundleId: "followLast", - strategy: .followLast(lastInputMethodId: "current.last"), + "none": makeRule(bundleId: "none", strategy: .none, date: currentDate), + "fixed": makeRule( + bundleId: "fixed", + strategy: .fixed(inputMethodId: "current.fixed"), date: currentDate ), - "ignored": makeRule(bundleId: "ignored", strategy: .ignored, date: currentDate), ] let legacyRules = [ "missing": makeRule(bundleId: "missing", strategy: .fixed(inputMethodId: "legacy.missing"), date: legacyDate), - "fixed": makeRule(bundleId: "fixed", strategy: .fixed(inputMethodId: "legacy.fixed"), date: legacyDate), - "followLast": makeRule(bundleId: "followLast", strategy: .fixed(inputMethodId: "legacy.last"), date: legacyDate), - "ignored": makeRule(bundleId: "ignored", strategy: .fixed(inputMethodId: "legacy.ignored"), date: legacyDate), + "none": makeRule(bundleId: "none", strategy: .fixed(inputMethodId: "legacy.none"), date: legacyDate), ] let mergedRules = AppRulesStoreMigration.merge( currentRules: currentRules, - legacyRules: legacyRules + legacyRules: legacyRules, + didCompleteLegacyMigration: true + ) + + XCTAssertEqual(mergedRules, currentRules) + XCTAssertNil(mergedRules["missing"]) + } + + func testMergeRecoversMissingAndUntouchedPlaceholderWhenLegacyMigrationWasIncomplete() { + let currentDate = Date(timeIntervalSince1970: 100) + let changedDate = Date(timeIntervalSince1970: 150) + let legacyDate = Date(timeIntervalSince1970: 200) + let placeholder = makeRule(bundleId: "placeholder", strategy: .none, date: currentDate) + var explicitDefault = makeRule(bundleId: "explicit", strategy: .none, date: currentDate) + explicitDefault.updatedAt = changedDate + let currentRules = [ + placeholder.bundleId: placeholder, + explicitDefault.bundleId: explicitDefault, + ] + let legacyRules = [ + "missing": makeRule(bundleId: "missing", strategy: .fixed(inputMethodId: "legacy.missing"), date: legacyDate), + "placeholder": makeRule(bundleId: "placeholder", strategy: .fixed(inputMethodId: "legacy.placeholder"), date: legacyDate), + "explicit": makeRule(bundleId: "explicit", strategy: .fixed(inputMethodId: "legacy.explicit"), date: legacyDate), + ] + + let mergedRules = AppRulesStoreMigration.merge( + currentRules: currentRules, + legacyRules: legacyRules, + didCompleteLegacyMigration: false ) XCTAssertEqual(mergedRules["missing"], legacyRules["missing"]) - XCTAssertEqual(mergedRules["fixed"], currentRules["fixed"]) - XCTAssertEqual(mergedRules["followLast"], currentRules["followLast"]) - XCTAssertEqual(mergedRules["ignored"], currentRules["ignored"]) + XCTAssertEqual(mergedRules["placeholder"]?.strategy, .fixed(inputMethodId: "legacy.placeholder")) + XCTAssertEqual(mergedRules["placeholder"]?.createdAt, currentDate) + XCTAssertEqual(mergedRules["placeholder"]?.updatedAt, legacyDate) + XCTAssertEqual(mergedRules["explicit"], explicitDefault) + } + + func testMergeRecoversEmptyStoreEvenWhenLegacyMigrationPreviouslyCompleted() { + let legacyRule = makeRule( + bundleId: "missing", + strategy: .fixed(inputMethodId: "legacy.missing"), + date: Date(timeIntervalSince1970: 200) + ) + + let mergedRules = AppRulesStoreMigration.merge( + currentRules: [:], + legacyRules: [legacyRule.bundleId: legacyRule], + didCompleteLegacyMigration: true + ) + + XCTAssertEqual(mergedRules, [legacyRule.bundleId: legacyRule]) + } + + func testMergePreservesCurrentMetadataWhenRecoveringPlaceholder() { + let currentDate = Date(timeIntervalSince1970: 100) + let legacyDate = Date(timeIntervalSince1970: 200) + let currentRule = AppRuleRecord( + bundleId: "com.test.external", + lastKnownPath: "/Volumes/External/External.app", + lastKnownName: "External", + strategy: .none, + createdAt: currentDate, + updatedAt: currentDate + ) + let legacyRule = AppRuleRecord( + bundleId: currentRule.bundleId, + lastKnownPath: nil, + lastKnownName: currentRule.bundleId, + strategy: .fixed(inputMethodId: "legacy.external"), + createdAt: legacyDate, + updatedAt: legacyDate + ) + + let mergedRules = AppRulesStoreMigration.merge( + currentRules: [currentRule.bundleId: currentRule], + legacyRules: [legacyRule.bundleId: legacyRule], + didCompleteLegacyMigration: false + ) + + XCTAssertEqual( + mergedRules[currentRule.bundleId], + AppRuleRecord( + bundleId: currentRule.bundleId, + lastKnownPath: currentRule.lastKnownPath, + lastKnownName: currentRule.lastKnownName, + strategy: legacyRule.strategy, + createdAt: currentDate, + updatedAt: legacyDate + ) + ) } - func testMergeRestoresNoneRuleUsingMatchedLegacyMetadata() { + func testMergeRecoversMissingMetadataFromMatchedLegacyRule() { let currentDate = Date(timeIntervalSince1970: 100) let legacyDate = Date(timeIntervalSince1970: 200) let currentRule = AppRuleRecord( bundleId: "com.test.editor", - lastKnownPath: "/Applications/Old Editor.app", - lastKnownName: "Old Editor", + lastKnownPath: nil, + lastKnownName: "com.test.editor", strategy: .none, createdAt: currentDate, updatedAt: currentDate @@ -205,7 +286,8 @@ final class AppRulesStoreMigrationTests: XCTestCase { let mergedRules = AppRulesStoreMigration.merge( currentRules: [currentRule.bundleId: currentRule], - legacyRules: [legacyRule.bundleId: legacyRule] + legacyRules: [legacyRule.bundleId: legacyRule], + didCompleteLegacyMigration: false ) XCTAssertEqual( @@ -248,7 +330,8 @@ final class AppRulesStoreMigrationTests: XCTestCase { let mergedRules = AppRulesStoreMigration.merge( currentRules: [currentRule.bundleId: currentRule], - legacyRules: [legacyRule.bundleId: legacyRule] + legacyRules: [legacyRule.bundleId: legacyRule], + didCompleteLegacyMigration: false ) let mergedRule = try XCTUnwrap(mergedRules[currentRule.bundleId]) @@ -280,11 +363,13 @@ final class AppRulesStoreMigrationTests: XCTestCase { let firstMerge = AppRulesStoreMigration.merge( currentRules: currentRules, - legacyRules: legacyRules + legacyRules: legacyRules, + didCompleteLegacyMigration: false ) let secondMerge = AppRulesStoreMigration.merge( currentRules: firstMerge, - legacyRules: legacyRules + legacyRules: legacyRules, + didCompleteLegacyMigration: false ) XCTAssertEqual(secondMerge, firstMerge) diff --git a/TypeSwitchTests/LegacyDefaultsMigrationTests.swift b/TypeSwitchTests/LegacyDefaultsMigrationTests.swift index 4bcde07..d07eb84 100644 --- a/TypeSwitchTests/LegacyDefaultsMigrationTests.swift +++ b/TypeSwitchTests/LegacyDefaultsMigrationTests.swift @@ -8,9 +8,10 @@ final class LegacyDefaultsMigrationTests: XCTestCase { let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) defer { defaults.removePersistentDomain(forName: suiteName) } - defaults.set(true, forKey: "didMigrateLegacyAppRules") + defaults.set(true, forKey: LegacyDefaultsMigration.legacyCompletionKey) XCTAssertEqual(LegacyDefaultsMigration.completedVersion(in: defaults), 0) + XCTAssertTrue(LegacyDefaultsMigration.didCompleteLegacyMigration(in: defaults)) } func testMakeRulesPreservesMatchedAndUnavailableApplications() {