diff --git a/Documentation/Screenshots/en-dark.png b/Documentation/Screenshots/en-dark.png index 6ad2f94..46cbfa6 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 94c50cb..4c024b2 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 9767179..a1d8a8b 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 85a3326..c15d1c0 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 f9c8730..fc10967 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 a282a0e..eb4a559 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 4741f3e..54edff4 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 8b61cad..692d7e8 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 0e1a338..f8ae454 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 50db22d..2c0c834 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 1e78672..fbd5af4 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 4a60678..195f9bd 100644 Binary files a/Documentation/Screenshots/zh-Hans-light.png and b/Documentation/Screenshots/zh-Hans-light.png differ diff --git a/README.md b/README.md index 26a8be0..7eb8c8a 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,8 @@ ## ✨ Highlights -- **Switch automatically for every app.** TypeSwitch watches the frontmost app and applies its saved input method rule as you move between apps. -- **Choose the behavior that fits.** Use `Don't Switch`, `Last Switch`, or a `Specific Input Method`, with a separate default rule for apps you have not configured yet. +- **Switch automatically by app.** TypeSwitch watches the frontmost app and applies its saved input method rule as you move between apps, except for apps you choose to ignore. +- **Choose the behavior that fits.** Let an app inherit the default rule, remember its `Last Switch`, select a `Specific Input Method`, or ignore apps you do not want TypeSwitch to manage. - **Configure apps where you find them.** Set a rule for the current app, review running apps by configuration status, or manage every saved rule without leaving the menu bar. - **Keep rules and results tidy.** Find rules for missing apps, remove stale settings, review successful switch counts, and clear statistics when needed. - **Fit TypeSwitch into your workflow.** Launch it at login, check for updates through Sparkle, open the GitHub repository, or press `Command + Q` to quit. @@ -97,9 +97,10 @@ brew upgrade typeswitch 2. Open the menu and use `Current App` to configure the frontmost app. 3. Use `Running · Unconfigured` to assign rules to running apps that do not have one yet. 4. Use `Running · Configured` and `All Configured Apps` to review and change existing app rules. -5. For each app, choose `Default`, `Last Switch`, `Don't Switch`, or a specific input method. -6. Use `Default Rule for Unconfigured Apps` to set the fallback behavior for apps without their own rule. -7. Check `Missing Apps` and `Switches` when you want to clean missing rules or review successful switches. +5. For each app, choose `Default` to inherit the fallback, `Last Switch`, or a specific input method. +6. Choose `Ignore This App` to stop automatic switching and hide the app from the regular lists. Restore one or all apps from `Ignored Apps`. +7. Use `Default Rule for Unconfigured Apps` to set the fallback behavior for apps without their own rule. +8. Check `Missing Apps` and `Switches` when you want to clean missing rules or review successful switches. ## 🔒 Privacy and Permissions diff --git a/README.zh-CN.md b/README.zh-CN.md index 7cae302..cb8fcfc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -29,8 +29,8 @@ ## ✨ 功能亮点 -- **为每个 App 自动切换。** TypeSwitch 会监听当前前台 App,在你切换 App 时自动应用已保存的输入法规则。 -- **选择适合自己的切换方式。** 可使用“不自动切换”、“记住上次切换”或“指定输入法”,并为尚未单独配置的 App 设置独立的默认规则。 +- **按 App 自动切换。** TypeSwitch 会监听当前前台 App,在你切换 App 时自动应用已保存的输入法规则,已忽略的 App 除外。 +- **选择适合自己的切换方式。** 可让 App 继承默认规则、记住上次切换、指定输入法,或忽略不希望 TypeSwitch 管理的 App。 - **随时配置遇到的 App。** 可直接设置当前 App,从按配置状态分组的运行中 App 里选择,或在菜单栏中管理全部已保存规则。 - **保持规则与统计整洁。** 查找不存在 App 的规则、移除失效设置、查看成功切换次数,并按需清零统计。 - **融入日常工作流程。** 支持登录时打开、通过 Sparkle 检查更新、访问 GitHub 仓库,以及使用 `Command + Q` 退出。 @@ -97,9 +97,10 @@ brew upgrade typeswitch 2. 打开菜单,用“当前应用”配置当前前台 App。 3. 用“运行中 · 未配置”为还没有规则的运行中 App 设置规则。 4. 用“运行中 · 已配置”和“全部已配置 App”查看并修改已有 App 规则。 -5. 为每个 App 选择“默认”、“记住上次切换”、“不自动切换”或指定输入法。 -6. 用“未配置 App 的默认规则”设置没有单独规则的 App 的默认行为。 -7. 按需查看“找不到的 App”和“切换统计”,清理缺失规则或查看成功切换次数。 +5. 为每个 App 选择“默认”以继承兜底规则,或选择“记住上次切换”及指定输入法。 +6. 选择“忽略此 App”可停止自动切换并将其从普通列表隐藏;可在“已忽略 App”中单项恢复或恢复全部。 +7. 用“未配置 App 的默认规则”设置没有单独规则的 App 的默认行为。 +8. 按需查看“找不到的 App”和“切换统计”,清理缺失规则或查看成功切换次数。 ## 🔒 隐私与权限 diff --git a/TypeSwitch/Resources/Base.lproj/Localizable.strings b/TypeSwitch/Resources/Base.lproj/Localizable.strings index cb8cc1a..99a1088 100644 --- a/TypeSwitch/Resources/Base.lproj/Localizable.strings +++ b/TypeSwitch/Resources/Base.lproj/Localizable.strings @@ -19,6 +19,10 @@ "apps.section.unconfigured_count" = "运行中 · 未配置(%d)"; "apps.section.configured_count" = "全部已配置 App(%d)"; "apps.section.unavailable_count" = "找不到的 App(%d)"; +"apps.ignore" = "忽略此 App"; +"apps.ignored.menu_title" = "已忽略 App(%d)"; +"apps.ignored.restore_hint" = "选择 App 以恢复"; +"apps.ignored.restore_all" = "恢复全部"; "apps.clear_unavailable" = "清理找不到的 App"; "apps.clear_unavailable_confirmation.title" = "清理找不到的 App?"; "apps.clear_unavailable_confirmation.message" = "会移除不存在 App 的设置。"; diff --git a/TypeSwitch/Resources/en.lproj/Localizable.strings b/TypeSwitch/Resources/en.lproj/Localizable.strings index df1e726..15cf6d8 100644 --- a/TypeSwitch/Resources/en.lproj/Localizable.strings +++ b/TypeSwitch/Resources/en.lproj/Localizable.strings @@ -19,6 +19,10 @@ "apps.section.unconfigured_count" = "Running · Unconfigured (%d)"; "apps.section.configured_count" = "All Configured Apps (%d)"; "apps.section.unavailable_count" = "Missing Apps (%d)"; +"apps.ignore" = "Ignore This App"; +"apps.ignored.menu_title" = "Ignored Apps (%d)"; +"apps.ignored.restore_hint" = "Select an app to restore"; +"apps.ignored.restore_all" = "Restore All"; "apps.clear_unavailable" = "Clear Missing Apps"; "apps.clear_unavailable_confirmation.title" = "Clear missing apps?"; "apps.clear_unavailable_confirmation.message" = "This removes settings for apps that no longer exist."; diff --git a/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings b/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings index cb8cc1a..99a1088 100644 --- a/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings +++ b/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings @@ -19,6 +19,10 @@ "apps.section.unconfigured_count" = "运行中 · 未配置(%d)"; "apps.section.configured_count" = "全部已配置 App(%d)"; "apps.section.unavailable_count" = "找不到的 App(%d)"; +"apps.ignore" = "忽略此 App"; +"apps.ignored.menu_title" = "已忽略 App(%d)"; +"apps.ignored.restore_hint" = "选择 App 以恢复"; +"apps.ignored.restore_all" = "恢复全部"; "apps.clear_unavailable" = "清理找不到的 App"; "apps.clear_unavailable_confirmation.title" = "清理找不到的 App?"; "apps.clear_unavailable_confirmation.message" = "会移除不存在 App 的设置。"; diff --git a/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings b/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings index 46fe649..653ffb1 100644 --- a/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings +++ b/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings @@ -19,6 +19,10 @@ "apps.section.unconfigured_count" = "執行中 · 未設定(%d)"; "apps.section.configured_count" = "全部已設定 App(%d)"; "apps.section.unavailable_count" = "找不到的 App(%d)"; +"apps.ignore" = "忽略此 App"; +"apps.ignored.menu_title" = "已忽略 App(%d)"; +"apps.ignored.restore_hint" = "選擇 App 以恢復"; +"apps.ignored.restore_all" = "恢復全部"; "apps.clear_unavailable" = "清理找不到的 App"; "apps.clear_unavailable_confirmation.title" = "清理找不到的 App?"; "apps.clear_unavailable_confirmation.message" = "會移除不存在 App 的設定。"; diff --git a/TypeSwitch/Sources/App/AppFeature+MenuState.swift b/TypeSwitch/Sources/App/AppFeature+MenuState.swift index 30db2aa..7e40d15 100644 --- a/TypeSwitch/Sources/App/AppFeature+MenuState.swift +++ b/TypeSwitch/Sources/App/AppFeature+MenuState.swift @@ -24,10 +24,12 @@ extension AppFeature.State { } var fallbackStrategy: InputMethodStrategy { - if case .followLast = fallbackRuleStore.strategy { + switch fallbackRuleStore.strategy { + case .followLast, .ignored: return .none + case .none, .fixed: + return fallbackRuleStore.strategy } - return fallbackRuleStore.strategy } var fallbackSelectedLabel: String? { @@ -42,14 +44,15 @@ extension AppFeature.State { } var totalSuccessfulSwitchCount: Int { - appSwitchStatisticsStore.counts.values - .filter { $0 > 0 } + appSwitchStatisticsStore.counts + .filter { !ignoredAppBundleIdsForMenu.contains($0.key) && $0.value > 0 } + .values .reduce(0, +) } var switchStatisticsItems: [SwitchStatisticsItem] { appSwitchStatisticsStore.counts.compactMap { bundleId, count in - guard count > 0 else { return nil } + guard count > 0, !ignoredAppBundleIdsForMenu.contains(bundleId) else { return nil } let appInfo = appInfo(for: bundleId) return SwitchStatisticsItem( bundleId: bundleId, @@ -74,12 +77,16 @@ extension AppFeature.State { var configuredApps: [AppMenuItem] { sortedRules - .filter { $0.strategy != .none && $0.isAvailable } - .map(menuItem(from:)) + .filter { + let strategy = strategyForMenu(bundleId: $0.bundleId) + return strategy != .none && strategy != .ignored && $0.isAvailable + } + .map { menuItem(from: $0, strategy: strategyForMenu(bundleId: $0.bundleId)) } } var currentAppMenuItem: AppMenuItem? { guard let currentFrontmostBundleId, + strategyForMenu(bundleId: currentFrontmostBundleId) != .ignored, let appInfo = knownAppInfo(for: currentFrontmostBundleId) else { return nil @@ -89,12 +96,12 @@ extension AppFeature.State { bundleId: appInfo.bundleId, name: appInfo.name, path: appInfo.path, - strategy: appRules[appInfo.bundleId]?.strategy ?? .none + strategy: strategyForMenu(bundleId: appInfo.bundleId) ) } var runningConfiguredMenuItems: [AppMenuItem] { - runningMenuItems { $0 != .none } + runningMenuItems { $0 != .none && $0 != .ignored } } var runningUnconfiguredMenuItems: [AppMenuItem] { @@ -103,19 +110,30 @@ extension AppFeature.State { var unavailableApps: [AppMenuItem] { sortedRules - .filter { !$0.isAvailable } + .filter { + !$0.isAvailable && strategyForMenu(bundleId: $0.bundleId) != .ignored + } .map { menuItem( bundleId: $0.bundleId, name: $0.lastKnownName, path: nil, - strategy: $0.strategy + strategy: strategyForMenu(bundleId: $0.bundleId) ) } } + var ignoredAppsForMenu: [AppInfo] { + sortedRules.compactMap { rule in + guard ignoredAppBundleIdsForMenu.contains(rule.bundleId) else { return nil } + return appInfo(for: rule.bundleId) + } + } + var hasMissingInputMethodRules: Bool { - appRules.values.contains { hasMissingInputMethod(in: $0.strategy) } + appRules.keys.contains { + hasMissingInputMethod(in: strategyForMenu(bundleId: $0)) + } } func strategy(for bundleId: String) -> InputMethodStrategy { @@ -124,7 +142,7 @@ extension AppFeature.State { func hasMissingInputMethod(in strategy: InputMethodStrategy) -> Bool { switch strategy { - case .none: + case .ignored, .none: return false case .fixed(let inputMethodId): return inputMethodName(for: inputMethodId) == nil @@ -140,12 +158,15 @@ extension AppFeature.State { } } - private func menuItem(from rule: AppRuleRecord) -> AppMenuItem { + private func menuItem( + from rule: AppRuleRecord, + strategy: InputMethodStrategy + ) -> AppMenuItem { menuItem( bundleId: rule.bundleId, name: rule.lastKnownName, path: rule.isAvailable ? rule.lastKnownPath : nil, - strategy: rule.strategy + strategy: strategy ) } @@ -157,7 +178,7 @@ extension AppFeature.State { return nil } - let strategy = appRules[app.bundleId]?.strategy ?? .none + let strategy = strategyForMenu(bundleId: app.bundleId) guard isIncluded(strategy) else { return nil } @@ -211,7 +232,7 @@ extension AppFeature.State { private var appDefaultOptionLabel: String { switch fallbackStrategy { - case .none: + case .ignored, .none: return TypeSwitchStrings.InputMethod.appDefaultFallbackNoneOption case .fixed(let inputMethodId): guard let inputMethodName = inputMethodName(for: inputMethodId) else { @@ -227,6 +248,8 @@ extension AppFeature.State { switch strategy { case .none: return nil + case .ignored: + return nil case .fixed(let inputMethodId): return inputMethodName(for: inputMethodId) ?? TypeSwitchStrings.InputMethod.deletedOption case .followLast(let lastInputMethodId): @@ -259,4 +282,17 @@ extension AppFeature.State { private func inputMethodName(for inputMethodId: String) -> String? { inputMethods.first(where: { $0.id == inputMethodId })?.name } + + private var ignoredAppBundleIdsForMenu: Set { + Set(appRules.keys.filter { + strategyForMenu(bundleId: $0) == .ignored + }) + } + + private func strategyForMenu(bundleId: String) -> InputMethodStrategy { + if isMenuPresented { + return menuStrategiesAtPresentation[bundleId] ?? .none + } + return strategy(for: bundleId) + } } diff --git a/TypeSwitch/Sources/App/AppFeature.swift b/TypeSwitch/Sources/App/AppFeature.swift index 9a604dd..ef3f2c7 100644 --- a/TypeSwitch/Sources/App/AppFeature.swift +++ b/TypeSwitch/Sources/App/AppFeature.swift @@ -24,6 +24,10 @@ struct AppFeature { let hasMissingInputMethod: Bool var id: String { bundleId } + + var appInfo: AppInfo { + AppInfo(bundleId: bundleId, name: name, path: path) + } } struct PendingProgrammaticSwitch: Equatable { @@ -45,8 +49,10 @@ struct AppFeature { @Shared var fallbackRuleStore: FallbackRuleStore var currentFrontmostBundleId: String? var inputMethods: [InputMethod] = [] + var isMenuPresented = false var isReadmeDemo = false var launchAtLoginStatus: LaunchAtLoginStatus = .disabled + var menuStrategiesAtPresentation: [String: InputMethodStrategy] = [:] var pendingProgrammaticSwitch: PendingProgrammaticSwitch? var runningApps: [AppInfo] = [] @@ -65,8 +71,10 @@ struct AppFeature { ), currentFrontmostBundleId: String? = nil, inputMethods: [InputMethod] = [], + isMenuPresented: Bool = false, isReadmeDemo: Bool = false, launchAtLoginStatus: LaunchAtLoginStatus = .disabled, + menuStrategiesAtPresentation: [String: InputMethodStrategy] = [:], pendingProgrammaticSwitch: PendingProgrammaticSwitch? = nil, runningApps: [AppInfo] = [] ) { @@ -75,8 +83,10 @@ struct AppFeature { self._fallbackRuleStore = fallbackRuleStore self.currentFrontmostBundleId = currentFrontmostBundleId self.inputMethods = inputMethods + self.isMenuPresented = isMenuPresented self.isReadmeDemo = isReadmeDemo self.launchAtLoginStatus = launchAtLoginStatus + self.menuStrategiesAtPresentation = menuStrategiesAtPresentation self.pendingProgrammaticSwitch = pendingProgrammaticSwitch self.runningApps = runningApps } @@ -84,8 +94,11 @@ struct AppFeature { enum ViewAction: Equatable, Sendable { case clearSwitchStatisticsTapped + case ignoreAppTapped(AppInfo) case removeMissingInputMethodRulesTapped case removeUnavailableRulesTapped + case restoreAllIgnoredAppsTapped + case restoreIgnoredAppTapped(bundleId: String) case setFallbackStrategy(InputMethodStrategy) case setLaunchAtLogin(Bool) case setStrategy(bundleId: String, strategy: InputMethodStrategy) @@ -108,6 +121,8 @@ struct AppFeature { } enum Action: Equatable, Sendable { + case menuDismissed + case menuPresented case task case view(ViewAction) case response(ResponseAction) @@ -123,6 +138,17 @@ struct AppFeature { var body: some ReducerOf { Reduce { state, action in switch action { + case .menuPresented: + guard !state.isMenuPresented else { return .none } + state.isMenuPresented = true + state.menuStrategiesAtPresentation = state.appRules.mapValues(\.strategy) + return .none + + case .menuDismissed: + state.isMenuPresented = false + state.menuStrategiesAtPresentation = [:] + return .none + case .task: guard !state.isReadmeDemo else { return .none } normalizeFallbackRule(in: &state) @@ -240,6 +266,28 @@ struct AppFeature { } return .none + case .view(.ignoreAppTapped(let appInfo)): + let updateDate = now + state.$appRulesStore.withLock { store in + let currentRule = store.rules[appInfo.bundleId] ?? AppRuleRecord( + bundleId: appInfo.bundleId, + lastKnownPath: appInfo.path, + lastKnownName: appInfo.name, + strategy: .none, + createdAt: updateDate, + updatedAt: updateDate + ) + guard currentRule.strategy != .ignored else { return } + + var updatedRule = currentRule + updatedRule.lastKnownPath = appInfo.path ?? currentRule.lastKnownPath + updatedRule.lastKnownName = appInfo.name + updatedRule.strategy = .ignored + updatedRule.updatedAt = updateDate + store.rules[appInfo.bundleId] = updatedRule + } + return .none + case .view(.removeMissingInputMethodRulesTapped): let updateDate = now let missingBundleIds = state.appRules.values @@ -258,7 +306,35 @@ struct AppFeature { case .view(.removeUnavailableRulesTapped): state.$appRulesStore.withLock { store in - store.rules = store.rules.filter { $0.value.isAvailable } + store.rules = store.rules.filter { + $0.value.isAvailable || $0.value.strategy == .ignored + } + } + return .none + + case .view(.restoreAllIgnoredAppsTapped): + let updateDate = now + state.$appRulesStore.withLock { store in + for bundleId in Array(store.rules.keys) { + guard var rule = store.rules[bundleId], rule.strategy == .ignored else { + continue + } + rule.strategy = .none + rule.updatedAt = updateDate + store.rules[bundleId] = rule + } + } + return .none + + case .view(.restoreIgnoredAppTapped(let bundleId)): + let updateDate = now + state.$appRulesStore.withLock { store in + guard var rule = store.rules[bundleId], rule.strategy == .ignored else { + return + } + rule.strategy = .none + rule.updatedAt = updateDate + store.rules[bundleId] = rule } return .none @@ -389,7 +465,7 @@ struct AppFeature { let candidateId: String? switch strategy { - case .none: + case .ignored, .none: return nil case .fixed(let inputMethodId): candidateId = inputMethodId @@ -404,10 +480,12 @@ struct AppFeature { } private func fallbackSupportedStrategy(_ strategy: InputMethodStrategy) -> InputMethodStrategy { - if case .followLast = strategy { + switch strategy { + case .followLast, .ignored: return .none + case .none, .fixed: + return strategy } - return strategy } private func normalizeFallbackRule(in state: inout State) { diff --git a/TypeSwitch/Sources/App/ReadmeScreenshotConfiguration.swift b/TypeSwitch/Sources/App/ReadmeScreenshotConfiguration.swift index 220f330..aa04847 100644 --- a/TypeSwitch/Sources/App/ReadmeScreenshotConfiguration.swift +++ b/TypeSwitch/Sources/App/ReadmeScreenshotConfiguration.swift @@ -132,6 +132,11 @@ struct ReadmeScreenshotConfiguration: Equatable { name: "Terminal", path: "/System/Applications/Utilities/Terminal.app" ) + let messages = AppInfo( + bundleId: "com.apple.MobileSMS", + name: "Messages", + path: "/System/Applications/Messages.app" + ) let rules = [ safari.bundleId: AppRuleRecord( bundleId: safari.bundleId, @@ -149,6 +154,14 @@ struct ReadmeScreenshotConfiguration: Equatable { createdAt: now, updatedAt: now ), + messages.bundleId: AppRuleRecord( + bundleId: messages.bundleId, + lastKnownPath: messages.path, + lastKnownName: messages.name, + strategy: .ignored, + createdAt: now, + updatedAt: now + ), "com.example.LegacyEditor": AppRuleRecord( bundleId: "com.example.LegacyEditor", lastKnownPath: nil, diff --git a/TypeSwitch/Sources/App/TypeSwitchApp.swift b/TypeSwitch/Sources/App/TypeSwitchApp.swift index dfdb78e..623a082 100644 --- a/TypeSwitch/Sources/App/TypeSwitchApp.swift +++ b/TypeSwitch/Sources/App/TypeSwitchApp.swift @@ -43,25 +43,36 @@ struct TypeSwitchApp: App { self.store = store self.updateMonitor = updateMonitor self.updaterController = updaterController - if let readmeMenuAppearance { - self.menuTrackingObservers = [ - NotificationCenter.default.addObserver( - forName: NSMenu.didBeginTrackingNotification, - object: nil, - queue: .main - ) { notification in - MainActor.assumeIsolated { - guard let menu = notification.object as? NSMenu else { return } + self.menuTrackingObservers = [ + NotificationCenter.default.addObserver( + forName: NSMenu.didBeginTrackingNotification, + object: nil, + queue: .main + ) { notification in + MainActor.assumeIsolated { + if let menu = notification.object as? NSMenu, + let readmeMenuAppearance + { menu.appearance = readmeMenuAppearance for item in menu.items { item.submenu?.appearance = readmeMenuAppearance } } - }, - ] - } else { - self.menuTrackingObservers = [] - } + guard MenuBarView.isRootMenuTrackingNotification(notification) else { return } + store.send(.menuPresented) + } + }, + NotificationCenter.default.addObserver( + forName: NSMenu.didEndTrackingNotification, + object: nil, + queue: .main + ) { notification in + MainActor.assumeIsolated { + guard MenuBarView.isRootMenuTrackingNotification(notification) else { return } + store.send(.menuDismissed) + } + }, + ] if startsLiveServices { store.send(.task) } diff --git a/TypeSwitch/Sources/Core/Models/InputMethodStrategy.swift b/TypeSwitch/Sources/Core/Models/InputMethodStrategy.swift index fa098dc..9083c17 100644 --- a/TypeSwitch/Sources/Core/Models/InputMethodStrategy.swift +++ b/TypeSwitch/Sources/Core/Models/InputMethodStrategy.swift @@ -2,6 +2,7 @@ import Foundation enum InputMethodStrategy: Codable, Hashable, Sendable { case none + case ignored case fixed(inputMethodId: String) case followLast(lastInputMethodId: String?) } diff --git a/TypeSwitch/Sources/UI/Views/MenuBar/AppRowView.swift b/TypeSwitch/Sources/UI/Views/MenuBar/AppRowView.swift index 9072acb..ed4f90e 100644 --- a/TypeSwitch/Sources/UI/Views/MenuBar/AppRowView.swift +++ b/TypeSwitch/Sources/UI/Views/MenuBar/AppRowView.swift @@ -5,6 +5,7 @@ import SwiftUI struct AppRowView: View { let item: AppFeature.State.AppMenuItem let inputMethods: [InputMethod] + let onIgnore: () -> Void let onSelectStrategy: (InputMethodStrategy) -> Void var body: some View { @@ -17,6 +18,12 @@ struct AppRowView: View { followLastOptionLabel: item.followLastOptionLabel, onSelectStrategy: onSelectStrategy ) + + Divider() + + Button(action: onIgnore) { + Label(TypeSwitchStrings.Apps.ignore, systemImage: "eye.slash") + } } label: { if item.path != nil { AppInfo(bundleId: item.bundleId, name: item.name, path: item.path).icon diff --git a/TypeSwitch/Sources/UI/Views/MenuBar/ConfiguredAppsView.swift b/TypeSwitch/Sources/UI/Views/MenuBar/ConfiguredAppsView.swift index 85b7113..f1cad17 100644 --- a/TypeSwitch/Sources/UI/Views/MenuBar/ConfiguredAppsView.swift +++ b/TypeSwitch/Sources/UI/Views/MenuBar/ConfiguredAppsView.swift @@ -31,7 +31,10 @@ struct ConfiguredAppsView: View { ForEach(store.configuredApps) { item in AppRowView( item: item, - inputMethods: store.inputMethods + inputMethods: store.inputMethods, + onIgnore: { + store.send(.view(.ignoreAppTapped(item.appInfo))) + } ) { strategy in store.send(.view(.setStrategy(bundleId: item.bundleId, strategy: strategy))) } diff --git a/TypeSwitch/Sources/UI/Views/MenuBar/IgnoredAppsView.swift b/TypeSwitch/Sources/UI/Views/MenuBar/IgnoredAppsView.swift new file mode 100644 index 0000000..244db05 --- /dev/null +++ b/TypeSwitch/Sources/UI/Views/MenuBar/IgnoredAppsView.swift @@ -0,0 +1,46 @@ +import ComposableArchitecture +import SwiftUI + +struct IgnoredAppsView: View { + let store: StoreOf + + var body: some View { + if !store.ignoredAppsForMenu.isEmpty { + Section { + Menu { + Section { + ForEach(store.ignoredAppsForMenu) { app in + Button { + store.send(.view(.restoreIgnoredAppTapped(bundleId: app.bundleId))) + } label: { + Label { + Text(app.name) + } icon: { + app.icon + } + } + } + } header: { + Text(TypeSwitchStrings.Apps.Ignored.restoreHint) + } + + Divider() + + Button { + store.send(.view(.restoreAllIgnoredAppsTapped)) + } label: { + Label( + TypeSwitchStrings.Apps.Ignored.restoreAll, + systemImage: "arrow.uturn.backward" + ) + } + } label: { + Label( + TypeSwitchStrings.Apps.Ignored.menuTitle(store.ignoredAppsForMenu.count), + systemImage: "eye.slash" + ) + } + } + } + } +} diff --git a/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift b/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift index 65fc71c..5a0023c 100644 --- a/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift +++ b/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift @@ -15,6 +15,7 @@ struct MenuBarView: View { RunningAppsView(store: store) ConfiguredAppsView(store: store) UnavailableAppsView(store: store) + IgnoredAppsView(store: store) SwitchStatisticsView(store: store) Divider() @@ -30,6 +31,13 @@ struct MenuBarView: View { } .labelStyle(.titleAndIcon) } + + static func isRootMenuTrackingNotification(_ notification: Notification) -> Bool { + guard let menu = notification.object as? NSMenu else { + return false + } + return menu.supermenu == nil && menu !== NSApp.mainMenu + } } private struct CurrentAppView: View { @@ -40,7 +48,10 @@ private struct CurrentAppView: View { Section(TypeSwitchStrings.Apps.Section.currentApp) { AppRowView( item: item, - inputMethods: store.inputMethods + inputMethods: store.inputMethods, + onIgnore: { + store.send(.view(.ignoreAppTapped(item.appInfo))) + } ) { strategy in store.send(.view(.setStrategy(bundleId: item.bundleId, strategy: strategy))) } diff --git a/TypeSwitch/Sources/UI/Views/MenuBar/RunningAppsView.swift b/TypeSwitch/Sources/UI/Views/MenuBar/RunningAppsView.swift index 9a7ad2e..efc8bdc 100644 --- a/TypeSwitch/Sources/UI/Views/MenuBar/RunningAppsView.swift +++ b/TypeSwitch/Sources/UI/Views/MenuBar/RunningAppsView.swift @@ -12,7 +12,10 @@ struct RunningAppsView: View { ForEach(store.runningUnconfiguredMenuItems) { item in AppRowView( item: item, - inputMethods: store.inputMethods + inputMethods: store.inputMethods, + onIgnore: { + store.send(.view(.ignoreAppTapped(item.appInfo))) + } ) { strategy in store.send(.view(.setStrategy(bundleId: item.bundleId, strategy: strategy))) } @@ -25,7 +28,10 @@ struct RunningAppsView: View { ForEach(store.runningConfiguredMenuItems) { item in AppRowView( item: item, - inputMethods: store.inputMethods + inputMethods: store.inputMethods, + onIgnore: { + store.send(.view(.ignoreAppTapped(item.appInfo))) + } ) { strategy in store.send(.view(.setStrategy(bundleId: item.bundleId, strategy: strategy))) } diff --git a/TypeSwitch/Sources/UI/Views/MenuBar/UnavailableAppsView.swift b/TypeSwitch/Sources/UI/Views/MenuBar/UnavailableAppsView.swift index adb5635..edc7586 100644 --- a/TypeSwitch/Sources/UI/Views/MenuBar/UnavailableAppsView.swift +++ b/TypeSwitch/Sources/UI/Views/MenuBar/UnavailableAppsView.swift @@ -25,7 +25,10 @@ struct UnavailableAppsView: View { ForEach(store.unavailableApps) { item in AppRowView( item: item, - inputMethods: store.inputMethods + inputMethods: store.inputMethods, + onIgnore: { + store.send(.view(.ignoreAppTapped(item.appInfo))) + } ) { strategy in store.send(.view(.setStrategy(bundleId: item.bundleId, strategy: strategy))) } diff --git a/TypeSwitchTests/AppFeatureTests.swift b/TypeSwitchTests/AppFeatureTests.swift index 0a3bc4f..f7cdb7b 100644 --- a/TypeSwitchTests/AppFeatureTests.swift +++ b/TypeSwitchTests/AppFeatureTests.swift @@ -1,3 +1,4 @@ +import AppKit import ComposableArchitecture import Foundation @testable import TypeSwitch @@ -157,6 +158,25 @@ final class AppFeatureTests: XCTestCase { XCTAssertEqual(store.state.fallbackStrategy, .none) } + func testSetFallbackStrategyCoercesIgnoredToNone() async { + var initialState = AppFeature.State() + initialState.$fallbackRuleStore.withLock { + $0.strategy = .fixed(inputMethodId: "ime.en") + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + + await store.send(.view(.setFallbackStrategy(.ignored))) { + $0.$fallbackRuleStore.withLock { + $0.strategy = .none + } + } + + XCTAssertEqual(store.state.fallbackStrategy, .none) + } + func testActivatedAppSwitchesFixedInputMethodWhenNeeded() async { let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") let targetInputMethod = "ime.en" @@ -394,6 +414,44 @@ final class AppFeatureTests: XCTestCase { XCTAssertEqual(switchedInputMethods, [fallbackInputMethod]) } + func testActivatedIgnoredAppOverridesFixedFallback() async { + let app = AppInfo(bundleId: "com.test.passwords", name: "Passwords", path: "/Applications/Passwords.app") + + var initialState = AppFeature.State() + initialState.inputMethods = [InputMethod(id: "ime.fallback", name: "Fallback")] + initialState.$fallbackRuleStore.withLock { + $0.strategy = .fixed(inputMethodId: "ime.fallback") + } + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.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 = { + XCTFail("Ignored apps must not query the current input method") + return "ime.other" + } + store.dependencies.inputMethodClient.switchToInputMethod = { _ in + XCTFail("Ignored apps must not switch input methods") + } + + await store.send(.system(.workspaceEvent(.activated(app)))) { + $0.currentFrontmostBundleId = app.bundleId + } + + 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") @@ -732,6 +790,260 @@ final class AppFeatureTests: XCTestCase { ]) } + func testIgnoredAppOnlyAppearsInIgnoredMenu() { + let app = AppInfo(bundleId: "com.test.passwords", name: "Passwords", path: "/Applications/Passwords.app") + + var state = AppFeature.State() + state.currentFrontmostBundleId = app.bundleId + state.runningApps = [app] + state.$appSwitchStatisticsStore.withLock { + $0.counts[app.bundleId] = 4 + } + state.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: nil, + lastKnownName: app.name, + strategy: .ignored, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + XCTAssertNil(state.currentAppMenuItem) + XCTAssertTrue(state.runningConfiguredMenuItems.isEmpty) + XCTAssertTrue(state.runningUnconfiguredMenuItems.isEmpty) + XCTAssertTrue(state.configuredApps.isEmpty) + XCTAssertTrue(state.unavailableApps.isEmpty) + XCTAssertTrue(state.switchStatisticsItems.isEmpty) + XCTAssertEqual(state.totalSuccessfulSwitchCount, 0) + XCTAssertEqual(state.ignoredAppsForMenu.map(\.bundleId), [app.bundleId]) + XCTAssertEqual(state.appSwitchStatisticsStore.counts[app.bundleId], 4) + } + + func testIgnoreAndRestoreAppUseIgnoredAndDefaultStrategies() async { + let app = AppInfo(bundleId: "com.test.passwords", name: "Passwords", path: "/Applications/Passwords.app") + + var initialState = AppFeature.State() + initialState.runningApps = [app] + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 20)) + + await store.send(.view(.ignoreAppTapped(app))) { + $0.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .ignored, + createdAt: Date(timeIntervalSince1970: 20), + updatedAt: Date(timeIntervalSince1970: 20) + ) + } + } + + await store.send(.view(.restoreIgnoredAppTapped(bundleId: app.bundleId))) { + $0.$appRulesStore.withLock { + guard var rule = $0.rules[app.bundleId] else { return } + rule.strategy = .none + rule.updatedAt = Date(timeIntervalSince1970: 20) + $0.rules[app.bundleId] = rule + } + } + + XCTAssertEqual(store.state.runningUnconfiguredMenuItems.map(\.bundleId), [app.bundleId]) + } + + func testIgnoringUnavailableAppPreservesLastKnownPath() async { + let bundleId = "com.test.missing" + let lastKnownPath = "/Applications/Missing.app" + + var initialState = AppFeature.State() + initialState.$appRulesStore.withLock { + $0.rules[bundleId] = AppRuleRecord( + bundleId: bundleId, + lastKnownPath: lastKnownPath, + lastKnownName: "Missing", + strategy: .fixed(inputMethodId: "ime.en"), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 20)) + + await store.send(.view(.ignoreAppTapped( + AppInfo(bundleId: bundleId, name: "Missing", path: nil) + ))) { + $0.$appRulesStore.withLock { + guard var rule = $0.rules[bundleId] else { return } + rule.strategy = .ignored + rule.updatedAt = Date(timeIntervalSince1970: 20) + $0.rules[bundleId] = rule + } + } + + XCTAssertEqual(store.state.appRules[bundleId]?.lastKnownPath, lastKnownPath) + } + + func testRestoreAllIgnoredAppsUsesDefaultStrategy() async { + var initialState = AppFeature.State() + initialState.$appRulesStore.withLock { + for bundleId in ["com.test.alpha", "com.test.beta"] { + $0.rules[bundleId] = AppRuleRecord( + bundleId: bundleId, + lastKnownPath: nil, + lastKnownName: bundleId, + strategy: .ignored, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 20)) + + await store.send(.view(.restoreAllIgnoredAppsTapped)) { + $0.$appRulesStore.withLock { + for bundleId in ["com.test.alpha", "com.test.beta"] { + guard var rule = $0.rules[bundleId] else { continue } + rule.strategy = .none + rule.updatedAt = Date(timeIntervalSince1970: 20) + $0.rules[bundleId] = rule + } + } + } + } + + func testMenuPresentationFreezesIgnoredVisibilityUntilDismissed() async { + let app = AppInfo(bundleId: "com.test.passwords", name: "Passwords", path: "/Applications/Passwords.app") + + var initialState = AppFeature.State() + initialState.runningApps = [app] + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 20)) + + await store.send(.menuPresented) { + $0.isMenuPresented = true + $0.menuStrategiesAtPresentation = [:] + } + await store.send(.view(.ignoreAppTapped(app))) { + $0.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .ignored, + createdAt: Date(timeIntervalSince1970: 20), + updatedAt: Date(timeIntervalSince1970: 20) + ) + } + } + + XCTAssertEqual(store.state.runningUnconfiguredMenuItems.map(\.bundleId), [app.bundleId]) + XCTAssertTrue(store.state.ignoredAppsForMenu.isEmpty) + + await store.send(.menuDismissed) { + $0.isMenuPresented = false + $0.menuStrategiesAtPresentation = [:] + } + + XCTAssertTrue(store.state.runningUnconfiguredMenuItems.isEmpty) + XCTAssertEqual(store.state.ignoredAppsForMenu.map(\.bundleId), [app.bundleId]) + + await store.send(.menuPresented) { + $0.isMenuPresented = true + $0.menuStrategiesAtPresentation = [app.bundleId: .ignored] + } + await store.send(.view(.restoreIgnoredAppTapped(bundleId: app.bundleId))) { + $0.$appRulesStore.withLock { + guard var rule = $0.rules[app.bundleId] else { return } + rule.strategy = .none + rule.updatedAt = Date(timeIntervalSince1970: 20) + $0.rules[app.bundleId] = rule + } + } + + XCTAssertEqual(store.state.ignoredAppsForMenu.map(\.bundleId), [app.bundleId]) + XCTAssertTrue(store.state.runningUnconfiguredMenuItems.isEmpty) + + await store.send(.menuDismissed) { + $0.isMenuPresented = false + $0.menuStrategiesAtPresentation = [:] + } + + XCTAssertTrue(store.state.ignoredAppsForMenu.isEmpty) + XCTAssertEqual(store.state.runningUnconfiguredMenuItems.map(\.bundleId), [app.bundleId]) + } + + func testRemoveUnavailableRulesPreservesIgnoredApps() async { + var initialState = AppFeature.State() + initialState.$appRulesStore.withLock { + $0.rules["ignored"] = AppRuleRecord( + bundleId: "ignored", + lastKnownPath: nil, + lastKnownName: "Ignored", + strategy: .ignored, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + $0.rules["missing"] = AppRuleRecord( + bundleId: "missing", + lastKnownPath: nil, + lastKnownName: "Missing", + strategy: .fixed(inputMethodId: "ime.en"), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + + await store.send(.view(.removeUnavailableRulesTapped)) { + $0.$appRulesStore.withLock { + $0.rules = ["ignored": $0.rules["ignored"]!] + } + } + } + + func testMenuTrackingNotificationOnlyAcceptsRootMenu() { + let rootMenu = NSMenu() + let submenu = NSMenu() + let submenuItem = NSMenuItem(title: "Submenu", action: nil, keyEquivalent: "") + rootMenu.addItem(submenuItem) + rootMenu.setSubmenu(submenu, for: submenuItem) + + XCTAssertTrue( + MenuBarView.isRootMenuTrackingNotification( + Notification(name: NSMenu.didBeginTrackingNotification, object: rootMenu) + ) + ) + XCTAssertFalse( + MenuBarView.isRootMenuTrackingNotification( + Notification(name: NSMenu.didEndTrackingNotification, object: submenu) + ) + ) + XCTAssertFalse( + MenuBarView.isRootMenuTrackingNotification( + Notification(name: NSMenu.didEndTrackingNotification, object: NSObject()) + ) + ) + } + func testCurrentAppMenuItemIsSeparatedFromRunningApps() { let chat = AppInfo(bundleId: "com.test.chat", name: "Chat", path: "/Applications/Chat.app") let notes = AppInfo(bundleId: "com.test.notes", name: "Notes", path: "/Applications/Notes.app") diff --git a/TypeSwitchTests/AppRulesStoreMigrationTests.swift b/TypeSwitchTests/AppRulesStoreMigrationTests.swift index d650ac2..508f712 100644 --- a/TypeSwitchTests/AppRulesStoreMigrationTests.swift +++ b/TypeSwitchTests/AppRulesStoreMigrationTests.swift @@ -27,6 +27,42 @@ final class AppRulesStoreMigrationTests: XCTestCase { XCTAssertEqual(try JSONDecoder().decode(AppRulesStore.self, from: data), store) } + func testAppRulesStoreRoundTripsIgnoredStrategy() throws { + let store = AppRulesStore( + rules: [ + "com.test.passwords": AppRuleRecord( + bundleId: "com.test.passwords", + lastKnownPath: "/Applications/Passwords.app", + lastKnownName: "Passwords", + strategy: .ignored, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 20) + ), + ] + ) + + let data = try JSONEncoder().encode(store) + + XCTAssertEqual(try JSONDecoder().decode(AppRulesStore.self, from: data), store) + } + + func testExistingInputMethodStrategiesStillDecode() throws { + let decoder = JSONDecoder() + + XCTAssertEqual( + try decoder.decode(InputMethodStrategy.self, from: Data(#"{"none":{}}"#.utf8)), + .none + ) + XCTAssertEqual( + try decoder.decode(InputMethodStrategy.self, from: Data(#"{"fixed":{"inputMethodId":"ime.zh"}}"#.utf8)), + .fixed(inputMethodId: "ime.zh") + ) + XCTAssertEqual( + try decoder.decode(InputMethodStrategy.self, from: Data(#"{"followLast":{"lastInputMethodId":"ime.jp"}}"#.utf8)), + .followLast(lastInputMethodId: "ime.jp") + ) + } + func testFallbackRuleStoreDefaultsAndEncodingIncludeVersion() throws { let defaultStore = FallbackRuleStore() XCTAssertEqual(defaultStore.v, MigrationVersion.current) diff --git a/TypeSwitchTests/LocalizationStringsTests.swift b/TypeSwitchTests/LocalizationStringsTests.swift index 61fb57e..5a85b2f 100644 --- a/TypeSwitchTests/LocalizationStringsTests.swift +++ b/TypeSwitchTests/LocalizationStringsTests.swift @@ -52,6 +52,10 @@ final class LocalizationStringsTests: XCTestCase { "apps.section.unconfigured_count": "运行中 · 未配置(%d)", "apps.section.configured_count": "全部已配置 App(%d)", "apps.section.unavailable_count": "找不到的 App(%d)", + "apps.ignore": "忽略此 App", + "apps.ignored.menu_title": "已忽略 App(%d)", + "apps.ignored.restore_hint": "选择 App 以恢复", + "apps.ignored.restore_all": "恢复全部", "apps.clear_unavailable": "清理找不到的 App", "apps.clear_unavailable_confirmation.title": "清理找不到的 App?", "switch_statistics.menu_title": "切换统计(%d 次)", @@ -69,6 +73,10 @@ final class LocalizationStringsTests: XCTestCase { "apps.section.unconfigured_count": "运行中 · 未配置(%d)", "apps.section.configured_count": "全部已配置 App(%d)", "apps.section.unavailable_count": "找不到的 App(%d)", + "apps.ignore": "忽略此 App", + "apps.ignored.menu_title": "已忽略 App(%d)", + "apps.ignored.restore_hint": "选择 App 以恢复", + "apps.ignored.restore_all": "恢复全部", "apps.clear_unavailable": "清理找不到的 App", "apps.clear_unavailable_confirmation.title": "清理找不到的 App?", "switch_statistics.menu_title": "切换统计(%d 次)", @@ -86,6 +94,10 @@ final class LocalizationStringsTests: XCTestCase { "apps.section.unconfigured_count": "執行中 · 未設定(%d)", "apps.section.configured_count": "全部已設定 App(%d)", "apps.section.unavailable_count": "找不到的 App(%d)", + "apps.ignore": "忽略此 App", + "apps.ignored.menu_title": "已忽略 App(%d)", + "apps.ignored.restore_hint": "選擇 App 以恢復", + "apps.ignored.restore_all": "恢復全部", "apps.clear_unavailable": "清理找不到的 App", "apps.clear_unavailable_confirmation.title": "清理找不到的 App?", "switch_statistics.menu_title": "切換統計(%d 次)", @@ -103,6 +115,10 @@ final class LocalizationStringsTests: XCTestCase { "apps.section.unconfigured_count": "Running · Unconfigured (%d)", "apps.section.configured_count": "All Configured Apps (%d)", "apps.section.unavailable_count": "Missing Apps (%d)", + "apps.ignore": "Ignore This App", + "apps.ignored.menu_title": "Ignored Apps (%d)", + "apps.ignored.restore_hint": "Select an app to restore", + "apps.ignored.restore_all": "Restore All", "apps.clear_unavailable": "Clear Missing Apps", "apps.clear_unavailable_confirmation.title": "Clear missing apps?", "switch_statistics.menu_title": "Switches (%d)", diff --git a/TypeSwitchTests/ReadmeScreenshotConfigurationTests.swift b/TypeSwitchTests/ReadmeScreenshotConfigurationTests.swift index 5832e17..2d09b37 100644 --- a/TypeSwitchTests/ReadmeScreenshotConfigurationTests.swift +++ b/TypeSwitchTests/ReadmeScreenshotConfigurationTests.swift @@ -84,6 +84,7 @@ final class ReadmeScreenshotConfigurationTests: XCTestCase { XCTAssertEqual(state.runningConfiguredMenuItems.map(\.name), ["Terminal"]) XCTAssertEqual(state.configuredApps.map(\.name), ["Safari", "Terminal"]) XCTAssertEqual(state.unavailableApps.map(\.name), ["Legacy Editor"]) + XCTAssertEqual(state.ignoredAppsForMenu.map(\.name), ["Messages"]) XCTAssertEqual(state.fallbackSelectedLabel, "ABC") XCTAssertEqual(state.totalSuccessfulSwitchCount, 51) XCTAssertTrue(state.launchAtLoginEnabled)