Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified Documentation/Screenshots/en-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Documentation/Screenshots/en-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Documentation/Screenshots/raw/en-dark-main.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Documentation/Screenshots/raw/en-dark-strategy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Documentation/Screenshots/raw/en-light-main.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Documentation/Screenshots/raw/en-light-strategy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Documentation/Screenshots/raw/zh-Hans-dark-main.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Documentation/Screenshots/raw/zh-Hans-dark-strategy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Documentation/Screenshots/raw/zh-Hans-light-main.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Documentation/Screenshots/raw/zh-Hans-light-strategy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Documentation/Screenshots/zh-Hans-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Documentation/Screenshots/zh-Hans-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 35 additions & 8 deletions TypeSwitch/Sources/App/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
Expand All @@ -131,6 +134,7 @@ struct AppFeature {
private enum CancelID {
case inputMethodAvailability
case inputMethodSelection
case programmaticSwitch
case workspaceEvents
}

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down Expand Up @@ -389,18 +398,28 @@ 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
upsertRecord(for: appInfo, in: &state)

guard let inputMethodId = targetInputMethodId(for: appInfo.bundleId, state: state) else {
state.pendingProgrammaticSwitch = nil
return .none
return .cancel(id: CancelID.programmaticSwitch)
}

state.pendingProgrammaticSwitch = .init(
Expand All @@ -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)
}
}
}
Expand All @@ -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
)))
}
}

Expand Down
36 changes: 26 additions & 10 deletions TypeSwitch/Sources/Migrations/AppRulesStoreMigration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +10 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recover missing legacy rules despite old completion flag

When a user already has the old didMigrateLegacyAppRules flag set and their current app-rules store contains any rule, this early return discards every loaded legacy rule. That misses the version-2 recovery path for legacy mappings that the original migration could mark complete without actually writing (for example unavailable apps that were previously dropped), and the caller then marks version 2 complete because mergedRules == state.appRules, making those rules unrecoverable. The merge should still add absent legacy entries while avoiding overwrites of existing rules.

Useful? React with 👍 / 👎.

}

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
}
}
}
Expand Down
10 changes: 10 additions & 0 deletions TypeSwitch/Sources/Migrations/LegacyDefaultsMigration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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
}
Expand All @@ -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")?
Expand All @@ -64,6 +73,7 @@ extension LegacyDefaultsMigrationClient: DependencyKey {

static let testValue = Self(
completedVersion: { LegacyDefaultsMigration.currentVersion },
didCompleteLegacyMigration: { false },
loadRules: { _ in [:] },
markCompleted: { _ in }
)
Expand Down
Loading
Loading