diff --git a/CHANGELOG.md b/CHANGELOG.md index f94f5d3..d808bb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,14 @@ #### 功能 - 新增输入法健康诊断:输入法列表为空、读取失败或自动切换失败时,菜单会显示具体状态,并提供重新加载输入法、重试当前 App 和打开键盘设置等恢复操作。 +- 恢复启动时静默检查并开启定时自动检查;发现新版本后,一级菜单会显示具体版本,只有用户点击后才会打开 Sparkle 更新窗口。 + +#### 优化 + +- 将版本、项目主页、开发者、帮助、诊断和问题反馈收进“帮助与关于”子菜单,减少低频支持入口对一级菜单空间的占用。 #### 修复 -- 取消启动时的静默更新检查,确保只有用户主动点击“检查更新…”时才会访问 GitHub。 - 修复恢复已忽略 App 时丢失原输入法策略的问题,并为菜单栏图标补充当前 App 状态的 VoiceOver 描述。 - 修复输入法目录加载期间激活 App 时可能漏掉自动切换的问题;目录加载完成后仅补偿处理此前被跳过的当前 App。 - 修复手动切换到正确输入法后仍保留失败警告的问题,并让 VoiceOver 在输入法异常时优先播报警告状态。 @@ -21,10 +25,14 @@ #### Features - Added input method health diagnostics. When the input method catalog is empty, fails to load, or an automatic switch fails, the menu now explains the state and offers recovery actions to reload input methods, retry the current app, or open Keyboard Settings. +- Restored a silent startup check and enabled scheduled automatic checks. When an update is available, the root menu shows its version, and Sparkle presents its update window only after the user clicks it. + +#### Improvements + +- Moved version, project, developer, help, diagnostics, and issue-reporting information into a `Help & About` submenu so low-frequency support actions no longer crowd the root menu. #### 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. - Fixed automatic switching being skipped when an app activates while the input method catalog is loading; TypeSwitch now retries only the current app whose activation was deferred. - Fixed stale failure warnings after manually selecting the correct input method, and made VoiceOver announce input method warnings before the current app state. diff --git a/Documentation/Screenshots/en-dark.png b/Documentation/Screenshots/en-dark.png index 312ebc0..43f104a 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 f9a6ae6..db6f00f 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 f35377c..aec294b 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 5c71ff9..3098b20 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 c60cef4..c34c395 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 368bc3b..70743d6 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 32ea8fc..f7780f5 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 ad6322a..65ac10b 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 9c8273b..905d08b 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 b5ed152..71ffe4b 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 542d02c..3f2c8e3 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 a94c491..e10ef57 100644 Binary files a/Documentation/Screenshots/zh-Hans-light.png and b/Documentation/Screenshots/zh-Hans-light.png differ diff --git a/Project.swift b/Project.swift index bccc37b..2037b6b 100644 --- a/Project.swift +++ b/Project.swift @@ -62,7 +62,9 @@ let project = Project( "CFBundleVersion": .string(buildVersion), // 构建版本号 "SUFeedURL": "https://github.com/ygsgdbd/TypeSwitch/releases/latest/download/appcast.xml", "SUPublicEDKey": "tzapCS0NiXBf0Alm3T2veTCaauxr2dmMCnIFYaK+lOs=", - "SUEnableAutomaticChecks": false, + "SUAllowsAutomaticUpdates": false, + "SUEnableAutomaticChecks": true, + "SUAutomaticallyUpdate": false, ]), sources: ["TypeSwitch/Sources/**"], resources: ["TypeSwitch/Resources/**"], diff --git a/README.md b/README.md index 427b8d4..8dd1adb 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ - **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. +- **Fit TypeSwitch into your workflow.** Launch it at login, let Sparkle check silently for updates, open project and support links from `Help & About`, or press `Command + Q` to quit. ## 🪶 Native and Lightweight @@ -91,7 +91,7 @@ brew upgrade typeswitch 3. In Applications, Control-click or right-click `TypeSwitch.app`, choose `Open`, then confirm that you want to open it. 4. If macOS still blocks the app, open System Settings → Privacy & Security, find the TypeSwitch security message, and choose `Open Anyway`. 5. Grant any system permissions macOS requests. -6. Use `Check for Updates…` from the menu bar app to check GitHub Releases for future updates. +6. TypeSwitch checks GitHub Releases silently. When a new version is available, the menu shows its version; click it to open the update window. ## 🧭 Usage @@ -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. -- TypeSwitch does not check for updates or contact GitHub at startup. `GitHub Repository` and `Check for Updates…` contact GitHub only after you click them. +- TypeSwitch contacts its GitHub appcast at startup and periodically while running to check for updates. Background checks only update the menu status and never present UI, download, or install automatically; the Sparkle update window opens only after you click `Check for Updates…` or `New Version … Available…`. - Input method switching uses macOS system input sources. - Launch at Login uses macOS Login Items, with a LaunchAgent fallback when needed. @@ -118,7 +118,7 @@ This project uses: - [The Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) `1.26.0` for app architecture and state management - [Sharing](https://github.com/pointfreeco/swift-sharing) `2.9.1` for file-backed shared state -- [Sparkle](https://github.com/sparkle-project/Sparkle) `2.9.4` for manual update checks and appcast support +- [Sparkle](https://github.com/sparkle-project/Sparkle) `2.9.4` for silent automatic checks, manual updates, and appcast support - [SwifterSwift](https://github.com/SwifterSwift/SwifterSwift) `8.0.0` for Swift extensions - Point-Free support libraries: CasePaths, Dependencies, and PerceptionCore - [Tuist](https://github.com/tuist/tuist) for project generation and build configuration diff --git a/README.zh-CN.md b/README.zh-CN.md index b912cfc..1bdfe3e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -33,7 +33,7 @@ - **选择适合自己的切换方式。** 可让 App 继承默认规则、记住上次切换、指定输入法,或忽略不希望 TypeSwitch 管理的 App。 - **随时配置遇到的 App。** 可直接设置当前 App,从按配置状态分组的运行中 App 里选择,或在菜单栏中管理全部已保存规则。 - **保持规则与统计整洁。** 查找不存在 App 的规则、移除失效设置、查看成功切换次数,并按需清零统计。 -- **融入日常工作流程。** 支持登录时打开、通过 Sparkle 检查更新、访问 GitHub 仓库,以及使用 `Command + Q` 退出。 +- **融入日常工作流程。** 支持登录时打开、通过 Sparkle 静默检查更新、从“帮助与关于”访问项目与支持入口,以及使用 `Command + Q` 退出。 ## 🪶 原生与轻量 @@ -91,7 +91,7 @@ brew upgrade typeswitch 3. 在“应用程序”中按住 Control 点击或右键点击 `TypeSwitch.app`,选择“打开”,然后确认打开。 4. 如果 macOS 仍然阻止运行,请打开“系统设置”→“隐私与安全性”,找到 TypeSwitch 的安全提示并选择“仍要打开”。 5. 授予 macOS 请求的系统权限。 -6. 后续可在菜单栏应用中使用“检查更新…”从 GitHub Releases 检查更新。 +6. TypeSwitch 会静默检查 GitHub Releases;发现新版本后,菜单会显示版本号,点击后才会打开更新窗口。 ## 🧭 使用说明 @@ -108,7 +108,7 @@ brew upgrade typeswitch - App 规则、未配置 App 的默认规则和切换统计都存储在本地。 - 本仓库中没有服务端组件。 -- TypeSwitch 启动时不会检查更新或访问 GitHub。只有在你主动点击“GitHub 仓库”或“检查更新…”后才会访问 GitHub。 +- TypeSwitch 会在启动时及运行期间定时访问 GitHub appcast 检查更新。后台检查只更新菜单状态,不会自动弹窗、下载或安装;点击“检查更新…”或“新版本 … 可用…”后才会打开 Sparkle 更新窗口。 - 输入法切换使用 macOS 系统输入源。 - “登录时打开”使用 macOS 登录项;必要时会回退到 LaunchAgent。 @@ -118,7 +118,7 @@ brew upgrade typeswitch - [The Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) `1.26.0`:应用架构和状态管理 - [Sharing](https://github.com/pointfreeco/swift-sharing) `2.9.1`:基于文件的共享状态持久化 -- [Sparkle](https://github.com/sparkle-project/Sparkle) `2.9.4`:手动检查更新和 appcast 支持 +- [Sparkle](https://github.com/sparkle-project/Sparkle) `2.9.4`:静默自动检查、手动更新和 appcast 支持 - [SwifterSwift](https://github.com/SwifterSwift/SwifterSwift) `8.0.0`:Swift 扩展 - Point-Free 支持库:CasePaths、Dependencies、PerceptionCore - [Tuist](https://github.com/tuist/tuist):项目生成和构建配置 diff --git a/TypeSwitch/Resources/Base.lproj/Localizable.strings b/TypeSwitch/Resources/Base.lproj/Localizable.strings index 1c7b209..3dff43f 100644 --- a/TypeSwitch/Resources/Base.lproj/Localizable.strings +++ b/TypeSwitch/Resources/Base.lproj/Localizable.strings @@ -1,6 +1,5 @@ // 菜单 "menu.quit" = "退出 TypeSwitch"; -"menu.github_repository" = "GitHub 仓库"; "menu.accessibility_configured" = "TypeSwitch,当前 App 已配置"; "menu.accessibility_unconfigured" = "TypeSwitch,当前 App 未配置"; "menu.accessibility_ignored" = "TypeSwitch,当前 App 已忽略"; @@ -17,13 +16,17 @@ "settings.general.auto_launch_requires_approval" = "请前往“系统设置”>“通用”>“登录项”允许 TypeSwitch。"; "settings.general.open_login_items" = "打开登录项设置"; "settings.general.check_for_updates" = "检查更新…"; +"settings.general.checking_for_updates" = "正在检查更新…"; +"settings.general.update_available" = "新版本 %@ 可用…"; "settings.fallback.default_input_method" = "未配置 App 的默认规则"; // 支持 "support.version" = "TypeSwitch v%@"; +"support.help_and_about" = "帮助与关于"; +"support.developer" = "开发者:%@"; "support.help" = "使用帮助"; "support.copy_diagnostics" = "复制诊断信息"; -"support.report_issue" = "报告问题"; +"support.report_issue" = "报告问题…"; // 应用列表 "apps.section.current_app" = "当前应用"; diff --git a/TypeSwitch/Resources/en.lproj/Localizable.strings b/TypeSwitch/Resources/en.lproj/Localizable.strings index 2330db0..ccee8c5 100644 --- a/TypeSwitch/Resources/en.lproj/Localizable.strings +++ b/TypeSwitch/Resources/en.lproj/Localizable.strings @@ -1,6 +1,5 @@ // 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"; @@ -17,13 +16,17 @@ "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.checking_for_updates" = "Checking for Updates…"; +"settings.general.update_available" = "New Version %@ Available…"; "settings.fallback.default_input_method" = "Default Rule for Unconfigured Apps"; // Support "support.version" = "TypeSwitch v%@"; +"support.help_and_about" = "Help & About"; +"support.developer" = "Developer: %@"; "support.help" = "Help"; "support.copy_diagnostics" = "Copy Diagnostics"; -"support.report_issue" = "Report an Issue"; +"support.report_issue" = "Report an Issue…"; // App List "apps.section.current_app" = "Current App"; diff --git a/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings b/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings index 1c7b209..3dff43f 100644 --- a/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings +++ b/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings @@ -1,6 +1,5 @@ // 菜单 "menu.quit" = "退出 TypeSwitch"; -"menu.github_repository" = "GitHub 仓库"; "menu.accessibility_configured" = "TypeSwitch,当前 App 已配置"; "menu.accessibility_unconfigured" = "TypeSwitch,当前 App 未配置"; "menu.accessibility_ignored" = "TypeSwitch,当前 App 已忽略"; @@ -17,13 +16,17 @@ "settings.general.auto_launch_requires_approval" = "请前往“系统设置”>“通用”>“登录项”允许 TypeSwitch。"; "settings.general.open_login_items" = "打开登录项设置"; "settings.general.check_for_updates" = "检查更新…"; +"settings.general.checking_for_updates" = "正在检查更新…"; +"settings.general.update_available" = "新版本 %@ 可用…"; "settings.fallback.default_input_method" = "未配置 App 的默认规则"; // 支持 "support.version" = "TypeSwitch v%@"; +"support.help_and_about" = "帮助与关于"; +"support.developer" = "开发者:%@"; "support.help" = "使用帮助"; "support.copy_diagnostics" = "复制诊断信息"; -"support.report_issue" = "报告问题"; +"support.report_issue" = "报告问题…"; // 应用列表 "apps.section.current_app" = "当前应用"; diff --git a/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings b/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings index 7c47498..de01f7d 100644 --- a/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings +++ b/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings @@ -1,6 +1,5 @@ // 選單 "menu.quit" = "結束 TypeSwitch"; -"menu.github_repository" = "GitHub 儲存庫"; "menu.accessibility_configured" = "TypeSwitch,目前 App 已設定"; "menu.accessibility_unconfigured" = "TypeSwitch,目前 App 未設定"; "menu.accessibility_ignored" = "TypeSwitch,目前 App 已忽略"; @@ -17,13 +16,17 @@ "settings.general.auto_launch_requires_approval" = "請前往「系統設定」>「一般」>「登入項目」允許 TypeSwitch。"; "settings.general.open_login_items" = "開啟登入項目設定"; "settings.general.check_for_updates" = "檢查更新…"; +"settings.general.checking_for_updates" = "正在檢查更新…"; +"settings.general.update_available" = "新版本 %@ 可用…"; "settings.fallback.default_input_method" = "未設定 App 的預設規則"; // 支援 "support.version" = "TypeSwitch v%@"; +"support.help_and_about" = "說明與關於"; +"support.developer" = "開發者:%@"; "support.help" = "使用說明"; "support.copy_diagnostics" = "複製診斷資訊"; -"support.report_issue" = "回報問題"; +"support.report_issue" = "回報問題…"; // 應用程式列表 "apps.section.current_app" = "目前應用"; diff --git a/TypeSwitch/Sources/App/TypeSwitchApp.swift b/TypeSwitch/Sources/App/TypeSwitchApp.swift index bb9f362..87b7b32 100644 --- a/TypeSwitch/Sources/App/TypeSwitchApp.swift +++ b/TypeSwitch/Sources/App/TypeSwitchApp.swift @@ -9,6 +9,7 @@ struct TypeSwitchApp: App { let readmeBackdropWindow: NSWindow? let readmeColorScheme: ColorScheme? let store: StoreOf + let updateMonitor: SparkleUpdateMonitor let updaterController: SPUStandardUpdaterController init() { @@ -33,12 +34,14 @@ struct TypeSwitchApp: App { AppFeature() } let startsUpdater = startsLiveServices && !isTesting + let updateMonitor = SparkleUpdateMonitor() let updaterController = SPUStandardUpdaterController( startingUpdater: startsUpdater, - updaterDelegate: nil, - userDriverDelegate: nil + updaterDelegate: updateMonitor, + userDriverDelegate: updateMonitor ) self.store = store + self.updateMonitor = updateMonitor self.updaterController = updaterController self.menuTrackingObservers = [ NotificationCenter.default.addObserver( @@ -73,12 +76,16 @@ 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) diff --git a/TypeSwitch/Sources/Services/AppManagement/AppInfoService.swift b/TypeSwitch/Sources/Services/AppManagement/AppInfoService.swift index 5b0b5a3..5d28498 100644 --- a/TypeSwitch/Sources/Services/AppManagement/AppInfoService.swift +++ b/TypeSwitch/Sources/Services/AppManagement/AppInfoService.swift @@ -4,9 +4,15 @@ import Foundation /// 应用信息服务类,负责管理应用相关链接 enum AppInfoService { /// GitHub 仓库信息 - private static let githubRepository = "ygsgdbd/TypeSwitch" + private static let githubAccount = "ygsgdbd" + private static let githubRepository = "\(githubAccount)/TypeSwitch" private static let githubBaseURL = "https://github.com" + /// 获取开发者 GitHub 主页 URL + static var githubProfileURL: URL? { + URL(string: "\(githubBaseURL)/\(githubAccount)") + } + /// 获取 GitHub 仓库 URL static var githubRepositoryURL: URL? { URL(string: "\(githubBaseURL)/\(githubRepository)") @@ -42,6 +48,13 @@ enum AppInfoService { NSWorkspace.shared.open(url) } + /// 打开开发者 GitHub 主页 + @MainActor + static func openGitHubProfile() { + guard let url = githubProfileURL else { return } + NSWorkspace.shared.open(url) + } + /// 打开 GitHub Releases 页面 @MainActor static func openGitHubReleases() { diff --git a/TypeSwitch/Sources/Services/Updates/SparkleUpdateMonitor.swift b/TypeSwitch/Sources/Services/Updates/SparkleUpdateMonitor.swift new file mode 100644 index 0000000..c8a7b71 --- /dev/null +++ b/TypeSwitch/Sources/Services/Updates/SparkleUpdateMonitor.swift @@ -0,0 +1,155 @@ +import Sparkle +import SwiftUI + +@MainActor +protocol SparkleUpdateChecking: AnyObject { + var canCheckForUpdates: Bool { get } + 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(version: String) + } + + @Published private(set) var status: Status = .idle + + var menuTitle: String { + switch status { + case .idle: + TypeSwitchStrings.Settings.General.checkForUpdates + case .checking: + TypeSwitchStrings.Settings.General.checkingForUpdates + case .updateAvailable(let version): + TypeSwitchStrings.Settings.General.updateAvailable(version) + } + } + + var isMenuActionEnabled: Bool { + status != .checking + } + + private var availableVersionBeforeCheck: String? + private var pendingVersion: String? + + func startSilentCheck(using updater: SparkleUpdateChecking) { + guard status != .checking, !updater.sessionInProgress else { return } + + beginCheck() + updater.checkForUpdateInformation() + } + + func showUpdate(using updater: SparkleUpdateChecking) { + guard status != .checking, updater.canCheckForUpdates else { return } + + updater.checkForUpdates() + } + + private var availableVersion: String? { + guard case .updateAvailable(let version) = status else { return nil } + return version + } + + private func beginCheck() { + guard status != .checking else { return } + + availableVersionBeforeCheck = availableVersion + pendingVersion = nil + status = .checking + } + + private func recordAvailableUpdate(_ update: SUAppcastItem) { + let version = update.displayVersionString + if status == .checking { + pendingVersion = version + } else { + status = .updateAvailable(version: version) + } + } + + func handleScheduledUpdate(_ update: SUAppcastItem) { + availableVersionBeforeCheck = nil + pendingVersion = nil + status = .updateAvailable(version: update.displayVersionString) + } + + private func finishCheck(error: Error?) { + guard status == .checking else { return } + + if isNoUpdateError(error) { + status = .idle + } else if error != nil { + if let availableVersionBeforeCheck { + status = .updateAvailable(version: availableVersionBeforeCheck) + } else { + status = .idle + } + } else if let pendingVersion { + status = .updateAvailable(version: pendingVersion) + } else { + status = .idle + } + + availableVersionBeforeCheck = nil + pendingVersion = nil + } + + private func isNoUpdateError(_ error: Error?) -> Bool { + guard let error = error as NSError? else { return false } + return error.domain == SUSparkleErrorDomain + && error.code == Int(SUError.noUpdateError.rawValue) + } +} + +extension SparkleUpdateMonitor: SPUUpdaterDelegate { + func updater(_: SPUUpdater, mayPerform updateCheck: SPUUpdateCheck) throws { + if updateCheck == .updatesInBackground { + beginCheck() + } + } + + func updater(_: SPUUpdater, didFindValidUpdate update: SUAppcastItem) { + recordAvailableUpdate(update) + } + + func updater( + _: SPUUpdater, + didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, + error: Error? + ) { + guard updateCheck == .updateInformation || updateCheck == .updatesInBackground else { + return + } + finishCheck(error: error) + } +} + +extension SparkleUpdateMonitor: @preconcurrency SPUStandardUserDriverDelegate { + var supportsGentleScheduledUpdateReminders: Bool { + true + } + + func standardUserDriverShouldHandleShowingScheduledUpdate( + _: SUAppcastItem, + andInImmediateFocus _: Bool + ) -> Bool { + false + } + + func standardUserDriverWillHandleShowingUpdate( + _ handleShowingUpdate: Bool, + forUpdate update: SUAppcastItem, + state _: SPUUserUpdateState + ) { + guard !handleShowingUpdate else { return } + handleScheduledUpdate(update) + } +} diff --git a/TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift b/TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift index 537392b..a8b3997 100644 --- a/TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift +++ b/TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift @@ -6,49 +6,66 @@ import SwiftUI /// 应用信息视图,显示项目链接和退出入口 struct AppInfoView: View { let store: StoreOf + @ObservedObject var updateMonitor: SparkleUpdateMonitor let updaterController: SPUStandardUpdaterController var body: some View { let diagnostics = supportDiagnostics Group { - Text(TypeSwitchStrings.Support.version(diagnostics.version)) - .foregroundStyle(.secondary) - Button { - updaterController.checkForUpdates(nil) + updateMonitor.showUpdate(using: updaterController.updater) } label: { Label( - TypeSwitchStrings.Settings.General.checkForUpdates, + updateMonitor.menuTitle, systemImage: "arrow.triangle.2.circlepath" ) } + .disabled(!updateMonitor.isMenuActionEnabled) - Button { - AppInfoService.openGitHubRepository() - } label: { - Label( - TypeSwitchStrings.Menu.githubRepository, - systemImage: "chevron.left.forwardslash.chevron.right" - ) - } + Menu { + Button { + AppInfoService.openGitHubRepository() + } label: { + Label( + TypeSwitchStrings.Support.version(diagnostics.version), + systemImage: "chevron.left.forwardslash.chevron.right" + ) + } - Button { - AppInfoService.openHelp() - } label: { - Label(TypeSwitchStrings.Support.help, systemImage: "questionmark.circle") - } + Button { + AppInfoService.openGitHubProfile() + } label: { + Label( + TypeSwitchStrings.Support.developer("ygsgdbd"), + systemImage: "person.crop.circle" + ) + } - Button { - AppInfoService.copySupportDiagnostics(diagnostics) - } label: { - Label(TypeSwitchStrings.Support.copyDiagnostics, systemImage: "doc.on.doc") - } + Divider() - Button { - AppInfoService.openReportIssue() + Button { + AppInfoService.openHelp() + } label: { + Label(TypeSwitchStrings.Support.help, systemImage: "questionmark.circle") + } + + Button { + AppInfoService.copySupportDiagnostics(diagnostics) + } label: { + Label(TypeSwitchStrings.Support.copyDiagnostics, systemImage: "doc.on.doc") + } + + Button { + AppInfoService.openReportIssue() + } label: { + Label(TypeSwitchStrings.Support.reportIssue, systemImage: "exclamationmark.bubble") + } } label: { - Label(TypeSwitchStrings.Support.reportIssue, systemImage: "exclamationmark.bubble") + Label( + TypeSwitchStrings.Support.helpAndAbout, + systemImage: "info.circle" + ) } Divider() diff --git a/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift b/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift index 81723e6..1cb4a31 100644 --- a/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift +++ b/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift @@ -6,6 +6,7 @@ import SwiftUI /// 菜单栏主视图 struct MenuBarView: View { let store: StoreOf + @ObservedObject var updateMonitor: SparkleUpdateMonitor let updaterController: SPUStandardUpdaterController var body: some View { @@ -37,7 +38,11 @@ struct MenuBarView: View { Divider() - AppInfoView(store: store, updaterController: updaterController) + AppInfoView( + store: store, + updateMonitor: updateMonitor, + updaterController: updaterController + ) } .labelStyle(.titleAndIcon) } @@ -156,6 +161,7 @@ private struct CurrentAppView: View { store: Store(initialState: AppFeature.State()) { AppFeature() }, + updateMonitor: SparkleUpdateMonitor(), updaterController: SPUStandardUpdaterController( startingUpdater: false, updaterDelegate: nil, diff --git a/TypeSwitchTests/LocalizationStringsTests.swift b/TypeSwitchTests/LocalizationStringsTests.swift index dd1cb4b..902194d 100644 --- a/TypeSwitchTests/LocalizationStringsTests.swift +++ b/TypeSwitchTests/LocalizationStringsTests.swift @@ -41,7 +41,6 @@ final class LocalizationStringsTests: XCTestCase { func testMenuCopyMatchesSharedAppConventions() throws { 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 已忽略", @@ -50,11 +49,15 @@ final class LocalizationStringsTests: XCTestCase { "settings.general.auto_launch_requires_approval": "请前往“系统设置”>“通用”>“登录项”允许 TypeSwitch。", "settings.general.open_login_items": "打开登录项设置", "settings.general.check_for_updates": "检查更新…", + "settings.general.checking_for_updates": "正在检查更新…", + "settings.general.update_available": "新版本 %@ 可用…", "settings.fallback.default_input_method": "未配置 App 的默认规则", "support.version": "TypeSwitch v%@", + "support.help_and_about": "帮助与关于", + "support.developer": "开发者:%@", "support.help": "使用帮助", "support.copy_diagnostics": "复制诊断信息", - "support.report_issue": "报告问题", + "support.report_issue": "报告问题…", "apps.section.running_count": "运行中 · 已配置(%d)", "apps.section.unconfigured_count": "运行中 · 未配置(%d)", "apps.section.configured_count": "全部已配置 App(%d)", @@ -69,7 +72,6 @@ final class LocalizationStringsTests: XCTestCase { "input_method.clear_missing_confirmation.message": "失效项会改为跟随未配置 App 的默认规则。", ], "zh-Hans": [ - "menu.github_repository": "GitHub 仓库", "menu.accessibility_configured": "TypeSwitch,当前 App 已配置", "menu.accessibility_unconfigured": "TypeSwitch,当前 App 未配置", "menu.accessibility_ignored": "TypeSwitch,当前 App 已忽略", @@ -78,11 +80,15 @@ final class LocalizationStringsTests: XCTestCase { "settings.general.auto_launch_requires_approval": "请前往“系统设置”>“通用”>“登录项”允许 TypeSwitch。", "settings.general.open_login_items": "打开登录项设置", "settings.general.check_for_updates": "检查更新…", + "settings.general.checking_for_updates": "正在检查更新…", + "settings.general.update_available": "新版本 %@ 可用…", "settings.fallback.default_input_method": "未配置 App 的默认规则", "support.version": "TypeSwitch v%@", + "support.help_and_about": "帮助与关于", + "support.developer": "开发者:%@", "support.help": "使用帮助", "support.copy_diagnostics": "复制诊断信息", - "support.report_issue": "报告问题", + "support.report_issue": "报告问题…", "apps.section.running_count": "运行中 · 已配置(%d)", "apps.section.unconfigured_count": "运行中 · 未配置(%d)", "apps.section.configured_count": "全部已配置 App(%d)", @@ -97,7 +103,6 @@ final class LocalizationStringsTests: XCTestCase { "input_method.clear_missing_confirmation.message": "失效项会改为跟随未配置 App 的默认规则。", ], "zh-Hant": [ - "menu.github_repository": "GitHub 儲存庫", "menu.accessibility_configured": "TypeSwitch,目前 App 已設定", "menu.accessibility_unconfigured": "TypeSwitch,目前 App 未設定", "menu.accessibility_ignored": "TypeSwitch,目前 App 已忽略", @@ -106,11 +111,15 @@ final class LocalizationStringsTests: XCTestCase { "settings.general.auto_launch_requires_approval": "請前往「系統設定」>「一般」>「登入項目」允許 TypeSwitch。", "settings.general.open_login_items": "開啟登入項目設定", "settings.general.check_for_updates": "檢查更新…", + "settings.general.checking_for_updates": "正在檢查更新…", + "settings.general.update_available": "新版本 %@ 可用…", "settings.fallback.default_input_method": "未設定 App 的預設規則", "support.version": "TypeSwitch v%@", + "support.help_and_about": "說明與關於", + "support.developer": "開發者:%@", "support.help": "使用說明", "support.copy_diagnostics": "複製診斷資訊", - "support.report_issue": "回報問題", + "support.report_issue": "回報問題…", "apps.section.running_count": "執行中 · 已設定(%d)", "apps.section.unconfigured_count": "執行中 · 未設定(%d)", "apps.section.configured_count": "全部已設定 App(%d)", @@ -125,7 +134,6 @@ final class LocalizationStringsTests: XCTestCase { "input_method.clear_missing_confirmation.message": "失效項會改為跟隨未設定 App 的預設規則。", ], "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", @@ -134,11 +142,15 @@ final class LocalizationStringsTests: XCTestCase { "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.checking_for_updates": "Checking for Updates…", + "settings.general.update_available": "New Version %@ Available…", "settings.fallback.default_input_method": "Default Rule for Unconfigured Apps", "support.version": "TypeSwitch v%@", + "support.help_and_about": "Help & About", + "support.developer": "Developer: %@", "support.help": "Help", "support.copy_diagnostics": "Copy Diagnostics", - "support.report_issue": "Report an Issue", + "support.report_issue": "Report an Issue…", "apps.section.running_count": "Running · Configured (%d)", "apps.section.unconfigured_count": "Running · Unconfigured (%d)", "apps.section.configured_count": "All Configured Apps (%d)", diff --git a/TypeSwitchTests/SparkleUpdateMonitorTests.swift b/TypeSwitchTests/SparkleUpdateMonitorTests.swift new file mode 100644 index 0000000..1536de5 --- /dev/null +++ b/TypeSwitchTests/SparkleUpdateMonitorTests.swift @@ -0,0 +1,223 @@ +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.checkingForUpdates + ) + } + + 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 testFoundUpdateShowsDisplayVersionAfterInformationCheckFinishes() { + let updater = UpdaterSpy() + let monitor = SparkleUpdateMonitor() + let update = SUAppcastItem.empty() + monitor.startSilentCheck(using: updater) + + monitor.updater(delegateUpdater, didFindValidUpdate: update) + + XCTAssertEqual(monitor.status, .checking) + + monitor.updater(delegateUpdater, didFinishUpdateCycleFor: .updateInformation, error: nil) + + XCTAssertEqual( + monitor.status, + .updateAvailable(version: update.displayVersionString) + ) + XCTAssertEqual( + monitor.menuTitle, + TypeSwitchStrings.Settings.General.updateAvailable(update.displayVersionString) + ) + XCTAssertTrue(monitor.isMenuActionEnabled) + } + + func testSuccessfulCheckWithoutUpdateRestoresIdleState() { + let updater = UpdaterSpy() + let monitor = SparkleUpdateMonitor() + monitor.startSilentCheck(using: updater) + + monitor.updater( + delegateUpdater, + didFinishUpdateCycleFor: .updateInformation, + error: noUpdateError + ) + + XCTAssertEqual(monitor.status, .idle) + XCTAssertEqual( + monitor.menuTitle, + TypeSwitchStrings.Settings.General.checkForUpdates + ) + } + + func testNoUpdateClearsPreviouslyKnownUpdate() { + let updater = UpdaterSpy() + let monitor = SparkleUpdateMonitor() + monitor.updater(delegateUpdater, didFindValidUpdate: .empty()) + monitor.startSilentCheck(using: updater) + + monitor.updater( + delegateUpdater, + didFinishUpdateCycleFor: .updateInformation, + error: noUpdateError + ) + + XCTAssertEqual(monitor.status, .idle) + } + + func testFailedCheckPreservesPreviouslyKnownUpdate() { + let updater = UpdaterSpy() + let monitor = SparkleUpdateMonitor() + let update = SUAppcastItem.empty() + monitor.updater(delegateUpdater, didFindValidUpdate: update) + monitor.startSilentCheck(using: updater) + + monitor.updater( + delegateUpdater, + didFinishUpdateCycleFor: .updateInformation, + error: NSError(domain: "SparkleUpdateMonitorTests", code: 1) + ) + + XCTAssertEqual( + monitor.status, + .updateAvailable(version: update.displayVersionString) + ) + } + + func testFailedCheckDoesNotPublishPendingUpdate() { + 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) + } + + func testScheduledUpdateUsesGentleReminderAndUpdatesMenuState() { + let monitor = SparkleUpdateMonitor() + let update = SUAppcastItem.empty() + + XCTAssertNoThrow( + try monitor.updater(delegateUpdater, mayPerform: .updatesInBackground) + ) + XCTAssertEqual(monitor.status, .checking) + XCTAssertFalse(monitor.isMenuActionEnabled) + + monitor.updater(delegateUpdater, didFindValidUpdate: update) + + let standardDriverHandlesUpdate = monitor.standardUserDriverShouldHandleShowingScheduledUpdate( + update, + andInImmediateFocus: true + ) + + XCTAssertTrue(monitor.supportsGentleScheduledUpdateReminders) + XCTAssertFalse(standardDriverHandlesUpdate) + monitor.handleScheduledUpdate(update) + + XCTAssertEqual( + monitor.status, + .updateAvailable(version: update.displayVersionString) + ) + XCTAssertTrue(monitor.isMenuActionEnabled) + } + + func testScheduledNoUpdateRestoresIdleState() { + let monitor = SparkleUpdateMonitor() + + XCTAssertNoThrow( + try monitor.updater(delegateUpdater, mayPerform: .updatesInBackground) + ) + monitor.updater( + delegateUpdater, + didFinishUpdateCycleFor: .updatesInBackground, + error: noUpdateError + ) + + XCTAssertEqual(monitor.status, .idle) + XCTAssertTrue(monitor.isMenuActionEnabled) + } + + func testUserCheckUsesCanCheckForUpdatesEvenWhenSessionIsActive() { + 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) + updater.sessionInProgress = true + monitor.showUpdate(using: updater) + + XCTAssertEqual(updater.userCheckCount, 1) + + updater.canCheckForUpdates = false + monitor.showUpdate(using: updater) + + XCTAssertEqual(updater.userCheckCount, 1) + } + + private var delegateUpdater: SPUUpdater { + SPUStandardUpdaterController( + startingUpdater: false, + updaterDelegate: nil, + userDriverDelegate: nil + ).updater + } + + private var noUpdateError: NSError { + NSError( + domain: SUSparkleErrorDomain, + code: Int(SUError.noUpdateError.rawValue) + ) + } +} + +@MainActor +private final class UpdaterSpy: SparkleUpdateChecking { + var canCheckForUpdates: Bool + var sessionInProgress: Bool + private(set) var informationCheckCount = 0 + private(set) var userCheckCount = 0 + + init(canCheckForUpdates: Bool = true, sessionInProgress: Bool = false) { + self.canCheckForUpdates = canCheckForUpdates + self.sessionInProgress = sessionInProgress + } + + func checkForUpdateInformation() { + informationCheckCount += 1 + } + + func checkForUpdates() { + userCheckCount += 1 + } +} diff --git a/TypeSwitchTests/SupportDiagnosticsTests.swift b/TypeSwitchTests/SupportDiagnosticsTests.swift index 3084cc4..2cca300 100644 --- a/TypeSwitchTests/SupportDiagnosticsTests.swift +++ b/TypeSwitchTests/SupportDiagnosticsTests.swift @@ -120,4 +120,12 @@ final class SupportDiagnosticsTests: XCTestCase { "bug_report.yml" ) } + + func testGitHubLinksUseRepositoryAndDeveloperProfile() throws { + let repositoryURL = try XCTUnwrap(AppInfoService.githubRepositoryURL) + let profileURL = try XCTUnwrap(AppInfoService.githubProfileURL) + + XCTAssertEqual(repositoryURL.absoluteString, "https://github.com/ygsgdbd/TypeSwitch") + XCTAssertEqual(profileURL.absoluteString, "https://github.com/ygsgdbd") + } } diff --git a/script/generate_readme_screenshots.sh b/script/generate_readme_screenshots.sh index 44c02d8..0a35b3d 100755 --- a/script/generate_readme_screenshots.sh +++ b/script/generate_readme_screenshots.sh @@ -125,18 +125,20 @@ on run argv tell application "System Events" tell first application process whose unix id is targetPID repeat with candidateMenuBar in menu bars - if exists menu bar item "TypeSwitch" of candidateMenuBar then - set candidateItem to menu bar item "TypeSwitch" of candidateMenuBar - set {itemX, itemY} to position of candidateItem - set {itemWidth, itemHeight} to size of candidateItem - set centerX to itemX + itemWidth / 2 - set centerY to itemY + itemHeight / 2 - if itemWidth > 0 and itemHeight > 0 ¬ - and centerX ≥ displayLeft and centerX < displayRight ¬ - and centerY ≥ displayTop and centerY < displayBottom then - return true + repeat with candidateItem in menu bar items of candidateMenuBar + set itemName to name of candidateItem + if itemName is not "TypeSwitch" and itemName starts with "TypeSwitch" then + set {itemX, itemY} to position of candidateItem + set {itemWidth, itemHeight} to size of candidateItem + set centerX to itemX + itemWidth / 2 + set centerY to itemY + itemHeight / 2 + if itemWidth > 0 and itemHeight > 0 ¬ + and centerX ≥ displayLeft and centerX < displayRight ¬ + and centerY ≥ displayTop and centerY < displayBottom then + return true + end if end if - end if + end repeat end repeat error "TypeSwitch menu bar item was not found on the selected display." end tell @@ -161,14 +163,16 @@ on run argv tell application "System Events" tell first application process whose unix id is targetPID repeat with candidateMenuBar in menu bars - if exists menu bar item "TypeSwitch" of candidateMenuBar then - set candidateItem to menu bar item "TypeSwitch" of candidateMenuBar - set {itemX, itemY} to position of candidateItem - set {itemWidth, itemHeight} to size of candidateItem - if itemWidth > 0 and itemHeight > 0 then - return (itemX as text) & "," & (itemY as text) + repeat with candidateItem in menu bar items of candidateMenuBar + set itemName to name of candidateItem + if itemName is not "TypeSwitch" and itemName starts with "TypeSwitch" then + set {itemX, itemY} to position of candidateItem + set {itemWidth, itemHeight} to size of candidateItem + if itemWidth > 0 and itemHeight > 0 then + return (itemX as text) & "," & (itemY as text) + end if end if - end if + end repeat end repeat error "A visible TypeSwitch menu bar item was not found." end tell @@ -215,19 +219,21 @@ on run argv tell application "System Events" tell first application process whose unix id is targetPID repeat with candidateMenuBar in menu bars - if exists menu bar item "TypeSwitch" of candidateMenuBar then - set candidateItem to menu bar item "TypeSwitch" of candidateMenuBar - set {itemX, itemY} to position of candidateItem - set {itemWidth, itemHeight} to size of candidateItem - set centerX to itemX + itemWidth / 2 - set centerY to itemY + itemHeight / 2 - if itemWidth > 0 and itemHeight > 0 ¬ - and centerX ≥ displayLeft and centerX < displayRight ¬ - and centerY ≥ displayTop and centerY < displayBottom then - click candidateItem - return + repeat with candidateItem in menu bar items of candidateMenuBar + set itemName to name of candidateItem + if itemName is not "TypeSwitch" and itemName starts with "TypeSwitch" then + set {itemX, itemY} to position of candidateItem + set {itemWidth, itemHeight} to size of candidateItem + set centerX to itemX + itemWidth / 2 + set centerY to itemY + itemHeight / 2 + if itemWidth > 0 and itemHeight > 0 ¬ + and centerX ≥ displayLeft and centerX < displayRight ¬ + and centerY ≥ displayTop and centerY < displayBottom then + click candidateItem + return + end if end if - end if + end repeat end repeat error "TypeSwitch menu bar item was not found on the selected display." end tell @@ -249,21 +255,23 @@ on run argv tell application "System Events" tell first application process whose unix id is targetPID repeat with candidateMenuBar in menu bars - if exists menu bar item "TypeSwitch" of candidateMenuBar then - set candidateItem to menu bar item "TypeSwitch" of candidateMenuBar - set {itemX, itemY} to position of candidateItem - set {itemWidth, itemHeight} to size of candidateItem - set centerX to itemX + itemWidth / 2 - set centerY to itemY + itemHeight / 2 - if itemWidth > 0 and itemHeight > 0 ¬ - and centerX ≥ displayLeft and centerX < displayRight ¬ - and centerY ≥ displayTop and centerY < displayBottom then - tell menu item "Safari" of menu 1 of candidateItem - perform action "AXPress" - end tell - return + repeat with candidateItem in menu bar items of candidateMenuBar + set itemName to name of candidateItem + if itemName is not "TypeSwitch" and itemName starts with "TypeSwitch" then + set {itemX, itemY} to position of candidateItem + set {itemWidth, itemHeight} to size of candidateItem + set centerX to itemX + itemWidth / 2 + set centerY to itemY + itemHeight / 2 + if itemWidth > 0 and itemHeight > 0 ¬ + and centerX ≥ displayLeft and centerX < displayRight ¬ + and centerY ≥ displayTop and centerY < displayBottom then + tell menu item "Safari" of menu 1 of candidateItem + perform action "AXPress" + end tell + return + end if end if - end if + end repeat end repeat error "TypeSwitch menu bar item was not found on the selected display." end tell