diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..b0d1133 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,75 @@ +name: Bug report +description: Report a reproducible problem with TypeSwitch +title: "[Bug] " +labels: + - bug +body: + - type: markdown + attributes: + value: | + 感谢反馈。请勿提交私人 App 名称、用户名或文件路径。 + Thanks for helping improve TypeSwitch. Do not include private app names, usernames, or file paths. + + - type: textarea + id: problem + attributes: + label: 问题描述 / Problem description + description: 请说明发生了什么,以及你观察到的实际行为。 + validations: + required: true + + - type: textarea + id: reproduction_steps + attributes: + label: 复现步骤 / Steps to reproduce + placeholder: | + 1. 打开…… + 2. 选择…… + 3. 观察到…… + validations: + required: true + + - type: textarea + id: expected_behavior + attributes: + label: 预期行为 / Expected behavior + description: 请说明你原本期望 TypeSwitch 如何运行。 + validations: + required: true + + - type: dropdown + id: installation_method + attributes: + label: 安装方式 / Installation method + options: + - Homebrew + - GitHub Release 手动安装 / Manual GitHub Release install + - 从源码构建 / Built from source + - 不确定 / Not sure + validations: + required: true + + - type: dropdown + id: reproducibility + attributes: + label: 是否稳定复现 / Reproducibility + options: + - 每次 / Every time + - 偶尔 / Sometimes + - 一次 / Once + validations: + required: true + + - type: textarea + id: diagnostics + attributes: + label: 诊断信息 / Diagnostics + description: 可选。粘贴与问题相关的错误信息或诊断内容,并先移除隐私数据。 + + - type: checkboxes + id: privacy_confirmation + attributes: + label: 隐私确认 / Privacy confirmation + options: + - label: 我已检查以上内容,不包含私人 App、用户名或文件路径。 + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..8005e32 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,2 @@ +blank_issues_enabled: false +contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..d23ded6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,43 @@ +name: Feature request +description: Suggest an improvement for TypeSwitch +title: "[Feature] " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + 感谢你的建议。请描述实际使用场景,并避免提交隐私信息。 + Thanks for your suggestion. Describe the real use case and avoid sharing private information. + + - type: textarea + id: use_case + attributes: + label: 使用场景 / Use case + description: 请说明你在什么情况下需要这个功能,以及它要解决的问题。 + validations: + required: true + + - type: textarea + id: current_workaround + attributes: + label: 当前绕行方式 / Current workaround + description: 请说明你现在如何处理这个需求;如果没有,请填写“无”。 + validations: + required: true + + - type: textarea + id: desired_behavior + attributes: + label: 期望行为 / Desired behavior + description: 请从用户角度说明期望 TypeSwitch 如何运行。 + validations: + required: true + + - type: checkboxes + id: privacy_confirmation + attributes: + label: 隐私确认 / Privacy confirmation + options: + - label: 我已检查以上内容,不包含私人 App、用户名或文件路径。 + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..8995114 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,18 @@ +## Summary + + + +## Testing + + + +## Changelog + +Select exactly one: + +- [ ] Updated both Chinese and English entries under `Unreleased` in `CHANGELOG.md`. +- [ ] No changelog entry is needed. Reason: + +## Privacy + +- [ ] This change does not add telemetry or upload user data, or the reason this is not applicable is explained above. diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 39cddba..8ef48b4 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -11,6 +11,17 @@ concurrency: cancel-in-progress: true jobs: + release-scripts: + name: Release Scripts + runs-on: macos-26 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Test release scripts + run: script/test_release_scripts.sh + format: name: Format runs-on: macos-26 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 494acaf..fcdce5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,16 +12,28 @@ on: type: string permissions: - contents: write - id-token: write - attestations: write - artifact-metadata: write + contents: read + +concurrency: + group: release-${{ github.repository }}-${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} + cancel-in-progress: false jobs: - build: - name: Build and Release + publish: + name: Publish GitHub Release runs-on: macos-26 - + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + outputs: + tag: ${{ steps.release_tag.outputs.tag }} + version: ${{ steps.release_tag.outputs.version }} + commit: ${{ steps.release_tag.outputs.commit }} + asset_name: ${{ steps.release_artifact.outputs.asset_name }} + sha256: ${{ steps.release_artifact.outputs.sha256 }} + steps: - name: Checkout uses: actions/checkout@v4 @@ -105,18 +117,12 @@ jobs: - name: Check publishing secrets env: SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} - HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} run: | if [[ -z "$SPARKLE_ED_PRIVATE_KEY" ]]; then echo "::error title=Missing Sparkle signing key::Set the SPARKLE_ED_PRIVATE_KEY repository secret before publishing a release." exit 1 fi - if [[ -z "$HOMEBREW_TAP_TOKEN" ]]; then - echo "::error title=Missing Homebrew tap token::Set the HOMEBREW_TAP_TOKEN repository secret before publishing a release." - exit 1 - fi - - name: Setup Xcode uses: maxim-lobanov/setup-xcode@v1 with: @@ -250,11 +256,16 @@ jobs: cat release-notes.html - name: Generate Checksums + id: release_artifact run: | + SHA256=$(shasum -a 256 TypeSwitch-macOS-universal.zip | awk '{print $1}') + echo "asset_name=TypeSwitch-macOS-universal.zip" >> "$GITHUB_OUTPUT" + echo "sha256=$SHA256" >> "$GITHUB_OUTPUT" + { echo "### SHA-256 Checksums" echo '```' - shasum -a 256 TypeSwitch-macOS-universal.zip + echo "$SHA256 TypeSwitch-macOS-universal.zip" echo '```' } > checksums.txt @@ -305,6 +316,14 @@ jobs: cp DerivedData/SparkleFeed/appcast.xml appcast.xml + - name: Verify local release artifacts + env: + RELEASE_TAG: ${{ steps.release_tag.outputs.tag }} + run: script/verify_release.sh local "$RELEASE_TAG" . + + - name: Test release scripts + run: script/test_release_scripts.sh + - name: Attest release artifact uses: actions/attest@v4 with: @@ -321,10 +340,52 @@ jobs: draft: false prerelease: false generate_release_notes: true + overwrite_files: true tag_name: ${{ steps.release_tag.outputs.tag }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Record publish failure + if: ${{ failure() }} + run: | + { + echo "### Release distribution status" + echo + echo '- Status: `publish_failed`' + echo "- Tag: \`${{ steps.release_tag.outputs.tag || github.ref_name }}\`" + echo "- Details: The publish job did not complete successfully." + } >> "$GITHUB_STEP_SUMMARY" + + sync_homebrew: + name: Sync Homebrew cask + needs: publish + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout release scripts + uses: actions/checkout@v4 + with: + ref: ${{ needs.publish.outputs.commit }} + + - name: Download published release asset + env: + ASSET_NAME: ${{ needs.publish.outputs.asset_name }} + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.publish.outputs.tag }} + run: | + mkdir -p release-assets + gh release download "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --dir release-assets \ + --pattern "$ASSET_NAME" + + ACTUAL_SHA256=$(sha256sum "release-assets/$ASSET_NAME" | awk '{print $1}') + if [[ "$ACTUAL_SHA256" != "${{ needs.publish.outputs.sha256 }}" ]]; then + echo "::error title=Published asset checksum mismatch::Expected ${{ needs.publish.outputs.sha256 }}, got ${ACTUAL_SHA256}." + exit 1 + fi + - name: Checkout Homebrew tap uses: actions/checkout@v4 with: @@ -332,40 +393,12 @@ jobs: token: ${{ secrets.HOMEBREW_TAP_TOKEN }} path: homebrew-tap - - name: Update Homebrew cask + - name: Update and push Homebrew cask env: - ASSET_NAME: TypeSwitch-macOS-universal.zip - RELEASE_TAG: ${{ steps.release_tag.outputs.tag }} - VERSION: ${{ steps.release_tag.outputs.version }} + RELEASE_TAG: ${{ needs.publish.outputs.tag }} + SHA256: ${{ needs.publish.outputs.sha256 }} run: | - SHA256=$(shasum -a 256 "$ASSET_NAME" | awk '{print $1}') - CASK_PATH="homebrew-tap/Casks/typeswitch.rb" - - export RELEASE_TAG VERSION SHA256 CASK_PATH - ruby <<'RUBY' - path = ENV.fetch("CASK_PATH") - release_tag = ENV.fetch("RELEASE_TAG") - version = ENV.fetch("VERSION") - sha256 = ENV.fetch("SHA256") - - abort "Homebrew cask does not exist: #{path}" unless File.file?(path) - - content = File.read(path) - replacements = { - /^ version ".*"$/ => " version \"#{version}\"", - /^ sha256 ".*"$/ => " sha256 \"#{sha256}\"", - /^ url ".*"$/ => " url \"https://github.com/ygsgdbd/TypeSwitch/releases/download/#{release_tag}/TypeSwitch-macOS-universal.zip\"", - } - - replacements.each do |pattern, replacement| - matches = content.scan(pattern).length - abort "Expected exactly one #{pattern.inspect} entry in #{path}, found #{matches}" unless matches == 1 - - content.sub!(pattern, replacement) - end - - File.write(path, content) - RUBY + ruby script/update_homebrew_cask.rb homebrew-tap/Casks/typeswitch.rb "$RELEASE_TAG" "$SHA256" cd homebrew-tap git config user.name "github-actions[bot]" @@ -379,3 +412,24 @@ jobs: git commit -m "Update TypeSwitch cask to ${RELEASE_TAG}" git push + + verify_distribution: + name: Verify published distribution + needs: [publish, sync_homebrew] + if: ${{ always() && needs.publish.result == 'success' }} + runs-on: macos-26 + permissions: + contents: read + attestations: read + steps: + - name: Checkout release scripts + uses: actions/checkout@v4 + with: + ref: ${{ needs.publish.outputs.commit }} + + - name: Verify GitHub Release assets and attestation + env: + EXPECTED_SHA256: ${{ needs.publish.outputs.sha256 }} + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.publish.outputs.tag }} + run: script/verify_release.sh remote "$RELEASE_TAG" "$GITHUB_REPOSITORY" "$EXPECTED_SHA256" diff --git a/CHANGELOG.md b/CHANGELOG.md index a87f984..356cded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,17 +4,27 @@ ### 🇨🇳 中文 +#### 功能 + +- 新增输入法健康诊断:输入法列表为空、读取失败或自动切换失败时,菜单会显示具体状态,并提供重新加载输入法、重试当前 App 和打开键盘设置等恢复操作。 + #### 修复 - 取消启动时的静默更新检查,确保只有用户主动点击“检查更新…”时才会访问 GitHub。 - 修复恢复已忽略 App 时丢失原输入法策略的问题,并为菜单栏图标补充当前 App 状态的 VoiceOver 描述。 +- 修复输入法目录加载期间激活 App 时可能漏掉自动切换的问题;目录加载完成后仅补偿处理此前被跳过的当前 App。 ### 🇺🇸 English +#### 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. + #### 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. ## v0.9.0 diff --git a/Documentation/CodeReview-2026-08-17.md b/Documentation/CodeReview-2026-08-17.md new file mode 100644 index 0000000..6fc6764 --- /dev/null +++ b/Documentation/CodeReview-2026-08-17.md @@ -0,0 +1,36 @@ +# 未提交改动审查记录(2026-08-17) + +本记录覆盖输入法切换补偿、支持诊断与发布链路相关改动。当前完整 macOS XCTest 和发布脚本测试均通过,但以下问题应在 PR 合并前处理。 + +## 待处理问题 + +### P1:发布前未验证 Homebrew 凭据 + +`.github/workflows/release.yml` 的发布前检查只验证 `SPARKLE_ED_PRIVATE_KEY`。如果 `HOMEBREW_TAP_TOKEN` 缺失、失效或没有 tap 写权限,GitHub Release 和 attestation 已公开后,Homebrew 同步才会失败,形成半完成发行。 + +建议在公开 Release 前检查 token 非空,并通过只读 GitHub API 请求确认其对 `ygsgdbd/homebrew-tap` 具备 push 权限。 + +### P1:不同版本的发布任务仍可并发 + +release concurrency group 当前包含 tag,因此只能阻止同一 tag 并发。不同 tag 可以同时修改全局 latest Release 和同一个 Homebrew cask;如果旧版本后完成,可能覆盖或降级新版本的 cask。 + +建议让同一仓库的所有发布共用一个 concurrency group,使完整发布链串行执行,并保持 `cancel-in-progress: false`。 + +### P2:诊断信息缺少 Locale + +`SupportDiagnostics` 已使用 `Bundle.main.preferredLocalizations.first` 报告 App 界面语言,但没有同时记录 `Locale.current.identifier`。区域格式、排序或界面语言与地区不一致的问题仍缺少必要上下文。 + +建议分别输出 `App Language` 和 `Locale`,并增加二者不同及 App language 缺失时的测试。 + +### P2:Homebrew updater 测试未覆盖 SHA 和 URL 替换 + +`script/test_release_scripts.sh` 的 updater fixture 只把 version 改为旧值,SHA 和 URL 原本就是正确值,也没有逐项断言第一次更新后的结果。如果 updater 以后不再替换 SHA 或 URL,测试仍会通过。 + +建议让 version、sha256 和 url 三个字段全部使用旧值,并逐项断言更新后的目标内容和第二次运行的幂等性。 + +## 已验证 + +- 重新生成 Tuist 工程后的完整 macOS XCTest:126 tests,0 failures。 +- `script/test_release_scripts.sh`:通过。 +- Shell、Ruby、workflow YAML 和 `git diff --check`:通过。 +- SwiftFormat 0.62.1:0 个文件需要格式化。 diff --git a/TypeSwitch/Resources/Base.lproj/Localizable.strings b/TypeSwitch/Resources/Base.lproj/Localizable.strings index caddb64..62a7c03 100644 --- a/TypeSwitch/Resources/Base.lproj/Localizable.strings +++ b/TypeSwitch/Resources/Base.lproj/Localizable.strings @@ -15,6 +15,12 @@ "settings.general.check_for_updates" = "检查更新…"; "settings.fallback.default_input_method" = "未配置 App 的默认规则"; +// 支持 +"support.version" = "TypeSwitch v%@"; +"support.help" = "使用帮助"; +"support.copy_diagnostics" = "复制诊断信息"; +"support.report_issue" = "报告问题"; + // 应用列表 "apps.section.current_app" = "当前应用"; "apps.section.running_count" = "运行中 · 已配置(%d)"; diff --git a/TypeSwitch/Resources/en.lproj/Localizable.strings b/TypeSwitch/Resources/en.lproj/Localizable.strings index 2baef17..bcfce8d 100644 --- a/TypeSwitch/Resources/en.lproj/Localizable.strings +++ b/TypeSwitch/Resources/en.lproj/Localizable.strings @@ -15,6 +15,12 @@ "settings.general.check_for_updates" = "Check for Updates…"; "settings.fallback.default_input_method" = "Default Rule for Unconfigured Apps"; +// Support +"support.version" = "TypeSwitch v%@"; +"support.help" = "Help"; +"support.copy_diagnostics" = "Copy Diagnostics"; +"support.report_issue" = "Report an Issue"; + // App List "apps.section.current_app" = "Current App"; "apps.section.running_count" = "Running · Configured (%d)"; diff --git a/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings b/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings index caddb64..62a7c03 100644 --- a/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings +++ b/TypeSwitch/Resources/zh-Hans.lproj/Localizable.strings @@ -15,6 +15,12 @@ "settings.general.check_for_updates" = "检查更新…"; "settings.fallback.default_input_method" = "未配置 App 的默认规则"; +// 支持 +"support.version" = "TypeSwitch v%@"; +"support.help" = "使用帮助"; +"support.copy_diagnostics" = "复制诊断信息"; +"support.report_issue" = "报告问题"; + // 应用列表 "apps.section.current_app" = "当前应用"; "apps.section.running_count" = "运行中 · 已配置(%d)"; diff --git a/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings b/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings index cdef145..389c6cf 100644 --- a/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings +++ b/TypeSwitch/Resources/zh-Hant.lproj/Localizable.strings @@ -15,6 +15,12 @@ "settings.general.check_for_updates" = "檢查更新…"; "settings.fallback.default_input_method" = "未設定 App 的預設規則"; +// 支援 +"support.version" = "TypeSwitch v%@"; +"support.help" = "使用說明"; +"support.copy_diagnostics" = "複製診斷資訊"; +"support.report_issue" = "回報問題"; + // 應用程式列表 "apps.section.current_app" = "目前應用"; "apps.section.running_count" = "執行中 · 已設定(%d)"; diff --git a/TypeSwitch/Sources/App/AppFeature.swift b/TypeSwitch/Sources/App/AppFeature.swift index bf84206..ff95707 100644 --- a/TypeSwitch/Sources/App/AppFeature.swift +++ b/TypeSwitch/Sources/App/AppFeature.swift @@ -109,6 +109,7 @@ struct AppFeature { var pendingInputMethodRefreshID: Int? var pendingProgrammaticSwitch: PendingProgrammaticSwitch? var runningApps: [AppInfo] = [] + var shouldRetryFrontmostAfterInputMethodRefresh = false init( appRulesStore: Shared = Shared( @@ -137,7 +138,8 @@ struct AppFeature { pendingFrontmostRetryID: Int? = nil, pendingInputMethodRefreshID: Int? = nil, pendingProgrammaticSwitch: PendingProgrammaticSwitch? = nil, - runningApps: [AppInfo] = [] + runningApps: [AppInfo] = [], + shouldRetryFrontmostAfterInputMethodRefresh: Bool = false ) { self._appRulesStore = appRulesStore self._appSwitchStatisticsStore = appSwitchStatisticsStore @@ -157,6 +159,7 @@ struct AppFeature { self.pendingInputMethodRefreshID = pendingInputMethodRefreshID self.pendingProgrammaticSwitch = pendingProgrammaticSwitch self.runningApps = runningApps + self.shouldRetryFrontmostAfterInputMethodRefresh = shouldRetryFrontmostAfterInputMethodRefresh } } @@ -319,7 +322,11 @@ struct AppFeature { state.pendingInputMethodRefreshID = nil state.inputMethodCatalogStatus = .ready state.inputMethods = inputMethods - return .none + guard state.shouldRetryFrontmostAfterInputMethodRefresh else { + return .none + } + state.shouldRetryFrontmostAfterInputMethodRefresh = false + return retryFrontmostApplicationEffect(in: &state) case let .response(.inputMethodsLoaded(refreshID, .failure(error))): guard state.pendingInputMethodRefreshID == refreshID else { @@ -463,15 +470,7 @@ struct AppFeature { return .none case .view(.retryCurrentAppTapped): - let retryID = state.nextFrontmostRetryID - state.nextFrontmostRetryID += 1 - state.pendingFrontmostRetryID = retryID - return .run { send in - await send(.response(.frontmostApplicationRetried( - retryID: retryID, - appInfo: await workspaceClient.frontmostApplication() - ))) - } + return retryFrontmostApplicationEffect(in: &state) case .response(.runningApps(let runningApps)): state.runningApps = runningApps @@ -530,6 +529,7 @@ struct AppFeature { if wasCurrentApp { state.currentFrontmostBundleId = nil state.pendingFrontmostRetryID = nil + state.shouldRetryFrontmostAfterInputMethodRefresh = false } let shouldCancelProgrammaticSwitch = wasCurrentApp || state.pendingProgrammaticSwitch?.bundleId == bundleId @@ -556,9 +556,15 @@ struct AppFeature { switch resolveSwitchTarget(for: appInfo.bundleId, state: state) { case .none: state.pendingProgrammaticSwitch = nil + state.shouldRetryFrontmostAfterInputMethodRefresh = false + return .cancel(id: CancelID.programmaticSwitch) + case .waitingForCatalog: + state.pendingProgrammaticSwitch = nil + state.shouldRetryFrontmostAfterInputMethodRefresh = true return .cancel(id: CancelID.programmaticSwitch) case let .unavailable(inputMethodId, ruleSource): state.pendingProgrammaticSwitch = nil + state.shouldRetryFrontmostAfterInputMethodRefresh = false state.lastSwitchAttempt = .init( appName: appInfo.name, bundleId: appInfo.bundleId, @@ -570,6 +576,7 @@ struct AppFeature { ) return .cancel(id: CancelID.programmaticSwitch) case let .target(inputMethod, ruleSource): + state.shouldRetryFrontmostAfterInputMethodRefresh = false let attemptID = state.nextSwitchAttemptID state.nextSwitchAttemptID += 1 state.pendingProgrammaticSwitch = .init( @@ -676,6 +683,7 @@ struct AppFeature { case none case target(InputMethod, State.RuleSource) case unavailable(String, State.RuleSource) + case waitingForCatalog } private func resolveSwitchTarget(for bundleId: String, state: State) -> SwitchTargetResolution { @@ -699,9 +707,26 @@ struct AppFeature { if let inputMethod = state.inputMethods.first(where: { $0.id == candidateId }) { return .target(inputMethod, ruleSource) } - return state.inputMethodCatalogStatus == .ready - ? .unavailable(candidateId, ruleSource) - : .none + switch state.inputMethodCatalogStatus { + case .loading: + return .waitingForCatalog + case .ready: + return .unavailable(candidateId, ruleSource) + case .failed: + return .waitingForCatalog + } + } + + private func retryFrontmostApplicationEffect(in state: inout State) -> Effect { + let retryID = state.nextFrontmostRetryID + state.nextFrontmostRetryID += 1 + state.pendingFrontmostRetryID = retryID + return .run { send in + await send(.response(.frontmostApplicationRetried( + retryID: retryID, + appInfo: await workspaceClient.frontmostApplication() + ))) + } } private func fallbackSupportedStrategy(_ strategy: InputMethodStrategy) -> InputMethodStrategy { diff --git a/TypeSwitch/Sources/Services/AppManagement/AppInfoService.swift b/TypeSwitch/Sources/Services/AppManagement/AppInfoService.swift index a0ae938..5b0b5a3 100644 --- a/TypeSwitch/Sources/Services/AppManagement/AppInfoService.swift +++ b/TypeSwitch/Sources/Services/AppManagement/AppInfoService.swift @@ -17,6 +17,24 @@ enum AppInfoService { URL(string: "\(githubBaseURL)/\(githubRepository)/releases") } + /// 获取使用帮助页面 URL + static var helpURL: URL? { + URL(string: "\(githubBaseURL)/\(githubRepository)#readme") + } + + /// 获取 GitHub Bug Report 表单 URL + static var bugReportURL: URL? { + guard var components = URLComponents( + string: "\(githubBaseURL)/\(githubRepository)/issues/new" + ) else { + return nil + } + components.queryItems = [ + URLQueryItem(name: "template", value: "bug_report.yml"), + ] + return components.url + } + /// 打开 GitHub 仓库页面 @MainActor static func openGitHubRepository() { @@ -30,4 +48,25 @@ enum AppInfoService { guard let url = githubReleasesURL else { return } NSWorkspace.shared.open(url) } + + /// 打开使用帮助页面 + @MainActor + static func openHelp() { + guard let url = helpURL else { return } + NSWorkspace.shared.open(url) + } + + /// 复制低敏感诊断信息 + @MainActor + static func copySupportDiagnostics(_ diagnostics: SupportDiagnostics) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(diagnostics.reportText, forType: .string) + } + + /// 打开 GitHub Issue 页面 + @MainActor + static func openReportIssue() { + guard let url = bugReportURL else { return } + NSWorkspace.shared.open(url) + } } diff --git a/TypeSwitch/Sources/Services/AppManagement/SupportDiagnostics.swift b/TypeSwitch/Sources/Services/AppManagement/SupportDiagnostics.swift new file mode 100644 index 0000000..fcd3f99 --- /dev/null +++ b/TypeSwitch/Sources/Services/AppManagement/SupportDiagnostics.swift @@ -0,0 +1,80 @@ +import Foundation + +/// 用户主动复制或报告问题时附带的低敏感环境信息。 +struct SupportDiagnostics: Equatable { + let version: String + let build: String + let operatingSystemVersion: String + let architecture: String + let appLanguage: String + let diagnosticCategory: String? + let errorDescription: String? + + init( + version: String?, + build: String?, + operatingSystemVersion: String, + architecture: String, + appLanguage: String?, + diagnosticCategory: String? = nil, + errorDescription: String? = nil + ) { + self.version = Self.valueOrPlaceholder(version) + self.build = Self.valueOrPlaceholder(build) + self.operatingSystemVersion = operatingSystemVersion + self.architecture = architecture + self.appLanguage = Self.valueOrPlaceholder(appLanguage) + self.diagnosticCategory = diagnosticCategory + self.errorDescription = errorDescription + } + + static func current( + diagnosticCategory: String? = nil, + errorDescription: String? = nil + ) -> Self { + let bundle = Bundle.main + let shortVersion = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let buildVersion = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String + + return Self( + version: shortVersion, + build: buildVersion, + operatingSystemVersion: ProcessInfo.processInfo.operatingSystemVersionString, + architecture: currentArchitecture, + appLanguage: bundle.preferredLocalizations.first, + diagnosticCategory: diagnosticCategory, + errorDescription: errorDescription + ) + } + + var reportText: String { + var lines = [ + "Version: \(version)", + "Build: \(build)", + "macOS: \(operatingSystemVersion)", + "Architecture: \(architecture)", + "App Language: \(appLanguage)", + ] + if let diagnosticCategory { + lines.append("Diagnostic category: \(diagnosticCategory)") + } + if let errorDescription { + lines.append("Error description: \(errorDescription)") + } + return lines.joined(separator: "\n") + } + + private static func valueOrPlaceholder(_ value: String?) -> String { + value.flatMap { $0.isEmpty ? nil : $0 } ?? "–" + } + + private static var currentArchitecture: String { + #if arch(arm64) + "arm64" + #elseif arch(x86_64) + "x86_64" + #else + "unknown" + #endif + } +} diff --git a/TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift b/TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift index 207fbad..537392b 100644 --- a/TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift +++ b/TypeSwitch/Sources/UI/Views/MenuBar/AppInfoView.swift @@ -1,13 +1,20 @@ import AppKit +import ComposableArchitecture import Sparkle import SwiftUI /// 应用信息视图,显示项目链接和退出入口 struct AppInfoView: View { + let store: StoreOf let updaterController: SPUStandardUpdaterController var body: some View { + let diagnostics = supportDiagnostics + Group { + Text(TypeSwitchStrings.Support.version(diagnostics.version)) + .foregroundStyle(.secondary) + Button { updaterController.checkForUpdates(nil) } label: { @@ -26,6 +33,24 @@ struct AppInfoView: View { ) } + 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") + } + Divider() Button(role: .destructive) { @@ -36,4 +61,23 @@ struct AppInfoView: View { .keyboardShortcut("q", modifiers: .command) } } + + private var supportDiagnostics: SupportDiagnostics { + let diagnostic = store.inputMethodDiagnostic + return .current( + diagnosticCategory: diagnostic.map { category(for: $0.kind) }, + errorDescription: diagnostic?.errorDescription + ) + } + + private func category(for kind: AppFeature.State.InputMethodDiagnostic.Kind) -> String { + switch kind { + case .catalogEmpty: + return "catalogEmpty" + case .catalogFailed: + return "catalogFailed" + case .switchFailed: + return "switchFailed" + } + } } diff --git a/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift b/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift index ffa80c2..81723e6 100644 --- a/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift +++ b/TypeSwitch/Sources/UI/Views/MenuBar/MenuBarView.swift @@ -37,7 +37,7 @@ struct MenuBarView: View { Divider() - AppInfoView(updaterController: updaterController) + AppInfoView(store: store, updaterController: updaterController) } .labelStyle(.titleAndIcon) } diff --git a/TypeSwitchTests/AppFeatureTests.swift b/TypeSwitchTests/AppFeatureTests.swift index 04b381b..4588487 100644 --- a/TypeSwitchTests/AppFeatureTests.swift +++ b/TypeSwitchTests/AppFeatureTests.swift @@ -532,6 +532,651 @@ final class AppFeatureTests: XCTestCase { XCTAssertEqual(store.state.inputMethods, latestInputMethods) } + func testInputMethodRefreshRetriesActivationSkippedWhileCatalogIsLoading() async { + let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") + let inputMethod = InputMethod(id: "ime.en", name: "English") + let timestamp = Date(timeIntervalSince1970: 10) + let recorder = SwitchRecorder() + let switchGate = InputMethodSwitchGate() + + var initialState = AppFeature.State() + initialState.nextInputMethodRefreshID = 1 + initialState.pendingInputMethodRefreshID = 0 + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = makeRule(app: app, strategy: .fixed(inputMethodId: inputMethod.id)) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(timestamp) + store.dependencies.workspaceClient.frontmostApplication = { app } + store.dependencies.inputMethodClient.currentInputMethodId = { "ime.zh" } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + await switchGate.wait() + } + + await store.send(.system(.workspaceEvent(.activated(app)))) { + $0.currentFrontmostBundleId = app.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([inputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [inputMethod] + $0.nextFrontmostRetryID = 1 + $0.pendingFrontmostRetryID = 0 + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: app))) { + $0.pendingFrontmostRetryID = nil + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: inputMethod.id, + inputMethodName: inputMethod.name + ) + } + await switchGate.waitUntilStarted() + await switchGate.resume() + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .switched + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: inputMethod.id, + inputMethodName: inputMethod.name, + outcome: .switched, + ruleSource: .app, + timestamp: timestamp + ) + $0.$appSwitchStatisticsStore.withLock { + $0.counts[app.bundleId] = 1 + } + } + + let switchedInputMethods = await recorder.values + XCTAssertEqual(switchedInputMethods, [inputMethod.id]) + } + + func testInputMethodRefreshRetriesOnlyLatestActivationSkippedWhileCatalogIsLoading() async { + let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") + let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") + let firstInputMethod = InputMethod(id: "ime.first", name: "First Input Method") + let secondInputMethod = InputMethod(id: "ime.second", name: "Second Input Method") + let timestamp = Date(timeIntervalSince1970: 10) + let recorder = SwitchRecorder() + let switchGate = InputMethodSwitchGate() + + var initialState = AppFeature.State() + initialState.nextInputMethodRefreshID = 1 + initialState.pendingInputMethodRefreshID = 0 + initialState.$appRulesStore.withLock { + $0.rules[firstApp.bundleId] = makeRule( + app: firstApp, + strategy: .fixed(inputMethodId: firstInputMethod.id) + ) + $0.rules[secondApp.bundleId] = makeRule( + app: secondApp, + strategy: .fixed(inputMethodId: secondInputMethod.id) + ) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(timestamp) + store.dependencies.workspaceClient.frontmostApplication = { secondApp } + store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + await switchGate.wait() + } + + await store.send(.system(.workspaceEvent(.activated(firstApp)))) { + $0.currentFrontmostBundleId = firstApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.system(.workspaceEvent(.activated(secondApp)))) { + $0.currentFrontmostBundleId = secondApp.bundleId + } + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([firstInputMethod, secondInputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [firstInputMethod, secondInputMethod] + $0.nextFrontmostRetryID = 1 + $0.pendingFrontmostRetryID = 0 + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: secondApp))) { + $0.pendingFrontmostRetryID = nil + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: secondApp.name, + bundleId: secondApp.bundleId, + inputMethodId: secondInputMethod.id, + inputMethodName: secondInputMethod.name + ) + } + await switchGate.waitUntilStarted() + await switchGate.resume() + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .switched + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: secondApp.name, + bundleId: secondApp.bundleId, + inputMethodId: secondInputMethod.id, + inputMethodName: secondInputMethod.name, + outcome: .switched, + ruleSource: .app, + timestamp: timestamp + ) + $0.$appSwitchStatisticsStore.withLock { + $0.counts[secondApp.bundleId] = 1 + } + } + + let switchedInputMethods = await recorder.values + XCTAssertEqual(switchedInputMethods, [secondInputMethod.id]) + } + + func testActivationWithoutSwitchTargetClearsPendingCatalogRetry() async { + let configuredApp = AppInfo( + bundleId: "com.test.configured", + name: "Configured", + path: "/Applications/Configured.app" + ) + let unconfiguredApp = AppInfo( + bundleId: "com.test.unconfigured", + name: "Unconfigured", + path: "/Applications/Unconfigured.app" + ) + let inputMethod = InputMethod(id: "ime.en", name: "English") + let timestamp = Date(timeIntervalSince1970: 10) + + var initialState = AppFeature.State() + initialState.nextInputMethodRefreshID = 1 + initialState.pendingInputMethodRefreshID = 0 + initialState.$appRulesStore.withLock { + $0.rules[configuredApp.bundleId] = makeRule( + app: configuredApp, + strategy: .fixed(inputMethodId: inputMethod.id) + ) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(timestamp) + store.dependencies.workspaceClient.frontmostApplication = { + XCTFail("A cleared catalog retry must not query the frontmost application") + return unconfiguredApp + } + store.dependencies.inputMethodClient.switchToInputMethod = { _ in + XCTFail("An unconfigured app must not trigger a compensated switch") + } + + await store.send(.system(.workspaceEvent(.activated(configuredApp)))) { + $0.currentFrontmostBundleId = configuredApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.system(.workspaceEvent(.activated(unconfiguredApp)))) { + $0.currentFrontmostBundleId = unconfiguredApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + $0.$appRulesStore.withLock { + $0.rules[unconfiguredApp.bundleId] = self.makeRule(app: unconfiguredApp, strategy: .none) + } + } + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([inputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [inputMethod] + } + } + + func testFailedInputMethodRefreshPreservesCatalogRetryAfterReactivationUntilReloadSucceeds() async { + let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") + let inputMethod = InputMethod(id: "ime.en", name: "English") + let timestamp = Date(timeIntervalSince1970: 10) + + var initialState = AppFeature.State() + initialState.nextInputMethodRefreshID = 1 + initialState.pendingInputMethodRefreshID = 0 + initialState.$fallbackRuleStore.withLock { + $0.strategy = .fixed(inputMethodId: inputMethod.id) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(timestamp) + store.dependencies.inputMethodClient.fetchInputMethods = { [inputMethod] } + store.dependencies.workspaceClient.frontmostApplication = { app } + store.dependencies.inputMethodClient.currentInputMethodId = { inputMethod.id } + + await store.send(.system(.workspaceEvent(.activated(app)))) { + $0.currentFrontmostBundleId = app.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + $0.$appRulesStore.withLock { + $0.rules[app.bundleId] = self.makeRule(app: app, strategy: .none) + } + } + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .failure(.failedToFetchInputMethods) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .failed(.failedToFetchInputMethods) + } + await store.send(.system(.workspaceEvent(.activated(app)))) + await store.send(.view(.reloadInputMethodsTapped)) { + $0.inputMethodCatalogStatus = .loading + $0.nextInputMethodRefreshID = 2 + $0.pendingInputMethodRefreshID = 1 + } + await store.receive(.response(.inputMethodsLoaded( + refreshID: 1, + result: .success([inputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [inputMethod] + $0.nextFrontmostRetryID = 1 + $0.pendingFrontmostRetryID = 0 + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: app))) { + $0.pendingFrontmostRetryID = nil + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: inputMethod.id, + inputMethodName: inputMethod.name, + ruleSource: .fallback + ) + } + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .alreadySelected + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: inputMethod.id, + inputMethodName: inputMethod.name, + outcome: .alreadySelected, + ruleSource: .fallback, + timestamp: timestamp + ) + } + } + + func testFailedCatalogRetriesOnlyFinalFrontmostConfiguredAppAfterReloadSucceeds() async { + let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") + let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") + let firstInputMethod = InputMethod(id: "ime.first", name: "First Input Method") + let secondInputMethod = InputMethod(id: "ime.second", name: "Second Input Method") + let timestamp = Date(timeIntervalSince1970: 10) + let recorder = SwitchRecorder() + let switchGate = InputMethodSwitchGate() + + var initialState = AppFeature.State( + inputMethodCatalogStatus: .failed(.failedToFetchInputMethods) + ) + initialState.$appRulesStore.withLock { + $0.rules[firstApp.bundleId] = makeRule( + app: firstApp, + strategy: .fixed(inputMethodId: firstInputMethod.id) + ) + $0.rules[secondApp.bundleId] = makeRule( + app: secondApp, + strategy: .followLast(lastInputMethodId: secondInputMethod.id) + ) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(timestamp) + store.dependencies.inputMethodClient.fetchInputMethods = { + [firstInputMethod, secondInputMethod] + } + store.dependencies.workspaceClient.frontmostApplication = { secondApp } + store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + await switchGate.wait() + } + + await store.send(.system(.workspaceEvent(.activated(firstApp)))) { + $0.currentFrontmostBundleId = firstApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.system(.workspaceEvent(.activated(secondApp)))) { + $0.currentFrontmostBundleId = secondApp.bundleId + } + await store.send(.view(.reloadInputMethodsTapped)) { + $0.inputMethodCatalogStatus = .loading + $0.nextInputMethodRefreshID = 1 + $0.pendingInputMethodRefreshID = 0 + } + await store.receive(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([firstInputMethod, secondInputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [firstInputMethod, secondInputMethod] + $0.nextFrontmostRetryID = 1 + $0.pendingFrontmostRetryID = 0 + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: secondApp))) { + $0.pendingFrontmostRetryID = nil + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: secondApp.name, + bundleId: secondApp.bundleId, + inputMethodId: secondInputMethod.id, + inputMethodName: secondInputMethod.name + ) + } + await switchGate.waitUntilStarted() + await switchGate.resume() + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .switched + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: secondApp.name, + bundleId: secondApp.bundleId, + inputMethodId: secondInputMethod.id, + inputMethodName: secondInputMethod.name, + outcome: .switched, + ruleSource: .app, + timestamp: timestamp + ) + $0.$appSwitchStatisticsStore.withLock { + $0.counts[secondApp.bundleId] = 1 + } + } + + let switchedInputMethods = await recorder.values + XCTAssertEqual(switchedInputMethods, [secondInputMethod.id]) + } + + func testFailedCatalogClearsRetryForAppsWithoutExplicitTarget() async { + let configuredApp = AppInfo( + bundleId: "com.test.configured", + name: "Configured", + path: "/Applications/Configured.app" + ) + let unconfiguredApp = AppInfo( + bundleId: "com.test.unconfigured", + name: "Unconfigured", + path: "/Applications/Unconfigured.app" + ) + let ignoredApp = AppInfo( + bundleId: "com.test.ignored", + name: "Ignored", + path: "/Applications/Ignored.app" + ) + let followLastApp = AppInfo( + bundleId: "com.test.follow-last", + name: "Follow Last", + path: "/Applications/Follow Last.app" + ) + let inputMethod = InputMethod(id: "ime.en", name: "English") + let timestamp = Date(timeIntervalSince1970: 10) + + var initialState = AppFeature.State( + inputMethodCatalogStatus: .failed(.failedToFetchInputMethods) + ) + initialState.$appRulesStore.withLock { + $0.rules[configuredApp.bundleId] = makeRule( + app: configuredApp, + strategy: .fixed(inputMethodId: inputMethod.id) + ) + $0.rules[ignoredApp.bundleId] = makeRule(app: ignoredApp, strategy: .ignored) + $0.rules[followLastApp.bundleId] = makeRule( + app: followLastApp, + strategy: .followLast(lastInputMethodId: nil) + ) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(timestamp) + store.dependencies.inputMethodClient.fetchInputMethods = { [inputMethod] } + store.dependencies.workspaceClient.frontmostApplication = { + XCTFail("A cleared catalog retry must not query the frontmost application") + return followLastApp + } + store.dependencies.inputMethodClient.switchToInputMethod = { _ in + XCTFail("An app without an explicit target must not trigger a compensated switch") + } + + await store.send(.system(.workspaceEvent(.activated(configuredApp)))) { + $0.currentFrontmostBundleId = configuredApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.system(.workspaceEvent(.activated(unconfiguredApp)))) { + $0.currentFrontmostBundleId = unconfiguredApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + $0.$appRulesStore.withLock { + $0.rules[unconfiguredApp.bundleId] = self.makeRule(app: unconfiguredApp, strategy: .none) + } + } + await store.send(.system(.workspaceEvent(.activated(configuredApp)))) { + $0.currentFrontmostBundleId = configuredApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.system(.workspaceEvent(.activated(ignoredApp)))) { + $0.currentFrontmostBundleId = ignoredApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.send(.system(.workspaceEvent(.activated(configuredApp)))) { + $0.currentFrontmostBundleId = configuredApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.system(.workspaceEvent(.activated(followLastApp)))) { + $0.currentFrontmostBundleId = followLastApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.send(.view(.reloadInputMethodsTapped)) { + $0.inputMethodCatalogStatus = .loading + $0.nextInputMethodRefreshID = 1 + $0.pendingInputMethodRefreshID = 0 + } + await store.receive(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([inputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [inputMethod] + } + } + + func testStaleInputMethodRefreshDoesNotConsumePendingCatalogRetry() async { + let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") + let inputMethod = InputMethod(id: "ime.en", name: "English") + let timestamp = Date(timeIntervalSince1970: 10) + + var initialState = AppFeature.State() + initialState.nextInputMethodRefreshID = 2 + initialState.pendingInputMethodRefreshID = 1 + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = makeRule(app: app, strategy: .fixed(inputMethodId: inputMethod.id)) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(timestamp) + store.dependencies.workspaceClient.frontmostApplication = { app } + store.dependencies.inputMethodClient.currentInputMethodId = { inputMethod.id } + + await store.send(.system(.workspaceEvent(.activated(app)))) { + $0.currentFrontmostBundleId = app.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([inputMethod]) + ))) + XCTAssertTrue(store.state.shouldRetryFrontmostAfterInputMethodRefresh) + + await store.send(.response(.inputMethodsLoaded( + refreshID: 1, + result: .success([inputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [inputMethod] + $0.nextFrontmostRetryID = 1 + $0.pendingFrontmostRetryID = 0 + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: app))) { + $0.pendingFrontmostRetryID = nil + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: inputMethod.id, + inputMethodName: inputMethod.name + ) + } + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .alreadySelected + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: inputMethod.id, + inputMethodName: inputMethod.name, + outcome: .alreadySelected, + ruleSource: .app, + timestamp: timestamp + ) + } + } + + func testCatalogRetryIgnoresFrontmostSnapshotAfterNewActivation() async { + let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") + let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") + let inputMethod = InputMethod(id: "ime.en", name: "English") + let frontmostGate = FrontmostApplicationGate(appInfo: firstApp) + + var initialState = AppFeature.State() + initialState.nextInputMethodRefreshID = 1 + initialState.pendingInputMethodRefreshID = 0 + initialState.$appRulesStore.withLock { + $0.rules[firstApp.bundleId] = makeRule( + app: firstApp, + strategy: .fixed(inputMethodId: inputMethod.id) + ) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.workspaceClient.frontmostApplication = { + await frontmostGate.value() + } + store.dependencies.inputMethodClient.switchToInputMethod = { _ in + XCTFail("A stale frontmost snapshot must not switch input methods") + } + + await store.send(.system(.workspaceEvent(.activated(firstApp)))) { + $0.currentFrontmostBundleId = firstApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([inputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [inputMethod] + $0.nextFrontmostRetryID = 1 + $0.pendingFrontmostRetryID = 0 + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await frontmostGate.waitUntilStarted() + + await store.send(.system(.workspaceEvent(.activated(secondApp)))) { + $0.currentFrontmostBundleId = secondApp.bundleId + $0.pendingFrontmostRetryID = nil + $0.$appRulesStore.withLock { + $0.rules[secondApp.bundleId] = self.makeRule(app: secondApp, strategy: .none) + } + } + + await frontmostGate.resume() + await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: firstApp))) + + XCTAssertEqual(store.state.currentFrontmostBundleId, secondApp.bundleId) + } + + func testSuccessfulInputMethodRefreshWithoutPendingCatalogRetryDoesNotReapplyCurrentRule() async { + let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") + let inputMethod = InputMethod(id: "ime.en", name: "English") + + var initialState = AppFeature.State() + initialState.currentFrontmostBundleId = app.bundleId + initialState.nextInputMethodRefreshID = 1 + initialState.pendingInputMethodRefreshID = 0 + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = makeRule(app: app, strategy: .fixed(inputMethodId: inputMethod.id)) + } + + let store = TestStore(initialState: initialState) { + AppFeature() + } + store.dependencies.workspaceClient.frontmostApplication = { + XCTFail("A normal catalog refresh must not query the frontmost application") + return app + } + store.dependencies.inputMethodClient.switchToInputMethod = { _ in + XCTFail("A normal catalog refresh must not reapply the current rule") + } + + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([inputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [inputMethod] + } + } + func testRetryCurrentAppUsesFreshFrontmostApplication() async { let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") let inputMethod = InputMethod(id: "ime.en", name: "English") @@ -2836,6 +3481,21 @@ final class AppFeatureTests: XCTestCase { ) } + private func makeRule( + app: AppInfo, + strategy: InputMethodStrategy, + timestamp: Date = Date(timeIntervalSince1970: 10) + ) -> AppRuleRecord { + AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: strategy, + createdAt: timestamp, + updatedAt: timestamp + ) + } + private func receiveStartupResponses(from store: TestStoreOf) async { await store.receive(.response(.launchAtLoginLoaded(.disabled))) await store.receive(.response(.frontmostApplicationLoaded(nil))) diff --git a/TypeSwitchTests/LocalizationStringsTests.swift b/TypeSwitchTests/LocalizationStringsTests.swift index 19a5a96..a1d96e5 100644 --- a/TypeSwitchTests/LocalizationStringsTests.swift +++ b/TypeSwitchTests/LocalizationStringsTests.swift @@ -50,6 +50,10 @@ final class LocalizationStringsTests: XCTestCase { "settings.general.open_login_items": "打开登录项设置", "settings.general.check_for_updates": "检查更新…", "settings.fallback.default_input_method": "未配置 App 的默认规则", + "support.version": "TypeSwitch v%@", + "support.help": "使用帮助", + "support.copy_diagnostics": "复制诊断信息", + "support.report_issue": "报告问题", "apps.section.running_count": "运行中 · 已配置(%d)", "apps.section.unconfigured_count": "运行中 · 未配置(%d)", "apps.section.configured_count": "全部已配置 App(%d)", @@ -73,6 +77,10 @@ final class LocalizationStringsTests: XCTestCase { "settings.general.open_login_items": "打开登录项设置", "settings.general.check_for_updates": "检查更新…", "settings.fallback.default_input_method": "未配置 App 的默认规则", + "support.version": "TypeSwitch v%@", + "support.help": "使用帮助", + "support.copy_diagnostics": "复制诊断信息", + "support.report_issue": "报告问题", "apps.section.running_count": "运行中 · 已配置(%d)", "apps.section.unconfigured_count": "运行中 · 未配置(%d)", "apps.section.configured_count": "全部已配置 App(%d)", @@ -96,6 +104,10 @@ final class LocalizationStringsTests: XCTestCase { "settings.general.open_login_items": "開啟登入項目設定", "settings.general.check_for_updates": "檢查更新…", "settings.fallback.default_input_method": "未設定 App 的預設規則", + "support.version": "TypeSwitch v%@", + "support.help": "使用說明", + "support.copy_diagnostics": "複製診斷資訊", + "support.report_issue": "回報問題", "apps.section.running_count": "執行中 · 已設定(%d)", "apps.section.unconfigured_count": "執行中 · 未設定(%d)", "apps.section.configured_count": "全部已設定 App(%d)", @@ -119,6 +131,10 @@ final class LocalizationStringsTests: XCTestCase { "settings.general.open_login_items": "Open Login Items Settings", "settings.general.check_for_updates": "Check for Updates…", "settings.fallback.default_input_method": "Default Rule for Unconfigured Apps", + "support.version": "TypeSwitch v%@", + "support.help": "Help", + "support.copy_diagnostics": "Copy Diagnostics", + "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/SupportDiagnosticsTests.swift b/TypeSwitchTests/SupportDiagnosticsTests.swift new file mode 100644 index 0000000..ed1067b --- /dev/null +++ b/TypeSwitchTests/SupportDiagnosticsTests.swift @@ -0,0 +1,101 @@ +import Foundation +@testable import TypeSwitch +import XCTest + +final class SupportDiagnosticsTests: XCTestCase { + func testReportTextWithoutActiveDiagnosticContainsOnlyEnvironmentFields() { + let diagnostics = SupportDiagnostics( + version: "1.2.3", + build: "45", + operatingSystemVersion: "macOS 15.6 (24G84)", + architecture: "arm64", + appLanguage: "zh-Hans" + ) + + XCTAssertEqual( + diagnostics.reportText, + """ + Version: 1.2.3 + Build: 45 + macOS: macOS 15.6 (24G84) + Architecture: arm64 + App Language: zh-Hans + """ + ) + } + + func testReportTextIncludesActiveDiagnosticWithoutAppOrInputMethodDetails() { + let diagnostics = SupportDiagnostics( + version: "1.2.3", + build: "45", + operatingSystemVersion: "macOS 15.6 (24G84)", + architecture: "arm64", + appLanguage: "zh-Hans", + diagnosticCategory: "switchFailed", + errorDescription: "Could not verify the selected input method" + ) + + XCTAssertEqual( + diagnostics.reportText, + """ + Version: 1.2.3 + Build: 45 + macOS: macOS 15.6 (24G84) + Architecture: arm64 + App Language: zh-Hans + Diagnostic category: switchFailed + Error description: Could not verify the selected input method + """ + ) + } + + func testMissingVersionAndBuildUsePlaceholders() { + let missingVersion = SupportDiagnostics( + version: nil, + build: "45", + operatingSystemVersion: "macOS 15.6 (24G84)", + architecture: "arm64", + appLanguage: "en" + ) + let missingBuild = SupportDiagnostics( + version: "1.2.3", + build: "", + operatingSystemVersion: "macOS 15.6 (24G84)", + architecture: "arm64", + appLanguage: "en" + ) + + XCTAssertEqual(missingVersion.version, "–") + XCTAssertEqual(missingVersion.build, "45") + XCTAssertTrue(missingVersion.reportText.hasPrefix("Version: –\nBuild: 45\n")) + XCTAssertEqual(missingBuild.version, "1.2.3") + XCTAssertEqual(missingBuild.build, "–") + XCTAssertTrue(missingBuild.reportText.hasPrefix("Version: 1.2.3\nBuild: –\n")) + } + + func testMissingAppLanguageUsesPlaceholder() { + let diagnostics = SupportDiagnostics( + version: "1.2.3", + build: "45", + operatingSystemVersion: "macOS 15.6 (24G84)", + architecture: "arm64", + appLanguage: nil + ) + + XCTAssertEqual(diagnostics.appLanguage, "–") + XCTAssertTrue(diagnostics.reportText.contains("App Language: –")) + } + + func testReportIssueURLUsesBugReportTemplate() throws { + let url = try XCTUnwrap(AppInfoService.bugReportURL) + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + + XCTAssertEqual(components.scheme, "https") + XCTAssertEqual(components.host, "github.com") + XCTAssertEqual(components.path, "/ygsgdbd/TypeSwitch/issues/new") + XCTAssertEqual( + components.queryItems?.first(where: { $0.name == "template" })?.value, + "bug_report.yml" + ) + } +} diff --git a/justfile b/justfile index 218ca6c..a28bddf 100644 --- a/justfile +++ b/justfile @@ -97,8 +97,12 @@ test: check-tuist CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_REQUIRED=NO +# Test release verification and Homebrew update scripts. +test-release-scripts: + script/test_release_scripts.sh + # Run the same formatting and test checks required by pull requests. -check: lint test +check: lint test test-release-scripts [private] check-swiftformat: diff --git a/script/test_release_scripts.sh b/script/test_release_scripts.sh new file mode 100755 index 0000000..089a81a --- /dev/null +++ b/script/test_release_scripts.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR=$(cd "$(dirname "$0")/.." && pwd) +VERIFY_SCRIPT="${ROOT_DIR}/script/verify_release.sh" +CASK_SCRIPT="${ROOT_DIR}/script/update_homebrew_cask.rb" +TEST_DIR=$(mktemp -d) +trap 'rm -rf "$TEST_DIR"' EXIT + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +assert_fails() { + if "$@" >/dev/null 2>&1; then + fail "Command unexpectedly succeeded: $*" + fi +} + +sha256_file() { + shasum -a 256 "$1" | awk '{print $1}' +} + +make_artifacts() { + local dir="$1" + local tag="$2" + local version="${tag#v}" + local plist_version="${FIXTURE_PLIST_VERSION:-$version}" + local build="${FIXTURE_BUILD-202608170101}" + local appcast_build="${FIXTURE_APPCAST_BUILD:-$build}" + local minimum="${FIXTURE_MINIMUM:-14.0}" + local feed_url="${FIXTURE_FEED_URL:-https://github.com/ygsgdbd/TypeSwitch/releases/latest/download/appcast.xml}" + local signature="${FIXTURE_SIGNATURE-test-signature}" + local appcast_layout="${FIXTURE_APPCAST_LAYOUT:-child}" + local staging="${TEST_DIR}/staging" + local sha size version_elements enclosure_version_attributes + + rm -rf "$dir" "$staging" + mkdir -p "$dir" "${staging}/TypeSwitch.app/Contents/MacOS" + printf 'fixture binary\n' > "${staging}/TypeSwitch.app/Contents/MacOS/TypeSwitch" + cat > "${staging}/TypeSwitch.app/Contents/Info.plist" < + + + + CFBundleShortVersionString${plist_version} + CFBundleVersion${build} + LSMinimumSystemVersion${minimum} + SUFeedURL${feed_url} + + +EOF + ditto -c -k --keepParent "${staging}/TypeSwitch.app" "${dir}/TypeSwitch-macOS-universal.zip" + sha=$(sha256_file "${dir}/TypeSwitch-macOS-universal.zip") + size=$(stat -f '%z' "${dir}/TypeSwitch-macOS-universal.zip") + printf '### SHA-256 Checksums\n```\n%s TypeSwitch-macOS-universal.zip\n```\n' "$sha" > "${dir}/checksums.txt" + if [[ "$appcast_layout" == "attributes" ]]; then + version_elements="" + enclosure_version_attributes=" sparkle:version=\"${appcast_build}\" sparkle:shortVersionString=\"${version}\"" + else + version_elements="${appcast_build}${version}" + enclosure_version_attributes="" + fi + cat > "${dir}/appcast.xml" < + + + + ${version_elements} + + + + +EOF +} + +mkdir -p "${TEST_DIR}/bin" +cat > "${TEST_DIR}/bin/lipo" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "${LIPO_ARCHS:-arm64 x86_64}" +EOF +chmod +x "${TEST_DIR}/bin/lipo" + +ARTIFACTS="${TEST_DIR}/artifacts" +make_artifacts "$ARTIFACTS" v1.2.3 +PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" >/dev/null +FIXTURE_APPCAST_LAYOUT=attributes make_artifacts "$ARTIFACTS" v1.2.3 +PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" >/dev/null +make_artifacts "$ARTIFACTS" v1.2.3 +assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local 1.2.3 "$ARTIFACTS" + +printf 'corruption\n' >> "${ARTIFACTS}/TypeSwitch-macOS-universal.zip" +FAILED_SUMMARY="${TEST_DIR}/failed-summary.md" +if GITHUB_STEP_SUMMARY="$FAILED_SUMMARY" PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" >/dev/null 2>&1; then + fail "Checksum corruption unexpectedly passed verification." +fi +grep -q '`publish_failed`' "$FAILED_SUMMARY" || fail "Local failure did not report publish_failed." + +FIXTURE_PLIST_VERSION=1.2.4 make_artifacts "$ARTIFACTS" v1.2.3 +assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" +FIXTURE_BUILD= make_artifacts "$ARTIFACTS" v1.2.3 +assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" +FIXTURE_MINIMUM=13.0 make_artifacts "$ARTIFACTS" v1.2.3 +assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" +FIXTURE_FEED_URL=https://example.invalid/appcast.xml make_artifacts "$ARTIFACTS" v1.2.3 +assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" +make_artifacts "$ARTIFACTS" v1.2.3 +assert_fails env PATH="${TEST_DIR}/bin:${PATH}" LIPO_ARCHS=arm64 "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" +FIXTURE_APPCAST_BUILD=wrong-build make_artifacts "$ARTIFACTS" v1.2.3 +assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" +FIXTURE_SIGNATURE= make_artifacts "$ARTIFACTS" v1.2.3 +assert_fails env PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" local v1.2.3 "$ARTIFACTS" +make_artifacts "$ARTIFACTS" v1.2.3 + +CASK="${TEST_DIR}/typeswitch.rb" +SHA=$(sha256_file "${ARTIFACTS}/TypeSwitch-macOS-universal.zip") +cat > "$CASK" < "${TEST_DIR}/bin/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == "api" && "$2" == "repos/ygsgdbd/TypeSwitch/releases/tags/v1.2.3" ]]; then + printf '{"tag_name":"v1.2.3","draft":false,"prerelease":false}\n' +elif [[ "$1 $2" == "release download" ]]; then + shift 2 + while [[ $# -gt 0 ]]; do + case "$1" in + --dir) destination="$2"; shift 2 ;; + --pattern) pattern="$2"; shift 2 ;; + *) shift ;; + esac + done + cp "${GH_FIXTURE_DIR}/${pattern}" "$destination/" +elif [[ "$1 $2" == "attestation verify" ]]; then + exit 0 +elif [[ "$1" == "api" && "${*: -1}" == "repos/ygsgdbd/homebrew-tap/contents/Casks/typeswitch.rb" ]]; then + cat "$GH_CASK_PATH" +else + echo "Unexpected gh invocation: $*" >&2 + exit 1 +fi +EOF +cat > "${TEST_DIR}/bin/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +while [[ $# -gt 0 ]]; do + case "$1" in + -o) destination="$2"; shift 2 ;; + *) shift ;; + esac +done +cp "$CURL_APPCAST_PATH" "$destination" +EOF +chmod +x "${TEST_DIR}/bin/gh" "${TEST_DIR}/bin/curl" + +REMOTE_SUMMARY="${TEST_DIR}/remote-summary.md" +GITHUB_STEP_SUMMARY="$REMOTE_SUMMARY" \ + GH_FIXTURE_DIR="$ARTIFACTS" \ + GH_CASK_PATH="$CASK" \ + CURL_APPCAST_PATH="${ARTIFACTS}/appcast.xml" \ + PATH="${TEST_DIR}/bin:${PATH}" \ + "$VERIFY_SCRIPT" remote v1.2.3 ygsgdbd/TypeSwitch "$SHA" >/dev/null +grep -q '`published_and_synced`' "$REMOTE_SUMMARY" || fail "Remote success did not report published_and_synced." + +assert_fails env \ + GH_FIXTURE_DIR="$ARTIFACTS" GH_CASK_PATH="$CASK" CURL_APPCAST_PATH="${ARTIFACTS}/appcast.xml" \ + PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" remote v1.2.3 ygsgdbd/TypeSwitch "$(printf '0%.0s' {1..64})" + +BAD_LATEST="${TEST_DIR}/bad-latest.xml" +sed 's/1.2.3/1.2.2/' "${ARTIFACTS}/appcast.xml" > "$BAD_LATEST" +assert_fails env \ + GH_FIXTURE_DIR="$ARTIFACTS" GH_CASK_PATH="$CASK" CURL_APPCAST_PATH="$BAD_LATEST" \ + PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" remote v1.2.3 ygsgdbd/TypeSwitch "$SHA" + +BAD_CASK="${TEST_DIR}/bad-typeswitch.rb" +sed 's/version "1.2.3"/version "1.2.2"/' "$CASK" > "$BAD_CASK" +PENDING_SUMMARY="${TEST_DIR}/pending-summary.md" +if GITHUB_STEP_SUMMARY="$PENDING_SUMMARY" \ + GH_FIXTURE_DIR="$ARTIFACTS" GH_CASK_PATH="$BAD_CASK" CURL_APPCAST_PATH="${ARTIFACTS}/appcast.xml" \ + PATH="${TEST_DIR}/bin:${PATH}" "$VERIFY_SCRIPT" remote v1.2.3 ygsgdbd/TypeSwitch "$SHA" >/dev/null 2>&1; then + fail "Outdated Homebrew cask unexpectedly passed verification." +fi +grep -q '`published_homebrew_pending`' "$PENDING_SUMMARY" || fail "Homebrew mismatch did not report published_homebrew_pending." + +UPDATED_CASK="${TEST_DIR}/update-typeswitch.rb" +sed 's/version "1.2.3"/version "0.1.0"/' "$CASK" > "$UPDATED_CASK" +ruby "$CASK_SCRIPT" "$UPDATED_CASK" v1.2.3 "$SHA" >/dev/null +EXPECTED_CASK=$(cat "$UPDATED_CASK") +ruby "$CASK_SCRIPT" "$UPDATED_CASK" v1.2.3 "$SHA" >/dev/null +[[ "$(cat "$UPDATED_CASK")" == "$EXPECTED_CASK" ]] || fail "Cask updater is not idempotent." +assert_fails ruby "$CASK_SCRIPT" "$UPDATED_CASK" 1.2.3 "$SHA" +printf ' version "9.9.9"\n' >> "$UPDATED_CASK" +assert_fails ruby "$CASK_SCRIPT" "$UPDATED_CASK" v1.2.3 "$SHA" + +WORKFLOW="${ROOT_DIR}/.github/workflows/release.yml" +PR_WORKFLOW="${ROOT_DIR}/.github/workflows/pr-checks.yml" +JUSTFILE="${ROOT_DIR}/justfile" +for job in publish sync_homebrew verify_distribution; do + grep -q "^ ${job}:" "$WORKFLOW" || fail "Workflow is missing the ${job} job." +done +[[ "$(grep -Fc 'secrets.HOMEBREW_TAP_TOKEN' "$WORKFLOW")" == "1" ]] || fail "Homebrew token must be scoped only to the Homebrew sync checkout." +grep -Fq 'overwrite_files: true' "$WORKFLOW" || fail "GitHub Release asset upload must be retry-safe." +grep -Fq 'if: ${{ always() && needs.publish.result == '\''success'\'' }}' "$WORKFLOW" || fail "Distribution verification must run after publish when Homebrew sync fails." +grep -A4 '^ verify_distribution:' "$WORKFLOW" | grep -q 'runs-on: macos-26' || fail "Distribution verification must run on macOS." +grep -Fq 'script/verify_release.sh remote "$RELEASE_TAG" "$GITHUB_REPOSITORY" "$EXPECTED_SHA256"' "$WORKFLOW" || fail "Distribution verification must pass the publish SHA." + +grep -q '^ release-scripts:$' "$PR_WORKFLOW" || fail "PR Checks must include an independent release-scripts job." +grep -A4 '^ release-scripts:$' "$PR_WORKFLOW" | grep -q 'runs-on: macos-26' || fail "PR release script tests must run on macOS." +grep -q '^test-release-scripts:$' "$JUSTFILE" || fail "justfile must expose test-release-scripts." +grep -Eq '^check: .*test-release-scripts' "$JUSTFILE" || fail "just check must include release script tests." + +echo "Release script tests passed." diff --git a/script/update_homebrew_cask.rb b/script/update_homebrew_cask.rb new file mode 100755 index 0000000..5f36d5f --- /dev/null +++ b/script/update_homebrew_cask.rb @@ -0,0 +1,32 @@ +#!/usr/bin/env ruby + +path, release_tag, sha256 = ARGV +abort "Usage: script/update_homebrew_cask.rb vX.Y.Z " unless ARGV.length == 3 +abort "Release tag must use strict vX.Y.Z format: #{release_tag}" unless release_tag.match?(/\Av\d+\.\d+\.\d+\z/) +abort "SHA-256 must contain exactly 64 hexadecimal characters" unless sha256.match?(/\A[0-9a-fA-F]{64}\z/) +abort "Homebrew cask does not exist: #{path}" unless File.file?(path) + +version = release_tag.delete_prefix("v") +sha256 = sha256.downcase +asset_url = "https://github.com/ygsgdbd/TypeSwitch/releases/download/#{release_tag}/TypeSwitch-macOS-universal.zip" +content = File.read(path) + +replacements = { + /^ version ".*"$/ => " version \"#{version}\"", + /^ sha256 ".*"$/ => " sha256 \"#{sha256}\"", + /^ url ".*"$/ => " url \"#{asset_url}\"", +} + +replacements.each do |pattern, replacement| + matches = content.scan(pattern).length + abort "Expected exactly one #{pattern.inspect} entry in #{path}, found #{matches}" unless matches == 1 + + content.sub!(pattern, replacement) +end + +if content == File.read(path) + puts "Homebrew cask is already up to date." +else + File.write(path, content) + puts "Updated #{path} to #{release_tag}." +end diff --git a/script/verify_release.sh b/script/verify_release.sh new file mode 100755 index 0000000..8a11643 --- /dev/null +++ b/script/verify_release.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ASSET_NAME="TypeSwitch-macOS-universal.zip" +CHECKSUMS_NAME="checksums.txt" +APPCAST_NAME="appcast.xml" +EXPECTED_MINIMUM_SYSTEM_VERSION="14.0" +EXPECTED_FEED_URL="https://github.com/ygsgdbd/TypeSwitch/releases/latest/download/appcast.xml" +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +MODE="${1:-}" +RELEASE_TAG="${2:-}" +TEMP_DIRS=() +VERIFIED_BUILD="" +VERIFIED_SHA256="" +NEW_TEMP_DIR="" + +cleanup() { + local path + for path in "${TEMP_DIRS[@]}"; do + rm -rf "$path" + done +} +trap cleanup EXIT + +new_temp_dir() { + NEW_TEMP_DIR=$(mktemp -d) + TEMP_DIRS+=("$NEW_TEMP_DIR") +} + +write_summary() { + local status="$1" + local detail="$2" + + case "$status" in + published_and_synced|published_homebrew_pending|publish_failed) ;; + *) echo "Invalid release summary status: $status" >&2; exit 1 ;; + esac + + if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then + { + echo "### Release distribution status" + echo + echo "- Status: \`${status}\`" + echo "- Tag: \`${RELEASE_TAG:-unknown}\`" + echo "- Details: ${detail}" + echo + } >> "$GITHUB_STEP_SUMMARY" + fi +} + +fail() { + local message="$1" + local status="${2:-publish_failed}" + echo "::error title=Release verification failed::${message}" >&2 + write_summary "$status" "$message" + exit 1 +} + +usage() { + cat >&2 <<'EOF' +Usage: + script/verify_release.sh local vX.Y.Z [artifact-directory] + script/verify_release.sh remote vX.Y.Z +EOF + exit 2 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "Required command is unavailable: $1" +} + +sha256_file() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + fail "Neither shasum nor sha256sum is available." + fi +} + +file_size() { + stat -f '%z' "$1" +} + +plist_value() { + plutil -extract "$1" raw -o - "$2" 2>/dev/null || fail "Info.plist is missing required key $1." +} + +verify_appcast() { + local appcast_path="$1" + local expected_version="$2" + local expected_build="$3" + local expected_url="$4" + local expected_size="$5" + + ruby -rrexml/document -rrexml/xpath - "$appcast_path" "$expected_version" "$expected_build" "$expected_url" "$expected_size" <<'RUBY' +path, expected_version, expected_build, expected_url, expected_size = ARGV +sparkle = { "sparkle" => "http://www.andymatuschak.org/xml-namespaces/sparkle" } + +begin + document = REXML::Document.new(File.read(path)) +rescue StandardError => error + abort "Invalid appcast XML: #{error.message}" +end + +items = REXML::XPath.match(document, "/rss/channel/item") +abort "Expected exactly one appcast item, found #{items.length}" unless items.length == 1 + +item = items.first +enclosure = REXML::XPath.first(item, "enclosure") +abort "Appcast item is missing its enclosure" unless enclosure + +version = REXML::XPath.first(item, "sparkle:shortVersionString", sparkle)&.text +version ||= enclosure.attributes["sparkle:shortVersionString"] +build = REXML::XPath.first(item, "sparkle:version", sparkle)&.text +build ||= enclosure.attributes["sparkle:version"] +checks = { + "short version" => [version, expected_version], + "build" => [build, expected_build], + "download URL" => [enclosure.attributes["url"], expected_url], + "file length" => [enclosure.attributes["length"], expected_size], +} +checks.each do |label, (actual, expected)| + abort "Appcast #{label} mismatch: expected #{expected.inspect}, got #{actual.inspect}" unless actual == expected +end + +signature = enclosure.attributes["sparkle:edSignature"] +abort "Appcast enclosure is missing the Sparkle EdDSA signature" if signature.nil? || signature.empty? +RUBY +} + +verify_local_artifacts() { + local artifact_dir="$1" + local zip_path="${artifact_dir}/${ASSET_NAME}" + local checksums_path="${artifact_dir}/${CHECKSUMS_NAME}" + local appcast_path="${artifact_dir}/${APPCAST_NAME}" + local expected_sha actual_sha expected_size expected_url version checksum_count + local extract_dir app_path info_plist binary_path bundle_version build minimum_system_version feed_url architectures + + require_command ditto + require_command lipo + require_command plutil + require_command ruby + + for path in "$zip_path" "$checksums_path" "$appcast_path"; do + [[ -s "$path" ]] || fail "Missing or empty release artifact: ${path}" + done + + checksum_count=$(awk -v asset="$ASSET_NAME" ' + $1 ~ /^[[:xdigit:]]{64}$/ && ($2 == asset || $2 == "*" asset) { count++ } + END { print count + 0 } + ' "$checksums_path") + [[ "$checksum_count" == "1" ]] || fail "${CHECKSUMS_NAME} must contain exactly one checksum for ${ASSET_NAME}; found ${checksum_count}." + + expected_sha=$(awk -v asset="$ASSET_NAME" ' + $1 ~ /^[[:xdigit:]]{64}$/ && ($2 == asset || $2 == "*" asset) { print tolower($1) } + ' "$checksums_path") + actual_sha=$(sha256_file "$zip_path") + [[ "$actual_sha" == "$expected_sha" ]] || fail "Checksum mismatch for ${ASSET_NAME}: expected ${expected_sha}, got ${actual_sha}." + + new_temp_dir + extract_dir="$NEW_TEMP_DIR" + ditto -x -k "$zip_path" "$extract_dir" || fail "Unable to extract ${ASSET_NAME}." + app_path="${extract_dir}/TypeSwitch.app" + info_plist="${app_path}/Contents/Info.plist" + binary_path="${app_path}/Contents/MacOS/TypeSwitch" + [[ -f "$info_plist" ]] || fail "Release archive is missing TypeSwitch.app/Contents/Info.plist." + [[ -f "$binary_path" ]] || fail "Release archive is missing the TypeSwitch executable." + + version="${RELEASE_TAG#v}" + bundle_version=$(plist_value CFBundleShortVersionString "$info_plist") + build=$(plist_value CFBundleVersion "$info_plist") + minimum_system_version=$(plist_value LSMinimumSystemVersion "$info_plist") + feed_url=$(plist_value SUFeedURL "$info_plist") + [[ "$bundle_version" == "$version" ]] || fail "Bundle short version mismatch: expected ${version}, got ${bundle_version}." + [[ -n "$build" ]] || fail "Bundle build version must not be empty." + [[ "$minimum_system_version" == "$EXPECTED_MINIMUM_SYSTEM_VERSION" ]] || fail "Minimum system version mismatch: expected ${EXPECTED_MINIMUM_SYSTEM_VERSION}, got ${minimum_system_version}." + [[ "$feed_url" == "$EXPECTED_FEED_URL" ]] || fail "Sparkle feed URL mismatch: expected ${EXPECTED_FEED_URL}, got ${feed_url}." + + architectures=$(lipo -archs "$binary_path") || fail "Unable to inspect TypeSwitch binary architectures." + [[ " $architectures " == *" arm64 "* && " $architectures " == *" x86_64 "* ]] || fail "Expected universal binary with arm64 and x86_64, got: ${architectures}." + + expected_size=$(file_size "$zip_path") + expected_url="https://github.com/ygsgdbd/TypeSwitch/releases/download/${RELEASE_TAG}/${ASSET_NAME}" + verify_appcast "$appcast_path" "$version" "$build" "$expected_url" "$expected_size" || fail "${APPCAST_NAME} does not match the app bundle and release artifact." + + VERIFIED_BUILD="$build" + VERIFIED_SHA256="$actual_sha" + echo "Verified ${RELEASE_TAG}: ${ASSET_NAME} (${actual_sha}), build ${build}, architectures ${architectures}." +} + +verify_release_metadata() { + local repository="$1" + local release_json + + release_json=$(gh api "repos/${repository}/releases/tags/${RELEASE_TAG}") || fail "Unable to load published GitHub Release ${RELEASE_TAG}." + if ! printf '%s' "$release_json" | ruby -rjson -e ' + release = JSON.parse(STDIN.read) + expected_tag = ARGV.fetch(0) + abort "GitHub Release tag does not match" unless release["tag_name"] == expected_tag + abort "GitHub Release is still a draft" unless release["draft"] == false + abort "GitHub Release is marked as a prerelease" unless release["prerelease"] == false + ' "$RELEASE_TAG"; then + fail "GitHub Release ${RELEASE_TAG} is not a published production release." + fi +} + +verify_latest_appcast() { + local repository="$1" + local artifact_dir="$2" + local latest_appcast="${artifact_dir}/latest-appcast.xml" + local version="${RELEASE_TAG#v}" + local expected_url="https://github.com/ygsgdbd/TypeSwitch/releases/download/${RELEASE_TAG}/${ASSET_NAME}" + local expected_size + + expected_size=$(file_size "${artifact_dir}/${ASSET_NAME}") + curl -fsSL -o "$latest_appcast" "https://github.com/${repository}/releases/latest/download/${APPCAST_NAME}" || fail "Unable to download the latest appcast endpoint." + verify_appcast "$latest_appcast" "$version" "$VERIFIED_BUILD" "$expected_url" "$expected_size" || fail "The latest appcast endpoint does not point to ${RELEASE_TAG}." +} + +verify_homebrew_cask() { + local artifact_dir="$1" + local cask_path="${artifact_dir}/typeswitch.rb" + local original_path="${artifact_dir}/typeswitch.original.rb" + + gh api \ + -H "Accept: application/vnd.github.raw+json" \ + "repos/ygsgdbd/homebrew-tap/contents/Casks/typeswitch.rb" \ + > "$cask_path" || fail "Unable to read the published Homebrew cask." published_homebrew_pending + cp "$cask_path" "$original_path" + + if ! ruby "${SCRIPT_DIR}/update_homebrew_cask.rb" "$cask_path" "$RELEASE_TAG" "$VERIFIED_SHA256" >/dev/null; then + fail "Published Homebrew cask has an invalid structure." published_homebrew_pending + fi + cmp -s "$original_path" "$cask_path" || fail "Published Homebrew cask does not match ${RELEASE_TAG} and its Release checksum." published_homebrew_pending +} + +[[ "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "Release tag must use strict vX.Y.Z format; got ${RELEASE_TAG:-}." + +case "$MODE" in + local) + [[ $# -le 3 ]] || usage + verify_local_artifacts "${3:-.}" + ;; + remote) + [[ $# -eq 4 ]] || usage + require_command curl + require_command gh + repository="$3" + [[ "$repository" =~ ^[^/]+/[^/]+$ ]] || fail "Repository must use owner/name format; got ${repository:-}." + expected_sha256=$(printf '%s' "$4" | tr '[:upper:]' '[:lower:]') + [[ "$expected_sha256" =~ ^[0-9a-f]{64}$ ]] || fail "Expected SHA-256 must contain exactly 64 hexadecimal characters." + + verify_release_metadata "$repository" + new_temp_dir + download_dir="$NEW_TEMP_DIR" + for asset in "$ASSET_NAME" "$CHECKSUMS_NAME" "$APPCAST_NAME"; do + gh release download "$RELEASE_TAG" --repo "$repository" --dir "$download_dir" --pattern "$asset" || fail "Unable to download ${asset} from ${RELEASE_TAG}." + done + + remote_sha256=$(sha256_file "${download_dir}/${ASSET_NAME}") + [[ "$remote_sha256" == "$expected_sha256" ]] || fail "Published ${ASSET_NAME} checksum mismatch: expected ${expected_sha256}, got ${remote_sha256}." + + verify_local_artifacts "$download_dir" + gh attestation verify "${download_dir}/${ASSET_NAME}" --repo "$repository" >/dev/null || fail "Artifact attestation verification failed for ${ASSET_NAME}." + verify_latest_appcast "$repository" "$download_dir" + verify_homebrew_cask "$download_dir" + write_summary published_and_synced "GitHub Release, latest appcast, attestation, and Homebrew cask are consistent." + ;; + *) + usage + ;; +esac