diff --git a/CHANGELOG.md b/CHANGELOG.md index bfaf92a..a87f984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased + +### 🇨🇳 中文 + +#### 修复 + +- 取消启动时的静默更新检查,确保只有用户主动点击“检查更新…”时才会访问 GitHub。 +- 修复恢复已忽略 App 时丢失原输入法策略的问题,并为菜单栏图标补充当前 App 状态的 VoiceOver 描述。 + +### 🇺🇸 English + +#### Fixes + +- Removed the silent update check at startup so TypeSwitch contacts GitHub for updates only after the user clicks `Check for Updates…`. +- Fixed restored ignored apps losing their previous input method strategy, and added VoiceOver descriptions for the current app state in the menu bar icon. + ## v0.9.0 ### 🇨🇳 中文 diff --git a/README.md b/README.md index 07f8311..427b8d4 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ brew upgrade typeswitch 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` 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`. +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` to recover their previous rules. 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. @@ -108,7 +108,7 @@ brew upgrade typeswitch - App rules, the Default Rule for Unconfigured Apps, and switch statistics are stored locally. - TypeSwitch has no server-side component in this repository. -- `GitHub Repository` and `Check for Updates…` contact GitHub only when you use them. +- TypeSwitch does not check for updates or contact GitHub at startup. `GitHub Repository` and `Check for Updates…` contact GitHub only after you click them. - Input method switching uses macOS system input sources. - Launch at Login uses macOS Login Items, with a LaunchAgent fallback when needed. diff --git a/README.zh-CN.md b/README.zh-CN.md index 75fffe2..b912cfc 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -100,7 +100,7 @@ brew upgrade typeswitch 3. 用“运行中 · 未配置”为还没有规则的运行中 App 设置规则。 4. 用“运行中 · 已配置”和“全部已配置 App”查看并修改已有 App 规则。 5. 为每个 App 选择“默认”以继承兜底规则,或选择“记住上次切换”及指定输入法。 -6. 选择“忽略此 App”可停止自动切换并将其从普通列表隐藏;可在“已忽略 App”中单项恢复或恢复全部。 +6. 选择“忽略此 App”可停止自动切换并将其从普通列表隐藏;可在“已忽略 App”中单项恢复或恢复全部,并还原忽略前的规则。 7. 用“未配置 App 的默认规则”设置没有单独规则的 App 的默认行为。 8. 按需查看“找不到的 App”和“切换统计”,清理缺失规则或查看成功切换次数。 @@ -108,7 +108,7 @@ brew upgrade typeswitch - App 规则、未配置 App 的默认规则和切换统计都存储在本地。 - 本仓库中没有服务端组件。 -- 菜单中的“GitHub 仓库”和“检查更新…”只会在你主动使用时访问 GitHub。 +- TypeSwitch 启动时不会检查更新或访问 GitHub。只有在你主动点击“GitHub 仓库”或“检查更新…”后才会访问 GitHub。 - 输入法切换使用 macOS 系统输入源。 - “登录时打开”使用 macOS 登录项;必要时会回退到 LaunchAgent。 diff --git a/TypeSwitch/Resources/Base.lproj/Localizable.strings b/TypeSwitch/Resources/Base.lproj/Localizable.strings index 99a1088..812e3f0 100644 --- a/TypeSwitch/Resources/Base.lproj/Localizable.strings +++ b/TypeSwitch/Resources/Base.lproj/Localizable.strings @@ -1,6 +1,9 @@ // 菜单 "menu.quit" = "退出 TypeSwitch"; "menu.github_repository" = "GitHub 仓库"; +"menu.accessibility_configured" = "TypeSwitch,当前 App 已配置"; +"menu.accessibility_unconfigured" = "TypeSwitch,当前 App 未配置"; +"menu.accessibility_ignored" = "TypeSwitch,当前 App 已忽略"; // 通用 "common.cancel" = "取消"; @@ -10,7 +13,6 @@ "settings.general.auto_launch_requires_approval" = "请前往“系统设置”>“通用”>“登录项”允许 TypeSwitch。"; "settings.general.open_login_items" = "打开登录项设置"; "settings.general.check_for_updates" = "检查更新…"; -"settings.general.update_available" = "发现新版本…"; "settings.fallback.default_input_method" = "未配置 App 的默认规则"; // 应用列表 diff --git a/TypeSwitch/Resources/en.lproj/Localizable.strings b/TypeSwitch/Resources/en.lproj/Localizable.strings index 15cf6d8..1fccfa3 100644 --- a/TypeSwitch/Resources/en.lproj/Localizable.strings +++ b/TypeSwitch/Resources/en.lproj/Localizable.strings @@ -1,6 +1,9 @@ // Menu "menu.quit" = "Quit TypeSwitch"; "menu.github_repository" = "GitHub Repository"; +"menu.accessibility_configured" = "TypeSwitch, current app configured"; +"menu.accessibility_unconfigured" = "TypeSwitch, current app unconfigured"; +"menu.accessibility_ignored" = "TypeSwitch, current app ignored"; // Common "common.cancel" = "Cancel"; @@ -10,7 +13,6 @@ "settings.general.auto_launch_requires_approval" = "Approve TypeSwitch in System Settings > General > Login Items."; "settings.general.open_login_items" = "Open Login Items Settings"; "settings.general.check_for_updates" = "Check for Updates…"; -"settings.general.update_available" = "New Version Available…"; "settings.fallback.default_input_method" = "Default Rule for Unconfigured Apps"; // App List diff --git a/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings b/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings index 99a1088..812e3f0 100644 --- a/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings +++ b/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings @@ -1,6 +1,9 @@ // 菜单 "menu.quit" = "退出 TypeSwitch"; "menu.github_repository" = "GitHub 仓库"; +"menu.accessibility_configured" = "TypeSwitch,当前 App 已配置"; +"menu.accessibility_unconfigured" = "TypeSwitch,当前 App 未配置"; +"menu.accessibility_ignored" = "TypeSwitch,当前 App 已忽略"; // 通用 "common.cancel" = "取消"; @@ -10,7 +13,6 @@ "settings.general.auto_launch_requires_approval" = "请前往“系统设置”>“通用”>“登录项”允许 TypeSwitch。"; "settings.general.open_login_items" = "打开登录项设置"; "settings.general.check_for_updates" = "检查更新…"; -"settings.general.update_available" = "发现新版本…"; "settings.fallback.default_input_method" = "未配置 App 的默认规则"; // 应用列表 diff --git a/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings b/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings index 653ffb1..6e000b7 100644 --- a/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings +++ b/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings @@ -1,6 +1,9 @@ // 選單 "menu.quit" = "結束 TypeSwitch"; "menu.github_repository" = "GitHub 儲存庫"; +"menu.accessibility_configured" = "TypeSwitch,目前 App 已設定"; +"menu.accessibility_unconfigured" = "TypeSwitch,目前 App 未設定"; +"menu.accessibility_ignored" = "TypeSwitch,目前 App 已忽略"; // 通用 "common.cancel" = "取消"; @@ -10,7 +13,6 @@ "settings.general.auto_launch_requires_approval" = "請前往「系統設定」>「一般」>「登入項目」允許 TypeSwitch。"; "settings.general.open_login_items" = "開啟登入項目設定"; "settings.general.check_for_updates" = "檢查更新…"; -"settings.general.update_available" = "發現新版本…"; "settings.fallback.default_input_method" = "未設定 App 的預設規則"; // 應用程式列表 diff --git a/TypeSwitch/Sources/App/AppFeature+MenuState.swift b/TypeSwitch/Sources/App/AppFeature+MenuState.swift index 7e40d15..558e4f5 100644 --- a/TypeSwitch/Sources/App/AppFeature+MenuState.swift +++ b/TypeSwitch/Sources/App/AppFeature+MenuState.swift @@ -23,6 +23,21 @@ extension AppFeature.State { : "keyboard" } + var menuBarAccessibilityLabel: String { + guard let currentFrontmostBundleId else { + return "TypeSwitch" + } + + switch strategy(for: currentFrontmostBundleId) { + case .none: + return TypeSwitchStrings.Menu.accessibilityUnconfigured + case .fixed, .followLast: + return TypeSwitchStrings.Menu.accessibilityConfigured + case .ignored: + return TypeSwitchStrings.Menu.accessibilityIgnored + } + } + var fallbackStrategy: InputMethodStrategy { switch fallbackRuleStore.strategy { case .followLast, .ignored: diff --git a/TypeSwitch/Sources/App/AppFeature.swift b/TypeSwitch/Sources/App/AppFeature.swift index af71794..7e4132d 100644 --- a/TypeSwitch/Sources/App/AppFeature.swift +++ b/TypeSwitch/Sources/App/AppFeature.swift @@ -288,6 +288,7 @@ struct AppFeature { var updatedRule = currentRule updatedRule.lastKnownPath = appInfo.path ?? currentRule.lastKnownPath updatedRule.lastKnownName = appInfo.name + updatedRule.strategyBeforeIgnoring = currentRule.strategy updatedRule.strategy = .ignored updatedRule.updatedAt = updateDate store.rules[appInfo.bundleId] = updatedRule @@ -327,7 +328,8 @@ struct AppFeature { guard var rule = store.rules[bundleId], rule.strategy == .ignored else { continue } - rule.strategy = .none + rule.strategy = rule.strategyBeforeIgnoring ?? .none + rule.strategyBeforeIgnoring = nil rule.updatedAt = updateDate store.rules[bundleId] = rule } @@ -340,7 +342,8 @@ struct AppFeature { guard var rule = store.rules[bundleId], rule.strategy == .ignored else { return } - rule.strategy = .none + rule.strategy = rule.strategyBeforeIgnoring ?? .none + rule.strategyBeforeIgnoring = nil rule.updatedAt = updateDate store.rules[bundleId] = rule } @@ -389,6 +392,7 @@ struct AppFeature { var updatedRule = currentRule updatedRule.strategy = strategy + updatedRule.strategyBeforeIgnoring = nil updatedRule.updatedAt = updateDate store.rules[bundleId] = updatedRule } diff --git a/TypeSwitch/Sources/App/TypeSwitchApp.swift b/TypeSwitch/Sources/App/TypeSwitchApp.swift index 623a082..bb9f362 100644 --- a/TypeSwitch/Sources/App/TypeSwitchApp.swift +++ b/TypeSwitch/Sources/App/TypeSwitchApp.swift @@ -9,7 +9,6 @@ struct TypeSwitchApp: App { let readmeBackdropWindow: NSWindow? let readmeColorScheme: ColorScheme? let store: StoreOf - let updateMonitor: SparkleUpdateMonitor let updaterController: SPUStandardUpdaterController init() { @@ -34,14 +33,12 @@ struct TypeSwitchApp: App { AppFeature() } let startsUpdater = startsLiveServices && !isTesting - let updateMonitor = SparkleUpdateMonitor() let updaterController = SPUStandardUpdaterController( startingUpdater: startsUpdater, - updaterDelegate: updateMonitor, + updaterDelegate: nil, userDriverDelegate: nil ) self.store = store - self.updateMonitor = updateMonitor self.updaterController = updaterController self.menuTrackingObservers = [ NotificationCenter.default.addObserver( @@ -76,16 +73,12 @@ struct TypeSwitchApp: App { if startsLiveServices { store.send(.task) } - if startsUpdater { - updateMonitor.startSilentCheck(using: updaterController.updater) - } } var body: some Scene { MenuBarExtra { MenuBarView( store: store, - updateMonitor: updateMonitor, updaterController: updaterController ) .preferredColorScheme(readmeColorScheme) @@ -101,6 +94,6 @@ private struct MenuBarIconView: View { var body: some View { Image(systemName: store.menuBarIconSystemName) - .accessibilityLabel("TypeSwitch") + .accessibilityLabel(store.menuBarAccessibilityLabel) } } diff --git a/TypeSwitch/Sources/Core/Models/AppRuleRecord.swift b/TypeSwitch/Sources/Core/Models/AppRuleRecord.swift index 8b63c6f..7eacb1e 100644 --- a/TypeSwitch/Sources/Core/Models/AppRuleRecord.swift +++ b/TypeSwitch/Sources/Core/Models/AppRuleRecord.swift @@ -5,6 +5,7 @@ struct AppRuleRecord: Identifiable, Codable, Hashable, Sendable { var lastKnownPath: String? var lastKnownName: String var strategy: InputMethodStrategy + var strategyBeforeIgnoring: InputMethodStrategy? = nil var createdAt: Date var updatedAt: Date diff --git a/TypeSwitch/Sources/Services/Updates/SparkleUpdateMonitor.swift b/TypeSwitch/Sources/Services/Updates/SparkleUpdateMonitor.swift deleted file mode 100644 index b2e1ba3..0000000 --- a/TypeSwitch/Sources/Services/Updates/SparkleUpdateMonitor.swift +++ /dev/null @@ -1,75 +0,0 @@ -import Sparkle -import SwiftUI - -@MainActor -protocol SparkleUpdateChecking: AnyObject { - var sessionInProgress: Bool { get } - - func checkForUpdateInformation() - func checkForUpdates() -} - -extension SPUUpdater: SparkleUpdateChecking {} - -@MainActor -final class SparkleUpdateMonitor: NSObject, ObservableObject { - enum Status: Equatable { - case idle - case checking - case updateAvailable - } - - @Published private(set) var status: Status = .idle - - var menuTitle: String { - status == .updateAvailable - ? TypeSwitchStrings.Settings.General.updateAvailable - : TypeSwitchStrings.Settings.General.checkForUpdates - } - - var isMenuActionEnabled: Bool { - status != .checking - } - - private var foundUpdate = false - - func startSilentCheck(using updater: SparkleUpdateChecking) { - guard status != .checking, !updater.sessionInProgress else { return } - - foundUpdate = false - status = .checking - updater.checkForUpdateInformation() - } - - func showUpdate(using updater: SparkleUpdateChecking) { - guard status != .checking else { return } - - updater.checkForUpdates() - } - - private func recordFoundUpdate() { - guard status == .checking else { return } - foundUpdate = true - } - - private func finishSilentCheck(error: Error?) { - guard status == .checking else { return } - status = error == nil && foundUpdate ? .updateAvailable : .idle - foundUpdate = false - } -} - -extension SparkleUpdateMonitor: SPUUpdaterDelegate { - func updater(_: SPUUpdater, didFindValidUpdate _: SUAppcastItem) { - recordFoundUpdate() - } - - func updater( - _: SPUUpdater, - didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, - error: Error? - ) { - guard updateCheck == .updateInformation else { return } - finishSilentCheck(error: error) - } -} diff --git a/TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift b/TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift index a88ed67..207fbad 100644 --- a/TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift +++ b/TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift @@ -4,20 +4,18 @@ import SwiftUI /// 应用信息视图,显示项目链接和退出入口 struct AppInfoView: View { - @ObservedObject var updateMonitor: SparkleUpdateMonitor let updaterController: SPUStandardUpdaterController var body: some View { Group { Button { - updateMonitor.showUpdate(using: updaterController.updater) + updaterController.checkForUpdates(nil) } label: { Label( - updateMonitor.menuTitle, + TypeSwitchStrings.Settings.General.checkForUpdates, systemImage: "arrow.triangle.2.circlepath" ) } - .disabled(!updateMonitor.isMenuActionEnabled) Button { AppInfoService.openGitHubRepository() diff --git a/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift b/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift index d91e008..6a1bb45 100644 --- a/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift +++ b/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift @@ -6,7 +6,6 @@ import SwiftUI /// 菜单栏主视图 struct MenuBarView: View { let store: StoreOf - let updateMonitor: SparkleUpdateMonitor let updaterController: SPUStandardUpdaterController var body: some View { @@ -32,10 +31,7 @@ struct MenuBarView: View { Divider() - AppInfoView( - updateMonitor: updateMonitor, - updaterController: updaterController - ) + AppInfoView(updaterController: updaterController) } .labelStyle(.titleAndIcon) } @@ -73,7 +69,6 @@ private struct CurrentAppView: View { store: Store(initialState: AppFeature.State()) { AppFeature() }, - updateMonitor: SparkleUpdateMonitor(), updaterController: SPUStandardUpdaterController( startingUpdater: false, updaterDelegate: nil, diff --git a/TypeSwitchTests/AppFeatureTests.swift b/TypeSwitchTests/AppFeatureTests.swift index c8a05da..c6238d2 100644 --- a/TypeSwitchTests/AppFeatureTests.swift +++ b/TypeSwitchTests/AppFeatureTests.swift @@ -493,6 +493,7 @@ final class AppFeatureTests: XCTestCase { $0.pendingProgrammaticSwitch = nil $0.$appRulesStore.withLock { guard var rule = $0.rules[app.bundleId] else { return } + rule.strategyBeforeIgnoring = .fixed(inputMethodId: targetInputMethod) rule.strategy = .ignored rule.updatedAt = updateDate $0.rules[app.bundleId] = rule @@ -1065,40 +1066,60 @@ final class AppFeatureTests: XCTestCase { 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)) + func testIgnoreAndRestoreAppPreservesPreviousStrategy() async { + let strategies: [InputMethodStrategy] = [ + .none, + .fixed(inputMethodId: "ime.en"), + .followLast(lastInputMethodId: "ime.zh"), + ] - await store.send(.view(.ignoreAppTapped(app))) { - $0.$appRulesStore.withLock { + for (index, strategy) in strategies.enumerated() { + let app = AppInfo( + bundleId: "com.test.app.\(index)", + name: "App \(index)", + path: "/Applications/App\(index).app" + ) + var initialState = AppFeature.State() + initialState.$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) + strategy: strategy, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) ) } - } - 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 + let store = TestStore(initialState: initialState) { + AppFeature() } - } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 20)) - XCTAssertEqual(store.state.runningUnconfiguredMenuItems.map(\.bundleId), [app.bundleId]) + await store.send(.view(.ignoreAppTapped(app))) { + $0.$appRulesStore.withLock { + guard var rule = $0.rules[app.bundleId] else { return } + rule.strategy = .ignored + rule.strategyBeforeIgnoring = strategy + rule.updatedAt = Date(timeIntervalSince1970: 20) + $0.rules[app.bundleId] = rule + } + } + + await store.send(.view(.ignoreAppTapped(app))) + + await store.send(.view(.restoreIgnoredAppTapped(bundleId: app.bundleId))) { + $0.$appRulesStore.withLock { + guard var rule = $0.rules[app.bundleId] else { return } + rule.strategy = strategy + rule.strategyBeforeIgnoring = nil + rule.updatedAt = Date(timeIntervalSince1970: 20) + $0.rules[app.bundleId] = rule + } + } + + await store.send(.view(.restoreIgnoredAppTapped(bundleId: app.bundleId))) + } } func testIgnoringUnavailableAppPreservesLastKnownPath() async { @@ -1127,6 +1148,7 @@ final class AppFeatureTests: XCTestCase { ))) { $0.$appRulesStore.withLock { guard var rule = $0.rules[bundleId] else { return } + rule.strategyBeforeIgnoring = .fixed(inputMethodId: "ime.en") rule.strategy = .ignored rule.updatedAt = Date(timeIntervalSince1970: 20) $0.rules[bundleId] = rule @@ -1136,19 +1158,35 @@ final class AppFeatureTests: XCTestCase { XCTAssertEqual(store.state.appRules[bundleId]?.lastKnownPath, lastKnownPath) } - func testRestoreAllIgnoredAppsUsesDefaultStrategy() async { + func testRestoreAllIgnoredAppsUsesSavedStrategyAndLegacyFallback() 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) - ) - } + $0.rules["com.test.fixed"] = AppRuleRecord( + bundleId: "com.test.fixed", + lastKnownPath: nil, + lastKnownName: "Fixed", + strategy: .ignored, + strategyBeforeIgnoring: .fixed(inputMethodId: "ime.en"), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + $0.rules["com.test.follow-last"] = AppRuleRecord( + bundleId: "com.test.follow-last", + lastKnownPath: nil, + lastKnownName: "Follow Last", + strategy: .ignored, + strategyBeforeIgnoring: .followLast(lastInputMethodId: "ime.zh"), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + $0.rules["com.test.legacy"] = AppRuleRecord( + bundleId: "com.test.legacy", + lastKnownPath: nil, + lastKnownName: "Legacy", + strategy: .ignored, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) } let store = TestStore(initialState: initialState) { @@ -1158,9 +1196,15 @@ final class AppFeatureTests: XCTestCase { await store.send(.view(.restoreAllIgnoredAppsTapped)) { $0.$appRulesStore.withLock { - for bundleId in ["com.test.alpha", "com.test.beta"] { + let restoredStrategies: [String: InputMethodStrategy] = [ + "com.test.fixed": .fixed(inputMethodId: "ime.en"), + "com.test.follow-last": .followLast(lastInputMethodId: "ime.zh"), + "com.test.legacy": .none, + ] + for (bundleId, strategy) in restoredStrategies { guard var rule = $0.rules[bundleId] else { continue } - rule.strategy = .none + rule.strategy = strategy + rule.strategyBeforeIgnoring = nil rule.updatedAt = Date(timeIntervalSince1970: 20) $0.rules[bundleId] = rule } @@ -1190,6 +1234,7 @@ final class AppFeatureTests: XCTestCase { lastKnownPath: app.path, lastKnownName: app.name, strategy: .ignored, + strategyBeforeIgnoring: .some(.none), createdAt: Date(timeIntervalSince1970: 20), updatedAt: Date(timeIntervalSince1970: 20) ) @@ -1215,6 +1260,7 @@ final class AppFeatureTests: XCTestCase { $0.$appRulesStore.withLock { guard var rule = $0.rules[app.bundleId] else { return } rule.strategy = .none + rule.strategyBeforeIgnoring = nil rule.updatedAt = Date(timeIntervalSince1970: 20) $0.rules[app.bundleId] = rule } @@ -1305,6 +1351,7 @@ final class AppFeatureTests: XCTestCase { let state = AppFeature.State() XCTAssertEqual(state.menuBarIconSystemName, "keyboard") + XCTAssertEqual(state.menuBarAccessibilityLabel, "TypeSwitch") } func testMenuBarIconUsesUnconfiguredIconForFrontmostAppWithoutRule() { @@ -1312,6 +1359,7 @@ final class AppFeatureTests: XCTestCase { state.currentFrontmostBundleId = "com.test.chat" XCTAssertEqual(state.menuBarIconSystemName, "keyboard.badge.ellipsis") + XCTAssertEqual(state.menuBarAccessibilityLabel, TypeSwitchStrings.Menu.accessibilityUnconfigured) } func testMenuBarIconUsesUnconfiguredIconForFrontmostAppWithNoneStrategy() { @@ -1331,6 +1379,7 @@ final class AppFeatureTests: XCTestCase { } XCTAssertEqual(state.menuBarIconSystemName, "keyboard.badge.ellipsis") + XCTAssertEqual(state.menuBarAccessibilityLabel, TypeSwitchStrings.Menu.accessibilityUnconfigured) } func testMenuBarIconUsesKeyboardForFrontmostAppWithFixedStrategy() { @@ -1350,6 +1399,7 @@ final class AppFeatureTests: XCTestCase { } XCTAssertEqual(state.menuBarIconSystemName, "keyboard") + XCTAssertEqual(state.menuBarAccessibilityLabel, TypeSwitchStrings.Menu.accessibilityConfigured) } func testMenuBarIconUsesKeyboardForFrontmostAppWithFollowLastStrategy() { @@ -1369,6 +1419,27 @@ final class AppFeatureTests: XCTestCase { } XCTAssertEqual(state.menuBarIconSystemName, "keyboard") + XCTAssertEqual(state.menuBarAccessibilityLabel, TypeSwitchStrings.Menu.accessibilityConfigured) + } + + func testMenuBarAccessibilityLabelDescribesIgnoredFrontmostApp() { + let app = AppInfo(bundleId: "com.test.chat", name: "Chat", path: "/Applications/Chat.app") + + var state = AppFeature.State() + state.currentFrontmostBundleId = app.bundleId + state.$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) + ) + } + + XCTAssertEqual(state.menuBarIconSystemName, "keyboard") + XCTAssertEqual(state.menuBarAccessibilityLabel, TypeSwitchStrings.Menu.accessibilityIgnored) } func testFollowLastWithoutRecordShowsEmptyMenuOption() { diff --git a/TypeSwitchTests/AppRulesStoreMigrationTests.swift b/TypeSwitchTests/AppRulesStoreMigrationTests.swift index f15eac9..4cd85d7 100644 --- a/TypeSwitchTests/AppRulesStoreMigrationTests.swift +++ b/TypeSwitchTests/AppRulesStoreMigrationTests.swift @@ -35,6 +35,7 @@ final class AppRulesStoreMigrationTests: XCTestCase { lastKnownPath: "/Applications/Passwords.app", lastKnownName: "Passwords", strategy: .ignored, + strategyBeforeIgnoring: .followLast(lastInputMethodId: "ime.zh"), createdAt: Date(timeIntervalSince1970: 10), updatedAt: Date(timeIntervalSince1970: 20) ), @@ -46,6 +47,17 @@ final class AppRulesStoreMigrationTests: XCTestCase { XCTAssertEqual(try JSONDecoder().decode(AppRulesStore.self, from: data), store) } + func testLegacyIgnoredRuleWithoutPreviousStrategyDecodesWithNil() throws { + let data = Data( + #"{"bundleId":"com.test.passwords","lastKnownName":"Passwords","strategy":{"ignored":{}},"createdAt":0,"updatedAt":0}"#.utf8 + ) + + let rule = try JSONDecoder().decode(AppRuleRecord.self, from: data) + + XCTAssertEqual(rule.strategy, .ignored) + XCTAssertNil(rule.strategyBeforeIgnoring) + } + func testExistingInputMethodStrategiesStillDecode() throws { let decoder = JSONDecoder() diff --git a/TypeSwitchTests/LocalizationStringsTests.swift b/TypeSwitchTests/LocalizationStringsTests.swift index 5a85b2f..19a5a96 100644 --- a/TypeSwitchTests/LocalizationStringsTests.swift +++ b/TypeSwitchTests/LocalizationStringsTests.swift @@ -42,11 +42,13 @@ final class LocalizationStringsTests: XCTestCase { let expectedValues: [String: [String: String]] = [ "Base": [ "menu.github_repository": "GitHub 仓库", + "menu.accessibility_configured": "TypeSwitch,当前 App 已配置", + "menu.accessibility_unconfigured": "TypeSwitch,当前 App 未配置", + "menu.accessibility_ignored": "TypeSwitch,当前 App 已忽略", "settings.general.auto_launch": "登录时打开", "settings.general.auto_launch_requires_approval": "请前往“系统设置”>“通用”>“登录项”允许 TypeSwitch。", "settings.general.open_login_items": "打开登录项设置", "settings.general.check_for_updates": "检查更新…", - "settings.general.update_available": "发现新版本…", "settings.fallback.default_input_method": "未配置 App 的默认规则", "apps.section.running_count": "运行中 · 已配置(%d)", "apps.section.unconfigured_count": "运行中 · 未配置(%d)", @@ -63,11 +65,13 @@ final class LocalizationStringsTests: XCTestCase { ], "zh-Hans": [ "menu.github_repository": "GitHub 仓库", + "menu.accessibility_configured": "TypeSwitch,当前 App 已配置", + "menu.accessibility_unconfigured": "TypeSwitch,当前 App 未配置", + "menu.accessibility_ignored": "TypeSwitch,当前 App 已忽略", "settings.general.auto_launch": "登录时打开", "settings.general.auto_launch_requires_approval": "请前往“系统设置”>“通用”>“登录项”允许 TypeSwitch。", "settings.general.open_login_items": "打开登录项设置", "settings.general.check_for_updates": "检查更新…", - "settings.general.update_available": "发现新版本…", "settings.fallback.default_input_method": "未配置 App 的默认规则", "apps.section.running_count": "运行中 · 已配置(%d)", "apps.section.unconfigured_count": "运行中 · 未配置(%d)", @@ -84,11 +88,13 @@ final class LocalizationStringsTests: XCTestCase { ], "zh-Hant": [ "menu.github_repository": "GitHub 儲存庫", + "menu.accessibility_configured": "TypeSwitch,目前 App 已設定", + "menu.accessibility_unconfigured": "TypeSwitch,目前 App 未設定", + "menu.accessibility_ignored": "TypeSwitch,目前 App 已忽略", "settings.general.auto_launch": "登入時開啟", "settings.general.auto_launch_requires_approval": "請前往「系統設定」>「一般」>「登入項目」允許 TypeSwitch。", "settings.general.open_login_items": "開啟登入項目設定", "settings.general.check_for_updates": "檢查更新…", - "settings.general.update_available": "發現新版本…", "settings.fallback.default_input_method": "未設定 App 的預設規則", "apps.section.running_count": "執行中 · 已設定(%d)", "apps.section.unconfigured_count": "執行中 · 未設定(%d)", @@ -105,11 +111,13 @@ final class LocalizationStringsTests: XCTestCase { ], "en": [ "menu.github_repository": "GitHub Repository", + "menu.accessibility_configured": "TypeSwitch, current app configured", + "menu.accessibility_unconfigured": "TypeSwitch, current app unconfigured", + "menu.accessibility_ignored": "TypeSwitch, current app ignored", "settings.general.auto_launch": "Launch at Login", "settings.general.auto_launch_requires_approval": "Approve TypeSwitch in System Settings > General > Login Items.", "settings.general.open_login_items": "Open Login Items Settings", "settings.general.check_for_updates": "Check for Updates…", - "settings.general.update_available": "New Version Available…", "settings.fallback.default_input_method": "Default Rule for Unconfigured Apps", "apps.section.running_count": "Running · Configured (%d)", "apps.section.unconfigured_count": "Running · Unconfigured (%d)", diff --git a/TypeSwitchTests/SparkleUpdateMonitorTests.swift b/TypeSwitchTests/SparkleUpdateMonitorTests.swift deleted file mode 100644 index d4381e7..0000000 --- a/TypeSwitchTests/SparkleUpdateMonitorTests.swift +++ /dev/null @@ -1,125 +0,0 @@ -import Sparkle -@testable import TypeSwitch -import XCTest - -@MainActor -final class SparkleUpdateMonitorTests: XCTestCase { - func testStartSilentCheckBeginsProbingAndDisablesMenuAction() { - let updater = UpdaterSpy() - let monitor = SparkleUpdateMonitor() - - monitor.startSilentCheck(using: updater) - - XCTAssertEqual(updater.informationCheckCount, 1) - XCTAssertEqual(monitor.status, .checking) - XCTAssertFalse(monitor.isMenuActionEnabled) - XCTAssertEqual(monitor.menuTitle, TypeSwitchStrings.Settings.General.checkForUpdates) - } - - func testStartSilentCheckSkipsActiveUpdaterSession() { - let updater = UpdaterSpy(sessionInProgress: true) - let monitor = SparkleUpdateMonitor() - - monitor.startSilentCheck(using: updater) - - XCTAssertEqual(updater.informationCheckCount, 0) - XCTAssertEqual(monitor.status, .idle) - XCTAssertTrue(monitor.isMenuActionEnabled) - } - - func testFoundUpdateChangesMenuAfterInformationCheckFinishes() { - let updater = UpdaterSpy() - let monitor = SparkleUpdateMonitor() - monitor.startSilentCheck(using: updater) - - monitor.updater(delegateUpdater, didFindValidUpdate: .empty()) - - XCTAssertEqual(monitor.status, .checking) - - monitor.updater(delegateUpdater, didFinishUpdateCycleFor: .updateInformation, error: nil) - - XCTAssertEqual(monitor.status, .updateAvailable) - XCTAssertEqual(monitor.menuTitle, TypeSwitchStrings.Settings.General.updateAvailable) - XCTAssertTrue(monitor.isMenuActionEnabled) - } - - func testNoUpdateRestoresIdleState() { - let updater = UpdaterSpy() - let monitor = SparkleUpdateMonitor() - monitor.startSilentCheck(using: updater) - - monitor.updater(delegateUpdater, didFinishUpdateCycleFor: .updateInformation, error: nil) - - XCTAssertEqual(monitor.status, .idle) - XCTAssertEqual(monitor.menuTitle, TypeSwitchStrings.Settings.General.checkForUpdates) - XCTAssertTrue(monitor.isMenuActionEnabled) - } - - func testErrorDoesNotReportUpdateAfterFindingOne() { - let updater = UpdaterSpy() - let monitor = SparkleUpdateMonitor() - monitor.startSilentCheck(using: updater) - monitor.updater(delegateUpdater, didFindValidUpdate: .empty()) - - monitor.updater( - delegateUpdater, - didFinishUpdateCycleFor: .updateInformation, - error: NSError(domain: "SparkleUpdateMonitorTests", code: 1) - ) - - XCTAssertEqual(monitor.status, .idle) - XCTAssertEqual(monitor.menuTitle, TypeSwitchStrings.Settings.General.checkForUpdates) - } - - func testDelegateCallbacksOutsideSilentCheckAreIgnored() { - let monitor = SparkleUpdateMonitor() - - monitor.updater(delegateUpdater, didFindValidUpdate: .empty()) - monitor.updater(delegateUpdater, didFinishUpdateCycleFor: .updateInformation, error: nil) - - XCTAssertEqual(monitor.status, .idle) - XCTAssertEqual(monitor.menuTitle, TypeSwitchStrings.Settings.General.checkForUpdates) - } - - func testUserCheckIsRejectedWhileCheckingAndAllowedAfterCompletion() { - let updater = UpdaterSpy() - let monitor = SparkleUpdateMonitor() - monitor.startSilentCheck(using: updater) - - monitor.showUpdate(using: updater) - - XCTAssertEqual(updater.userCheckCount, 0) - - monitor.updater(delegateUpdater, didFinishUpdateCycleFor: .updateInformation, error: nil) - monitor.showUpdate(using: updater) - - XCTAssertEqual(updater.userCheckCount, 1) - } - - private var delegateUpdater: SPUUpdater { - SPUStandardUpdaterController( - startingUpdater: false, - updaterDelegate: nil, - userDriverDelegate: nil - ).updater - } -} - -@MainActor -private final class UpdaterSpy: SparkleUpdateChecking { - var sessionInProgress: Bool - private(set) var informationCheckCount = 0 - private(set) var userCheckCount = 0 - - init(sessionInProgress: Bool = false) { - self.sessionInProgress = sessionInProgress - } - - func checkForUpdateInformation() { - informationCheckCount += 1 - } - - func checkForUpdates() { - userCheckCount += 1 - } -}