diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cd1d993..b152cf2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,7 +52,7 @@ jobs:
"$temporary_directory/release/gloss-release-manifest.json"
grep -F \
'https://github.com/SunChJ/gloss-releases/releases/download/v0.0.0/' \
- "$temporary_directory/release/Casks/gloss.rb"
+ "$temporary_directory/release/gloss.rb"
(
cd "$temporary_directory/release"
shasum -a 256 --check SHA256SUMS
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9f49483..a29cee1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -38,10 +38,12 @@ jobs:
env:
DISTRIBUTION_TOKEN: ${{ secrets.GLOSS_DISTRIBUTION_TOKEN }}
EXTENSION_SSH_KEY: ${{ secrets.GLOSS_EXTENSION_SSH_KEY }}
+ MANIFEST_SIGNING_KEY: ${{ secrets.GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY }}
PUBLISH_RELEASE: ${{ github.event_name == 'push' || inputs.publish_release }}
run: |
missing=()
[[ -n "$EXTENSION_SSH_KEY" ]] || missing+=("GLOSS_EXTENSION_SSH_KEY")
+ [[ -n "$MANIFEST_SIGNING_KEY" ]] || missing+=("GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY")
if [[ "$PUBLISH_RELEASE" == "true" ]]; then
[[ -n "$DISTRIBUTION_TOKEN" ]] || missing+=("GLOSS_DISTRIBUTION_TOKEN")
fi
@@ -69,12 +71,12 @@ jobs:
GLOSS_SIGN_IDENTITY: "-"
steps:
- name: Check out Gloss
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
path: gloss
ref: ${{ github.event_name == 'push' && github.ref || inputs.release_tag }}
- name: Check out browser extensions
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
repository: SunChJ/personal-immersive-translator
ref: 3e9c7c8cb75ce4b08e56a714ee0e4eb7ebaa652e
@@ -106,6 +108,12 @@ jobs:
EXPECTED_ARCHITECTURE: ${{ matrix.architecture }}
run: |
lipo dist/Gloss.app/Contents/MacOS/Gloss -verify_arch "$EXPECTED_ARCHITECTURE"
+ test ! -e "dist/Gloss.app/Contents/PlugIns/Gloss Extension.appex"
+ test "$(
+ /usr/libexec/PlistBuddy \
+ -c 'Print :GlossSafariExtensionAvailable' \
+ dist/Gloss.app/Contents/Info.plist
+ )" = "false"
codesign --verify --deep --strict --verbose=2 dist/Gloss.app
codesign --display --verbose=4 dist/Gloss.app 2>&1 \
| grep -F "Signature=adhoc"
@@ -115,7 +123,7 @@ jobs:
GLOSS_RELEASE_ARCHITECTURE: ${{ matrix.architecture }}
run: Scripts/package_release.sh
- name: Upload architecture artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Gloss-${{ matrix.architecture }}
path: gloss/dist/release/Gloss-macos-${{ matrix.architecture }}.zip
@@ -131,12 +139,12 @@ jobs:
RELEASE_TAG: ${{ github.event_name == 'push' && github.ref_name || inputs.release_tag }}
steps:
- name: Check out Gloss
- uses: actions/checkout@v4
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
path: gloss
ref: ${{ github.event_name == 'push' && github.ref || inputs.release_tag }}
- name: Download architecture artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
pattern: Gloss-*
path: release-input
@@ -155,8 +163,43 @@ jobs:
dist/release \
"$RELEASE_TAG" \
"$GLOSS_RELEASE_REPOSITORY"
+ - name: Sign and verify update manifest
+ working-directory: gloss
+ env:
+ GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY: ${{ secrets.GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY }}
+ run: |
+ swift Scripts/sign_release_manifest.swift dist/release/gloss-release-manifest.json
+ signature_path=dist/release/gloss-release-manifest.json.sig
+ test -f "$signature_path"
+ test "$(base64 -D <"$signature_path" | wc -c | tr -d '[:space:]')" = "64"
+ (
+ cd dist/release
+ shasum -a 256 --check SHA256SUMS
+ )
+ python3 - <<'PY'
+ import hashlib
+ import json
+ import pathlib
+
+ release = pathlib.Path("dist/release")
+ manifest = json.loads(
+ (release / "gloss-release-manifest.json").read_text(encoding="utf-8")
+ )
+ cask = release / "gloss.rb"
+ payload = cask.read_bytes()
+ metadata = manifest["homebrewCask"]
+ expected_url = (
+ "https://github.com/SunChJ/gloss-releases/releases/download/"
+ f"{manifest['releaseTag']}/gloss.rb"
+ )
+ assert manifest["schemaVersion"] == 2
+ assert metadata["token"] == "sunchj/tap/gloss"
+ assert metadata["url"] == expected_url
+ assert metadata["size"] == len(payload)
+ assert metadata["sha256"] == hashlib.sha256(payload).hexdigest()
+ PY
- name: Upload combined workflow artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: Gloss-release-${{ env.RELEASE_TAG }}
path: |
@@ -164,7 +207,8 @@ jobs:
gloss/dist/release/Gloss-macos-x86_64.zip
gloss/dist/release/SHA256SUMS
gloss/dist/release/gloss-release-manifest.json
- gloss/dist/release/Casks/gloss.rb
+ gloss/dist/release/gloss-release-manifest.json.sig
+ gloss/dist/release/gloss.rb
if-no-files-found: error
- name: Publish GitHub release assets
if: github.event_name == 'push' || inputs.publish_release
@@ -207,7 +251,8 @@ jobs:
dist/release/Gloss-macos-x86_64.zip \
dist/release/SHA256SUMS \
dist/release/gloss-release-manifest.json \
- dist/release/Casks/gloss.rb \
+ dist/release/gloss-release-manifest.json.sig \
+ dist/release/gloss.rb \
--clobber
gh release edit "$RELEASE_TAG" \
--repo "$GLOSS_RELEASE_REPOSITORY" \
diff --git a/Package.swift b/Package.swift
index e7ca097..d60d6fa 100644
--- a/Package.swift
+++ b/Package.swift
@@ -10,7 +10,11 @@ let package = Package(
products: [
.library(name: "GlossCore", targets: ["GlossCore"]),
.executable(name: "Gloss", targets: ["Gloss"]),
- .executable(name: "gloss-cli", targets: ["GlossCLI"])
+ .executable(name: "gloss-cli", targets: ["GlossCLI"]),
+ .executable(
+ name: "gloss-update-helper",
+ targets: ["GlossUpdateHelper"]
+ ),
],
targets: [
.target(name: "GlossCore"),
@@ -23,6 +27,10 @@ let package = Package(
name: "GlossCLI",
dependencies: ["GlossCore"]
),
+ .executableTarget(
+ name: "GlossUpdateHelper",
+ dependencies: ["GlossCore"]
+ ),
.testTarget(
name: "GlossCoreTests",
dependencies: ["GlossCore"]
@@ -34,6 +42,6 @@ let package = Package(
.testTarget(
name: "GlossAppTests",
dependencies: ["Gloss"]
- )
+ ),
]
)
diff --git a/README.md b/README.md
index 58e5c41..a00af03 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,25 @@
# Gloss
-Gloss 是一款 macOS 原生、上下文感知的系统级翻译工具:选中任何文本,原地理解、翻译并继续工作。
+Gloss 是一款 macOS 原生翻译工具。当前默认产品面聚焦两条已经闭环的业务场景:
+Chrome 浏览器翻译(Apple 签名构建同时支持 Safari),以及保留版式的 PDF 批量翻译。
-当前实现已经打通第一条完整链路:
+## 核心能力与业务场景
+
+Gloss 不再按窗口堆叠功能,而是由可复用核心能力拼装业务场景。App 菜单、后台服务和
+`gloss-cli` 都读取同一份能力注册表;关闭一个场景会同时收起入口并停止它独占的常驻服务,
+不会删除底层实现。
+
+| 默认场景 | 组合的主要能力 | macOS 入口 | CLI 映射 |
+| --- | --- | --- | --- |
+| 浏览器翻译 | 文本翻译、Provider/语言路由、本机回环桥接、Chrome 适配与 Apple 签名构建的 Safari 适配 | Chrome 扩展;Apple 签名构建另含 Safari 扩展 | `gloss-cli browser` |
+| PDF 翻译 | 文档翻译、版面分析、翻译桥接、BabelDOC runtime、批量队列、PDF 导出 | PDF 模块、Finder 打开与拖拽 | `gloss-cli pdf` |
+
+可使用 `gloss-cli capabilities --json` 获取稳定、机器可读的核心能力、启用场景和命令映射;
+结果会反映当前分发包实际可用的浏览器适配器。
+剪贴板、OCR/截图、系统选区、术语表和历史记录的实现仍保留在代码中,但默认不启动相关监听,
+也不在主菜单和设置中展示。
+
+底层已经实现的能力包括:
- 监听鼠标选区、已选文本长按和可录制的全局快捷键(默认 `⌃⌥G`)
- 可全局关闭自动出现,或仅在指定 App 中停用;手动快捷键仍然可用
@@ -15,7 +32,7 @@ Gloss 是一款 macOS 原生、上下文感知的系统级翻译工具:选中
- 本地选项使用 `Hy-MT2-1.8B-GGUF:Q4_K_M`,通过 Metal 运行,原文和译文都留在设备上
- 使用结构化输出、只读沙盒和禁用工具的临时线程
- 译文与原文在同一结果卡片中直接对照,并支持复制译文、替换原文和追加双语
-- 提供分开的 macOS 文本与图片“服务”入口,避免被系统归入错误分类
+- 保留 macOS 文本与图片“服务”的处理实现,但当前聚焦发行不向系统注册入口
- 可翻译剪贴板图片、交互式截图及系统“服务”传入的图片或图片文件
- 截图写入权限隔离的临时目录并在读取后立即删除,不占用系统剪贴板
- 复制式选区与替换回退会恢复普通剪贴板;遇到密码管理器、临时内容、文件承诺或超大内容时不执行破坏性回退
@@ -28,8 +45,8 @@ Gloss 是一款 macOS 原生、上下文感知的系统级翻译工具:选中
- 缓存重复内容,并合并并发的相同请求
- 默认使用低延迟的 `gpt-5.3-codex-spark`,最多并发执行 3 个独立翻译 turn;每次完成后回滚该 turn,避免跨批上下文累积
- 内置只监听 `127.0.0.1` 的浏览器桥接,与扩展共享同一个翻译代理和缓存
-- Chrome 扩展与 Safari Web Extension 均随 `Gloss.app` 打包,共用 WXT 源码
-- 使用每机随机令牌鉴权:Chrome 自动注入 App 管理副本,Safari 通过 App Group 安全配对
+- Chrome 扩展与 Safari Web Extension 共用 WXT 源码;Safari 入口仅在 Apple 签名构建中启用
+- 使用每机随机令牌鉴权:Chrome 自动注入 App 管理副本,Apple 签名构建中的 Safari 通过 App Group 安全配对
- 提供 `gloss-cli` 作为脚本与诊断入口
## 运行日志
@@ -52,13 +69,15 @@ tail -f ~/Library/Logs/Gloss/gloss.log
1. GPT 订阅:首次启动时,在 Gloss 设置中点击“登录 ChatGPT”。默认构建不需要单独安装 Codex CLI 或 Node.js;CLI 构建需要用户已安装支持 `app-server` 的 Codex CLI。
2. 本地模型:安装 `llama.cpp`(`brew install llama.cpp`),然后在翻译引擎中选择“本地模型”。首次启动会从 Hugging Face 下载约 1.1 GB 的 Q4 模型。
-3. 首次使用选区翻译时,在系统设置中允许 Gloss 使用“辅助功能”。
+3. 重新启用选区翻译场景后,首次使用时需在系统设置中允许 Gloss 使用“辅助功能”。
4. Chrome:在 Gloss 设置中点“显示扩展”,从 `chrome://extensions` 加载这个已自动配对的目录。
-5. Safari:在 Gloss 设置中点“Safari 设置”,启用随 App 内置的 Gloss Extension。
+5. Safari(仅 Apple 签名构建):在 Gloss 设置中点“Safari 设置”,启用随 App 内置的
+ Gloss Extension。Homebrew 的 ad-hoc 构建不会显示这个入口。
Gloss 的登录状态和 Codex 配置保存在 `~/Library/Application Support/Gloss/Codex/`,不会修改系统 Codex CLI 的数据。
-系统“服务”入口默认由 macOS 管理。可在 Gloss 设置中打开“键盘快捷键”,再到“服务”里启用文本或图片翻译入口。
+剪贴板或图片翻译场景的处理代码仍保留,但当前构建不会注册系统“服务”;重新发布这些场景时
+需要同步恢复对应的 `NSServices` 构建配置。
## 开发
@@ -70,6 +89,20 @@ swift run Gloss
直接验证翻译后端:
```bash
+# 查询 App 与 CLI 共用的能力/场景映射
+swift run gloss-cli capabilities --json
+
+# 浏览器翻译场景(默认 content kind 为 webpage)
+swift run gloss-cli browser --target 'Chinese (Simplified)' 'Translate this webpage.'
+
+# PDF 批量翻译场景
+swift run gloss-cli pdf paper-a.pdf paper-b.pdf \
+ --output ./translated \
+ --target 'Chinese (Simplified)' \
+ --mode mono
+
+# 向后兼容的文本翻译入口
+swift run gloss-cli text --target 'Chinese (Simplified)' 'Translate this text.'
swift run gloss-cli --target 'Chinese (Simplified)' 'Translate this text.'
swift run gloss-cli --provider llama --target 'Chinese (Simplified)' 'Translate locally.'
swift run gloss-cli --provider codex --model gpt-5.3-codex-spark --reasoning low 'Translate quickly.'
@@ -77,6 +110,11 @@ printf 'Translate stdin.\n' | swift run gloss-cli --target Japanese
swift run gloss-cli --kind ocr 'Text recognized from an image.'
```
+`browser` 可从参数或 stdin 读取已经提取的网页正文,并使用与 Safari/Chrome 扩展相同的
+网页翻译语义;它不会自动操控浏览器 UI。`pdf` 接受一个或多个 PDF,顺序处理并复用同一
+BabelDOC 会话;进度写入 stderr,最终产物路径以 JSON 写入 stdout,适合脚本调用。默认源语言
+为 `en`、目标语言为 `Chinese (Simplified)`,输出方式为仅译文 PDF。
+
开发时可以覆盖原生 app-server 和独立数据目录:
```bash
@@ -138,7 +176,12 @@ swift run gloss-cli --provider llama 'Hello from local Gloss.'
open dist/Gloss.app
```
-构建脚本会按 `CodexRuntime.lock` 下载并校验固定版本的官方 Rust app-server,把它与许可证一起嵌入 App;本地 provider 当前复用系统安装的 `llama-server`。随后脚本在相邻的 `personal-immersive-translator` 仓库中生成 Chrome/Safari 产物,并把 Chrome 资源与 Safari `.appex` 嵌入 App。结果位于 `dist/Gloss.app`。脚本默认使用 `-` 做 ad-hoc codesign;这种签名没有 Apple 开发者身份,Safari 配对不可用。
+构建脚本会按 `CodexRuntime.lock` 下载并校验固定版本的官方 Rust app-server,把它与许可证一起
+嵌入 App;本地 provider 当前复用系统安装的 `llama-server`。随后脚本在相邻的
+`personal-immersive-translator` 仓库中生成 Chrome 产物。只有显式传入 Apple 签名身份以及
+宿主与扩展的 provisioning profile 时,才会同时构建并嵌入 Safari `.appex`。结果位于
+`dist/Gloss.app`。脚本默认使用 `-` 做 ad-hoc codesign,因此不会把无法完成身份配对的
+Safari 扩展放进 App。
如果不希望下载或嵌入固定 Rust app-server,可构建依赖用户 Codex CLI 的轻量版本:
@@ -148,10 +191,14 @@ open dist/Gloss.app
该脚本会先确认当前环境中的 `codex app-server` 可用,但不会把 Codex runtime、许可证或版本锁文件放入 App。运行时 Gloss 会查找 `GLOSS_CODEX_BIN`、`PATH`、Homebrew 与常用本地安装路径,并执行 `codex app-server --listen stdio://`。进程与 thread 仍统一经过 `CodexAppServerClient`,因此会复用相同的静态模型目录、隔离工作目录和 MCP/skills/tools 禁用配置,不会退回较慢的默认启动方式。
-本机调试 Safari 配对时,可显式传入钥匙串中的 Apple Development 证书:
+本机调试 Safari 配对时,可显式传入钥匙串中的 Apple Development 证书,以及允许
+`group.com.samsoncj.gloss` App Group 的宿主和扩展 provisioning profile:
```bash
-GLOSS_SIGN_IDENTITY="Apple Development: Your Name (TEAMID)" ./Scripts/build_app.sh
+GLOSS_SIGN_IDENTITY="Apple Development: Your Name (TEAMID)" \
+GLOSS_SAFARI_HOST_PROVISIONING_PROFILE="/path/to/Gloss.provisionprofile" \
+GLOSS_SAFARI_EXTENSION_PROVISIONING_PROFILE="/path/to/Gloss-Extension.provisionprofile" \
+./Scripts/build_app.sh
```
当前公开 Homebrew 发行也明确使用 ad-hoc 签名,不要求 Developer ID 或 Apple 公证。
@@ -168,7 +215,7 @@ atomic state file 防止半安装状态。完整 manifest schema、安全边界
### GitHub Release 与 Homebrew
推送与 `Resources/Info.plist` 一致的 `v*` tag 会运行 Release workflow,产出
-arm64 与 x86_64 两套 `Gloss.app` zip、`SHA256SUMS`、release manifest 和带
+arm64 与 x86_64 两套 `Gloss.app` zip、`SHA256SUMS`、Ed25519 签名的 release manifest 和带
`on_arm` / `on_intel` 校验的 Homebrew cask。私有 `SunChJ/gloss` 只负责构建;ad-hoc
签名后的资产发布到公开 `SunChJ/gloss-releases`,随后自动 dispatch
`SunChJ/homebrew-tap` 更新 Cask。下载 URL 不会指向私有主仓。
@@ -182,6 +229,9 @@ brew update
brew upgrade --cask sunchj/tap/gloss
```
+Homebrew 同时把 App 内置的 `gloss-cli` 链接到其 `bin` 目录;安装后可直接运行
+`gloss-cli capabilities --json`,无需从 `.app` 包内手工定位可执行文件。
+
Release workflow 使用只读 `GLOSS_EXTENSION_SSH_KEY` 检出私有浏览器扩展;正式 tag 另外
要求跨仓库 `GLOSS_DISTRIBUTION_TOKEN`。缺失时 workflow 会在构建和上传前 fail closed。
手工 workflow 不发布,但仍需要 extension deploy key 才能生成完整 App artifact。
@@ -189,6 +239,18 @@ Cask 的 `postflight` 会重新 ad-hoc 签名、移除 quarantine 并验证签
Gatekeeper 交互;这也意味着 macOS 无法验证 Apple 开发者身份或公证票据。公开仓库初始化、
fine-grained token 权限、完整安全取舍、发行顺序与恢复步骤见
[发行文档](docs/runtime-distribution.md)。
+Homebrew 构建的能力报告会启用 Chrome、关闭 Safari;Safari App Extension 仅在使用 Apple
+Development 或 distribution identity 且 App Group entitlement 可用的构建中进入能力注册表
+和设置页;运行时会以系统返回的 App Group container 作为最终配对条件。
+
+正式版 App 启动后会延迟、静默检查签名 manifest,并以 24 小时为自动检查间隔。只有确认
+当前 `Gloss.app` 由 `sunchj/tap/gloss` 管理时,界面才提供“一键更新并重新启动”;独立 helper
+会在 App 退出后调用固定的 `brew update` / `brew upgrade --cask --require-sha` 参数。helper
+会独立复验原始 manifest 签名,并在执行升级前确认 tap 中的 `gloss.rb`、Cask 版本及当前架构
+URL/SHA 都与签名 manifest 完全一致;升级命令禁用隐式 auto-update,随后再精确验证安装版本、
+当前架构、ad-hoc 签名与 quarantine 状态。App 只在 helper 完成签名复验和旧版恢复副本校验后
+退出;升级损坏安装时会恢复并重新验证上一版本。任何浏览器或 PDF 翻译任务进行中时都不会启动
+升级。非 Homebrew 安装只会打开官方 release 页面。
## 代码结构
@@ -196,5 +258,5 @@ fine-grained token 权限、完整安全取舍、发行顺序与恢复步骤见
Sources/GlossCore/ Codex/llama 客户端、provider 路由、翻译模型、缓存与并发合并
Sources/GlossOCR/ 本地 Vision OCR 与版面阅读顺序恢复
Sources/Gloss/ macOS 选区、图片、截图、结果面板、文本替换与浏览器桥接
-Sources/GlossCLI/ 薄命令行入口
+Sources/GlossCLI/ 浏览器/PDF 场景命令与向后兼容的文本入口
```
diff --git a/Resources/Info.plist b/Resources/Info.plist
index 30d4453..58ae526 100644
--- a/Resources/Info.plist
+++ b/Resources/Info.plist
@@ -19,9 +19,11 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.8.2
+ 0.8.3
CFBundleVersion
- 10
+ 11
+ GlossSafariExtensionAvailable
+
CFBundleDocumentTypes
@@ -37,43 +39,6 @@
- NSServices
-
-
- NSMenuItem
-
- default
- 使用 Gloss 翻译
-
- NSMessage
- translateTextWithGloss
- NSPortName
- Gloss
- NSSendTypes
-
- public.utf8-plain-text
-
-
-
- NSMenuItem
-
- default
- 使用 Gloss 翻译图片
-
- NSMessage
- translateImageWithGloss
- NSPortName
- Gloss
- NSSendTypes
-
- public.image
-
- NSSendFileTypes
-
- public.image
-
-
-
LSApplicationCategoryType
public.app-category.productivity
LSMinimumSystemVersion
diff --git a/Scripts/build_app.sh b/Scripts/build_app.sh
index 647292d..0feed6a 100755
--- a/Scripts/build_app.sh
+++ b/Scripts/build_app.sh
@@ -41,19 +41,33 @@ case "$CODEX_RUNTIME_MODE" in
;;
esac
SIGN_IDENTITY="${GLOSS_SIGN_IDENTITY:--}"
+SAFARI_HOST_PROFILE="${GLOSS_SAFARI_HOST_PROVISIONING_PROFILE:-}"
+SAFARI_EXTENSION_PROFILE="${GLOSS_SAFARI_EXTENSION_PROVISIONING_PROFILE:-}"
+
+if [[ "$SIGN_IDENTITY" != "-" ]]; then
+ if [[ ! -f "$SAFARI_HOST_PROFILE" || ! -f "$SAFARI_EXTENSION_PROFILE" ]]; then
+ echo "Apple-signed Safari builds require host and extension provisioning profiles." >&2
+ echo "Set GLOSS_SAFARI_HOST_PROVISIONING_PROFILE and GLOSS_SAFARI_EXTENSION_PROVISIONING_PROFILE." >&2
+ exit 1
+ fi
+ /usr/bin/security cms -D -i "$SAFARI_HOST_PROFILE" >/dev/null
+ /usr/bin/security cms -D -i "$SAFARI_EXTENSION_PROFILE" >/dev/null
+fi
if [[ ! -x "$PLUGIN_DIR/node_modules/.bin/wxt" ]]; then
npm --prefix "$PLUGIN_DIR" ci
fi
npm --prefix "$PLUGIN_DIR" run build
-xcodebuild \
- -project "$SAFARI_PROJECT" \
- -scheme Gloss \
- -configuration Release \
- -derivedDataPath "$SAFARI_BUILD_DIR" \
- CODE_SIGNING_ALLOWED=NO \
- build \
- -quiet
+if [[ "$SIGN_IDENTITY" != "-" ]]; then
+ xcodebuild \
+ -project "$SAFARI_PROJECT" \
+ -scheme Gloss \
+ -configuration Release \
+ -derivedDataPath "$SAFARI_BUILD_DIR" \
+ CODE_SIGNING_ALLOWED=NO \
+ build \
+ -quiet
+fi
cd "$ROOT_DIR"
swift build -c release
@@ -64,7 +78,7 @@ if [[ ! -f "$BROWSER_EXTENSION_DIR/manifest.json" ]]; then
echo "Browser extension not found: $BROWSER_EXTENSION_DIR" >&2
exit 1
fi
-if [[ ! -d "$SAFARI_EXTENSION" ]]; then
+if [[ "$SIGN_IDENTITY" != "-" && ! -d "$SAFARI_EXTENSION" ]]; then
echo "Safari extension not found: $SAFARI_EXTENSION" >&2
exit 1
fi
@@ -72,7 +86,22 @@ fi
install -d "$MACOS_DIR" "$HELPERS_DIR" "$RESOURCES_DIR" "$PLUGINS_DIR"
install -m 755 "$BIN_DIR/Gloss" "$MACOS_DIR/Gloss"
install -m 755 "$BIN_DIR/gloss-cli" "$HELPERS_DIR/gloss-cli"
+install -m 755 "$BIN_DIR/gloss-update-helper" "$HELPERS_DIR/gloss-update-helper"
install -m 644 "$ROOT_DIR/Resources/Info.plist" "$CONTENTS_DIR/Info.plist"
+if [[ "$SIGN_IDENTITY" == "-" ]]; then
+ /usr/libexec/PlistBuddy \
+ -c "Set :GlossSafariExtensionAvailable false" \
+ "$CONTENTS_DIR/Info.plist"
+else
+ /usr/libexec/PlistBuddy \
+ -c "Set :GlossSafariExtensionAvailable true" \
+ "$CONTENTS_DIR/Info.plist"
+fi
+if [[ "$SIGN_IDENTITY" != "-" ]]; then
+ install -m 644 \
+ "$SAFARI_HOST_PROFILE" \
+ "$CONTENTS_DIR/embedded.provisionprofile"
+fi
install -m 644 "$ROOT_DIR/Resources/Gloss.icns" "$RESOURCES_DIR/Gloss.icns"
if [[ "$CODEX_RUNTIME_MODE" == "bundled" ]]; then
install -m 755 "$CODEX_RUNTIME" "$HELPERS_DIR/gloss-codex-app-server"
@@ -80,23 +109,34 @@ if [[ "$CODEX_RUNTIME_MODE" == "bundled" ]]; then
install -m 644 "$ROOT_DIR/CodexRuntime.lock" "$RESOURCES_DIR/CodexRuntime.lock"
fi
/usr/bin/ditto "$BROWSER_EXTENSION_DIR" "$RESOURCES_DIR/BrowserExtension"
-/usr/bin/ditto "$SAFARI_EXTENSION" "$PLUGINS_DIR/Gloss Extension.appex"
+if [[ "$SIGN_IDENTITY" != "-" ]]; then
+ /usr/bin/ditto "$SAFARI_EXTENSION" "$PLUGINS_DIR/Gloss Extension.appex"
+ install -m 644 \
+ "$SAFARI_EXTENSION_PROFILE" \
+ "$PLUGINS_DIR/Gloss Extension.appex/Contents/embedded.provisionprofile"
+fi
SIGN_ARGS=(--force --sign "$SIGN_IDENTITY")
if [[ "$SIGN_IDENTITY" != "-" ]]; then
SIGN_ARGS+=(--options runtime --timestamp)
else
- echo "Warning: Safari pairing requires an Apple Development or distribution signature." >&2
+ echo "Safari extension omitted: pairing requires an Apple signing identity." >&2
fi
-codesign "${SIGN_ARGS[@]}" --entitlements "$SAFARI_ENTITLEMENTS" "$PLUGINS_DIR/Gloss Extension.appex"
+if [[ "$SIGN_IDENTITY" != "-" ]]; then
+ codesign "${SIGN_ARGS[@]}" \
+ --entitlements "$SAFARI_ENTITLEMENTS" \
+ "$PLUGINS_DIR/Gloss Extension.appex"
+fi
if [[ "$CODEX_RUNTIME_MODE" == "bundled" ]]; then
codesign "${SIGN_ARGS[@]}" "$HELPERS_DIR/gloss-codex-app-server"
fi
codesign "${SIGN_ARGS[@]}" "$HELPERS_DIR/gloss-cli"
+codesign "${SIGN_ARGS[@]}" "$HELPERS_DIR/gloss-update-helper"
codesign "${SIGN_ARGS[@]}" --entitlements "$APP_ENTITLEMENTS" "$APP_DIR"
if [[ "$CODEX_RUNTIME_MODE" == "bundled" ]]; then
codesign --verify --strict "$HELPERS_DIR/gloss-codex-app-server"
fi
+codesign --verify --strict "$HELPERS_DIR/gloss-update-helper"
codesign --verify --deep --strict "$APP_DIR"
echo "$APP_DIR"
diff --git a/Scripts/generate_homebrew_cask.sh b/Scripts/generate_homebrew_cask.sh
index 3cb94e8..45eb6ae 100755
--- a/Scripts/generate_homebrew_cask.sh
+++ b/Scripts/generate_homebrew_cask.sh
@@ -37,6 +37,10 @@ if [[ ! "$RELEASE_TAG" =~ ^v[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]]; then
echo "Invalid release tag: $RELEASE_TAG" >&2
exit 65
fi
+if [[ "$RELEASE_TAG" != "v$VERSION" ]]; then
+ echo "Release tag $RELEASE_TAG does not match version $VERSION." >&2
+ exit 65
+fi
for archive in "$ARM64_ARCHIVE" "$X86_64_ARCHIVE"; do
if [[ "$archive" == *"/"* || -z "$archive" ]]; then
echo "Invalid archive name: $archive" >&2
@@ -75,11 +79,11 @@ cask "gloss" do
depends_on macos: :sonoma
app "Gloss.app"
+ binary "#{appdir}/Gloss.app/Contents/Helpers/gloss-cli", target: "gloss-cli"
postflight do
app_path = "#{appdir}/Gloss.app"
- extension_path = "#{app_path}/Contents/PlugIns/Gloss Extension.appex"
- entitlement_paths = [extension_path, app_path]
+ entitlement_paths = [app_path]
entitlements_before = entitlement_paths.map do |code_path|
system_command("/usr/bin/codesign",
args: ["--display", "--entitlements", "-", code_path],
@@ -88,9 +92,9 @@ cask "gloss" do
print_stderr: false).stdout
end
code_paths = [
- extension_path,
"#{app_path}/Contents/Helpers/gloss-codex-app-server",
"#{app_path}/Contents/Helpers/gloss-cli",
+ "#{app_path}/Contents/Helpers/gloss-update-helper",
app_path,
]
code_paths.each do |code_path|
diff --git a/Scripts/generate_release_metadata.sh b/Scripts/generate_release_metadata.sh
index 2c16166..2713d6c 100755
--- a/Scripts/generate_release_metadata.sh
+++ b/Scripts/generate_release_metadata.sh
@@ -32,6 +32,10 @@ if [[ ! "$RELEASE_TAG" =~ ^v[0-9A-Za-z][0-9A-Za-z.+_-]*$ ]]; then
echo "Invalid release tag: $RELEASE_TAG" >&2
exit 65
fi
+if [[ "$RELEASE_TAG" != "v$VERSION" ]]; then
+ echo "Release tag $RELEASE_TAG does not match version $VERSION." >&2
+ exit 65
+fi
ASSET_URL_PATTERN='^https://[^[:space:]"\\]+$'
if [[ ! "$ASSET_BASE_URL" =~ $ASSET_URL_PATTERN ]]; then
echo "Invalid HTTPS asset base URL: $ASSET_BASE_URL" >&2
@@ -77,6 +81,23 @@ MANIFEST_PATH="$OUTPUT_DIRECTORY/gloss-release-manifest.json"
CHECKSUMS_PATH="$OUTPUT_DIRECTORY/SHA256SUMS"
ASSET_BASE_URL="${ASSET_BASE_URL%/}"
+GLOSS_CASK_DOWNLOAD_BASE_URL="$ASSET_BASE_URL" \
+ bash "$(dirname "$0")/generate_homebrew_cask.sh" \
+ "$VERSION" \
+ "$ARM64_SHA256" \
+ "$X86_64_SHA256" \
+ "$OUTPUT_DIRECTORY/gloss.rb" \
+ "$RELEASE_TAG" \
+ "$REPOSITORY" \
+ "$ARM64_NAME" \
+ "$X86_64_NAME"
+bash "$(dirname "$0")/validate_homebrew_cask.sh" \
+ "$OUTPUT_DIRECTORY/gloss.rb"
+CASK_PATH="$OUTPUT_DIRECTORY/gloss.rb"
+CASK_SHA256="$(shasum -a 256 "$CASK_PATH" | awk '{print $1}')"
+CASK_SIZE="$(stat -f '%z' "$CASK_PATH")"
+CASK_URL="$ASSET_BASE_URL/gloss.rb"
+
python3 - \
"$MANIFEST_PATH" \
"$VERSION" \
@@ -88,7 +109,10 @@ python3 - \
"$ARM64_SIZE" \
"$X86_64_NAME" \
"$X86_64_SHA256" \
- "$X86_64_SIZE" <<'PY'
+ "$X86_64_SIZE" \
+ "$CASK_URL" \
+ "$CASK_SHA256" \
+ "$CASK_SIZE" <<'PY'
import json
import pathlib
import sys
@@ -105,9 +129,12 @@ import sys
x86_64_name,
x86_64_sha256,
x86_64_size,
+ cask_url,
+ cask_sha256,
+ cask_size,
) = sys.argv[1:]
manifest = {
- "schemaVersion": 1,
+ "schemaVersion": 2,
"channel": "stable",
"version": version,
"releaseTag": release_tag,
@@ -129,6 +156,12 @@ manifest = {
"size": int(x86_64_size),
},
],
+ "homebrewCask": {
+ "token": "sunchj/tap/gloss",
+ "url": cask_url,
+ "sha256": cask_sha256,
+ "size": int(cask_size),
+ },
}
pathlib.Path(output).write_text(
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
@@ -140,21 +173,9 @@ MANIFEST_SHA256="$(shasum -a 256 "$MANIFEST_PATH" | awk '{print $1}')"
{
printf '%s %s\n' "$ARM64_SHA256" "$ARM64_NAME"
printf '%s %s\n' "$X86_64_SHA256" "$X86_64_NAME"
+ printf '%s %s\n' "$CASK_SHA256" "gloss.rb"
printf '%s %s\n' "$MANIFEST_SHA256" "$(basename "$MANIFEST_PATH")"
} >"$CHECKSUMS_PATH"
-GLOSS_CASK_DOWNLOAD_BASE_URL="$ASSET_BASE_URL" \
- bash "$(dirname "$0")/generate_homebrew_cask.sh" \
- "$VERSION" \
- "$ARM64_SHA256" \
- "$X86_64_SHA256" \
- "$OUTPUT_DIRECTORY/Casks/gloss.rb" \
- "$RELEASE_TAG" \
- "$REPOSITORY" \
- "$ARM64_NAME" \
- "$X86_64_NAME"
-bash "$(dirname "$0")/validate_homebrew_cask.sh" \
- "$OUTPUT_DIRECTORY/Casks/gloss.rb"
-
echo "$MANIFEST_PATH"
echo "$CHECKSUMS_PATH"
diff --git a/Scripts/sign_release_manifest.swift b/Scripts/sign_release_manifest.swift
new file mode 100644
index 0000000..68d5b26
--- /dev/null
+++ b/Scripts/sign_release_manifest.swift
@@ -0,0 +1,88 @@
+#!/usr/bin/env swift
+
+import CryptoKit
+import Foundation
+
+private let expectedPublicKey = Data(
+ base64Encoded: "FtPLO0dyvSMP4BUSZtk4ROgObX6F2GAToLeNfOygcVY="
+)!
+
+private enum SigningError: LocalizedError {
+ case usage
+ case missingSigningKey
+ case invalidSigningKey
+ case unexpectedPublicKey
+ case invalidSignature
+
+ var errorDescription: String? {
+ switch self {
+ case .usage:
+ "Usage: sign_release_manifest.swift [signature-path]"
+ case .missingSigningKey:
+ "GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY is required."
+ case .invalidSigningKey:
+ "GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY must be a Base64-encoded Ed25519 private key."
+ case .unexpectedPublicKey:
+ "The manifest signing key does not match Gloss's pinned public key."
+ case .invalidSignature:
+ "Failed to create a 64-byte Ed25519 signature."
+ }
+ }
+}
+
+private func run() throws {
+ let arguments = CommandLine.arguments.dropFirst()
+ guard arguments.count == 1 || arguments.count == 2 else {
+ throw SigningError.usage
+ }
+
+ let manifestURL = URL(fileURLWithPath: String(arguments[arguments.startIndex]))
+ let signatureURL: URL
+ if arguments.count == 2 {
+ signatureURL = URL(fileURLWithPath: String(arguments[arguments.index(after: arguments.startIndex)]))
+ } else {
+ signatureURL = URL(fileURLWithPath: manifestURL.path + ".sig")
+ }
+
+ guard
+ let encodedPrivateKey = ProcessInfo.processInfo.environment[
+ "GLOSS_APP_UPDATE_MANIFEST_SIGNING_KEY"
+ ]?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !encodedPrivateKey.isEmpty
+ else {
+ throw SigningError.missingSigningKey
+ }
+ guard let privateKeyData = Data(base64Encoded: encodedPrivateKey) else {
+ throw SigningError.invalidSigningKey
+ }
+
+ let privateKey: Curve25519.Signing.PrivateKey
+ do {
+ privateKey = try Curve25519.Signing.PrivateKey(rawRepresentation: privateKeyData)
+ } catch {
+ throw SigningError.invalidSigningKey
+ }
+ guard privateKey.publicKey.rawRepresentation == expectedPublicKey else {
+ throw SigningError.unexpectedPublicKey
+ }
+
+ let manifestData = try Data(contentsOf: manifestURL)
+ let signature = try privateKey.signature(for: manifestData)
+ let publicKey = try Curve25519.Signing.PublicKey(
+ rawRepresentation: expectedPublicKey
+ )
+ guard signature.count == 64,
+ publicKey.isValidSignature(signature, for: manifestData)
+ else {
+ throw SigningError.invalidSignature
+ }
+
+ try signature.base64EncodedData().write(to: signatureURL, options: .atomic)
+}
+
+do {
+ try run()
+} catch {
+ fputs("sign_release_manifest.swift: \(error.localizedDescription)\n", stderr)
+ exit(1)
+}
diff --git a/Scripts/validate_homebrew_cask.sh b/Scripts/validate_homebrew_cask.sh
index 514c751..7d5440a 100755
--- a/Scripts/validate_homebrew_cask.sh
+++ b/Scripts/validate_homebrew_cask.sh
@@ -25,12 +25,13 @@ required = [
%r{^ url "https://[^"]+/Gloss-macos-arm64\.zip"$},
%r{^ url "https://[^"]+/Gloss-macos-x86_64\.zip"$},
/^ app "Gloss\.app"$/,
+ %r{^ binary "#\{appdir\}/Gloss\.app/Contents/Helpers/gloss-cli", target: "gloss-cli"$},
/^ postflight do$/,
%r{^ system_command "/usr/bin/codesign",$},
%r{^ system_command "/usr/bin/codesign",$},
- %r{#\{app_path\}/Contents/PlugIns/Gloss Extension\.appex},
%r{#\{app_path\}/Contents/Helpers/gloss-codex-app-server},
%r{#\{app_path\}/Contents/Helpers/gloss-cli},
+ %r{#\{app_path\}/Contents/Helpers/gloss-update-helper},
/^ code_paths\.each do \|code_path\|$/,
/entitlements_before = entitlement_paths\.map/,
/entitlements_after = entitlement_paths\.map/,
@@ -46,13 +47,16 @@ required = [
]
missing = required.reject { |pattern| content.match?(pattern) }
abort "Cask is missing required declarations: #{missing.join(", ")}" unless missing.empty?
+if content.include?("Gloss Extension.appex")
+ abort "Ad-hoc Homebrew cask must not contain a Safari App Extension"
+end
code_paths = content.match(%r{^ code_paths = \[$(.*?)^ \]$}m)&.[](1)
abort "Cask explicit signing paths are missing" unless code_paths
expected_order = [
- "extension_path,",
'"#{app_path}/Contents/Helpers/gloss-codex-app-server",',
'"#{app_path}/Contents/Helpers/gloss-cli",',
+ '"#{app_path}/Contents/Helpers/gloss-update-helper",',
"app_path,",
]
cursor = -1
diff --git a/Sources/Gloss/AppUpdateController.swift b/Sources/Gloss/AppUpdateController.swift
new file mode 100644
index 0000000..0488cf8
--- /dev/null
+++ b/Sources/Gloss/AppUpdateController.swift
@@ -0,0 +1,245 @@
+import Foundation
+import GlossCore
+
+enum AppUpdateStagingCleaner {
+ enum CleanupError: Error, Equatable {
+ case invalidDirectory
+ }
+
+ static func removeStagedHelpers(
+ at stagingRootURL: URL,
+ fileManager: FileManager = .default
+ ) throws {
+ let standardizedURL = stagingRootURL.standardizedFileURL
+ guard standardizedURL.lastPathComponent == "staging",
+ standardizedURL.deletingLastPathComponent().lastPathComponent
+ == "AppUpdater"
+ else {
+ throw CleanupError.invalidDirectory
+ }
+ guard fileManager.fileExists(atPath: standardizedURL.path) else {
+ return
+ }
+ try fileManager.removeItem(at: standardizedURL)
+ }
+}
+
+@MainActor
+final class AppUpdateController {
+ struct Dependencies {
+ var check:
+ (GlossAppUpdateCheckMode) async throws
+ -> GlossAppUpdateCheckResult
+ var detectHomebrewInstallation:
+ () async throws
+ -> GlossHomebrewInstallation?
+ var launchHomebrewUpdate:
+ (
+ GlossHomebrewInstallation,
+ GlossAppUpdateAvailability
+ ) async throws -> Void
+ var openReleasePage: (URL) -> Bool
+ var isBusinessTaskActive: () async -> Bool
+ var requestApplicationTermination: () -> Void
+ var operatingSystemVersion: () -> OperatingSystemVersion
+ }
+
+ private(set) var state: AppUpdateDashboardState {
+ didSet {
+ onStateChange?(state)
+ }
+ }
+
+ var onStateChange: ((AppUpdateDashboardState) -> Void)?
+
+ private let currentVersion: String
+ private let dependencies: Dependencies
+ private var automaticCheckTask: Task?
+ private var operationInProgress = false
+
+ init(
+ currentVersion: String,
+ dependencies: Dependencies,
+ initialState: AppUpdateDashboardState? = nil
+ ) {
+ self.currentVersion = currentVersion
+ self.dependencies = dependencies
+ state = initialState ?? .idle(currentVersion: currentVersion)
+ }
+
+ func startAutomaticCheck(
+ after delay: Duration = .seconds(4)
+ ) {
+ guard automaticCheckTask == nil else { return }
+ automaticCheckTask = Task { @MainActor [weak self] in
+ do {
+ try await Task.sleep(for: delay)
+ } catch {
+ return
+ }
+ guard let self, !Task.isCancelled else { return }
+ await check(mode: .automatic)
+ }
+ }
+
+ func cancel() {
+ automaticCheckTask?.cancel()
+ automaticCheckTask = nil
+ }
+
+ func check(mode: GlossAppUpdateCheckMode) async {
+ guard !operationInProgress else { return }
+ operationInProgress = true
+ let previousState = state
+ state = .checking(currentVersion: currentVersion)
+ defer { operationInProgress = false }
+
+ do {
+ let result = try await dependencies.check(mode)
+ switch result {
+ case .throttled:
+ state =
+ if case .checking = previousState {
+ .idle(currentVersion: currentVersion)
+ } else {
+ previousState
+ }
+ case .upToDate(let latestVersion):
+ state = .upToDate(
+ currentVersion: currentVersion,
+ latestVersion: latestVersion
+ )
+ case .updateAvailable(let update):
+ guard
+ Self.supports(
+ minimumMacOSVersion: update.minimumMacOSVersion,
+ current: dependencies.operatingSystemVersion()
+ )
+ else {
+ state = .unavailable(
+ currentVersion: currentVersion,
+ reason:
+ "Gloss \(update.version) 需要 macOS \(update.minimumMacOSVersion) 或更高版本"
+ )
+ return
+ }
+
+ let installation =
+ try await dependencies.detectHomebrewInstallation()
+ state = .updateAvailable(
+ update,
+ delivery: installation.map(AppUpdateDelivery.homebrew)
+ ?? .releasePage
+ )
+ }
+ } catch is CancellationError {
+ state = previousState
+ } catch {
+ state = .failed(
+ currentVersion: currentVersion,
+ message: error.localizedDescription
+ )
+ }
+ }
+
+ func performPrimaryAction() async {
+ guard let action = state.action else { return }
+ switch action {
+ case .check:
+ await check(mode: .manual)
+ case .openReleasePage:
+ guard case .updateAvailable(let update, .releasePage) = state else {
+ return
+ }
+ if !dependencies.openReleasePage(update.releasePageURL) {
+ state = .failed(
+ currentVersion: currentVersion,
+ message: "无法打开官方发布页面。"
+ )
+ }
+ case .install:
+ await installAvailableUpdate()
+ }
+ }
+
+ static func supports(
+ minimumMacOSVersion: String,
+ current: OperatingSystemVersion
+ ) -> Bool {
+ let parts = minimumMacOSVersion.split(separator: ".")
+ guard (2...3).contains(parts.count),
+ let major = Int(parts[0]),
+ let minor = Int(parts[1])
+ else {
+ return false
+ }
+ let patch: Int
+ if parts.count == 3 {
+ guard let parsedPatch = Int(parts[2]) else { return false }
+ patch = parsedPatch
+ } else {
+ patch = 0
+ }
+ let required = OperatingSystemVersion(
+ majorVersion: major,
+ minorVersion: minor,
+ patchVersion: patch
+ )
+ let currentParts = [
+ current.majorVersion,
+ current.minorVersion,
+ current.patchVersion,
+ ]
+ let requiredParts = [
+ required.majorVersion,
+ required.minorVersion,
+ required.patchVersion,
+ ]
+ return !currentParts.lexicographicallyPrecedes(requiredParts)
+ }
+
+ private func installAvailableUpdate() async {
+ guard !operationInProgress else { return }
+
+ let update: GlossAppUpdateAvailability
+ let installation: GlossHomebrewInstallation
+ switch state {
+ case .updateAvailable(let available, .homebrew(let managedInstallation)):
+ update = available
+ installation = managedInstallation
+ case .blockedByBusinessTask(
+ let available,
+ let managedInstallation
+ ):
+ update = available
+ installation = managedInstallation
+ default:
+ return
+ }
+
+ guard !(await dependencies.isBusinessTaskActive()) else {
+ state = .blockedByBusinessTask(
+ update,
+ installation: installation
+ )
+ return
+ }
+
+ operationInProgress = true
+ state = .preparingInstall(version: update.version)
+ defer { operationInProgress = false }
+
+ do {
+ try await dependencies.launchHomebrewUpdate(
+ installation,
+ update
+ )
+ dependencies.requestApplicationTermination()
+ } catch {
+ state = .failed(
+ currentVersion: currentVersion,
+ message: error.localizedDescription
+ )
+ }
+ }
+}
diff --git a/Sources/Gloss/AppUpdateDashboardState.swift b/Sources/Gloss/AppUpdateDashboardState.swift
new file mode 100644
index 0000000..d58fa78
--- /dev/null
+++ b/Sources/Gloss/AppUpdateDashboardState.swift
@@ -0,0 +1,232 @@
+import Foundation
+import GlossCore
+
+enum AppUpdateDelivery: Equatable {
+ case homebrew(GlossHomebrewInstallation)
+ case releasePage
+}
+
+enum AppUpdateDashboardAction: Equatable {
+ case check
+ case install
+ case openReleasePage
+}
+
+enum AppUpdateDashboardState: Equatable {
+ case unavailable(currentVersion: String, reason: String)
+ case idle(currentVersion: String)
+ case checking(currentVersion: String)
+ case upToDate(currentVersion: String, latestVersion: String)
+ case updateAvailable(
+ GlossAppUpdateAvailability,
+ delivery: AppUpdateDelivery
+ )
+ case blockedByBusinessTask(
+ GlossAppUpdateAvailability,
+ installation: GlossHomebrewInstallation
+ )
+ case preparingInstall(version: String)
+ case failed(currentVersion: String, message: String)
+
+ static func fromHomebrewResult(
+ _ result: GlossHomebrewUpgradeResult,
+ currentVersion: String
+ ) -> Self {
+ switch result.outcome {
+ case .succeeded:
+ return .upToDate(
+ currentVersion: currentVersion,
+ latestVersion:
+ result.installedVersion ?? result.expectedVersion
+ )
+ case .failed:
+ let baseMessage =
+ result.message
+ ?? "Homebrew 更新未完成(\(result.errorCode ?? "未知错误"))"
+ let recoveryDetail =
+ if result.recoveredPreviousInstallation {
+ " 已恢复上一版本。"
+ } else if let recoveryError = result.recoveryError {
+ " 恢复失败:\(recoveryError)"
+ } else {
+ ""
+ }
+ return .failed(
+ currentVersion: currentVersion,
+ message: baseMessage + recoveryDetail
+ )
+ }
+ }
+
+ var action: AppUpdateDashboardAction? {
+ switch self {
+ case .unavailable, .checking, .preparingInstall:
+ nil
+ case .idle, .upToDate, .failed:
+ .check
+ case .updateAvailable(_, let delivery):
+ switch delivery {
+ case .homebrew:
+ .install
+ case .releasePage:
+ .openReleasePage
+ }
+ case .blockedByBusinessTask:
+ .install
+ }
+ }
+
+ var presentation: AppUpdateDashboardPresentation {
+ switch self {
+ case .unavailable(let currentVersion, let reason):
+ AppUpdateDashboardPresentation(
+ headline: "应用更新不可用",
+ detail: "Gloss \(currentVersion) · \(reason)",
+ tone: .neutral,
+ actionTitle: "不可用",
+ actionEnabled: false,
+ showsProgress: false
+ )
+ case .idle(let currentVersion):
+ AppUpdateDashboardPresentation(
+ headline: "自动检查应用更新",
+ detail: "Gloss \(currentVersion) · 每 24 小时后台检查一次",
+ tone: .neutral,
+ actionTitle: "检查更新",
+ actionEnabled: true,
+ showsProgress: false
+ )
+ case .checking(let currentVersion):
+ AppUpdateDashboardPresentation(
+ headline: "正在检查应用更新…",
+ detail: "当前版本 Gloss \(currentVersion)",
+ tone: .neutral,
+ actionTitle: "正在检查…",
+ actionEnabled: false,
+ showsProgress: true
+ )
+ case .upToDate(let currentVersion, let latestVersion):
+ AppUpdateDashboardPresentation(
+ headline: "Gloss 已是最新版本",
+ detail: "当前 \(currentVersion) · 最新 \(latestVersion)",
+ tone: .positive,
+ actionTitle: "再次检查",
+ actionEnabled: true,
+ showsProgress: false
+ )
+ case .updateAvailable(let update, let delivery):
+ switch delivery {
+ case .homebrew:
+ AppUpdateDashboardPresentation(
+ headline: "Gloss \(update.version) 可用",
+ detail: "由 Homebrew 安全升级,完成后自动重新启动",
+ tone: .warning,
+ actionTitle: "更新并重新启动",
+ actionEnabled: true,
+ showsProgress: false
+ )
+ case .releasePage:
+ AppUpdateDashboardPresentation(
+ headline: "Gloss \(update.version) 可用",
+ detail: "当前 App 不是由 sunchj/tap/gloss 管理",
+ tone: .warning,
+ actionTitle: "查看下载",
+ actionEnabled: true,
+ showsProgress: false
+ )
+ }
+ case .blockedByBusinessTask(let update, _):
+ AppUpdateDashboardPresentation(
+ headline: "等待当前翻译任务完成",
+ detail: "完成当前任务后即可安装 Gloss \(update.version)",
+ tone: .warning,
+ actionTitle: "重试更新",
+ actionEnabled: true,
+ showsProgress: false
+ )
+ case .preparingInstall(let version):
+ AppUpdateDashboardPresentation(
+ headline: "正在准备更新到 Gloss \(version)…",
+ detail: "Gloss 即将退出;Homebrew 完成升级后会自动重新启动",
+ tone: .neutral,
+ actionTitle: "正在准备…",
+ actionEnabled: false,
+ showsProgress: true
+ )
+ case .failed(let currentVersion, let message):
+ AppUpdateDashboardPresentation(
+ headline: "应用更新失败",
+ detail: "Gloss \(currentVersion) · \(message)",
+ tone: .negative,
+ actionTitle: "重试",
+ actionEnabled: true,
+ showsProgress: false
+ )
+ }
+ }
+
+ var menuPresentation: AppUpdateMenuPresentation {
+ switch self {
+ case .unavailable:
+ return AppUpdateMenuPresentation(
+ title: "检查更新不可用",
+ isEnabled: false
+ )
+ case .idle, .upToDate:
+ return AppUpdateMenuPresentation(
+ title: "检查更新…",
+ isEnabled: true
+ )
+ case .checking:
+ return AppUpdateMenuPresentation(
+ title: "正在检查更新…",
+ isEnabled: false
+ )
+ case .updateAvailable(let update, let delivery):
+ let title =
+ switch delivery {
+ case .homebrew:
+ "更新 Gloss 到 \(update.version)…"
+ case .releasePage:
+ "下载 Gloss \(update.version)…"
+ }
+ return AppUpdateMenuPresentation(title: title, isEnabled: true)
+ case .blockedByBusinessTask(let update, _):
+ return AppUpdateMenuPresentation(
+ title: "完成当前任务后更新到 \(update.version)…",
+ isEnabled: true
+ )
+ case .preparingInstall:
+ return AppUpdateMenuPresentation(
+ title: "正在准备更新…",
+ isEnabled: false
+ )
+ case .failed:
+ return AppUpdateMenuPresentation(
+ title: "重试检查更新…",
+ isEnabled: true
+ )
+ }
+ }
+}
+
+struct AppUpdateDashboardPresentation: Equatable {
+ enum Tone: Equatable {
+ case neutral
+ case positive
+ case warning
+ case negative
+ }
+
+ let headline: String
+ let detail: String
+ let tone: Tone
+ let actionTitle: String
+ let actionEnabled: Bool
+ let showsProgress: Bool
+}
+
+struct AppUpdateMenuPresentation: Equatable {
+ let title: String
+ let isEnabled: Bool
+}
diff --git a/Sources/Gloss/GlossAppDelegate.swift b/Sources/Gloss/GlossAppDelegate.swift
index 18a1aa6..71afa97 100644
--- a/Sources/Gloss/GlossAppDelegate.swift
+++ b/Sources/Gloss/GlossAppDelegate.swift
@@ -11,8 +11,13 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
static let automaticSelection = 10_001
static let currentApplication = 10_002
static let launchAtLogin = 10_003
+ static let appUpdate = 10_004
}
+ private let capabilityRegistry = GlossCapabilityRegistry.current
+ private lazy var scenarioActivation = GlossAppScenarioActivation(
+ registry: capabilityRegistry
+ )
private let languages = TranslationLanguages.common
private let targetLanguageKey = "targetLanguage"
private let reverseLanguageKey = "reverseLanguage"
@@ -26,6 +31,7 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private let codexReasoningEffortKey = "codexReasoningEffort"
private let glossaryRevisionKey = "glossaryRevision"
private let welcomeVersionKey = "welcomeVersion"
+ private let appUpdateLastCheckKey = "appUpdateLastCheck"
private let glossaryStore = GlossaryStore()
private let runtimeLog = GlossRuntimeLog.shared
private let dispatchState = TranslationDispatchState()
@@ -54,6 +60,9 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var pdfRuntimeActionTask: Task?
private var pdfRuntimeActionID: UUID?
private lazy var pdfRuntimeController = PDFRuntimeController()
+ private var appUpdateActionTask: Task?
+ private lazy var appUpdateController: AppUpdateController? =
+ makeAppUpdateController()
private var selectionMonitor: SelectionMonitor?
private var currentSelection: SelectionSnapshot?
private var activeTranslationID: UUID?
@@ -88,6 +97,176 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
return trimmed.isEmpty ? "dev" : trimmed
}
+ private var currentAppUpdateState: AppUpdateDashboardState {
+ appUpdateController?.state
+ ?? .unavailable(
+ currentVersion: applicationVersion,
+ reason: "仅正式版本支持自动更新"
+ )
+ }
+
+ private var appUpdaterRootURL: URL? {
+ FileManager.default.urls(
+ for: .cachesDirectory,
+ in: .userDomainMask
+ ).first?
+ .appendingPathComponent("Gloss", isDirectory: true)
+ .appendingPathComponent("AppUpdater", isDirectory: true)
+ }
+
+ private func makeAppUpdateController() -> AppUpdateController? {
+ guard capabilityRegistry.supports(.appUpdates),
+ GlossSemanticVersion(applicationVersion) != nil,
+ let updaterRootURL = appUpdaterRootURL
+ else {
+ return nil
+ }
+
+ do {
+ let historyKey = appUpdateLastCheckKey
+ let history = GlossAppUpdateCheckHistory(
+ lastCheck: {
+ UserDefaults.standard.object(forKey: historyKey) as? Date
+ },
+ recordCheck: { date in
+ UserDefaults.standard.set(date, forKey: historyKey)
+ }
+ )
+ let discovery = try GlossAppUpdateDiscovery(
+ currentVersion: applicationVersion,
+ fetcher: .live,
+ history: history
+ )
+ let detector = GlossHomebrewInstallationDetector(
+ commandRunner: .live
+ )
+ let currentBundleURL = Bundle.main.bundleURL
+ let bundledHelperURL =
+ currentBundleURL
+ .appendingPathComponent("Contents", isDirectory: true)
+ .appendingPathComponent("Helpers", isDirectory: true)
+ .appendingPathComponent(
+ GlossAppUpdateHelperLauncher.helperName
+ )
+ let stagingRootURL = updaterRootURL.appendingPathComponent(
+ "staging",
+ isDirectory: true
+ )
+ let resultURL = updaterRootURL.appendingPathComponent(
+ "latest-result.json"
+ )
+ let launcher = GlossAppUpdateHelperLauncher()
+ let initialState = consumeAppUpdateResult(at: resultURL)
+ if initialState != nil {
+ do {
+ try AppUpdateStagingCleaner.removeStagedHelpers(
+ at: stagingRootURL
+ )
+ } catch {
+ runtimeLog.write(
+ "app-update",
+ "staging_cleanup_failed error=\(error.localizedDescription)"
+ )
+ }
+ }
+
+ let controller = AppUpdateController(
+ currentVersion: applicationVersion,
+ dependencies: AppUpdateController.Dependencies(
+ check: { mode in
+ try await discovery.check(mode: mode)
+ },
+ detectHomebrewInstallation: {
+ try await detector.detect(
+ currentBundleURL: currentBundleURL
+ )
+ },
+ launchHomebrewUpdate: { installation, update in
+ _ = try await launcher.launch(
+ bundledHelperURL: bundledHelperURL,
+ installation: installation,
+ update: update,
+ parentProcessIdentifier:
+ Int32(ProcessInfo.processInfo.processIdentifier),
+ currentBundleURL: currentBundleURL,
+ cacheRootURL: stagingRootURL,
+ resultURL: resultURL
+ )
+ },
+ openReleasePage: { url in
+ NSWorkspace.shared.open(url)
+ },
+ isBusinessTaskActive: { [weak self] in
+ guard let self else { return false }
+ if pdfTranslationWindowController?
+ .hasActiveTranslation == true
+ {
+ return true
+ }
+ if case .translating =
+ pdfRuntimeController.dashboardState
+ {
+ return true
+ }
+ let dispatch = await dispatchState.snapshot()
+ return dispatch.pendingInteractiveJobs > 0
+ || dispatch.pendingVisibleJobs > 0
+ || dispatch.pendingBackgroundJobs > 0
+ || dispatch.activeInteractiveJobs > 0
+ || dispatch.activeVisibleJobs > 0
+ || dispatch.activeBackgroundJobs > 0
+ || dispatch.upstreamBackgroundItems > 0
+ },
+ requestApplicationTermination: {
+ NSApp.terminate(nil)
+ },
+ operatingSystemVersion: {
+ ProcessInfo.processInfo.operatingSystemVersion
+ }
+ ),
+ initialState: initialState
+ )
+ controller.onStateChange = { [weak self] state in
+ self?.showAppUpdateState(state)
+ }
+ return controller
+ } catch {
+ runtimeLog.write(
+ "app-update",
+ "initialization_failed error=\(error.localizedDescription)"
+ )
+ return nil
+ }
+ }
+
+ private func consumeAppUpdateResult(
+ at resultURL: URL
+ ) -> AppUpdateDashboardState? {
+ guard FileManager.default.fileExists(atPath: resultURL.path) else {
+ return nil
+ }
+ do {
+ let result = try GlossHomebrewUpgradeResultStore.load(
+ from: resultURL
+ )
+ try FileManager.default.removeItem(at: resultURL)
+ return .fromHomebrewResult(
+ result,
+ currentVersion: applicationVersion
+ )
+ } catch {
+ runtimeLog.write(
+ "app-update",
+ "result_read_failed error=\(error.localizedDescription)"
+ )
+ try? FileManager.default.removeItem(at: resultURL)
+ return .failed(
+ currentVersion: applicationVersion,
+ message: "无法读取上一次更新结果。"
+ )
+ }
+ }
+
private var glossBar: GlossBarController {
if let glossBarController { return glossBarController }
let controller = GlossBarController()
@@ -118,7 +297,7 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var settingsWindow: SettingsWindowController {
if let settingsWindowController { return settingsWindowController }
- let controller = SettingsWindowController()
+ let controller = SettingsWindowController(capabilityRegistry: capabilityRegistry)
controller.onRequestAccessibility = { [weak self] in
self?.requestAccessibility()
}
@@ -146,8 +325,10 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
controller.onRevealBrowserExtension = { [weak self] in
self?.revealBrowserExtension()
}
- controller.onOpenSafariExtensionSettings = { [weak self] in
- self?.openSafariExtensionSettings()
+ if capabilityRegistry.supports(.safariExtension) {
+ controller.onOpenSafariExtensionSettings = { [weak self] in
+ self?.openSafariExtensionSettings()
+ }
}
controller.onBridgeAction = { [weak self] in
self?.performBridgeDashboardAction()
@@ -155,6 +336,9 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
controller.onPDFRuntimeAction = { [weak self] action in
self?.performPDFRuntimeAction(action)
}
+ controller.onAppUpdateAction = { [weak self] in
+ self?.performAppUpdateAction()
+ }
controller.onRevealLogs = { [weak self] in
self?.revealLogs()
}
@@ -167,12 +351,21 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
controller.onSetGlobalShortcut = { [weak self] shortcut in
self?.setGlobalShortcut(shortcut)
}
- controller.showBridgeState(bridgeDashboardState)
- controller.showPDFRuntimeState(pdfRuntimeController.dashboardState)
- controller.showBrowserExtensionStatus(
- browserExtensionStatus.message,
- succeeded: browserExtensionStatus.succeeded
- )
+ if scenarioActivation.startsTranslationBridge {
+ controller.showBridgeState(bridgeDashboardState)
+ }
+ if scenarioActivation.preparesPDFRuntime {
+ controller.showPDFRuntimeState(pdfRuntimeController.dashboardState)
+ }
+ if capabilityRegistry.supports(.appUpdates) {
+ controller.showAppUpdateState(currentAppUpdateState)
+ }
+ if capabilityRegistry.isEnabled(.browserTranslation) {
+ controller.showBrowserExtensionStatus(
+ browserExtensionStatus.message,
+ succeeded: browserExtensionStatus.succeeded
+ )
+ }
controller.showProviderConfiguration(providerConfiguration)
if let succeeded = engineStatus.succeeded {
controller.showProviderResult(engineStatus.message, succeeded: succeeded)
@@ -340,19 +533,36 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
try? runtimeLog.prepare()
runtimeLog.write("app", "started version=\(applicationVersion)")
- startObservingPDFRuntime()
- pdfRuntimeController.prepareAtLaunch()
+ if scenarioActivation.preparesPDFRuntime {
+ startObservingPDFRuntime()
+ pdfRuntimeController.prepareAtLaunch()
+ }
NSApp.setActivationPolicy(.accessory)
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) { [weak self] in
self?.configureStatusItem()
}
- configureServices()
- configureSelectionMonitor()
- configureWorkspaceObservation()
- startBrowserBridge()
- prewarmProvider()
+ if scenarioActivation.configuresSystemServices {
+ configureServices()
+ }
+ if capabilityRegistry.isEnabled(.selectionTranslation) {
+ configureSelectionMonitor()
+ }
+ if scenarioActivation.observesWorkspaceApplications {
+ configureWorkspaceObservation()
+ }
+ if scenarioActivation.startsTranslationBridge {
+ startBrowserBridge()
+ }
+ if scenarioActivation.prewarmsTranslationProvider {
+ prewarmProvider()
+ }
+ if capabilityRegistry.supports(.appUpdates) {
+ appUpdateController?.startAutomaticCheck()
+ }
- if !reconcileAccessibility() {
+ if capabilityRegistry.isEnabled(.selectionTranslation),
+ !reconcileAccessibility()
+ {
startAccessibilityPolling()
}
@@ -365,38 +575,51 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
return URL(fileURLWithPath: rawArguments[index + 1])
}
if !pdfURLs.isEmpty {
- let requestedPage =
- rawArguments.firstIndex(of: "--pdf-page")
- .flatMap { index in
- rawArguments.indices.contains(index + 1)
- ? Int(rawArguments[index + 1])
- : nil
- }
- .map { max(0, $0 - 1) } ?? 0
- DispatchQueue.main.async { [weak self] in
- for (index, url) in pdfURLs.enumerated() {
- do {
- try self?.pdfTranslationWindow.open(
- url,
- pageIndex: index == 0 ? requestedPage : 0
- )
- } catch {
- self?.showAlert(
- title: "无法打开 \(url.lastPathComponent)",
- message: error.localizedDescription
- )
+ if scenarioActivation.acceptsPDFOpenRequests {
+ let requestedPage =
+ rawArguments.firstIndex(of: "--pdf-page")
+ .flatMap { index in
+ rawArguments.indices.contains(index + 1)
+ ? Int(rawArguments[index + 1])
+ : nil
+ }
+ .map { max(0, $0 - 1) } ?? 0
+ DispatchQueue.main.async { [weak self] in
+ for (index, url) in pdfURLs.enumerated() {
+ do {
+ try self?.pdfTranslationWindow.open(
+ url,
+ pageIndex: index == 0 ? requestedPage : 0
+ )
+ } catch {
+ self?.showAlert(
+ title: "无法打开 \(url.lastPathComponent)",
+ message: error.localizedDescription
+ )
+ }
}
}
+ } else {
+ runtimeLog.write(
+ "app",
+ "ignored_pdf_open count=\(pdfURLs.count) reason=scenario_disabled"
+ )
}
- } else if arguments.contains("--show-history") {
+ } else if capabilityRegistry.isEnabled(.translationHistory),
+ arguments.contains("--show-history")
+ {
DispatchQueue.main.async { [weak self] in
self?.historyWindow.show()
}
- } else if arguments.contains("--show-glossary") {
+ } else if capabilityRegistry.isEnabled(.glossaryManagement),
+ arguments.contains("--show-glossary")
+ {
DispatchQueue.main.async { [weak self] in
self?.glossaryWindow.show()
}
- } else if arguments.contains("--show-exclusions") {
+ } else if capabilityRegistry.isEnabled(.selectionTranslation),
+ arguments.contains("--show-exclusions")
+ {
DispatchQueue.main.async { [weak self] in
self?.showAppExclusions()
}
@@ -442,6 +665,8 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
activeOCRTask?.cancel()
pdfRuntimeObservationTask?.cancel()
pdfRuntimeActionTask?.cancel()
+ appUpdateActionTask?.cancel()
+ appUpdateController?.cancel()
stopAccessibilityPolling()
selectionMonitor?.stop()
NSWorkspace.shared.notificationCenter.removeObserver(self)
@@ -449,6 +674,14 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
func application(_ sender: NSApplication, openFiles filenames: [String]) {
+ guard scenarioActivation.acceptsPDFOpenRequests else {
+ runtimeLog.write(
+ "app",
+ "ignored_pdf_open count=\(filenames.count) reason=scenario_disabled"
+ )
+ sender.reply(toOpenOrPrint: .failure)
+ return
+ }
let pdfURLs =
filenames
.map(URL.init(fileURLWithPath:))
@@ -484,14 +717,35 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
pendingPDFRuntimeAction?.cancel()
pdfRuntimeActionTask = nil
pdfRuntimeActionID = nil
- let pdfWindow = pdfTranslationWindowController
- let pdfRuntime = pdfRuntimeController
- Task { [codex, llama, pendingPDFRuntimeAction, pdfWindow, pdfRuntime] in
+ let pdfWindow =
+ scenarioActivation.preparesPDFRuntime
+ ? pdfTranslationWindowController
+ : nil
+ let pdfRuntime =
+ scenarioActivation.preparesPDFRuntime
+ ? pdfRuntimeController
+ : nil
+ let codexBackend =
+ scenarioActivation.prewarmsTranslationProvider
+ ? codex
+ : nil
+ let llamaBackend =
+ scenarioActivation.prewarmsTranslationProvider
+ ? llama
+ : nil
+ Task {
+ [
+ codexBackend,
+ llamaBackend,
+ pendingPDFRuntimeAction,
+ pdfWindow,
+ pdfRuntime,
+ ] in
await pendingPDFRuntimeAction?.value
await pdfWindow?.stopAndWait()
- await pdfRuntime.shutdown()
- await codex.stop()
- await llama.stop()
+ await pdfRuntime?.shutdown()
+ await codexBackend?.stop()
+ await llamaBackend?.stop()
NSApp.reply(toApplicationShouldTerminate: true)
}
return .terminateLater
@@ -501,7 +755,7 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
if let button = statusItem.button {
button.image = GlossBrand.markImage(pointSize: 18)
button.image?.accessibilityDescription = "Gloss"
- button.toolTip = "Gloss · 选中,即懂"
+ button.toolTip = "Gloss · 浏览器与 PDF 翻译"
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { [weak self] in
guard let self else { return }
@@ -654,76 +908,94 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
title.isEnabled = false
menu.addItem(title)
- let clipboard = NSMenuItem(
- title: "翻译剪贴板文本",
- action: #selector(translateClipboard),
- keyEquivalent: "t"
- )
- clipboard.keyEquivalentModifierMask = [.command, .option]
- clipboard.target = self
- menu.addItem(clipboard)
-
- let clipboardImage = NSMenuItem(
- title: "翻译剪贴板图片",
- action: #selector(translateClipboardImage),
- keyEquivalent: "i"
- )
- clipboardImage.keyEquivalentModifierMask = [.command, .option]
- clipboardImage.target = self
- menu.addItem(clipboardImage)
+ if capabilityRegistry.isEnabled(.browserTranslation) {
+ let browser = NSMenuItem(
+ title: "浏览器翻译与扩展…",
+ action: #selector(showSettings),
+ keyEquivalent: ""
+ )
+ browser.target = self
+ menu.addItem(browser)
+ }
- let screenshot = NSMenuItem(
- title: "截图翻译…",
- action: #selector(translateScreenshot),
- keyEquivalent: ""
- )
- screenshot.target = self
- menu.addItem(screenshot)
+ if capabilityRegistry.isEnabled(.pdfTranslation) {
+ let pdf = NSMenuItem(
+ title: "打开 PDF 翻译…",
+ action: #selector(openPDFTranslation),
+ keyEquivalent: "o"
+ )
+ pdf.keyEquivalentModifierMask = [.command, .option]
+ pdf.target = self
+ menu.addItem(pdf)
+ }
- let pdf = NSMenuItem(
- title: "打开 PDF 翻译…",
- action: #selector(openPDFTranslation),
- keyEquivalent: "o"
- )
- pdf.keyEquivalentModifierMask = [.command, .option]
- pdf.target = self
- menu.addItem(pdf)
+ if capabilityRegistry.isEnabled(.clipboardTranslation) {
+ let clipboard = NSMenuItem(
+ title: "翻译剪贴板文本",
+ action: #selector(translateClipboard),
+ keyEquivalent: "t"
+ )
+ clipboard.keyEquivalentModifierMask = [.command, .option]
+ clipboard.target = self
+ menu.addItem(clipboard)
+ }
- let selectedText = NSMenuItem(
- title: "翻译当前选区(\(globalShortcut.displayName))",
- action: #selector(translateCurrentSelectionFromMenu),
- keyEquivalent: ""
- )
- selectedText.target = self
- selectedText.isEnabled = SelectionMonitor.isAccessibilityTrusted
- menu.addItem(selectedText)
+ if capabilityRegistry.isEnabled(.imageTranslation) {
+ let clipboardImage = NSMenuItem(
+ title: "翻译剪贴板图片",
+ action: #selector(translateClipboardImage),
+ keyEquivalent: "i"
+ )
+ clipboardImage.keyEquivalentModifierMask = [.command, .option]
+ clipboardImage.target = self
+ menu.addItem(clipboardImage)
- let automaticSelection = NSMenuItem(
- title: "选中文字后自动显示",
- action: #selector(toggleAutomaticSelection(_:)),
- keyEquivalent: ""
- )
- automaticSelection.tag = MenuTag.automaticSelection
- automaticSelection.target = self
- menu.addItem(automaticSelection)
+ let screenshot = NSMenuItem(
+ title: "截图翻译…",
+ action: #selector(translateScreenshot),
+ keyEquivalent: ""
+ )
+ screenshot.target = self
+ menu.addItem(screenshot)
+ }
- let currentApplication = NSMenuItem(
- title: "在当前 App 中自动显示",
- action: #selector(toggleCurrentApplication(_:)),
- keyEquivalent: ""
- )
- currentApplication.tag = MenuTag.currentApplication
- currentApplication.target = self
- menu.addItem(currentApplication)
- let exclusionCount = excludedApplicationBundleIdentifiers.count
- let manageApplications = NSMenuItem(
- title: exclusionCount == 0 ? "管理 App 例外…" : "管理 App 例外…(\(exclusionCount))",
- action: #selector(showAppExclusions),
- keyEquivalent: ""
- )
- manageApplications.target = self
- menu.addItem(manageApplications)
- updateSelectionMenuItems(in: menu)
+ if capabilityRegistry.isEnabled(.selectionTranslation) {
+ let selectedText = NSMenuItem(
+ title: "翻译当前选区(\(globalShortcut.displayName))",
+ action: #selector(translateCurrentSelectionFromMenu),
+ keyEquivalent: ""
+ )
+ selectedText.target = self
+ selectedText.isEnabled = SelectionMonitor.isAccessibilityTrusted
+ menu.addItem(selectedText)
+
+ let automaticSelection = NSMenuItem(
+ title: "选中文字后自动显示",
+ action: #selector(toggleAutomaticSelection(_:)),
+ keyEquivalent: ""
+ )
+ automaticSelection.tag = MenuTag.automaticSelection
+ automaticSelection.target = self
+ menu.addItem(automaticSelection)
+
+ let currentApplication = NSMenuItem(
+ title: "在当前 App 中自动显示",
+ action: #selector(toggleCurrentApplication(_:)),
+ keyEquivalent: ""
+ )
+ currentApplication.tag = MenuTag.currentApplication
+ currentApplication.target = self
+ menu.addItem(currentApplication)
+ let exclusionCount = excludedApplicationBundleIdentifiers.count
+ let manageApplications = NSMenuItem(
+ title: exclusionCount == 0 ? "管理 App 例外…" : "管理 App 例外…(\(exclusionCount))",
+ action: #selector(showAppExclusions),
+ keyEquivalent: ""
+ )
+ manageApplications.target = self
+ menu.addItem(manageApplications)
+ updateSelectionMenuItems(in: menu)
+ }
menu.addItem(.separator())
let languageItem = NSMenuItem(title: "目标语言", action: nil, keyEquivalent: "")
@@ -812,44 +1084,50 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
profileItem.submenu = profileMenu
menu.addItem(profileItem)
- let glossary = NSMenuItem(
- title: "术语表…",
- action: #selector(showGlossary),
- keyEquivalent: ""
- )
- glossary.target = self
- menu.addItem(glossary)
-
- let history = NSMenuItem(
- title: "翻译历史…",
- action: #selector(showHistory),
- keyEquivalent: ""
- )
- history.target = self
- menu.addItem(history)
+ if capabilityRegistry.isEnabled(.glossaryManagement) {
+ let glossary = NSMenuItem(
+ title: "术语表…",
+ action: #selector(showGlossary),
+ keyEquivalent: ""
+ )
+ glossary.target = self
+ menu.addItem(glossary)
+ }
- let saveHistory = NSMenuItem(
- title: "保存本地翻译历史",
- action: #selector(toggleHistory(_:)),
- keyEquivalent: ""
- )
- saveHistory.target = self
- saveHistory.state = historyEnabled ? .on : .off
- menu.addItem(saveHistory)
- menu.addItem(.separator())
+ if capabilityRegistry.isEnabled(.translationHistory) {
+ let history = NSMenuItem(
+ title: "翻译历史…",
+ action: #selector(showHistory),
+ keyEquivalent: ""
+ )
+ history.target = self
+ menu.addItem(history)
- if SelectionMonitor.isAccessibilityTrusted {
- let access = NSMenuItem(title: "选区访问已启用", action: nil, keyEquivalent: "")
- access.isEnabled = false
- menu.addItem(access)
- } else {
- let access = NSMenuItem(
- title: "启用选区翻译…",
- action: #selector(requestAccessibility),
+ let saveHistory = NSMenuItem(
+ title: "保存本地翻译历史",
+ action: #selector(toggleHistory(_:)),
keyEquivalent: ""
)
- access.target = self
- menu.addItem(access)
+ saveHistory.target = self
+ saveHistory.state = historyEnabled ? .on : .off
+ menu.addItem(saveHistory)
+ }
+ menu.addItem(.separator())
+
+ if capabilityRegistry.isEnabled(.selectionTranslation) {
+ if SelectionMonitor.isAccessibilityTrusted {
+ let access = NSMenuItem(title: "选区访问已启用", action: nil, keyEquivalent: "")
+ access.isEnabled = false
+ menu.addItem(access)
+ } else {
+ let access = NSMenuItem(
+ title: "启用选区翻译…",
+ action: #selector(requestAccessibility),
+ keyEquivalent: ""
+ )
+ access.target = self
+ menu.addItem(access)
+ }
}
let loginItem = NSMenuItem(
@@ -895,6 +1173,18 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
settings.target = self
menu.addItem(settings)
+ if capabilityRegistry.supports(.appUpdates) {
+ let update = NSMenuItem(
+ title: "",
+ action: #selector(performAppUpdateAction),
+ keyEquivalent: ""
+ )
+ update.tag = MenuTag.appUpdate
+ update.target = self
+ updateAppUpdateMenuItem(update)
+ menu.addItem(update)
+ }
+
let logs = NSMenuItem(
title: "查看运行日志…",
action: #selector(revealLogs),
@@ -921,6 +1211,9 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
if let item = menu.item(withTag: MenuTag.launchAtLogin) {
updateLaunchAtLoginMenuItem(item)
}
+ if let item = menu.item(withTag: MenuTag.appUpdate) {
+ updateAppUpdateMenuItem(item)
+ }
updateLaunchAtLoginStatus()
}
@@ -1553,17 +1846,46 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
@objc private func showAbout() {
+ let browserSupport =
+ capabilityRegistry.supports(.safariExtension)
+ ? "Safari、Chrome 页面翻译"
+ : "Chrome 页面翻译"
showAlert(
title: "Gloss",
- message: "选中,即懂。\n\n系统级、上下文感知的 macOS 翻译工具。"
+ message: "浏览器与 PDF 翻译\n\n支持 \(browserSupport)与批量 PDF 翻译。"
)
}
@objc private func showSettings() {
updateLaunchAtLoginStatus()
+ settingsWindowController?.showAppUpdateState(currentAppUpdateState)
settingsWindow.show()
}
+ @objc private func performAppUpdateAction() {
+ guard appUpdateActionTask == nil,
+ let controller = appUpdateController
+ else { return }
+ appUpdateActionTask = Task { @MainActor [weak self, controller] in
+ await controller.performPrimaryAction()
+ self?.appUpdateActionTask = nil
+ }
+ }
+
+ private func updateAppUpdateMenuItem(_ item: NSMenuItem) {
+ let presentation = currentAppUpdateState.menuPresentation
+ item.title = presentation.title
+ item.isEnabled = presentation.isEnabled
+ }
+
+ private func showAppUpdateState(_ state: AppUpdateDashboardState) {
+ settingsWindowController?.showAppUpdateState(state)
+ if let item = statusItem.menu?.item(withTag: MenuTag.appUpdate) {
+ updateAppUpdateMenuItem(item)
+ }
+ runtimeLog.write("app-update", "state=\(state)")
+ }
+
@objc private func revealLogs() {
do {
try runtimeLog.prepare()
@@ -1648,7 +1970,9 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
let token = try PairingTokenStore.loadOrCreate()
pairingToken = token
- prepareBrowserExtension(pairingToken: token)
+ if scenarioActivation.preparesBrowserExtensions {
+ prepareBrowserExtension(pairingToken: token)
+ }
try Task.checkCancellation()
occupant = try await bridgePortManager.inspect(token: token)
diff --git a/Sources/Gloss/GlossAppScenarioActivation.swift b/Sources/Gloss/GlossAppScenarioActivation.swift
new file mode 100644
index 0000000..09812bb
--- /dev/null
+++ b/Sources/Gloss/GlossAppScenarioActivation.swift
@@ -0,0 +1,27 @@
+import GlossCore
+
+/// Maps enabled business scenarios to App lifecycle work and external entry points.
+///
+/// Keep this policy separate from `GlossAppDelegate` so a scenario being hidden in
+/// the UI also prevents its background services and file handlers from starting.
+struct GlossAppScenarioActivation: Equatable {
+ let configuresSystemServices: Bool
+ let observesWorkspaceApplications: Bool
+ let startsTranslationBridge: Bool
+ let preparesBrowserExtensions: Bool
+ let preparesPDFRuntime: Bool
+ let prewarmsTranslationProvider: Bool
+ let acceptsPDFOpenRequests: Bool
+
+ init(registry: GlossCapabilityRegistry) {
+ configuresSystemServices =
+ registry.supports(.clipboardText)
+ || registry.supports(.clipboardImage)
+ observesWorkspaceApplications = registry.supports(.selectionCapture)
+ startsTranslationBridge = registry.supports(.translationLoopbackBridge)
+ preparesBrowserExtensions = registry.isEnabled(.browserTranslation)
+ preparesPDFRuntime = registry.supports(.pdfRuntime)
+ prewarmsTranslationProvider = registry.supports(.textTranslation)
+ acceptsPDFOpenRequests = registry.isEnabled(.pdfTranslation)
+ }
+}
diff --git a/Sources/Gloss/PDFRuntimeController.swift b/Sources/Gloss/PDFRuntimeController.swift
index 5c58e93..0b43c8b 100644
--- a/Sources/Gloss/PDFRuntimeController.swift
+++ b/Sources/Gloss/PDFRuntimeController.swift
@@ -3,15 +3,26 @@ import GlossCore
@MainActor
final class PDFRuntimeController {
+ nonisolated static let minimumCompatibleManagedRuntimeVersion =
+ BabelDOCRuntimeCompatibility.minimumManagedVersion
+
struct PreparedRuntime {
let launch: BabelDOCRuntimeLaunch
let layoutServiceBaseURL: URL
let layoutCacheDirectoryURL: URL?
}
+ enum ManagedRuntimePreparation: Equatable {
+ case install
+ case installAvailableUpdate
+ case startCurrent
+ case updateRequired(currentVersion: String)
+ }
+
private enum ControllerError: LocalizedError {
case runtimeManagerUnavailable
case runtimeUnavailable
+ case runtimeUpdateRequired(current: String, minimum: String)
var errorDescription: String? {
switch self {
@@ -19,6 +30,8 @@ final class PDFRuntimeController {
"无法初始化 BabelDOC 运行时管理器。"
case .runtimeUnavailable:
"没有可用的 BabelDOC 运行时。"
+ case .runtimeUpdateRequired(let current, let minimum):
+ "当前 BabelDOC 运行时 \(current) 已知不兼容。请联网更新到 \(minimum) 或更高版本后再使用 PDF 翻译。"
}
}
}
@@ -96,13 +109,7 @@ final class PDFRuntimeController {
}
var currentRuntimeLaunch: BabelDOCRuntimeLaunch? {
- if let executable = runtimeSnapshot?.currentExecutableURL {
- return Self.managedLaunch(
- executable: executable,
- version: runtimeSnapshot?.currentVersion
- )
- }
- return BabelDOCExternalEngine.resolveRuntime()
+ Self.compatibleManagedRuntimeLaunch(for: runtimeSnapshot)
}
func prepareAtLaunch() {
@@ -350,68 +357,82 @@ final class PDFRuntimeController {
) async throws -> PreparedRuntime {
try await waitForLaunchPreparation()
try Task.checkCancellation()
+ guard let runtimeManager else {
+ throw ControllerError.runtimeManagerUnavailable
+ }
+
var changedManagedVersion = false
- if let runtimeManager {
- var snapshot = await runtimeManager.snapshot()
+ var snapshot = await runtimeManager.snapshot()
+ runtimeSnapshot = snapshot
+
+ // A verified local runtime is sufficient to start the resident service.
+ // Launch-time update discovery remains advisory and must not put the
+ // network on the critical path or replace a runtime already in use.
+ if let launch = Self.compatibleManagedRuntimeLaunch(for: snapshot) {
+ return try await start(launch)
+ }
+
+ if snapshot.currentExecutableURL != nil, !forceUpdateCheck {
+ await waitForBackgroundUpdateCheck()
try Task.checkCancellation()
+ snapshot = await runtimeManager.snapshot()
runtimeSnapshot = snapshot
+ }
- if snapshot.currentExecutableURL == nil {
- do {
- snapshot = try await runtimeManager.update()
- try Task.checkCancellation()
- changedManagedVersion = true
- } catch is CancellationError {
- throw CancellationError()
- } catch {
- runtimeSnapshot = await runtimeManager.snapshot()
- if BabelDOCExternalEngine.resolveRuntime() == nil {
- throw error
- }
- logNonfatalUpdateFailure(error)
- }
- } else if forceUpdateCheck {
- do {
- snapshot = try await runtimeManager.checkForUpdates()
- try Task.checkCancellation()
- } catch is CancellationError {
- throw CancellationError()
- } catch {
- snapshot = await runtimeManager.snapshot()
- logNonfatalUpdateFailure(error)
- }
+ if snapshot.currentExecutableURL == nil {
+ do {
+ snapshot = try await runtimeManager.update()
+ try Task.checkCancellation()
+ changedManagedVersion = true
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ runtimeSnapshot = await runtimeManager.snapshot()
+ throw error
}
+ } else if forceUpdateCheck {
+ do {
+ snapshot = try await runtimeManager.checkForUpdates()
+ try Task.checkCancellation()
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ snapshot = await runtimeManager.snapshot()
+ runtimeSnapshot = snapshot
+ throw error
+ }
+ }
- if forceUpdateCheck, snapshot.updateAvailable {
- try await stopServiceForRuntimeReplacement()
+ if Self.managedRuntimePreparation(for: snapshot) == .installAvailableUpdate {
+ try await stopServiceForRuntimeReplacement()
+ try Task.checkCancellation()
+ do {
+ snapshot = try await runtimeManager.installAvailableUpdate()
try Task.checkCancellation()
- do {
- snapshot = try await runtimeManager.update()
- try Task.checkCancellation()
- changedManagedVersion = true
- } catch is CancellationError {
- throw CancellationError()
- } catch {
- snapshot = await runtimeManager.snapshot()
- guard snapshot.currentExecutableURL != nil else {
- throw error
- }
- logNonfatalUpdateFailure(error)
- }
+ changedManagedVersion = true
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ runtimeSnapshot = await runtimeManager.snapshot()
+ throw error
}
+ }
+ if case .updateRequired = Self.managedRuntimePreparation(for: snapshot) {
runtimeSnapshot = snapshot
+ throw Self.runtimeUpdateRequiredError(for: snapshot)
}
+ runtimeSnapshot = snapshot
guard let launch = currentRuntimeLaunch else {
- throw ControllerError.runtimeUnavailable
+ throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot)
}
do {
return try await start(launch)
} catch is CancellationError {
throw CancellationError()
} catch {
+ logNonfatalUpdateFailure(error)
guard changedManagedVersion,
- let runtimeManager,
(await runtimeManager.snapshot()).previousVersion != nil
else {
throw error
@@ -420,8 +441,11 @@ final class PDFRuntimeController {
try Task.checkCancellation()
runtimeSnapshot = try await runtimeManager.rollback()
try Task.checkCancellation()
+ if case .updateRequired = Self.managedRuntimePreparation(for: runtimeSnapshot) {
+ throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot)
+ }
guard let rollbackLaunch = currentRuntimeLaunch else {
- throw ControllerError.runtimeUnavailable
+ throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot)
}
return try await start(rollbackLaunch)
}
@@ -437,17 +461,26 @@ final class PDFRuntimeController {
try await stopServiceForRuntimeReplacement()
try Task.checkCancellation()
do {
- runtimeSnapshot = try await runtimeManager.update()
+ let checkedSnapshot = await runtimeManager.snapshot()
+ if checkedSnapshot.updateAvailable {
+ runtimeSnapshot = try await runtimeManager.installAvailableUpdate()
+ } else {
+ runtimeSnapshot = try await runtimeManager.update()
+ }
try Task.checkCancellation()
changedManagedVersion =
runtimeSnapshot?.currentVersion != versionBeforeUpdate
+ if case .updateRequired = Self.managedRuntimePreparation(for: runtimeSnapshot) {
+ throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot)
+ }
guard let launch = currentRuntimeLaunch else {
- throw ControllerError.runtimeUnavailable
+ throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot)
}
return try await start(launch)
} catch is CancellationError {
throw CancellationError()
} catch {
+ logNonfatalUpdateFailure(error)
let snapshot = await runtimeManager.snapshot()
if changedManagedVersion, snapshot.previousVersion != nil {
try await stopServiceForRuntimeReplacement()
@@ -457,6 +490,9 @@ final class PDFRuntimeController {
runtimeSnapshot = snapshot
logNonfatalUpdateFailure(error)
}
+ if case .updateRequired = Self.managedRuntimePreparation(for: runtimeSnapshot) {
+ throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot)
+ }
guard let launch = currentRuntimeLaunch else {
throw error
}
@@ -473,14 +509,14 @@ final class PDFRuntimeController {
runtimeSnapshot = try await runtimeManager.rollback()
try Task.checkCancellation()
guard let launch = currentRuntimeLaunch else {
- throw ControllerError.runtimeUnavailable
+ throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot)
}
return try await start(launch)
}
private func reconnect() async throws -> PreparedRuntime {
guard let launch = currentRuntimeLaunch else {
- throw ControllerError.runtimeUnavailable
+ throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot)
}
let baseURL = try await service.reconnect(runtime: launch, force: true)
let cacheURL = await service.layoutCacheDirectoryURL
@@ -551,6 +587,11 @@ final class PDFRuntimeController {
backgroundUpdateID = nil
}
+ private func waitForBackgroundUpdateCheck() async {
+ let task = backgroundUpdateTask
+ await task?.value
+ }
+
private func takeAndCancelModulePreparation() -> Task? {
let task = modulePreparationTask
task?.cancel()
@@ -574,11 +615,14 @@ final class PDFRuntimeController {
private func logNonfatalUpdateFailure(_ error: Error) {
GlossRuntimeLog.shared.write(
"pdf-runtime",
- "continuing_with_current_runtime update_error=\(error.localizedDescription)"
+ "runtime_update_or_start_failed error=\(error.localizedDescription)"
)
}
private func start(_ launch: BabelDOCRuntimeLaunch) async throws -> PreparedRuntime {
+ guard launch == Self.compatibleManagedRuntimeLaunch(for: runtimeSnapshot) else {
+ throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot)
+ }
let baseURL = try await service.start(runtime: launch)
let cacheURL = await service.layoutCacheDirectoryURL
return PreparedRuntime(
@@ -630,8 +674,7 @@ final class PDFRuntimeController {
runtime: BabelDOCRuntimeSnapshot?,
service: BabelDOCExecutorServiceSnapshot,
activeDocumentName: String?,
- fallbackRuntimeAvailable: Bool =
- BabelDOCExternalEngine.resolveRuntime() != nil
+ fallbackRuntimeAvailable: Bool = false
) -> PDFRuntimeDashboardState {
if service.lifecycleState != .ready, let runtime {
switch runtime.operation {
@@ -718,7 +761,7 @@ final class PDFRuntimeController {
}
}
- private static func managedLaunch(
+ private nonisolated static func managedLaunch(
executable: URL,
version: String?
) -> BabelDOCRuntimeLaunch {
@@ -728,4 +771,55 @@ final class PDFRuntimeController {
executorExecutable: executable.path
)
}
+
+ nonisolated static func compatibleManagedRuntimeLaunch(
+ for snapshot: BabelDOCRuntimeSnapshot?
+ ) -> BabelDOCRuntimeLaunch? {
+ guard let snapshot,
+ let executable = snapshot.currentExecutableURL,
+ isCompatibleManagedRuntimeVersion(snapshot.currentVersion)
+ else {
+ return nil
+ }
+ return managedLaunch(
+ executable: executable,
+ version: snapshot.currentVersion
+ )
+ }
+
+ nonisolated static func managedRuntimePreparation(
+ for snapshot: BabelDOCRuntimeSnapshot?
+ ) -> ManagedRuntimePreparation {
+ switch BabelDOCRuntimeCompatibility.preparation(
+ for: snapshot
+ ) {
+ case .install:
+ return .install
+ case .installAvailableUpdate:
+ return .installAvailableUpdate
+ case .useCurrent:
+ return .startCurrent
+ case .updateRequired(let currentVersion):
+ return .updateRequired(
+ currentVersion: currentVersion == "unknown"
+ ? "未知版本"
+ : currentVersion
+ )
+ }
+ }
+
+ nonisolated static func isCompatibleManagedRuntimeVersion(
+ _ version: String?
+ ) -> Bool {
+ BabelDOCRuntimeCompatibility.isCompatible(version)
+ }
+
+ private nonisolated static func runtimeUpdateRequiredError(
+ for snapshot: BabelDOCRuntimeSnapshot?
+ ) -> ControllerError {
+ ControllerError.runtimeUpdateRequired(
+ current: snapshot?.currentVersion ?? "未知版本",
+ minimum: minimumCompatibleManagedRuntimeVersion
+ )
+ }
}
diff --git a/Sources/Gloss/PDFTranslationWindowController.swift b/Sources/Gloss/PDFTranslationWindowController.swift
index 0ad12c1..c34f716 100644
--- a/Sources/Gloss/PDFTranslationWindowController.swift
+++ b/Sources/Gloss/PDFTranslationWindowController.swift
@@ -570,6 +570,10 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate {
refreshInterface()
}
+ var hasActiveTranslation: Bool {
+ batchCoordinator.isActive
+ }
+
func stop() {
serviceStartupGeneration &+= 1
serviceStartupTask?.cancel()
diff --git a/Sources/Gloss/PairingTokenStore.swift b/Sources/Gloss/PairingTokenStore.swift
index 062c862..2a0eff5 100644
--- a/Sources/Gloss/PairingTokenStore.swift
+++ b/Sources/Gloss/PairingTokenStore.swift
@@ -1,4 +1,5 @@
import Foundation
+import GlossCore
import Security
enum PairingTokenStore {
@@ -40,18 +41,24 @@ enum PairingTokenStore {
}
private static func shareWithSafari(_ token: String) {
- guard Bundle.main.url(
- forResource: "embedded",
- withExtension: "provisionprofile"
- ) != nil else { return }
+ guard GlossDistributionProfile.current.safariExtensionAvailable else {
+ return
+ }
let fileManager = FileManager.default
- guard let directory = fileManager.containerURL(
- forSecurityApplicationGroupIdentifier: safariAppGroup
- ) else { return }
+ guard
+ let directory = fileManager.containerURL(
+ forSecurityApplicationGroupIdentifier: safariAppGroup
+ )
+ else {
+ return
+ }
let tokenURL = directory.appendingPathComponent(fileName, isDirectory: false)
try? Data(token.utf8).write(to: tokenURL, options: .atomic)
- try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tokenURL.path)
+ try? fileManager.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: tokenURL.path
+ )
}
private static func applicationSupportDirectory() throws -> URL {
diff --git a/Sources/Gloss/SettingsWindowController.swift b/Sources/Gloss/SettingsWindowController.swift
index 92ee3ec..3df6ec3 100644
--- a/Sources/Gloss/SettingsWindowController.swift
+++ b/Sources/Gloss/SettingsWindowController.swift
@@ -15,11 +15,13 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
var onOpenSafariExtensionSettings: (() -> Void)?
var onBridgeAction: (() -> Void)?
var onPDFRuntimeAction: ((PDFRuntimeDashboardAction) -> Void)?
+ var onAppUpdateAction: (() -> Void)?
var onRevealLogs: (() -> Void)?
var onOpenServicesSettings: (() -> Void)?
var onSetLaunchAtLogin: ((Bool) -> Bool)?
var onSetGlobalShortcut: ((GlobalShortcut) -> Void)?
+ private let capabilityRegistry: GlossCapabilityRegistry
private let window: NSWindow
private let accessibilityStatus = NSTextField(labelWithString: "")
private let providerStatus = NSTextField(labelWithString: "正在启动翻译引擎")
@@ -31,6 +33,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
labelWithString: "正在验证已安装版本与残留进程"
)
private let pdfRuntimePath = NSTextField(labelWithString: "")
+ private let appUpdateStatus = NSTextField(labelWithString: "自动检查应用更新")
+ private let appUpdateDetail = NSTextField(
+ labelWithString: "每 24 小时后台检查一次"
+ )
private let browserExtensionStatus = NSTextField(labelWithString: "正在准备浏览器扩展")
private let shortcutStatus = NSTextField(labelWithString: "手动翻译当前选区")
private let launchAtLoginStatus = NSTextField(labelWithString: "关闭")
@@ -56,10 +62,27 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
)
private let pdfRuntimeProgressIndicator = NSProgressIndicator()
private var pdfRuntimeState = PDFRuntimeDashboardState.checking
+ private let appUpdateActionButton = NSButton(
+ title: "检查更新",
+ target: nil,
+ action: nil
+ )
+ private let appUpdateProgressIndicator = NSProgressIndicator()
+ private var appUpdateState = AppUpdateDashboardState.unavailable(
+ currentVersion: "dev",
+ reason: "开发构建"
+ )
- override init() {
+ init(capabilityRegistry: GlossCapabilityRegistry = .current) {
+ self.capabilityRegistry = capabilityRegistry
+ let windowHeight: CGFloat =
+ capabilityRegistry.isEnabled(.selectionTranslation)
+ || capabilityRegistry.isEnabled(.clipboardTranslation)
+ || capabilityRegistry.isEnabled(.imageTranslation)
+ ? 840
+ : capabilityRegistry.supports(.appUpdates) ? 720 : 640
window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 560, height: 840),
+ contentRect: NSRect(x: 0, y: 0, width: 560, height: windowHeight),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: true
@@ -69,7 +92,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
func show() {
- updateAccessibilityStatus()
+ if capabilityRegistry.isEnabled(.selectionTranslation) {
+ updateAccessibilityStatus()
+ }
window.center()
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
@@ -130,7 +155,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
launchAtLoginStatus.stringValue = "等待在系统设置中批准"
launchAtLoginStatus.textColor = .systemOrange
} else {
- launchAtLoginStatus.stringValue = "关闭;可让选区翻译随时可用"
+ launchAtLoginStatus.stringValue =
+ capabilityRegistry.isEnabled(.selectionTranslation)
+ ? "关闭;可让选区翻译随时可用"
+ : "关闭;Gloss 仅在手动启动后可用"
launchAtLoginStatus.textColor = .secondaryLabelColor
}
}
@@ -143,7 +171,13 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
window.title = "Gloss"
window.isReleasedWhenClosed = false
window.delegate = self
- window.minSize = NSSize(width: 520, height: 810)
+ window.minSize = NSSize(
+ width: 520,
+ height:
+ capabilityRegistry.isEnabled(.selectionTranslation)
+ ? 810
+ : capabilityRegistry.supports(.appUpdates) ? 690 : 610
+ )
let root = NSView()
let scrollView = NSScrollView()
@@ -181,7 +215,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
let title = NSTextField(labelWithString: "Gloss")
title.font = .systemFont(ofSize: 28, weight: .bold)
- let subtitle = NSTextField(labelWithString: "选中,即懂。")
+ let subtitle = NSTextField(
+ labelWithString: productSubtitle
+ )
subtitle.font = .systemFont(ofSize: 15)
subtitle.textColor = .secondaryLabelColor
let heading = NSStackView(views: [title, subtitle])
@@ -196,151 +232,161 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
header.spacing = 14
header.translatesAutoresizingMaskIntoConstraints = false
- let accessibilityCard = makeStatusCard(
- symbol: "selection.pin.in.out",
- title: "系统选区",
- status: accessibilityStatus,
- accessory: accessibilityButton
- )
- accessibilityButton.target = self
- accessibilityButton.action = #selector(requestAccessibility)
-
- let shortcutCard = makeStatusCard(
- symbol: "command",
- title: "全局快捷键",
- status: shortcutStatus,
- accessory: shortcutButton
- )
- shortcutButton.onChange = { [weak self] shortcut in
- self?.onSetGlobalShortcut?(shortcut)
+ var cards: [NSView] = []
+ if capabilityRegistry.isEnabled(.selectionTranslation) {
+ let accessibilityCard = makeStatusCard(
+ symbol: "selection.pin.in.out",
+ title: "系统选区",
+ status: accessibilityStatus,
+ accessory: accessibilityButton
+ )
+ accessibilityButton.target = self
+ accessibilityButton.action = #selector(requestAccessibility)
+ cards.append(accessibilityCard)
+
+ let shortcutCard = makeStatusCard(
+ symbol: "command",
+ title: "全局快捷键",
+ status: shortcutStatus,
+ accessory: shortcutButton
+ )
+ shortcutButton.onChange = { [weak self] shortcut in
+ self?.onSetGlobalShortcut?(shortcut)
+ }
+ cards.append(shortcutCard)
}
- let servicesStatus = NSTextField(
- labelWithString: "在“键盘快捷键 › 服务”中启用文本与图片入口"
- )
- let servicesCard = makeStatusCard(
- symbol: "keyboard",
- title: "系统服务",
- status: servicesStatus,
- accessory: servicesButton
- )
- servicesButton.target = self
- servicesButton.action = #selector(openServicesSettings)
-
- let providerCard = makeProviderCard()
-
- let browserCard = makeBridgeCard()
- let pdfRuntimeCard = makePDFRuntimeCard()
-
- let launchAtLoginCard = makeStatusCard(
- symbol: "power",
- title: "登录时启动",
- status: launchAtLoginStatus,
- accessory: launchAtLoginSwitch
- )
- launchAtLoginSwitch.target = self
- launchAtLoginSwitch.action = #selector(setLaunchAtLogin(_:))
+ if capabilityRegistry.isEnabled(.clipboardTranslation)
+ || capabilityRegistry.isEnabled(.imageTranslation)
+ {
+ let servicesStatus = NSTextField(
+ labelWithString: "在“键盘快捷键 › 服务”中启用文本与图片入口"
+ )
+ let servicesCard = makeStatusCard(
+ symbol: "keyboard",
+ title: "系统服务",
+ status: servicesStatus,
+ accessory: servicesButton
+ )
+ servicesButton.target = self
+ servicesButton.action = #selector(openServicesSettings)
+ cards.append(servicesCard)
+ }
- let clipboardButton = NSButton(title: "翻译文本", target: self, action: #selector(translateClipboard))
- clipboardButton.bezelStyle = .rounded
- clipboardButton.controlSize = .large
- clipboardButton.keyEquivalent = "\r"
+ if capabilityRegistry.supports(.providerConfiguration) {
+ cards.append(makeProviderCard())
+ }
+ if capabilityRegistry.supports(.translationLoopbackBridge) {
+ cards.append(makeBridgeCard())
+ }
+ if capabilityRegistry.isEnabled(.pdfTranslation) {
+ cards.append(makePDFRuntimeCard())
+ }
+ if capabilityRegistry.supports(.appUpdates) {
+ cards.append(makeAppUpdateCard())
+ }
+ if capabilityRegistry.supports(.launchAtLogin) {
+ let launchAtLoginCard = makeStatusCard(
+ symbol: "power",
+ title: "登录时启动",
+ status: launchAtLoginStatus,
+ accessory: launchAtLoginSwitch
+ )
+ launchAtLoginSwitch.target = self
+ launchAtLoginSwitch.action = #selector(setLaunchAtLogin(_:))
+ cards.append(launchAtLoginCard)
+ }
- let imageButton = NSButton(
- title: "翻译图片",
- target: self,
- action: #selector(translateClipboardImage)
- )
- imageButton.bezelStyle = .rounded
- imageButton.controlSize = .large
+ let cardsStack = NSStackView(views: cards)
+ cardsStack.orientation = .vertical
+ cardsStack.alignment = .leading
+ cardsStack.spacing = 10
+ cardsStack.translatesAutoresizingMaskIntoConstraints = false
+ for card in cards {
+ card.widthAnchor.constraint(equalTo: cardsStack.widthAnchor).isActive = true
+ }
- let screenshotButton = NSButton(
- title: "截图翻译…",
- target: self,
- action: #selector(translateScreenshot)
- )
- screenshotButton.bezelStyle = .rounded
- screenshotButton.controlSize = .large
-
- let actionButtons = NSStackView(views: [clipboardButton, imageButton, screenshotButton])
- actionButtons.orientation = .horizontal
- actionButtons.alignment = .centerY
- actionButtons.spacing = 8
- actionButtons.translatesAutoresizingMaskIntoConstraints = false
-
- let hint = NSTextField(
- wrappingLabelWithString:
- "选择文本或长按已有选区后 GlossBar 会自动出现;也可以使用全局快捷键,翻译剪贴板图片或截图。图片始终在本机完成 OCR;使用本地模型时,文字也不会离开设备。"
- )
- hint.font = .systemFont(ofSize: 12)
- hint.textColor = .tertiaryLabelColor
- hint.alignment = .center
- hint.translatesAutoresizingMaskIntoConstraints = false
-
- for view in [
- header,
- accessibilityCard,
- shortcutCard,
- servicesCard,
- providerCard,
- browserCard,
- pdfRuntimeCard,
- launchAtLoginCard,
- actionButtons,
- hint,
- ] {
- content.addSubview(view)
+ var quickActionButtons: [NSButton] = []
+ if capabilityRegistry.isEnabled(.clipboardTranslation) {
+ let clipboardButton = NSButton(
+ title: "翻译文本",
+ target: self,
+ action: #selector(translateClipboard)
+ )
+ clipboardButton.bezelStyle = .rounded
+ clipboardButton.controlSize = .large
+ clipboardButton.keyEquivalent = "\r"
+ quickActionButtons.append(clipboardButton)
+ }
+ if capabilityRegistry.isEnabled(.imageTranslation) {
+ let imageButton = NSButton(
+ title: "翻译图片",
+ target: self,
+ action: #selector(translateClipboardImage)
+ )
+ imageButton.bezelStyle = .rounded
+ imageButton.controlSize = .large
+ quickActionButtons.append(imageButton)
+
+ let screenshotButton = NSButton(
+ title: "截图翻译…",
+ target: self,
+ action: #selector(translateScreenshot)
+ )
+ screenshotButton.bezelStyle = .rounded
+ screenshotButton.controlSize = .large
+ quickActionButtons.append(screenshotButton)
}
- NSLayoutConstraint.activate([
+ content.addSubview(header)
+ content.addSubview(cardsStack)
+
+ var layoutConstraints = [
header.topAnchor.constraint(equalTo: content.topAnchor, constant: 28),
header.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 32),
header.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -32),
icon.widthAnchor.constraint(equalToConstant: 48),
icon.heightAnchor.constraint(equalToConstant: 48),
-
- accessibilityCard.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 26),
- accessibilityCard.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 28),
- accessibilityCard.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -28),
-
- shortcutCard.topAnchor.constraint(equalTo: accessibilityCard.bottomAnchor, constant: 10),
- shortcutCard.leadingAnchor.constraint(equalTo: accessibilityCard.leadingAnchor),
- shortcutCard.trailingAnchor.constraint(equalTo: accessibilityCard.trailingAnchor),
-
- servicesCard.topAnchor.constraint(equalTo: shortcutCard.bottomAnchor, constant: 10),
- servicesCard.leadingAnchor.constraint(equalTo: accessibilityCard.leadingAnchor),
- servicesCard.trailingAnchor.constraint(equalTo: accessibilityCard.trailingAnchor),
-
- providerCard.topAnchor.constraint(equalTo: servicesCard.bottomAnchor, constant: 10),
- providerCard.leadingAnchor.constraint(equalTo: servicesCard.leadingAnchor),
- providerCard.trailingAnchor.constraint(equalTo: servicesCard.trailingAnchor),
-
- browserCard.topAnchor.constraint(equalTo: providerCard.bottomAnchor, constant: 10),
- browserCard.leadingAnchor.constraint(equalTo: providerCard.leadingAnchor),
- browserCard.trailingAnchor.constraint(equalTo: providerCard.trailingAnchor),
-
- pdfRuntimeCard.topAnchor.constraint(
- equalTo: browserCard.bottomAnchor,
- constant: 10
- ),
- pdfRuntimeCard.leadingAnchor.constraint(equalTo: browserCard.leadingAnchor),
- pdfRuntimeCard.trailingAnchor.constraint(equalTo: browserCard.trailingAnchor),
-
- launchAtLoginCard.topAnchor.constraint(
- equalTo: pdfRuntimeCard.bottomAnchor,
- constant: 10
- ),
- launchAtLoginCard.leadingAnchor.constraint(equalTo: browserCard.leadingAnchor),
- launchAtLoginCard.trailingAnchor.constraint(equalTo: browserCard.trailingAnchor),
-
- actionButtons.topAnchor.constraint(equalTo: launchAtLoginCard.bottomAnchor, constant: 24),
- actionButtons.centerXAnchor.constraint(equalTo: content.centerXAnchor),
-
- hint.topAnchor.constraint(equalTo: actionButtons.bottomAnchor, constant: 16),
- hint.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 44),
- hint.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -44),
- hint.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -20),
- ])
+ cardsStack.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 26),
+ cardsStack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 28),
+ cardsStack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -28),
+ ]
+
+ if quickActionButtons.isEmpty {
+ layoutConstraints.append(
+ cardsStack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -24)
+ )
+ } else {
+ let actionButtons = NSStackView(views: quickActionButtons)
+ actionButtons.orientation = .horizontal
+ actionButtons.alignment = .centerY
+ actionButtons.spacing = 8
+ actionButtons.translatesAutoresizingMaskIntoConstraints = false
+
+ let hint = NSTextField(
+ wrappingLabelWithString:
+ "辅助翻译入口可随业务场景重新启用;图片始终在本机完成 OCR。"
+ )
+ hint.font = .systemFont(ofSize: 12)
+ hint.textColor = .tertiaryLabelColor
+ hint.alignment = .center
+ hint.translatesAutoresizingMaskIntoConstraints = false
+
+ content.addSubview(actionButtons)
+ content.addSubview(hint)
+ layoutConstraints.append(contentsOf: [
+ actionButtons.topAnchor.constraint(
+ equalTo: cardsStack.bottomAnchor,
+ constant: 24
+ ),
+ actionButtons.centerXAnchor.constraint(equalTo: content.centerXAnchor),
+ hint.topAnchor.constraint(equalTo: actionButtons.bottomAnchor, constant: 16),
+ hint.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 44),
+ hint.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -44),
+ hint.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -20),
+ ])
+ }
+ NSLayoutConstraint.activate(layoutConstraints)
}
private func makePDFRuntimeCard() -> NSView {
@@ -441,6 +487,93 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
return card
}
+ private func makeAppUpdateCard() -> NSView {
+ let card = NSVisualEffectView()
+ card.material = .contentBackground
+ card.blendingMode = .withinWindow
+ card.state = .active
+ card.wantsLayer = true
+ card.layer?.cornerRadius = 10
+ card.translatesAutoresizingMaskIntoConstraints = false
+
+ let icon = NSImageView()
+ icon.image = NSImage(
+ systemSymbolName: "arrow.triangle.2.circlepath.circle",
+ accessibilityDescription: "Gloss 更新"
+ )
+ icon.contentTintColor = .secondaryLabelColor
+ icon.translatesAutoresizingMaskIntoConstraints = false
+
+ let titleLabel = NSTextField(labelWithString: "Gloss 更新")
+ titleLabel.font = .systemFont(ofSize: 13, weight: .semibold)
+ titleLabel.setContentHuggingPriority(.required, for: .horizontal)
+
+ appUpdateProgressIndicator.style = .spinning
+ appUpdateProgressIndicator.controlSize = .small
+ appUpdateProgressIndicator.isHidden = true
+
+ appUpdateStatus.font = .systemFont(ofSize: 12, weight: .medium)
+ appUpdateStatus.lineBreakMode = .byTruncatingTail
+ appUpdateStatus.setContentCompressionResistancePriority(
+ .defaultLow,
+ for: .horizontal
+ )
+
+ let statusStack = NSStackView(
+ views: [appUpdateProgressIndicator, appUpdateStatus]
+ )
+ statusStack.orientation = .horizontal
+ statusStack.alignment = .centerY
+ statusStack.spacing = 5
+
+ appUpdateActionButton.target = self
+ appUpdateActionButton.action = #selector(performAppUpdateAction)
+ appUpdateActionButton.setContentHuggingPriority(
+ .required,
+ for: .horizontal
+ )
+
+ let topRow = NSStackView(
+ views: [titleLabel, statusStack, appUpdateActionButton]
+ )
+ topRow.orientation = .horizontal
+ topRow.alignment = .centerY
+ topRow.spacing = 10
+ topRow.distribution = .fill
+
+ appUpdateDetail.font = .systemFont(ofSize: 11.5)
+ appUpdateDetail.textColor = .secondaryLabelColor
+ appUpdateDetail.lineBreakMode = .byTruncatingMiddle
+ appUpdateDetail.setContentCompressionResistancePriority(
+ .defaultLow,
+ for: .horizontal
+ )
+
+ let content = NSStackView(views: [topRow, appUpdateDetail])
+ content.orientation = .vertical
+ content.alignment = .leading
+ content.spacing = 5
+ content.translatesAutoresizingMaskIntoConstraints = false
+
+ card.addSubview(icon)
+ card.addSubview(content)
+ NSLayoutConstraint.activate([
+ card.heightAnchor.constraint(equalToConstant: 76),
+ icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
+ icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 16),
+ icon.widthAnchor.constraint(equalToConstant: 22),
+ icon.heightAnchor.constraint(equalToConstant: 22),
+ content.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 12),
+ content.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
+ content.topAnchor.constraint(equalTo: card.topAnchor, constant: 11),
+ content.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10),
+ topRow.widthAnchor.constraint(equalTo: content.widthAnchor),
+ appUpdateDetail.widthAnchor.constraint(equalTo: content.widthAnchor),
+ ])
+ showAppUpdateState(appUpdateState)
+ return card
+ }
+
private func makeBridgeCard() -> NSView {
let card = NSVisualEffectView()
card.material = .contentBackground
@@ -514,16 +647,24 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
target: self,
action: #selector(copyBrowserToken)
)
- let safariButton = NSButton(
- title: "Safari 设置",
- target: self,
- action: #selector(openSafariExtensionSettings)
- )
- for button in [safariButton, revealExtensionButton, tokenButton] {
+ for button in [revealExtensionButton, tokenButton] {
button.controlSize = .small
}
+ var browserButtonViews: [NSView] = [
+ revealExtensionButton,
+ tokenButton,
+ ]
+ if capabilityRegistry.supports(.safariExtension) {
+ let safariButton = NSButton(
+ title: "Safari 设置",
+ target: self,
+ action: #selector(openSafariExtensionSettings)
+ )
+ safariButton.controlSize = .small
+ browserButtonViews.insert(safariButton, at: 0)
+ }
let browserButtons = NSStackView(
- views: [safariButton, revealExtensionButton, tokenButton]
+ views: browserButtonViews
)
browserButtons.orientation = .horizontal
browserButtons.alignment = .centerY
@@ -534,6 +675,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
extensionRow.alignment = .centerY
extensionRow.spacing = 8
extensionRow.distribution = .fill
+ extensionRow.isHidden = !capabilityRegistry.isEnabled(.browserTranslation)
let content = NSStackView(
views: [topRow, bridgeDetail, bridgePath, extensionRow]
@@ -546,7 +688,12 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
card.addSubview(icon)
card.addSubview(content)
NSLayoutConstraint.activate([
- card.heightAnchor.constraint(equalToConstant: 118),
+ card.heightAnchor.constraint(
+ equalToConstant:
+ capabilityRegistry.isEnabled(.browserTranslation)
+ ? 118
+ : 92
+ ),
icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 16),
icon.widthAnchor.constraint(equalToConstant: 22),
@@ -564,6 +711,25 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
return card
}
+ private var productSubtitle: String {
+ let browser = capabilityRegistry.isEnabled(.browserTranslation)
+ let pdf = capabilityRegistry.isEnabled(.pdfTranslation)
+ switch (browser, pdf) {
+ case (true, true):
+ return "浏览器与 PDF 翻译"
+ case (true, false):
+ return capabilityRegistry.supports(.safariExtension)
+ ? "Safari 与 Chrome 翻译"
+ : "Chrome 翻译"
+ case (false, true):
+ return "批量 PDF 翻译"
+ case (false, false):
+ return capabilityRegistry.isEnabled(.selectionTranslation)
+ ? "选中,即懂。"
+ : "按需组合的翻译工具"
+ }
+ }
+
private func makeProviderCard() -> NSView {
let card = NSVisualEffectView()
card.material = .contentBackground
@@ -798,6 +964,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
onPDFRuntimeAction?(action)
}
+ @objc private func performAppUpdateAction() {
+ onAppUpdateAction?()
+ }
+
@objc private func openServicesSettings() {
onOpenServicesSettings?()
}
@@ -864,6 +1034,30 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
}
}
+ func showAppUpdateState(_ state: AppUpdateDashboardState) {
+ appUpdateState = state
+ let presentation = state.presentation
+ appUpdateStatus.stringValue = presentation.headline
+ appUpdateStatus.toolTip = presentation.headline
+ appUpdateDetail.stringValue = presentation.detail
+ appUpdateDetail.toolTip = presentation.detail
+ appUpdateActionButton.title = presentation.actionTitle
+ appUpdateActionButton.isEnabled = presentation.actionEnabled
+ appUpdateProgressIndicator.isHidden = !presentation.showsProgress
+ if presentation.showsProgress {
+ appUpdateProgressIndicator.startAnimation(nil)
+ } else {
+ appUpdateProgressIndicator.stopAnimation(nil)
+ }
+ appUpdateStatus.textColor =
+ switch presentation.tone {
+ case .neutral: .secondaryLabelColor
+ case .positive: .systemGreen
+ case .warning: .systemOrange
+ case .negative: .systemRed
+ }
+ }
+
func showBrowserExtensionStatus(_ message: String, succeeded: Bool) {
browserExtensionStatus.stringValue = message
browserExtensionStatus.textColor = succeeded ? .secondaryLabelColor : .systemRed
diff --git a/Sources/GlossCLI/CLICommandSupport.swift b/Sources/GlossCLI/CLICommandSupport.swift
new file mode 100644
index 0000000..665e5ae
--- /dev/null
+++ b/Sources/GlossCLI/CLICommandSupport.swift
@@ -0,0 +1,171 @@
+import Foundation
+import GlossCore
+
+enum ProviderHandle: Sendable {
+ case codex(CodexAppServerClient, model: String, reasoning: CodexReasoningEffort)
+ case llama(LlamaServerClient, model: String)
+
+ init(
+ provider: TranslationProvider,
+ model: String?,
+ reasoningEffort: CodexReasoningEffort
+ ) {
+ switch provider {
+ case .codex:
+ let selectedModel =
+ model ?? TranslationProviderConfiguration.defaultCodexModel
+ self = .codex(
+ CodexAppServerClient(
+ model: model,
+ reasoningEffort: reasoningEffort
+ ),
+ model: selectedModel,
+ reasoning: reasoningEffort
+ )
+ case .llama:
+ let selectedModel =
+ model ?? TranslationProviderConfiguration.defaultLlamaModel
+ self = .llama(
+ LlamaServerClient(model: selectedModel),
+ model: selectedModel
+ )
+ }
+ }
+
+ var backend: any TranslationBackend {
+ switch self {
+ case .codex(let client, _, _):
+ client
+ case .llama(let client, _):
+ client
+ }
+ }
+
+ var status: TranslationProviderStatus {
+ switch self {
+ case .codex(_, let model, let reasoning):
+ TranslationProviderStatus(
+ provider: .codex,
+ model: model,
+ reasoningEffort: reasoning,
+ configurationRevision: "cli",
+ isWarm: false
+ )
+ case .llama(_, let model):
+ TranslationProviderStatus(
+ provider: .llama,
+ model: model,
+ configurationRevision: "cli",
+ isWarm: false
+ )
+ }
+ }
+
+ func stop() async {
+ switch self {
+ case .codex(let client, _, _):
+ await client.stop()
+ case .llama(let client, _):
+ await client.stop()
+ }
+ }
+}
+
+enum JSONOutput {
+ static func encode(
+ _ value: T
+ ) throws -> String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ return String(
+ decoding: try encoder.encode(value),
+ as: UTF8.self
+ )
+ }
+}
+
+final class StandardError: @unchecked Sendable {
+ private static let lock = NSLock()
+
+ static func write(_ value: String) {
+ lock.lock()
+ defer { lock.unlock() }
+ FileHandle.standardError.write(Data(value.utf8))
+ }
+}
+
+enum Help {
+ static func text(for command: GlossCLICommand?) -> String {
+ switch command {
+ case .capabilities:
+ """
+ Usage: gloss-cli capabilities --json
+
+ Prints the capability registry, enabled business scenarios, and CLI
+ command mappings as stable JSON.
+ """
+ case .text:
+ """
+ Usage: gloss-cli text [options] [text]
+
+ -t, --target LANGUAGE Target language (default: Chinese (Simplified))
+ -p, --profile PROFILE faithful | natural | technical | academic | subtitle
+ -k, --kind KIND selection | webpage | document | subtitle | ocr
+ --provider NAME codex | llama (default: codex)
+ --model MODEL Codex model, Hugging Face GGUF repo, or local GGUF path
+ --reasoning LEVEL minimal | low | medium | high | xhigh (Codex only)
+
+ Invokes the reusable text-translation capability. If text is omitted,
+ gloss-cli reads from stdin.
+ """
+ case .browser:
+ """
+ Usage: gloss-cli browser [options] [text]
+
+ -t, --target LANGUAGE Target language (default: Chinese (Simplified))
+ -p, --profile PROFILE faithful | natural | technical | academic | subtitle
+ -k, --kind webpage Browser content kind is always webpage
+ --provider NAME codex | llama (default: codex)
+ --model MODEL Codex model, Hugging Face GGUF repo, or local GGUF path
+ --reasoning LEVEL minimal | low | medium | high | xhigh (Codex only)
+
+ If text is omitted, gloss-cli reads from stdin.
+ """
+ case .pdf:
+ """
+ Usage: gloss-cli pdf INPUT... --output DIR [options]
+
+ -o, --output DIR Output directory (required)
+ --source CODE Source language code (default: en)
+ -t, --target LANGUAGE Target language (default: Chinese (Simplified))
+ --mode MODE mono | bilingual (default: mono)
+ --provider NAME codex | llama (default: codex)
+ --model MODEL Codex model, Hugging Face GGUF repo, or local GGUF path
+ --reasoning LEVEL minimal | low | medium | high | xhigh (Codex only)
+
+ Processes one or more PDFs in order while reusing one private
+ managed BabelDOC/layout session. Progress is written to stderr and
+ the generated paths are returned as a JSON array on stdout.
+ """
+ case nil:
+ """
+ Usage:
+ gloss-cli [options] [text]
+ gloss-cli text [options] [text]
+ gloss-cli browser [options] [text]
+ gloss-cli pdf INPUT... --output DIR [options]
+ gloss-cli capabilities --json
+
+ Legacy text options:
+ -t, --target LANGUAGE Target language (default: Chinese (Simplified))
+ -p, --profile PROFILE faithful | natural | technical | academic | subtitle
+ -k, --kind KIND selection | webpage | document | subtitle | ocr
+ --provider NAME codex | llama (default: codex)
+ --model MODEL Codex model, Hugging Face GGUF repo, or local GGUF path
+ --reasoning LEVEL minimal | low | medium | high | xhigh (Codex only)
+
+ If text is omitted, gloss-cli reads from stdin.
+ """
+ }
+ }
+}
diff --git a/Sources/GlossCLI/GlossCommand.swift b/Sources/GlossCLI/GlossCommand.swift
new file mode 100644
index 0000000..577e193
--- /dev/null
+++ b/Sources/GlossCLI/GlossCommand.swift
@@ -0,0 +1,85 @@
+import Foundation
+import GlossCore
+
+@main
+struct GlossCommand {
+ static func main() async {
+ do {
+ let invocation = try GlossCLIInvocationParser.parse(
+ Array(CommandLine.arguments.dropFirst())
+ )
+ switch invocation {
+ case .capabilitiesJSON:
+ print(
+ try JSONOutput.encode(
+ GlossCapabilitiesReport()
+ )
+ )
+ case .help(let command):
+ print(Help.text(for: command))
+ case .legacyText(let options):
+ try await translateText(options)
+ case .text(let options):
+ try await translateText(options)
+ case .browser(let options):
+ try await translateText(options)
+ case .pdf(let options):
+ print(try await PDFScenarioCommand.run(options))
+ }
+ } catch {
+ StandardError.write("gloss: \(error.localizedDescription)\n")
+ exit(1)
+ }
+ }
+
+ private static func translateText(
+ _ options: GlossCLITextOptions
+ ) async throws {
+ let source = try sourceText(from: options.textParts)
+ let provider = ProviderHandle(
+ provider: options.provider,
+ model: options.model,
+ reasoningEffort: options.reasoningEffort
+ )
+ let broker = TranslationBroker(backend: provider.backend)
+
+ do {
+ let result = try await broker.translateText(
+ source,
+ targetLanguage: options.targetLanguage,
+ profile: options.profile,
+ contentKind: options.contentKind
+ )
+ print(result)
+ await provider.stop()
+ } catch {
+ await provider.stop()
+ throw error
+ }
+ }
+
+ private static func sourceText(
+ from textParts: [String]
+ ) throws -> String {
+ let value: String
+ if textParts.isEmpty {
+ value =
+ String(
+ data: FileHandle.standardInput.readDataToEndOfFile(),
+ encoding: .utf8
+ ) ?? ""
+ } else {
+ value = textParts.joined(separator: " ")
+ }
+ guard
+ !value.trimmingCharacters(
+ in: .whitespacesAndNewlines
+ ).isEmpty
+ else {
+ throw GlossCLIUsageError(
+ "请通过参数或 stdin 提供文本。"
+ )
+ }
+ return value
+ }
+}
diff --git a/Sources/GlossCLI/PDFScenarioCommand.swift b/Sources/GlossCLI/PDFScenarioCommand.swift
new file mode 100644
index 0000000..2ee9cdf
--- /dev/null
+++ b/Sources/GlossCLI/PDFScenarioCommand.swift
@@ -0,0 +1,351 @@
+import Darwin
+import Foundation
+import GlossCore
+
+enum PDFScenarioCommand {
+ private struct Output: Codable {
+ let input: String
+ let outputs: [String]
+ }
+
+ static func run(_ options: GlossCLIPDFOptions) async throws -> String {
+ let inputs = try options.inputPaths.map(resolvePDF)
+ let outputDirectory = URL(
+ fileURLWithPath: options.outputDirectoryPath,
+ isDirectory: true
+ ).standardizedFileURL
+ try FileManager.default.createDirectory(
+ at: outputDirectory,
+ withIntermediateDirectories: true,
+ attributes: nil
+ )
+ guard
+ let targetLanguageCode =
+ TranslationLanguages.babelDOCCode(
+ forTargetName: options.targetLanguage
+ )
+ else {
+ throw GlossCLIUsageError(
+ "BabelDOC 暂不支持目标语言:\(options.targetLanguage)"
+ )
+ }
+
+ guard let glossVersion = GlossProductVersionResolver.resolve() else {
+ throw GlossCLIUsageError(
+ "无法确定 Gloss 版本,不能安全验证 BabelDOC runtime manifest。"
+ )
+ }
+ let runtimeManager = try BabelDOCRuntimeManager(
+ currentGlossVersion: glossVersion
+ )
+ let runtimeSnapshot = try await managedRuntime(
+ from: runtimeManager
+ )
+ guard let executableURL = runtimeSnapshot.currentExecutableURL,
+ BabelDOCRuntimeCompatibility.isCompatible(
+ runtimeSnapshot.currentVersion
+ )
+ else {
+ throw GlossCLIUsageError(
+ "BabelDOC runtime 需要 \(BabelDOCRuntimeCompatibility.minimumManagedVersion) 或更高版本。"
+ )
+ }
+ let runtime = BabelDOCRuntimeLaunch(
+ executable: executableURL.path,
+ source: runtimeSnapshot.currentVersion.map {
+ "Gloss runtime \($0)"
+ } ?? "Gloss runtime",
+ executorExecutable: executableURL.path
+ )
+
+ let privateRoot = FileManager.default.temporaryDirectory
+ .appendingPathComponent(
+ "Gloss-CLI-PDF-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ try FileManager.default.createDirectory(
+ at: privateRoot,
+ withIntermediateDirectories: false,
+ attributes: [.posixPermissions: 0o700]
+ )
+ let stateDirectory = privateRoot.appendingPathComponent(
+ "service-state",
+ isDirectory: true
+ )
+ let service = BabelDOCServiceSession(
+ persistedStateDirectoryURL: stateDirectory
+ )
+ let provider = ProviderHandle(
+ provider: options.provider,
+ model: options.model,
+ reasoningEffort: options.reasoningEffort
+ )
+ let token = UUID().uuidString + UUID().uuidString
+ let port = try availableLoopbackPort()
+ let bridge = LoopbackServer(
+ broker: TranslationBroker(backend: provider.backend),
+ token: token,
+ version: glossVersion,
+ port: port,
+ providerStatus: { provider.status }
+ )
+
+ do {
+ try bridge.start()
+ let bridgeBaseURL = URL(
+ string: "http://127.0.0.1:\(port)"
+ )!
+ try await waitForBridge(
+ bridgeBaseURL,
+ token: token
+ )
+ StandardError.write(
+ "pdf: 正在启动独立 BabelDOC 服务…\n"
+ )
+ let layoutServiceBaseURL = try await service.start(
+ runtime: runtime
+ )
+ let layoutCacheDirectoryURL =
+ await service.layoutCacheDirectoryURL
+ let engine = BabelDOCExternalEngine(
+ executorManager: service
+ )
+ var records: [Output] = []
+
+ for (index, input) in inputs.enumerated() {
+ try Task.checkCancellation()
+ let jobDirectory = privateRoot.appendingPathComponent(
+ "job-\(index + 1)",
+ isDirectory: true
+ )
+ StandardError.write(
+ "pdf: [\(index + 1)/\(inputs.count)] \(input.lastPathComponent)\n"
+ )
+ let result = try await engine.translate(
+ BabelDOCTranslationRequest(
+ inputURL: input,
+ outputDirectory: jobDirectory,
+ sourceLanguageCode: options.sourceLanguageCode,
+ targetLanguageCode: targetLanguageCode,
+ bridgeBaseURL: bridgeBaseURL.appendingPathComponent(
+ "v1"
+ ),
+ bridgeToken: token,
+ outputMode: options.outputMode,
+ layoutServiceBaseURL: layoutServiceBaseURL,
+ layoutCacheDirectoryURL:
+ layoutCacheDirectoryURL
+ ),
+ runtime: runtime,
+ onProgress: { progress in
+ StandardError.write(
+ progressLine(
+ progress,
+ item: index + 1,
+ count: inputs.count
+ )
+ )
+ }
+ )
+ let generated: URL? =
+ switch options.outputMode {
+ case .monolingual:
+ result.monolingualPDF
+ case .bilingual:
+ result.bilingualPDF
+ }
+ guard let generated else {
+ throw BabelDOCExternalEngineError.outputMissing
+ }
+ let destination = availableDestination(
+ for: input,
+ mode: options.outputMode,
+ in: outputDirectory
+ )
+ try FileManager.default.copyItem(
+ at: generated,
+ to: destination
+ )
+ records.append(
+ Output(
+ input: input.path,
+ outputs: [destination.path]
+ )
+ )
+ }
+
+ bridge.stop()
+ _ = await service.stop()
+ await provider.stop()
+ try? FileManager.default.removeItem(at: privateRoot)
+ return try JSONOutput.encode(records)
+ } catch {
+ bridge.stop()
+ _ = await service.stop()
+ await provider.stop()
+ try? FileManager.default.removeItem(at: privateRoot)
+ throw error
+ }
+ }
+
+ private static func managedRuntime(
+ from manager: BabelDOCRuntimeManager
+ ) async throws -> BabelDOCRuntimeSnapshot {
+ let snapshot = await manager.snapshot()
+ if snapshot.currentExecutableURL != nil,
+ BabelDOCRuntimeCompatibility.isCompatible(
+ snapshot.currentVersion
+ )
+ {
+ return snapshot
+ }
+
+ let progress: BabelDOCRuntimeManager.ProgressHandler = {
+ update in
+ let version = update.version.map { " \($0)" } ?? ""
+ StandardError.write(
+ "runtime: \(update.operation.rawValue)\(version)\n"
+ )
+ }
+ if snapshot.currentExecutableURL == nil {
+ return try await manager.update(progress: progress)
+ }
+
+ let checked = try await manager.checkForUpdates(
+ progress: progress
+ )
+ guard checked.updateAvailable,
+ BabelDOCRuntimeCompatibility.isCompatible(
+ checked.availableVersion
+ )
+ else {
+ throw GlossCLIUsageError(
+ "已安装 BabelDOC runtime \(snapshot.currentVersion ?? "unknown") 不兼容,且没有可用的兼容更新。"
+ )
+ }
+ return try await manager.installAvailableUpdate(
+ progress: progress
+ )
+ }
+
+ private static func resolvePDF(_ path: String) throws -> URL {
+ let url = URL(fileURLWithPath: path).standardizedFileURL
+ let values = try? url.resourceValues(
+ forKeys: [.isRegularFileKey]
+ )
+ guard values?.isRegularFile == true,
+ url.pathExtension.lowercased() == "pdf"
+ else {
+ throw GlossCLIUsageError(
+ "不是可读取的 PDF 文件:\(path)"
+ )
+ }
+ return url
+ }
+
+ private static func availableDestination(
+ for input: URL,
+ mode: BabelDOCOutputMode,
+ in outputDirectory: URL
+ ) -> URL {
+ let stem = input.deletingPathExtension().lastPathComponent
+ let suffix =
+ mode == .monolingual ? "gloss-mono" : "gloss-dual"
+ var candidate = outputDirectory.appendingPathComponent(
+ "\(stem)-\(suffix).pdf"
+ )
+ var copy = 2
+ while FileManager.default.fileExists(atPath: candidate.path) {
+ candidate = outputDirectory.appendingPathComponent(
+ "\(stem)-\(suffix)-\(copy).pdf"
+ )
+ copy += 1
+ }
+ return candidate
+ }
+
+ private static func progressLine(
+ _ progress: BabelDOCProgressUpdate,
+ item: Int,
+ count: Int
+ ) -> String {
+ let percent = Int(progress.overallProgress.rounded())
+ let stage = progress.stageName.map { " \($0)" } ?? ""
+ return
+ "pdf: [\(item)/\(count)] \(progress.phase.rawValue) \(percent)%\(stage)\n"
+ }
+
+ private static func availableLoopbackPort() throws -> UInt16 {
+ let descriptor = socket(AF_INET, SOCK_STREAM, 0)
+ guard descriptor >= 0 else {
+ throw GlossCLIUsageError("无法创建 CLI 翻译桥接端口。")
+ }
+ defer { Darwin.close(descriptor) }
+
+ var address = sockaddr_in()
+ address.sin_len = UInt8(MemoryLayout.size)
+ address.sin_family = sa_family_t(AF_INET)
+ address.sin_port = 0
+ address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
+ let bindResult = withUnsafePointer(to: &address) { pointer in
+ pointer.withMemoryRebound(
+ to: sockaddr.self,
+ capacity: 1
+ ) {
+ bind(
+ descriptor,
+ $0,
+ socklen_t(MemoryLayout.size)
+ )
+ }
+ }
+ guard bindResult == 0 else {
+ throw GlossCLIUsageError("无法绑定 CLI 翻译桥接端口。")
+ }
+
+ var boundAddress = sockaddr_in()
+ var length = socklen_t(MemoryLayout.size)
+ let readResult = withUnsafeMutablePointer(
+ to: &boundAddress
+ ) { pointer in
+ pointer.withMemoryRebound(
+ to: sockaddr.self,
+ capacity: 1
+ ) {
+ getsockname(descriptor, $0, &length)
+ }
+ }
+ guard readResult == 0 else {
+ throw GlossCLIUsageError("无法读取 CLI 翻译桥接端口。")
+ }
+ return UInt16(bigEndian: boundAddress.sin_port)
+ }
+
+ private static func waitForBridge(
+ _ baseURL: URL,
+ token: String
+ ) async throws {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.timeoutIntervalForRequest = 0.5
+ configuration.timeoutIntervalForResource = 0.5
+ let session = URLSession(configuration: configuration)
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: .seconds(5))
+ while clock.now < deadline {
+ try Task.checkCancellation()
+ var request = URLRequest(
+ url: baseURL.appendingPathComponent("health")
+ )
+ request.setValue(token, forHTTPHeaderField: "X-Gloss-Token")
+ if let (_, response) = try? await session.data(for: request),
+ (response as? HTTPURLResponse)?.statusCode == 200
+ {
+ return
+ }
+ try await Task.sleep(for: .milliseconds(80))
+ }
+ throw GlossCLIUsageError(
+ "CLI 翻译桥接服务启动超时。"
+ )
+ }
+}
diff --git a/Sources/GlossCLI/main.swift b/Sources/GlossCLI/main.swift
deleted file mode 100644
index 089746e..0000000
--- a/Sources/GlossCLI/main.swift
+++ /dev/null
@@ -1,164 +0,0 @@
-import Darwin
-import Foundation
-import GlossCore
-
-@main
-struct GlossCommand {
- static func main() async {
- do {
- let arguments = try Arguments(CommandLine.arguments.dropFirst())
- let source = try arguments.sourceText()
- let backend: any TranslationBackend
- let stop: @Sendable () async -> Void
- switch arguments.provider {
- case .codex:
- let codex = CodexAppServerClient(
- model: arguments.model,
- reasoningEffort: arguments.reasoningEffort
- )
- backend = codex
- stop = { await codex.stop() }
- case .llama:
- let llama = LlamaServerClient(
- model: arguments.model
- ?? TranslationProviderConfiguration.defaultLlamaModel
- )
- backend = llama
- stop = { await llama.stop() }
- }
- let broker = TranslationBroker(backend: backend)
-
- do {
- let result = try await broker.translateText(
- source,
- targetLanguage: arguments.targetLanguage,
- profile: arguments.profile,
- contentKind: arguments.contentKind
- )
- print(result)
- await stop()
- } catch {
- await stop()
- throw error
- }
- } catch {
- FileHandle.standardError.write(Data("gloss: \(error.localizedDescription)\n".utf8))
- exit(1)
- }
- }
-}
-
-private struct Arguments {
- let targetLanguage: String
- let profile: TranslationProfile
- let contentKind: TranslationContentKind
- let provider: TranslationProvider
- let model: String?
- let reasoningEffort: CodexReasoningEffort
- let textParts: [String]
-
- init(_ rawArguments: ArraySlice) throws {
- var targetLanguage = "Chinese (Simplified)"
- var profile: TranslationProfile = .natural
- var contentKind: TranslationContentKind = .selection
- var provider: TranslationProvider = .codex
- var model: String?
- var reasoningEffort: CodexReasoningEffort = .low
- var textParts: [String] = []
- var iterator = rawArguments.makeIterator()
-
- while let argument = iterator.next() {
- switch argument {
- case "--target", "-t":
- guard let value = iterator.next() else {
- throw UsageError("--target 需要语言名称。")
- }
- targetLanguage = value
- case "--profile", "-p":
- guard let value = iterator.next(), let parsed = TranslationProfile(rawValue: value) else {
- throw UsageError("--profile 可选 faithful、natural、technical、academic 或 subtitle。")
- }
- profile = parsed
- case "--kind", "-k":
- guard let value = iterator.next(),
- let parsed = TranslationContentKind(rawValue: value)
- else {
- throw UsageError(
- "--kind 可选 selection、webpage、document、subtitle 或 ocr。"
- )
- }
- contentKind = parsed
- case "--provider":
- guard let value = iterator.next(),
- let parsed = TranslationProvider(rawValue: value)
- else {
- throw UsageError("--provider 可选 codex 或 llama。")
- }
- provider = parsed
- case "--model":
- guard let value = iterator.next(), !value.isEmpty else {
- throw UsageError("--model 需要模型名称或 GGUF 路径。")
- }
- model = value
- case "--reasoning":
- guard let value = iterator.next(),
- let parsed = CodexReasoningEffort(rawValue: value)
- else {
- throw UsageError(
- "--reasoning 可选 minimal、low、medium、high 或 xhigh。"
- )
- }
- reasoningEffort = parsed
- case "--help", "-h":
- print(Self.help)
- exit(0)
- default:
- textParts.append(argument)
- }
- }
-
- self.targetLanguage = targetLanguage
- self.profile = profile
- self.contentKind = contentKind
- self.provider = provider
- self.model = model
- self.reasoningEffort = reasoningEffort
- self.textParts = textParts
- }
-
- func sourceText() throws -> String {
- let value: String
- if textParts.isEmpty {
- value = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? ""
- } else {
- value = textParts.joined(separator: " ")
- }
- guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
- throw UsageError("请通过参数或 stdin 提供文本。")
- }
- return value
- }
-
- static let help = """
- Usage: gloss-cli [options] [text]
-
- -t, --target LANGUAGE Target language (default: Chinese (Simplified))
- -p, --profile PROFILE faithful | natural | technical | academic | subtitle
- -k, --kind KIND selection | webpage | document | subtitle | ocr
- --provider NAME codex | llama (default: codex)
- --model MODEL Codex model, Hugging Face GGUF repo, or local GGUF path
- --reasoning LEVEL minimal | low | medium | high | xhigh (Codex only)
-
- If text is omitted, gloss-cli reads from stdin.
- """
-}
-
-private struct UsageError: LocalizedError {
- let message: String
-
- init(_ message: String) {
- self.message = message
- }
-
- var errorDescription: String? { message }
-}
diff --git a/Sources/GlossCore/BabelDOCRuntimeCompatibility.swift b/Sources/GlossCore/BabelDOCRuntimeCompatibility.swift
new file mode 100644
index 0000000..8b272a1
--- /dev/null
+++ b/Sources/GlossCore/BabelDOCRuntimeCompatibility.swift
@@ -0,0 +1,183 @@
+import Foundation
+
+public enum BabelDOCManagedRuntimePreparation: Equatable, Sendable {
+ case install
+ case installAvailableUpdate
+ case useCurrent
+ case updateRequired(currentVersion: String)
+}
+
+public enum BabelDOCRuntimeCompatibility {
+ public static let minimumManagedVersion = "0.6.4+gloss.5"
+
+ public static func isCompatible(_ version: String?) -> Bool {
+ guard let version = parsedVersion(version) else { return false }
+ let minimumCore = [0, 6, 4]
+ if version.core != minimumCore {
+ return version.core.lexicographicallyPrecedes(minimumCore) == false
+ }
+ return version.glossRevision.map { $0 >= 5 } ?? false
+ }
+
+ public static func preparation(
+ for snapshot: BabelDOCRuntimeSnapshot?
+ ) -> BabelDOCManagedRuntimePreparation {
+ guard let snapshot, snapshot.currentExecutableURL != nil else {
+ return .install
+ }
+ if isCompatible(snapshot.currentVersion) {
+ return .useCurrent
+ }
+ return snapshot.updateAvailable
+ ? .installAvailableUpdate
+ : .updateRequired(
+ currentVersion: snapshot.currentVersion ?? "unknown"
+ )
+ }
+
+ private static func parsedVersion(
+ _ value: String?
+ ) -> (core: [Int], glossRevision: Int?)? {
+ guard let value,
+ value == value.trimmingCharacters(in: .whitespacesAndNewlines),
+ !value.isEmpty,
+ !value.contains("-")
+ else {
+ return nil
+ }
+
+ let versionAndMetadata = value.split(
+ separator: "+",
+ maxSplits: 1,
+ omittingEmptySubsequences: false
+ )
+ guard
+ let core = parseNumericComponents(
+ String(versionAndMetadata[0]),
+ count: 3
+ )
+ else {
+ return nil
+ }
+
+ guard versionAndMetadata.count == 2 else {
+ return (core, nil)
+ }
+ let metadata = versionAndMetadata[1].split(
+ separator: ".",
+ omittingEmptySubsequences: false
+ )
+ guard metadata.count == 2,
+ metadata[0] == "gloss",
+ let revision = parseNumericComponent(metadata[1])
+ else {
+ return nil
+ }
+ return (core, revision)
+ }
+
+ private static func parseNumericComponents(
+ _ value: String,
+ count: Int
+ ) -> [Int]? {
+ let components = value.split(
+ separator: ".",
+ omittingEmptySubsequences: false
+ )
+ guard components.count == count else { return nil }
+ let values = components.compactMap(parseNumericComponent)
+ return values.count == count ? values : nil
+ }
+
+ private static func parseNumericComponent(
+ _ value: Substring
+ ) -> Int? {
+ guard !value.isEmpty,
+ value.allSatisfy(\.isNumber),
+ value.count == 1 || value.first != "0"
+ else {
+ return nil
+ }
+ return Int(value)
+ }
+}
+
+/// Resolves the Gloss product version for a standalone helper executable.
+///
+/// A helper inside `Gloss.app/Contents/Helpers` does not always receive the app
+/// bundle as `Bundle.main`. Homebrew may also invoke it through a symlink. Walk
+/// from the resolved executable first, then support a package checkout's
+/// `Resources/Info.plist` for `swift run gloss-cli`.
+public enum GlossProductVersionResolver {
+ public static func resolve(
+ bundleVersion: String? = Bundle.main.object(
+ forInfoDictionaryKey: "CFBundleShortVersionString"
+ ) as? String,
+ executableURL: URL? = Bundle.main.executableURL,
+ workingDirectoryURL: URL = URL(
+ fileURLWithPath: FileManager.default.currentDirectoryPath,
+ isDirectory: true
+ )
+ ) -> String? {
+ if let bundleVersion = normalized(bundleVersion) {
+ return bundleVersion
+ }
+
+ if var candidate = executableURL?.resolvingSymlinksInPath()
+ .deletingLastPathComponent()
+ {
+ for _ in 0..<10 {
+ if candidate.pathExtension.lowercased() == "app",
+ let version = version(
+ in: candidate.appendingPathComponent(
+ "Contents/Info.plist"
+ )
+ )
+ {
+ return version
+ }
+ if let version = version(
+ in: candidate.appendingPathComponent(
+ "Resources/Info.plist"
+ )
+ ) {
+ return version
+ }
+ let parent = candidate.deletingLastPathComponent()
+ guard parent.path != candidate.path else { break }
+ candidate = parent
+ }
+ }
+
+ return version(
+ in: workingDirectoryURL.appendingPathComponent(
+ "Resources/Info.plist"
+ )
+ )
+ }
+
+ private static func version(in plistURL: URL) -> String? {
+ guard let data = try? Data(contentsOf: plistURL),
+ let value = try? PropertyListSerialization.propertyList(
+ from: data,
+ options: [],
+ format: nil
+ ),
+ let dictionary = value as? [String: Any]
+ else { return nil }
+ return normalized(
+ dictionary["CFBundleShortVersionString"] as? String
+ )
+ }
+
+ private static func normalized(_ value: String?) -> String? {
+ guard
+ let value = value?.trimmingCharacters(
+ in: .whitespacesAndNewlines
+ ), !value.isEmpty
+ else {
+ return nil
+ }
+ return value
+ }
+}
diff --git a/Sources/GlossCore/BabelDOCRuntimeDistribution.swift b/Sources/GlossCore/BabelDOCRuntimeDistribution.swift
index 5c755cb..4f27339 100644
--- a/Sources/GlossCore/BabelDOCRuntimeDistribution.swift
+++ b/Sources/GlossCore/BabelDOCRuntimeDistribution.swift
@@ -611,12 +611,25 @@ public actor BabelDOCRuntimeManager {
signatureURL: URL? = nil,
progress: ProgressHandler? = nil
) async throws -> BabelDOCRuntimeSnapshot {
- let checked = try await checkForUpdates(
+ _ = try await checkForUpdates(
manifestURL: manifestURL,
signatureURL: signatureURL,
progress: progress
)
- guard checked.updateAvailable, let manifest = availableManifest else {
+ return try await installAvailableUpdate(progress: progress)
+ }
+
+ /// Installs the manifest most recently accepted by `checkForUpdates`.
+ ///
+ /// The cached manifest has already passed the detached-signature and policy
+ /// checks. Keeping this operation separate lets launch-time callers wait for
+ /// their in-flight check and install that exact result without fetching
+ /// mutable "latest" metadata a second time.
+ @discardableResult
+ public func installAvailableUpdate(
+ progress: ProgressHandler? = nil
+ ) async throws -> BabelDOCRuntimeSnapshot {
+ guard makeSnapshot().updateAvailable, let manifest = availableManifest else {
throw BabelDOCRuntimeDistributionError.noUpdateAvailable
}
return try await install(manifest, progress: progress)
diff --git a/Sources/GlossCore/BabelDOCServiceSession.swift b/Sources/GlossCore/BabelDOCServiceSession.swift
index 662459f..c742d21 100644
--- a/Sources/GlossCore/BabelDOCServiceSession.swift
+++ b/Sources/GlossCore/BabelDOCServiceSession.swift
@@ -29,6 +29,9 @@ public enum BabelDOCServiceError: LocalizedError, Equatable, Sendable {
/// tied to the Gloss process identity.
public actor BabelDOCServiceSession: BabelDOCExecutorManaging {
public static let shared = BabelDOCServiceSession()
+ /// A cold packaged runtime can spend close to two minutes loading the
+ /// DocLayout model on first launch.
+ public static let defaultStartupTimeout: Duration = .seconds(180)
static let readyPrefix = "__GLOSS_BABELDOC_LAYOUT_READY__"
static let executorReadyPrefix = "__GLOSS_BABELDOC_SERVICE_READY__"
@@ -232,7 +235,7 @@ public actor BabelDOCServiceSession: BabelDOCExecutorManaging {
public func start(
runtime: BabelDOCRuntimeLaunch,
- timeout: Duration = .seconds(90)
+ timeout: Duration = defaultStartupTimeout
) async throws -> URL {
await acquireLifecycleOperation()
defer { releaseLifecycleOperation() }
@@ -640,7 +643,7 @@ public actor BabelDOCServiceSession: BabelDOCExecutorManaging {
public func reconnect(
runtime: BabelDOCRuntimeLaunch,
force: Bool = false,
- timeout: Duration = .seconds(90)
+ timeout: Duration = defaultStartupTimeout
) async throws -> URL {
await acquireLifecycleOperation()
defer { releaseLifecycleOperation() }
diff --git a/Sources/GlossCore/CodexAppServerClient.swift b/Sources/GlossCore/CodexAppServerClient.swift
index 3a2fa23..bb4cc58 100644
--- a/Sources/GlossCore/CodexAppServerClient.swift
+++ b/Sources/GlossCore/CodexAppServerClient.swift
@@ -984,7 +984,8 @@ public actor CodexAppServerClient: TranslationBackend {
successfulTurnsByThreadIndex.removeAll()
availableThreadIndices = Array(startedThreadIDs.indices)
activeThreadPriorities.removeAll()
- let hedgeMilliseconds = modelWaitHedgeNanoseconds
+ let hedgeMilliseconds =
+ modelWaitHedgeNanoseconds
.map { String($0 / 1_000_000) } ?? "off"
runtimeLog.write(
"codex",
@@ -1709,7 +1710,8 @@ public actor CodexAppServerClient: TranslationBackend {
static func resolveRuntime(
environment: [String: String],
- bundleURL: URL = Bundle.main.bundleURL
+ bundleURL: URL = Bundle.main.bundleURL,
+ executableURL: URL? = Bundle.main.executableURL
) -> CodexRuntimeLaunch? {
if let configured = environment["GLOSS_CODEX_APP_SERVER_BIN"]?.nilIfBlank,
FileManager.default.isExecutableFile(atPath: configured)
@@ -1733,6 +1735,20 @@ public actor CodexAppServerClient: TranslationBackend {
)
}
+ if let sibling = executableURL?
+ .resolvingSymlinksInPath()
+ .deletingLastPathComponent()
+ .appendingPathComponent("gloss-codex-app-server")
+ .path,
+ FileManager.default.isExecutableFile(atPath: sibling)
+ {
+ return CodexRuntimeLaunch(
+ executable: sibling,
+ argumentPrefix: [],
+ source: "sibling-app-server"
+ )
+ }
+
guard let executable = resolveCodexExecutable(environment: environment) else {
return nil
}
@@ -1785,9 +1801,10 @@ public actor CodexAppServerClient: TranslationBackend {
_ environment: [String: String],
configured: CodexReasoningEffort?
) -> CodexReasoningEffort? {
- guard let rawValue = environment["GLOSS_CODEX_DOCUMENT_REASONING_EFFORT"]?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased(),
+ guard
+ let rawValue = environment["GLOSS_CODEX_DOCUMENT_REASONING_EFFORT"]?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased(),
!rawValue.isEmpty
else { return configured }
if rawValue == "inherit" { return nil }
@@ -2075,9 +2092,10 @@ public actor CodexAppServerClient: TranslationBackend {
_ environment: [String: String],
dispatchAware: Bool = false
) -> UInt64? {
- guard let rawValue = environment["GLOSS_CODEX_MODEL_WAIT_HEDGE_SECONDS"]?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased(),
+ guard
+ let rawValue = environment["GLOSS_CODEX_MODEL_WAIT_HEDGE_SECONDS"]?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased(),
!rawValue.isEmpty
else {
return dispatchAware
@@ -2092,9 +2110,10 @@ public actor CodexAppServerClient: TranslationBackend {
}
static func readThreadRotationTurns(_ environment: [String: String]) -> Int? {
- guard let rawValue = environment["GLOSS_CODEX_THREAD_ROTATION_TURNS"]?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- .lowercased(),
+ guard
+ let rawValue = environment["GLOSS_CODEX_THREAD_ROTATION_TURNS"]?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased(),
!rawValue.isEmpty
else { return defaultThreadRotationTurns }
if ["0", "off", "disabled"].contains(rawValue) { return nil }
diff --git a/Sources/GlossCore/GlossAppUpdateDiscovery.swift b/Sources/GlossCore/GlossAppUpdateDiscovery.swift
new file mode 100644
index 0000000..8748269
--- /dev/null
+++ b/Sources/GlossCore/GlossAppUpdateDiscovery.swift
@@ -0,0 +1,698 @@
+import CryptoKit
+import Foundation
+
+public struct GlossSemanticVersion: Comparable, CustomStringConvertible, Sendable {
+ public let major: Int
+ public let minor: Int
+ public let patch: Int
+ public let prerelease: [String]
+ public let buildMetadata: [String]
+
+ public init?(_ value: String) {
+ let buildParts = value.split(separator: "+", maxSplits: 1, omittingEmptySubsequences: false)
+ guard buildParts.count <= 2,
+ buildParts.first?.isEmpty == false
+ else {
+ return nil
+ }
+
+ let precedence = buildParts[0]
+ let precedenceParts = precedence.split(
+ separator: "-",
+ maxSplits: 1,
+ omittingEmptySubsequences: false
+ )
+ guard precedenceParts.count <= 2,
+ precedenceParts.first?.isEmpty == false
+ else {
+ return nil
+ }
+
+ let core = precedenceParts[0].split(
+ separator: ".",
+ omittingEmptySubsequences: false
+ )
+ guard core.count == 3,
+ let major = Self.parseCoreNumber(core[0]),
+ let minor = Self.parseCoreNumber(core[1]),
+ let patch = Self.parseCoreNumber(core[2])
+ else {
+ return nil
+ }
+
+ let prerelease =
+ precedenceParts.count == 2
+ ? precedenceParts[1].split(
+ separator: ".",
+ omittingEmptySubsequences: false
+ ).map(String.init)
+ : []
+ guard Self.validateIdentifiers(prerelease, rejectNumericLeadingZeroes: true) else {
+ return nil
+ }
+
+ let buildMetadata =
+ buildParts.count == 2
+ ? buildParts[1].split(
+ separator: ".",
+ omittingEmptySubsequences: false
+ ).map(String.init)
+ : []
+ guard Self.validateIdentifiers(buildMetadata, rejectNumericLeadingZeroes: false) else {
+ return nil
+ }
+
+ self.major = major
+ self.minor = minor
+ self.patch = patch
+ self.prerelease = prerelease
+ self.buildMetadata = buildMetadata
+ }
+
+ public var description: String {
+ var value = "\(major).\(minor).\(patch)"
+ if !prerelease.isEmpty {
+ value += "-\(prerelease.joined(separator: "."))"
+ }
+ if !buildMetadata.isEmpty {
+ value += "+\(buildMetadata.joined(separator: "."))"
+ }
+ return value
+ }
+
+ public static func == (lhs: Self, rhs: Self) -> Bool {
+ !(lhs < rhs) && !(rhs < lhs)
+ }
+
+ public static func < (lhs: Self, rhs: Self) -> Bool {
+ let lhsCore = [lhs.major, lhs.minor, lhs.patch]
+ let rhsCore = [rhs.major, rhs.minor, rhs.patch]
+ if lhsCore != rhsCore {
+ return lhsCore.lexicographicallyPrecedes(rhsCore)
+ }
+
+ if lhs.prerelease.isEmpty || rhs.prerelease.isEmpty {
+ return !lhs.prerelease.isEmpty && rhs.prerelease.isEmpty
+ }
+
+ for (left, right) in zip(lhs.prerelease, rhs.prerelease) {
+ guard left != right else {
+ continue
+ }
+ let leftIsNumeric = left.allSatisfy(\.isNumber)
+ let rightIsNumeric = right.allSatisfy(\.isNumber)
+ switch (leftIsNumeric, rightIsNumeric) {
+ case (true, true):
+ if left.count != right.count {
+ return left.count < right.count
+ }
+ return left < right
+ case (true, false):
+ return true
+ case (false, true):
+ return false
+ case (false, false):
+ return left < right
+ }
+ }
+ return lhs.prerelease.count < rhs.prerelease.count
+ }
+
+ private static func parseCoreNumber(_ value: Substring) -> Int? {
+ guard !value.isEmpty,
+ value.allSatisfy(\.isNumber),
+ value == "0" || value.first != "0"
+ else {
+ return nil
+ }
+ return Int(value)
+ }
+
+ private static func validateIdentifiers(
+ _ identifiers: [String],
+ rejectNumericLeadingZeroes: Bool
+ ) -> Bool {
+ identifiers.allSatisfy { identifier in
+ guard !identifier.isEmpty,
+ identifier.utf8.allSatisfy({
+ ($0 >= 48 && $0 <= 57)
+ || ($0 >= 65 && $0 <= 90)
+ || ($0 >= 97 && $0 <= 122)
+ || $0 == 45
+ })
+ else {
+ return false
+ }
+ return !rejectNumericLeadingZeroes
+ || !identifier.allSatisfy(\.isNumber)
+ || identifier == "0"
+ || identifier.first != "0"
+ }
+ }
+}
+
+public struct GlossAppReleaseManifest: Codable, Equatable, Sendable {
+ public struct Asset: Codable, Equatable, Sendable {
+ public let operatingSystem: String
+ public let architecture: String
+ public let url: URL
+ public let sha256: String
+ public let size: Int64
+
+ public init(
+ operatingSystem: String,
+ architecture: String,
+ url: URL,
+ sha256: String,
+ size: Int64
+ ) {
+ self.operatingSystem = operatingSystem
+ self.architecture = architecture
+ self.url = url
+ self.sha256 = sha256
+ self.size = size
+ }
+ }
+
+ public struct HomebrewCask: Codable, Equatable, Sendable {
+ public let token: String
+ public let url: URL
+ public let sha256: String
+ public let size: Int64
+
+ public init(
+ token: String = GlossHomebrewInstallationDetector.caskToken,
+ url: URL,
+ sha256: String,
+ size: Int64
+ ) {
+ self.token = token
+ self.url = url
+ self.sha256 = sha256
+ self.size = size
+ }
+ }
+
+ public let schemaVersion: Int
+ public let channel: String
+ public let version: String
+ public let releaseTag: String
+ public let publishedAt: String
+ public let minimumMacOSVersion: String
+ public let assets: [Asset]
+ public let homebrewCask: HomebrewCask
+
+ public init(
+ schemaVersion: Int = 2,
+ channel: String = "stable",
+ version: String,
+ releaseTag: String,
+ publishedAt: String,
+ minimumMacOSVersion: String,
+ assets: [Asset],
+ homebrewCask: HomebrewCask
+ ) {
+ self.schemaVersion = schemaVersion
+ self.channel = channel
+ self.version = version
+ self.releaseTag = releaseTag
+ self.publishedAt = publishedAt
+ self.minimumMacOSVersion = minimumMacOSVersion
+ self.assets = assets
+ self.homebrewCask = homebrewCask
+ }
+}
+
+public enum GlossAppArchitecture {
+ public static var current: String {
+ #if arch(arm64)
+ "arm64"
+ #elseif arch(x86_64)
+ "x86_64"
+ #else
+ "unsupported"
+ #endif
+ }
+}
+
+public enum GlossAppReleaseEndpoint {
+ public static let manifestURL = URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/latest/download/gloss-release-manifest.json"
+ )!
+ public static let signatureURL = URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/latest/download/gloss-release-manifest.json.sig"
+ )!
+ public static let releasesURL = URL(
+ string: "https://github.com/SunChJ/gloss-releases/releases"
+ )!
+
+ public static func releasePageURL(for tag: String) -> URL {
+ releasesURL.appendingPathComponent("tag").appendingPathComponent(tag)
+ }
+}
+
+public enum GlossAppUpdateError: LocalizedError, Equatable, Sendable {
+ case invalidCurrentVersion(String)
+ case invalidSigningKey
+ case invalidManifestSignature
+ case invalidManifest(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .invalidCurrentVersion(let version):
+ "当前 Gloss 版本无效:\(version)"
+ case .invalidSigningKey:
+ "Gloss 内置的应用更新 manifest 签名公钥无效。"
+ case .invalidManifestSignature:
+ "Gloss 应用更新 manifest 的 Ed25519 签名无效。"
+ case .invalidManifest(let reason):
+ "Gloss 应用更新 manifest 无效:\(reason)"
+ }
+ }
+}
+
+public struct GlossAppReleaseManifestValidator: Sendable {
+ public static let pinnedManifestSigningPublicKey = Data(
+ base64Encoded: "FtPLO0dyvSMP4BUSZtk4ROgObX6F2GAToLeNfOygcVY="
+ )!
+
+ private let publicKey: Curve25519.Signing.PublicKey
+
+ public init(
+ manifestSigningPublicKey: Data = Self.pinnedManifestSigningPublicKey
+ ) throws {
+ do {
+ publicKey = try Curve25519.Signing.PublicKey(
+ rawRepresentation: manifestSigningPublicKey
+ )
+ } catch {
+ throw GlossAppUpdateError.invalidSigningKey
+ }
+ }
+
+ public func validate(
+ manifestData: Data,
+ detachedSignatureData: Data
+ ) throws -> GlossAppReleaseManifest {
+ let signature = try Self.decodeSignature(detachedSignatureData)
+ guard publicKey.isValidSignature(signature, for: manifestData) else {
+ throw GlossAppUpdateError.invalidManifestSignature
+ }
+
+ let manifest: GlossAppReleaseManifest
+ do {
+ manifest = try JSONDecoder().decode(
+ GlossAppReleaseManifest.self,
+ from: manifestData
+ )
+ } catch {
+ throw GlossAppUpdateError.invalidManifest("JSON 无法解析")
+ }
+ try Self.validateContents(manifest)
+ return manifest
+ }
+
+ private static func decodeSignature(_ data: Data) throws -> Data {
+ if data.count == 64 {
+ return data
+ }
+ if let text = String(data: data, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ let decoded = Data(base64Encoded: text),
+ decoded.count == 64
+ {
+ return decoded
+ }
+ throw GlossAppUpdateError.invalidManifestSignature
+ }
+
+ private static func validateContents(_ manifest: GlossAppReleaseManifest) throws {
+ guard manifest.schemaVersion == 2 else {
+ throw GlossAppUpdateError.invalidManifest("不支持 schemaVersion")
+ }
+ guard manifest.channel == "stable" else {
+ throw GlossAppUpdateError.invalidManifest("仅支持 stable 通道")
+ }
+ guard GlossSemanticVersion(manifest.version) != nil else {
+ throw GlossAppUpdateError.invalidManifest("version 不是有效的语义化版本")
+ }
+ guard manifest.releaseTag == "v\(manifest.version)" else {
+ throw GlossAppUpdateError.invalidManifest("releaseTag 与 version 不匹配")
+ }
+
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ guard formatter.date(from: manifest.publishedAt) != nil else {
+ throw GlossAppUpdateError.invalidManifest("publishedAt 不是 ISO 8601 时间")
+ }
+ guard
+ manifest.minimumMacOSVersion.range(
+ of: #"^[0-9]+(\.[0-9]+){1,2}$"#,
+ options: .regularExpression
+ ) != nil
+ else {
+ throw GlossAppUpdateError.invalidManifest("minimumMacOSVersion 无效")
+ }
+
+ var architectures = Set()
+ for asset in manifest.assets {
+ guard asset.operatingSystem == "macos",
+ ["arm64", "x86_64"].contains(asset.architecture),
+ architectures.insert(asset.architecture).inserted
+ else {
+ throw GlossAppUpdateError.invalidManifest("asset 平台无效或重复")
+ }
+ guard asset.size > 0 else {
+ throw GlossAppUpdateError.invalidManifest("asset size 必须大于零")
+ }
+ guard
+ asset.sha256.range(
+ of: "^[0-9a-f]{64}$",
+ options: .regularExpression
+ ) != nil
+ else {
+ throw GlossAppUpdateError.invalidManifest("asset SHA-256 无效")
+ }
+ try validateOfficialAssetURL(
+ asset.url,
+ architecture: asset.architecture,
+ releaseTag: manifest.releaseTag
+ )
+ }
+ guard architectures == Set(["arm64", "x86_64"]) else {
+ throw GlossAppUpdateError.invalidManifest("缺少受支持架构的 asset")
+ }
+ guard
+ manifest.homebrewCask.token
+ == GlossHomebrewInstallationDetector.caskToken
+ else {
+ throw GlossAppUpdateError.invalidManifest(
+ "homebrewCask token 无效"
+ )
+ }
+ guard manifest.homebrewCask.size > 0 else {
+ throw GlossAppUpdateError.invalidManifest(
+ "homebrewCask size 必须大于零"
+ )
+ }
+ guard
+ manifest.homebrewCask.sha256.range(
+ of: "^[0-9a-f]{64}$",
+ options: .regularExpression
+ ) != nil
+ else {
+ throw GlossAppUpdateError.invalidManifest(
+ "homebrewCask SHA-256 无效"
+ )
+ }
+ try validateOfficialHomebrewCaskURL(
+ manifest.homebrewCask.url,
+ releaseTag: manifest.releaseTag
+ )
+ }
+
+ static func validateOfficialAssetURL(
+ _ url: URL,
+ architecture: String,
+ releaseTag: String
+ ) throws {
+ guard
+ let components = URLComponents(
+ url: url,
+ resolvingAgainstBaseURL: false
+ ),
+ components.scheme == "https",
+ components.host?.lowercased() == "github.com",
+ components.port == nil,
+ components.user == nil,
+ components.password == nil,
+ components.query == nil,
+ components.fragment == nil,
+ components.path
+ == "/SunChJ/gloss-releases/releases/download/\(releaseTag)/Gloss-macos-\(architecture).zip"
+ else {
+ throw GlossAppUpdateError.invalidManifest("asset URL 不是官方发行地址")
+ }
+ }
+
+ static func validateOfficialHomebrewCaskURL(
+ _ url: URL,
+ releaseTag: String
+ ) throws {
+ guard
+ let components = URLComponents(
+ url: url,
+ resolvingAgainstBaseURL: false
+ ),
+ components.scheme == "https",
+ components.host?.lowercased() == "github.com",
+ components.port == nil,
+ components.user == nil,
+ components.password == nil,
+ components.query == nil,
+ components.fragment == nil,
+ components.path
+ == "/SunChJ/gloss-releases/releases/download/\(releaseTag)/gloss.rb"
+ else {
+ throw GlossAppUpdateError.invalidManifest(
+ "homebrewCask URL 不是官方发行地址"
+ )
+ }
+ }
+}
+
+public struct GlossAppUpdateDataFetcher: Sendable {
+ public typealias Fetch = @Sendable (URL) async throws -> Data
+
+ private let fetchImplementation: Fetch
+
+ public init(fetch: @escaping Fetch) {
+ fetchImplementation = fetch
+ }
+
+ public func fetch(from url: URL) async throws -> Data {
+ try await fetchImplementation(url)
+ }
+
+ public static let live = ephemeral()
+
+ public static func ephemeral(
+ requestTimeout: TimeInterval = 12,
+ resourceTimeout: TimeInterval = 60
+ ) -> Self {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.timeoutIntervalForRequest = requestTimeout
+ configuration.timeoutIntervalForResource = resourceTimeout
+ configuration.waitsForConnectivity = false
+ configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
+ let session = URLSession(configuration: configuration)
+
+ return Self { url in
+ let (data, response) = try await session.data(from: url)
+ if let response = response as? HTTPURLResponse,
+ !(200..<300).contains(response.statusCode)
+ {
+ throw GlossAppUpdateTransportError.httpFailure(
+ url: url,
+ statusCode: response.statusCode
+ )
+ }
+ return data
+ }
+ }
+}
+
+public enum GlossAppUpdateTransportError: LocalizedError, Equatable, Sendable {
+ case httpFailure(url: URL, statusCode: Int)
+
+ public var errorDescription: String? {
+ switch self {
+ case .httpFailure(let url, let statusCode):
+ "获取 \(url.absoluteString) 失败(HTTP \(statusCode))。"
+ }
+ }
+}
+
+public struct GlossAppUpdateCheckHistory: Sendable {
+ public typealias LastCheck = @Sendable () async -> Date?
+ public typealias RecordCheck = @Sendable (Date) async -> Void
+
+ private let lastCheckImplementation: LastCheck
+ private let recordCheckImplementation: RecordCheck
+
+ public init(
+ lastCheck: @escaping LastCheck,
+ recordCheck: @escaping RecordCheck
+ ) {
+ lastCheckImplementation = lastCheck
+ recordCheckImplementation = recordCheck
+ }
+
+ public func lastCheck() async -> Date? {
+ await lastCheckImplementation()
+ }
+
+ public func recordCheck(at date: Date) async {
+ await recordCheckImplementation(date)
+ }
+
+ public static let transient = Self(
+ lastCheck: { nil },
+ recordCheck: { _ in }
+ )
+}
+
+public enum GlossAppUpdateCheckMode: Sendable {
+ case automatic
+ case manual
+}
+
+public struct GlossAppUpdateAvailability: Equatable, Sendable {
+ public let version: String
+ public let releaseTag: String
+ public let publishedAt: Date
+ public let minimumMacOSVersion: String
+ public let releasePageURL: URL
+ public let architecture: String
+ public let assetURL: URL
+ public let assetSHA256: String
+ public let assetSize: Int64
+ public let homebrewCask: GlossAppReleaseManifest.HomebrewCask
+ public let manifestData: Data
+ public let detachedSignatureData: Data
+
+ public init(
+ version: String,
+ releaseTag: String,
+ publishedAt: Date,
+ minimumMacOSVersion: String,
+ releasePageURL: URL,
+ architecture: String,
+ assetURL: URL,
+ assetSHA256: String,
+ assetSize: Int64,
+ homebrewCask: GlossAppReleaseManifest.HomebrewCask,
+ manifestData: Data,
+ detachedSignatureData: Data
+ ) {
+ self.version = version
+ self.releaseTag = releaseTag
+ self.publishedAt = publishedAt
+ self.minimumMacOSVersion = minimumMacOSVersion
+ self.releasePageURL = releasePageURL
+ self.architecture = architecture
+ self.assetURL = assetURL
+ self.assetSHA256 = assetSHA256
+ self.assetSize = assetSize
+ self.homebrewCask = homebrewCask
+ self.manifestData = manifestData
+ self.detachedSignatureData = detachedSignatureData
+ }
+}
+
+public enum GlossAppUpdateCheckResult: Equatable, Sendable {
+ case throttled(nextCheckAt: Date)
+ case upToDate(latestVersion: String)
+ case updateAvailable(GlossAppUpdateAvailability)
+}
+
+public actor GlossAppUpdateDiscovery {
+ public static let automaticCheckInterval: TimeInterval = 24 * 60 * 60
+
+ private let currentVersion: GlossSemanticVersion
+ private let fetcher: GlossAppUpdateDataFetcher
+ private let history: GlossAppUpdateCheckHistory
+ private let validator: GlossAppReleaseManifestValidator
+
+ public init(
+ currentVersion: String,
+ fetcher: GlossAppUpdateDataFetcher,
+ history: GlossAppUpdateCheckHistory = .transient,
+ manifestSigningPublicKey: Data =
+ GlossAppReleaseManifestValidator.pinnedManifestSigningPublicKey
+ ) throws {
+ guard let parsedCurrentVersion = GlossSemanticVersion(currentVersion) else {
+ throw GlossAppUpdateError.invalidCurrentVersion(currentVersion)
+ }
+ self.currentVersion = parsedCurrentVersion
+ self.fetcher = fetcher
+ self.history = history
+ validator = try GlossAppReleaseManifestValidator(
+ manifestSigningPublicKey: manifestSigningPublicKey
+ )
+ }
+
+ public func check(
+ mode: GlossAppUpdateCheckMode = .automatic,
+ now: Date = Date()
+ ) async throws -> GlossAppUpdateCheckResult {
+ if mode == .automatic,
+ let lastCheck = await history.lastCheck()
+ {
+ let nextCheckAt = lastCheck.addingTimeInterval(
+ Self.automaticCheckInterval
+ )
+ if now < nextCheckAt {
+ return .throttled(nextCheckAt: nextCheckAt)
+ }
+ }
+
+ await history.recordCheck(at: now)
+ let manifestData = try await fetcher.fetch(
+ from: GlossAppReleaseEndpoint.manifestURL
+ )
+ let signatureData = try await fetcher.fetch(
+ from: GlossAppReleaseEndpoint.signatureURL
+ )
+ let manifest = try validator.validate(
+ manifestData: manifestData,
+ detachedSignatureData: signatureData
+ )
+ guard let availableVersion = GlossSemanticVersion(manifest.version) else {
+ throw GlossAppUpdateError.invalidManifest(
+ "version 不是有效的语义化版本"
+ )
+ }
+ guard availableVersion > currentVersion else {
+ return .upToDate(latestVersion: manifest.version)
+ }
+
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ guard let publishedAt = formatter.date(from: manifest.publishedAt) else {
+ throw GlossAppUpdateError.invalidManifest(
+ "publishedAt 不是 ISO 8601 时间"
+ )
+ }
+ guard
+ let asset = manifest.assets.first(where: {
+ $0.operatingSystem == "macos"
+ && $0.architecture == GlossAppArchitecture.current
+ })
+ else {
+ throw GlossAppUpdateError.invalidManifest(
+ "缺少当前架构 \(GlossAppArchitecture.current) 的 asset"
+ )
+ }
+ return .updateAvailable(
+ GlossAppUpdateAvailability(
+ version: manifest.version,
+ releaseTag: manifest.releaseTag,
+ publishedAt: publishedAt,
+ minimumMacOSVersion: manifest.minimumMacOSVersion,
+ releasePageURL: GlossAppReleaseEndpoint.releasePageURL(
+ for: manifest.releaseTag
+ ),
+ architecture: asset.architecture,
+ assetURL: asset.url,
+ assetSHA256: asset.sha256,
+ assetSize: asset.size,
+ homebrewCask: manifest.homebrewCask,
+ manifestData: manifestData,
+ detachedSignatureData: signatureData
+ )
+ )
+ }
+}
diff --git a/Sources/GlossCore/GlossCLIInvocation.swift b/Sources/GlossCore/GlossCLIInvocation.swift
new file mode 100644
index 0000000..81db844
--- /dev/null
+++ b/Sources/GlossCore/GlossCLIInvocation.swift
@@ -0,0 +1,399 @@
+import Foundation
+
+public struct GlossCLITextOptions: Sendable {
+ public let targetLanguage: String
+ public let profile: TranslationProfile
+ public let contentKind: TranslationContentKind
+ public let provider: TranslationProvider
+ public let model: String?
+ public let reasoningEffort: CodexReasoningEffort
+ public let textParts: [String]
+
+ public init(
+ targetLanguage: String,
+ profile: TranslationProfile,
+ contentKind: TranslationContentKind,
+ provider: TranslationProvider,
+ model: String?,
+ reasoningEffort: CodexReasoningEffort,
+ textParts: [String]
+ ) {
+ self.targetLanguage = targetLanguage
+ self.profile = profile
+ self.contentKind = contentKind
+ self.provider = provider
+ self.model = model
+ self.reasoningEffort = reasoningEffort
+ self.textParts = textParts
+ }
+}
+
+public struct GlossCLIPDFOptions: Sendable {
+ public let inputPaths: [String]
+ public let outputDirectoryPath: String
+ public let sourceLanguageCode: String
+ public let targetLanguage: String
+ public let outputMode: BabelDOCOutputMode
+ public let provider: TranslationProvider
+ public let model: String?
+ public let reasoningEffort: CodexReasoningEffort
+
+ public init(
+ inputPaths: [String],
+ outputDirectoryPath: String,
+ sourceLanguageCode: String,
+ targetLanguage: String,
+ outputMode: BabelDOCOutputMode,
+ provider: TranslationProvider,
+ model: String?,
+ reasoningEffort: CodexReasoningEffort
+ ) {
+ self.inputPaths = inputPaths
+ self.outputDirectoryPath = outputDirectoryPath
+ self.sourceLanguageCode = sourceLanguageCode
+ self.targetLanguage = targetLanguage
+ self.outputMode = outputMode
+ self.provider = provider
+ self.model = model
+ self.reasoningEffort = reasoningEffort
+ }
+}
+
+public enum GlossCLIInvocation: Sendable {
+ case legacyText(GlossCLITextOptions)
+ case text(GlossCLITextOptions)
+ case browser(GlossCLITextOptions)
+ case pdf(GlossCLIPDFOptions)
+ case capabilitiesJSON
+ case help(GlossCLICommand?)
+}
+
+public struct GlossCLIUsageError: LocalizedError, Equatable, Sendable {
+ public let message: String
+
+ public init(_ message: String) {
+ self.message = message
+ }
+
+ public var errorDescription: String? { message }
+}
+
+public enum GlossCLIInvocationParser {
+ public static func parse(
+ _ arguments: [String],
+ registry: GlossCapabilityRegistry = .current
+ ) throws -> GlossCLIInvocation {
+ guard let first = arguments.first,
+ let command = GlossCLICommand(rawValue: first)
+ else {
+ if arguments.contains("--help") || arguments.contains("-h") {
+ return .help(nil)
+ }
+ guard registry.isEnabled(.text) else {
+ throw GlossCLIUsageError(
+ "旧式文本翻译入口所需的能力当前未启用。"
+ )
+ }
+ return .legacyText(
+ try parseTextOptions(arguments, scenario: nil)
+ )
+ }
+
+ let remaining = Array(arguments.dropFirst())
+ guard registry.isEnabled(command) else {
+ throw GlossCLIUsageError(
+ "\(command.rawValue) 命令所需的场景或能力当前未启用。"
+ )
+ }
+ switch command {
+ case .capabilities:
+ if remaining == ["--json"] {
+ return .capabilitiesJSON
+ }
+ if remaining == ["--help"] || remaining == ["-h"] {
+ return .help(.capabilities)
+ }
+ throw GlossCLIUsageError(
+ "capabilities 当前仅支持 --json。"
+ )
+ case .text:
+ if remaining.contains("--help") || remaining.contains("-h") {
+ return .help(.text)
+ }
+ return .text(
+ try parseTextOptions(remaining, scenario: nil)
+ )
+ case .browser:
+ if remaining.contains("--help") || remaining.contains("-h") {
+ return .help(.browser)
+ }
+ return .browser(
+ try parseTextOptions(
+ remaining,
+ scenario: .browserTranslation
+ )
+ )
+ case .pdf:
+ return try parsePDFOptions(remaining)
+ }
+ }
+
+ private static func parseTextOptions(
+ _ arguments: [String],
+ scenario: GlossBusinessScenario?
+ ) throws -> GlossCLITextOptions {
+ var targetLanguage = "Chinese (Simplified)"
+ var profile: TranslationProfile = .natural
+ var contentKind: TranslationContentKind =
+ scenario == .browserTranslation ? .webpage : .selection
+ var provider: TranslationProvider = .codex
+ var model: String?
+ var reasoningEffort: CodexReasoningEffort = .low
+ var textParts: [String] = []
+ var iterator = arguments.makeIterator()
+
+ while let argument = iterator.next() {
+ switch argument {
+ case "--target", "-t":
+ targetLanguage = try requiredValue(
+ iterator.next(),
+ option: "--target",
+ description: "语言名称"
+ )
+ case "--profile", "-p":
+ let value = try requiredValue(
+ iterator.next(),
+ option: "--profile",
+ description: "翻译风格"
+ )
+ guard let parsed = TranslationProfile(rawValue: value) else {
+ throw GlossCLIUsageError(
+ "--profile 可选 faithful、natural、technical、academic 或 subtitle。"
+ )
+ }
+ profile = parsed
+ case "--kind", "-k":
+ let value = try requiredValue(
+ iterator.next(),
+ option: "--kind",
+ description: "内容类型"
+ )
+ guard let parsed = TranslationContentKind(rawValue: value) else {
+ throw GlossCLIUsageError(
+ "--kind 可选 selection、webpage、document、subtitle 或 ocr。"
+ )
+ }
+ if scenario == .browserTranslation, parsed != .webpage {
+ throw GlossCLIUsageError(
+ "browser 场景的 --kind 必须是 webpage。"
+ )
+ }
+ contentKind = parsed
+ case "--provider":
+ provider = try parseProvider(iterator.next())
+ case "--model":
+ model = try requiredValue(
+ iterator.next(),
+ option: "--model",
+ description: "模型名称或 GGUF 路径"
+ )
+ case "--reasoning":
+ reasoningEffort = try parseReasoning(iterator.next())
+ case "--":
+ while let text = iterator.next() {
+ textParts.append(text)
+ }
+ default:
+ textParts.append(argument)
+ }
+ }
+
+ return GlossCLITextOptions(
+ targetLanguage: targetLanguage,
+ profile: profile,
+ contentKind: contentKind,
+ provider: provider,
+ model: model,
+ reasoningEffort: reasoningEffort,
+ textParts: textParts
+ )
+ }
+
+ private static func parsePDFOptions(
+ _ arguments: [String]
+ ) throws -> GlossCLIInvocation {
+ var inputPaths: [String] = []
+ var outputDirectoryPath: String?
+ var sourceLanguageCode = "en"
+ var targetLanguage = "Chinese (Simplified)"
+ var outputMode: BabelDOCOutputMode = .monolingual
+ var provider: TranslationProvider = .codex
+ var model: String?
+ var reasoningEffort: CodexReasoningEffort = .low
+ var iterator = arguments.makeIterator()
+
+ while let argument = iterator.next() {
+ switch argument {
+ case "--output", "-o":
+ outputDirectoryPath = try requiredValue(
+ iterator.next(),
+ option: "--output",
+ description: "输出目录"
+ )
+ case "--source":
+ sourceLanguageCode = try requiredValue(
+ iterator.next(),
+ option: "--source",
+ description: "源语言代码"
+ )
+ case "--target", "-t":
+ targetLanguage = try requiredValue(
+ iterator.next(),
+ option: "--target",
+ description: "语言名称"
+ )
+ case "--mode":
+ let value = try requiredValue(
+ iterator.next(),
+ option: "--mode",
+ description: "输出模式"
+ )
+ switch value {
+ case "mono", "monolingual":
+ outputMode = .monolingual
+ case "dual", "bilingual":
+ outputMode = .bilingual
+ default:
+ throw GlossCLIUsageError(
+ "--mode 可选 mono 或 bilingual。"
+ )
+ }
+ case "--provider":
+ provider = try parseProvider(iterator.next())
+ case "--model":
+ model = try requiredValue(
+ iterator.next(),
+ option: "--model",
+ description: "模型名称或 GGUF 路径"
+ )
+ case "--reasoning":
+ reasoningEffort = try parseReasoning(iterator.next())
+ case "--help", "-h":
+ return .help(.pdf)
+ default:
+ guard !argument.hasPrefix("-") else {
+ throw GlossCLIUsageError("未知的 pdf 选项:\(argument)")
+ }
+ inputPaths.append(argument)
+ }
+ }
+
+ guard !inputPaths.isEmpty else {
+ throw GlossCLIUsageError("pdf 需要至少一个 INPUT PDF 路径。")
+ }
+ guard let outputDirectoryPath else {
+ throw GlossCLIUsageError("pdf 需要 --output DIR。")
+ }
+ guard isLanguageCode(sourceLanguageCode) else {
+ throw GlossCLIUsageError("--source 需要有效的语言代码,例如 en。")
+ }
+ guard
+ TranslationLanguages.babelDOCCode(
+ forTargetName: targetLanguage
+ ) != nil
+ else {
+ throw GlossCLIUsageError(
+ "BabelDOC 暂不支持目标语言:\(targetLanguage)"
+ )
+ }
+
+ return .pdf(
+ GlossCLIPDFOptions(
+ inputPaths: inputPaths,
+ outputDirectoryPath: outputDirectoryPath,
+ sourceLanguageCode: sourceLanguageCode,
+ targetLanguage: targetLanguage,
+ outputMode: outputMode,
+ provider: provider,
+ model: model,
+ reasoningEffort: reasoningEffort
+ )
+ )
+ }
+
+ private static func parseProvider(
+ _ value: String?
+ ) throws -> TranslationProvider {
+ guard let value, let parsed = TranslationProvider(rawValue: value) else {
+ throw GlossCLIUsageError("--provider 可选 codex 或 llama。")
+ }
+ return parsed
+ }
+
+ private static func parseReasoning(
+ _ value: String?
+ ) throws -> CodexReasoningEffort {
+ guard let value, let parsed = CodexReasoningEffort(rawValue: value) else {
+ throw GlossCLIUsageError(
+ "--reasoning 可选 minimal、low、medium、high 或 xhigh。"
+ )
+ }
+ return parsed
+ }
+
+ private static func requiredValue(
+ _ value: String?,
+ option: String,
+ description: String
+ ) throws -> String {
+ guard let value,
+ !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ else {
+ throw GlossCLIUsageError("\(option) 需要\(description)。")
+ }
+ return value
+ }
+
+ private static func isLanguageCode(_ value: String) -> Bool {
+ value.range(
+ of: #"^[A-Za-z]{2,3}(?:-[A-Za-z]{2,4})?$"#,
+ options: .regularExpression
+ ) != nil
+ }
+}
+
+public struct GlossCapabilitiesReport: Codable, Equatable, Sendable {
+ public let schemaVersion: Int
+ public let availableCoreCapabilities: [GlossCapability]
+ public let enabledCoreCapabilities: [GlossCapability]
+ public let enabledCapabilities: [GlossCapability]
+ public let availableScenarios: [GlossBusinessScenario]
+ public let enabledScenarios: [GlossBusinessScenario]
+ public let commandMappings: [GlossCLICommandMapping]
+
+ public init(
+ registry: GlossCapabilityRegistry = .current
+ ) {
+ schemaVersion = 1
+ availableCoreCapabilities = registry.availableCoreCapabilities.sorted {
+ $0.rawValue < $1.rawValue
+ }
+ enabledCoreCapabilities = registry.enabledCoreCapabilities.sorted {
+ $0.rawValue < $1.rawValue
+ }
+ enabledCapabilities = registry.enabledCapabilities.sorted {
+ $0.rawValue < $1.rawValue
+ }
+ availableScenarios = GlossBusinessScenario.allCases
+ .filter(registry.isAvailable)
+ .sorted {
+ $0.rawValue < $1.rawValue
+ }
+ enabledScenarios = registry.enabledScenarios.sorted {
+ $0.rawValue < $1.rawValue
+ }
+ commandMappings = registry.commandMappings.sorted {
+ $0.command.rawValue < $1.command.rawValue
+ }
+ }
+}
diff --git a/Sources/GlossCore/GlossCapabilityRegistry.swift b/Sources/GlossCore/GlossCapabilityRegistry.swift
new file mode 100644
index 0000000..1bc4902
--- /dev/null
+++ b/Sources/GlossCore/GlossCapabilityRegistry.swift
@@ -0,0 +1,297 @@
+/// Reusable building blocks that can be shared by App and CLI product surfaces.
+public enum GlossCapability: String, CaseIterable, Codable, Sendable {
+ case textTranslation
+ case documentTranslation
+ case imageTextRecognition
+ case translationLoopbackBridge
+ case pdfLayoutAnalysis
+ case pdfExport
+ case providerConfiguration
+ case languageConfiguration
+ case appUpdates
+ case diagnostics
+ case launchAtLogin
+ case browserBridge
+ case chromeExtension
+ case safariExtension
+ case pdfRuntime
+ case pdfBatchQueue
+ case clipboardText
+ case clipboardImage
+ case screenshotCapture
+ case selectionCapture
+ case automaticSelection
+ case appExclusions
+ case glossaryManagement
+ case translationHistory
+}
+
+/// User-facing workflows composed from one or more capabilities.
+public enum GlossBusinessScenario: String, CaseIterable, Codable, Sendable {
+ case browserTranslation
+ case pdfTranslation
+ case clipboardTranslation
+ case imageTranslation
+ case selectionTranslation
+ case glossaryManagement
+ case translationHistory
+
+ public var requiredCapabilities: Set {
+ switch self {
+ case .browserTranslation:
+ [
+ .textTranslation,
+ .translationLoopbackBridge,
+ .providerConfiguration,
+ .languageConfiguration,
+ .browserBridge,
+ .chromeExtension,
+ .safariExtension,
+ ]
+ case .pdfTranslation:
+ [
+ .textTranslation,
+ .documentTranslation,
+ .translationLoopbackBridge,
+ .pdfLayoutAnalysis,
+ .pdfExport,
+ .providerConfiguration,
+ .languageConfiguration,
+ .pdfRuntime,
+ .pdfBatchQueue,
+ ]
+ case .clipboardTranslation:
+ [
+ .textTranslation,
+ .providerConfiguration,
+ .languageConfiguration,
+ .clipboardText,
+ ]
+ case .imageTranslation:
+ [
+ .textTranslation,
+ .imageTextRecognition,
+ .providerConfiguration,
+ .languageConfiguration,
+ .clipboardImage,
+ .screenshotCapture,
+ ]
+ case .selectionTranslation:
+ [
+ .textTranslation,
+ .providerConfiguration,
+ .languageConfiguration,
+ .selectionCapture,
+ .automaticSelection,
+ .appExclusions,
+ ]
+ case .glossaryManagement:
+ [.glossaryManagement]
+ case .translationHistory:
+ [.translationHistory]
+ }
+ }
+}
+
+/// Explicit CLI entry points. Both the parser and capability reporting use this
+/// enum so product scenario names cannot drift from their command mappings.
+public enum GlossCLICommand: String, CaseIterable, Codable, Sendable {
+ case capabilities
+ case text
+ case browser
+ case pdf
+
+ public var businessScenario: GlossBusinessScenario? {
+ switch self {
+ case .capabilities:
+ nil
+ case .text:
+ nil
+ case .browser:
+ .browserTranslation
+ case .pdf:
+ .pdfTranslation
+ }
+ }
+
+ /// Capabilities exercised by the command itself. These can be narrower than
+ /// the complete App scenario: `browser` translates already-extracted webpage
+ /// text and therefore does not claim to drive Safari or Chrome.
+ public var requiredCapabilities: Set {
+ switch self {
+ case .capabilities:
+ []
+ case .text, .browser:
+ [
+ .textTranslation,
+ .providerConfiguration,
+ .languageConfiguration,
+ ]
+ case .pdf:
+ GlossBusinessScenario.pdfTranslation.requiredCapabilities
+ }
+ }
+}
+
+public struct GlossCLICommandMapping: Codable, Equatable, Sendable {
+ public let command: GlossCLICommand
+ public let scenario: GlossBusinessScenario?
+ public let enabled: Bool
+ public let requiredCapabilities: [GlossCapability]
+ public let scenarioCapabilities: [GlossCapability]
+
+ public init(
+ command: GlossCLICommand,
+ scenario: GlossBusinessScenario?,
+ enabled: Bool,
+ requiredCapabilities: [GlossCapability],
+ scenarioCapabilities: [GlossCapability]
+ ) {
+ self.command = command
+ self.scenario = scenario
+ self.enabled = enabled
+ self.requiredCapabilities = requiredCapabilities
+ self.scenarioCapabilities = scenarioCapabilities
+ }
+}
+
+public struct GlossCapabilityRegistry: Equatable, Sendable {
+ private static let browserAdapterCapabilities: Set = [
+ .chromeExtension,
+ .safariExtension,
+ ]
+
+ /// The focused product surface. Secondary scenarios remain defined and can be
+ /// restored by constructing a registry with a larger set.
+ public static let defaultEnabledScenarios: Set = [
+ .browserTranslation,
+ .pdfTranslation,
+ ]
+
+ /// Operational controls are not business scenarios and remain available
+ /// regardless of which translation surfaces are currently promoted.
+ public static let infrastructureCapabilities: Set = [
+ .providerConfiguration,
+ .languageConfiguration,
+ .appUpdates,
+ .diagnostics,
+ .launchAtLogin,
+ ]
+
+ /// Reusable execution and integration units that business scenarios compose.
+ /// Configuration and operational controls remain regular capabilities, but
+ /// are intentionally excluded from the core execution list.
+ public static let coreExecutionCapabilities: Set = [
+ .textTranslation,
+ .documentTranslation,
+ .imageTextRecognition,
+ .translationLoopbackBridge,
+ .browserBridge,
+ .chromeExtension,
+ .safariExtension,
+ .pdfLayoutAnalysis,
+ .pdfRuntime,
+ .pdfBatchQueue,
+ .pdfExport,
+ ]
+
+ public static let current = GlossCapabilityRegistry(
+ distributionProfile: .current
+ )
+
+ private let configuredScenarios: Set
+ public let unavailableCapabilities: Set
+
+ public init(
+ enabledScenarios: Set = Self.defaultEnabledScenarios,
+ distributionProfile: GlossDistributionProfile = .current
+ ) {
+ configuredScenarios = enabledScenarios
+ unavailableCapabilities =
+ distributionProfile.safariExtensionAvailable
+ ? []
+ : [.safariExtension]
+ }
+
+ public var enabledScenarios: Set {
+ configuredScenarios.filter(isAvailable)
+ }
+
+ public var availableCoreCapabilities: Set {
+ Self.coreExecutionCapabilities.subtracting(unavailableCapabilities)
+ }
+
+ public var enabledCapabilities: Set {
+ enabledScenarios.reduce(
+ into: Self.infrastructureCapabilities.subtracting(
+ unavailableCapabilities
+ )
+ ) {
+ $0.formUnion(availableCapabilities(for: $1))
+ }
+ }
+
+ public var enabledCoreCapabilities: Set {
+ enabledCapabilities.intersection(Self.coreExecutionCapabilities)
+ }
+
+ public var commandMappings: [GlossCLICommandMapping] {
+ GlossCLICommand.allCases.map { command in
+ let scenario = command.businessScenario
+ let requiredCapabilities = command.requiredCapabilities.sorted {
+ $0.rawValue < $1.rawValue
+ }
+ let scenarioCapabilities =
+ scenario.map(availableCapabilities(for:))?.sorted {
+ $0.rawValue < $1.rawValue
+ } ?? []
+ return GlossCLICommandMapping(
+ command: command,
+ scenario: scenario,
+ enabled: (scenario.map(isEnabled) ?? true)
+ && requiredCapabilities.allSatisfy(supports),
+ requiredCapabilities: requiredCapabilities,
+ scenarioCapabilities: scenarioCapabilities
+ )
+ }
+ }
+
+ public func isEnabled(_ command: GlossCLICommand) -> Bool {
+ commandMappings.first { $0.command == command }?.enabled == true
+ }
+
+ public func isEnabled(_ scenario: GlossBusinessScenario) -> Bool {
+ configuredScenarios.contains(scenario) && isAvailable(scenario)
+ }
+
+ public func isAvailable(_ scenario: GlossBusinessScenario) -> Bool {
+ let required = scenario.requiredCapabilities
+ let unavailableRequired = required.intersection(
+ unavailableCapabilities
+ )
+ guard scenario == .browserTranslation else {
+ return unavailableRequired.isEmpty
+ }
+
+ let unavailableBase = unavailableRequired.subtracting(
+ Self.browserAdapterCapabilities
+ )
+ let availableAdapters = Self.browserAdapterCapabilities
+ .intersection(required)
+ .subtracting(unavailableCapabilities)
+ return unavailableBase.isEmpty && !availableAdapters.isEmpty
+ }
+
+ public func availableCapabilities(
+ for scenario: GlossBusinessScenario
+ ) -> Set {
+ guard isAvailable(scenario) else { return [] }
+ return scenario.requiredCapabilities.subtracting(
+ unavailableCapabilities
+ )
+ }
+
+ public func supports(_ capability: GlossCapability) -> Bool {
+ enabledCapabilities.contains(capability)
+ }
+}
diff --git a/Sources/GlossCore/GlossDistributionProfile.swift b/Sources/GlossCore/GlossDistributionProfile.swift
new file mode 100644
index 0000000..359a2e9
--- /dev/null
+++ b/Sources/GlossCore/GlossDistributionProfile.swift
@@ -0,0 +1,86 @@
+import Foundation
+
+/// Capabilities that depend on how the current Gloss build was distributed.
+///
+/// Homebrew releases use an ad-hoc signature, which cannot pair a Safari App
+/// Extension with its containing App. Apple-signed builds set the bundle flag
+/// to keep Safari available.
+public struct GlossDistributionProfile: Equatable, Sendable {
+ public let safariExtensionAvailable: Bool
+
+ public init(safariExtensionAvailable: Bool) {
+ self.safariExtensionAvailable = safariExtensionAvailable
+ }
+
+ public static let current = resolve()
+
+ public static func resolve(
+ bundleValue: Bool? = Bundle.main.object(
+ forInfoDictionaryKey: "GlossSafariExtensionAvailable"
+ ) as? Bool,
+ executableURL: URL? = Bundle.main.executableURL,
+ workingDirectoryURL: URL = URL(
+ fileURLWithPath: FileManager.default.currentDirectoryPath,
+ isDirectory: true
+ )
+ ) -> GlossDistributionProfile {
+ if let bundleValue {
+ return GlossDistributionProfile(
+ safariExtensionAvailable: bundleValue
+ )
+ }
+
+ if var candidate = executableURL?.resolvingSymlinksInPath()
+ .deletingLastPathComponent()
+ {
+ for _ in 0..<10 {
+ if candidate.pathExtension.lowercased() == "app",
+ let value = safariExtensionValue(
+ in: candidate.appendingPathComponent(
+ "Contents/Info.plist"
+ )
+ )
+ {
+ return GlossDistributionProfile(
+ safariExtensionAvailable: value
+ )
+ }
+ if let value = safariExtensionValue(
+ in: candidate.appendingPathComponent(
+ "Resources/Info.plist"
+ )
+ ) {
+ return GlossDistributionProfile(
+ safariExtensionAvailable: value
+ )
+ }
+ let parent = candidate.deletingLastPathComponent()
+ guard parent.path != candidate.path else { break }
+ candidate = parent
+ }
+ }
+
+ let checkoutValue = safariExtensionValue(
+ in: workingDirectoryURL.appendingPathComponent(
+ "Resources/Info.plist"
+ )
+ )
+ return GlossDistributionProfile(
+ safariExtensionAvailable: checkoutValue ?? false
+ )
+ }
+
+ private static func safariExtensionValue(in plistURL: URL) -> Bool? {
+ guard let data = try? Data(contentsOf: plistURL),
+ let value = try? PropertyListSerialization.propertyList(
+ from: data,
+ options: [],
+ format: nil
+ ),
+ let dictionary = value as? [String: Any]
+ else {
+ return nil
+ }
+ return dictionary["GlossSafariExtensionAvailable"] as? Bool
+ }
+}
diff --git a/Sources/GlossCore/GlossHomebrewInstallation.swift b/Sources/GlossCore/GlossHomebrewInstallation.swift
new file mode 100644
index 0000000..bdbe69c
--- /dev/null
+++ b/Sources/GlossCore/GlossHomebrewInstallation.swift
@@ -0,0 +1,1109 @@
+import Darwin
+import Foundation
+
+public struct GlossCommandOutput: Equatable, Sendable {
+ public let terminationStatus: Int32
+ public let standardOutput: Data
+ public let standardError: Data
+
+ public init(
+ terminationStatus: Int32,
+ standardOutput: Data,
+ standardError: Data = Data()
+ ) {
+ self.terminationStatus = terminationStatus
+ self.standardOutput = standardOutput
+ self.standardError = standardError
+ }
+}
+
+public enum GlossCommandRunnerError: LocalizedError, Equatable, Sendable {
+ case outputLimitExceeded(
+ executablePath: String,
+ maximumByteCount: Int
+ )
+ case timedOut(executablePath: String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .outputLimitExceeded(
+ let executablePath,
+ let maximumByteCount
+ ):
+ "命令输出超过限制(\(maximumByteCount) 字节):\(executablePath)"
+ case .timedOut(let executablePath):
+ "命令执行超时:\(executablePath)"
+ }
+ }
+}
+
+public struct GlossCommandRunner: Sendable {
+ public static let maximumCapturedOutputByteCount = 16 * 1024 * 1024
+
+ public typealias Run =
+ @Sendable (URL, [String], [String: String], Duration?) async throws
+ -> GlossCommandOutput
+
+ private let runImplementation: Run
+
+ public init(
+ run:
+ @escaping @Sendable (URL, [String]) async throws
+ -> GlossCommandOutput
+ ) {
+ runImplementation = { executableURL, arguments, _, _ in
+ try await run(executableURL, arguments)
+ }
+ }
+
+ public init(runWithTimeout: @escaping Run) {
+ runImplementation = runWithTimeout
+ }
+
+ public func run(
+ executableURL: URL,
+ arguments: [String],
+ environment: [String: String] = [:],
+ timeout: Duration? = nil
+ ) async throws -> GlossCommandOutput {
+ try await runImplementation(
+ executableURL,
+ arguments,
+ environment,
+ timeout
+ )
+ }
+
+ public static let live = Self(runWithTimeout: {
+ executableURL,
+ arguments,
+ environment,
+ timeout in
+ let state = GlossCommandProcessState()
+ return try await withTaskCancellationHandler {
+ try await Task.detached(priority: .utility) {
+ try GlossSpawnedCommand.run(
+ executableURL: executableURL,
+ arguments: arguments,
+ environment: environment,
+ timeout: timeout,
+ state: state
+ )
+ }.value
+ } onCancel: {
+ state.cancel()
+ }
+ })
+}
+
+private final class GlossCommandProcessState: @unchecked Sendable {
+ private let lock = NSLock()
+ private var cancellationRequested = false
+
+ func cancel() {
+ lock.lock()
+ cancellationRequested = true
+ lock.unlock()
+ }
+
+ var isCancellationRequested: Bool {
+ lock.lock()
+ defer { lock.unlock() }
+ return cancellationRequested
+ }
+}
+
+private enum GlossSpawnedCommand {
+ private enum CompletionReason {
+ case cancelled
+ case failed(Int32)
+ case leaderExited
+ case outputLimitExceeded
+ case timedOut
+
+ var checksOutputLimit: Bool {
+ if case .leaderExited = self {
+ return true
+ }
+ return false
+ }
+
+ var requiresImmediateKill: Bool {
+ switch self {
+ case .failed, .outputLimitExceeded:
+ return true
+ case .cancelled, .leaderExited, .timedOut:
+ return false
+ }
+ }
+ }
+
+ private enum LeaderState {
+ case exited
+ case failed(Int32)
+ case running
+ }
+
+ private struct CaptureFiles {
+ let directoryPath: String
+ let standardOutputPath: String
+ let standardErrorPath: String
+ let standardOutputDescriptor: Int32
+ let standardErrorDescriptor: Int32
+
+ static func create() throws -> Self {
+ var template = Array(
+ (FileManager.default.temporaryDirectory.path
+ + "/gloss-command.XXXXXX").utf8CString
+ )
+ let directoryPath = try template.withUnsafeMutableBufferPointer {
+ buffer in
+ guard let path = mkdtemp(buffer.baseAddress) else {
+ throw posixError(errno)
+ }
+ return String(cString: path)
+ }
+ let standardOutputPath =
+ directoryPath + "/standard-output"
+ let standardErrorPath =
+ directoryPath + "/standard-error"
+ do {
+ let standardOutputDescriptor = try openCaptureFile(
+ at: standardOutputPath
+ )
+ do {
+ let standardErrorDescriptor = try openCaptureFile(
+ at: standardErrorPath
+ )
+ return Self(
+ directoryPath: directoryPath,
+ standardOutputPath: standardOutputPath,
+ standardErrorPath: standardErrorPath,
+ standardOutputDescriptor: standardOutputDescriptor,
+ standardErrorDescriptor: standardErrorDescriptor
+ )
+ } catch {
+ Darwin.close(standardOutputDescriptor)
+ unlink(standardOutputPath)
+ throw error
+ }
+ } catch {
+ rmdir(directoryPath)
+ throw error
+ }
+ }
+
+ func cleanup() {
+ Darwin.close(standardOutputDescriptor)
+ Darwin.close(standardErrorDescriptor)
+ unlink(standardOutputPath)
+ unlink(standardErrorPath)
+ rmdir(directoryPath)
+ }
+
+ private static func openCaptureFile(
+ at path: String
+ ) throws -> Int32 {
+ let openedDescriptor = Darwin.open(
+ path,
+ O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW,
+ mode_t(S_IRUSR | S_IWUSR)
+ )
+ guard openedDescriptor >= 0 else {
+ throw posixError(errno)
+ }
+ guard openedDescriptor <= STDERR_FILENO else {
+ return openedDescriptor
+ }
+
+ let duplicatedDescriptor = fcntl(
+ openedDescriptor,
+ F_DUPFD_CLOEXEC,
+ STDERR_FILENO + 1
+ )
+ let duplicationError = errno
+ Darwin.close(openedDescriptor)
+ guard duplicatedDescriptor >= 0 else {
+ throw posixError(duplicationError)
+ }
+ return duplicatedDescriptor
+ }
+
+ private static func posixError(_ code: Int32) -> Error {
+ NSError(
+ domain: NSPOSIXErrorDomain,
+ code: Int(code),
+ userInfo: nil
+ )
+ }
+ }
+
+ private static let supervisionIntervalMicroseconds: UInt32 = 10_000
+ private static let terminationGrace: Duration = .milliseconds(250)
+ private static let forcedCompletionGrace: Duration = .milliseconds(500)
+
+ static func run(
+ executableURL: URL,
+ arguments: [String],
+ environment: [String: String],
+ timeout: Duration?,
+ state: GlossCommandProcessState
+ ) throws -> GlossCommandOutput {
+ guard !state.isCancellationRequested else {
+ throw CancellationError()
+ }
+ let captureFiles = try CaptureFiles.create()
+ defer { captureFiles.cleanup() }
+ guard !state.isCancellationRequested else {
+ throw CancellationError()
+ }
+ let processIdentifier = try spawn(
+ executableURL: executableURL,
+ arguments: arguments,
+ environment: environment,
+ standardOutputDescriptor:
+ captureFiles.standardOutputDescriptor,
+ standardErrorDescriptor:
+ captureFiles.standardErrorDescriptor
+ )
+ return try supervise(
+ processIdentifier: processIdentifier,
+ executableURL: executableURL,
+ captureFiles: captureFiles,
+ timeout: timeout,
+ state: state
+ )
+ }
+
+ private static func signalProcessGroup(
+ _ identifier: pid_t,
+ signal: Int32
+ ) {
+ guard identifier > 0 else {
+ return
+ }
+ _ = Darwin.kill(-identifier, signal)
+ }
+
+ private static func supervise(
+ processIdentifier: pid_t,
+ executableURL: URL,
+ captureFiles: CaptureFiles,
+ timeout: Duration?,
+ state: GlossCommandProcessState
+ ) throws -> GlossCommandOutput {
+ let clock = ContinuousClock()
+ let timeoutDeadline = timeout.map { clock.now.advanced(by: $0) }
+ var leaderExited = false
+ var completionReason: CompletionReason?
+ var terminationStartedAt: ContinuousClock.Instant?
+ var forcedCompletionDeadline: ContinuousClock.Instant?
+ var sentKill = false
+ var exceededOutputLimit = false
+
+ while true {
+ let now = clock.now
+ if completionReason == nil {
+ if state.isCancellationRequested {
+ completionReason = .cancelled
+ } else if let timeoutDeadline, now >= timeoutDeadline {
+ completionReason = .timedOut
+ }
+ }
+
+ if captureSizeExceedsLimit(captureFiles) {
+ exceededOutputLimit = true
+ if completionReason == nil
+ || completionReason!.checksOutputLimit
+ {
+ completionReason = .outputLimitExceeded
+ }
+ }
+
+ if !leaderExited {
+ switch observeLeader(processIdentifier) {
+ case .exited:
+ leaderExited = true
+ case .failed(let code):
+ guard code != ECHILD else {
+ // The child may already have been reaped, so its PID no
+ // longer anchors the process group. Never signal a
+ // potentially reused numeric PGID.
+ throw posixError(code)
+ }
+ completionReason = .failed(code)
+ case .running:
+ break
+ }
+ }
+
+ if leaderExited {
+ let hasDescendants = processGroupHasDescendants(
+ processIdentifier
+ )
+ if completionReason == nil {
+ if !hasDescendants {
+ return try completeNormally(
+ processIdentifier: processIdentifier,
+ executableURL: executableURL,
+ captureFiles: captureFiles
+ )
+ }
+ completionReason = .leaderExited
+ } else if !hasDescendants {
+ if case .leaderExited = completionReason! {
+ return try completeNormally(
+ processIdentifier: processIdentifier,
+ executableURL: executableURL,
+ captureFiles: captureFiles
+ )
+ }
+ _ = try reapLeader(processIdentifier)
+ try finish(
+ reason: completionReason!,
+ executableURL: executableURL
+ )
+ }
+ }
+
+ if let completionReason, terminationStartedAt == nil {
+ let requiresImmediateKill =
+ completionReason.requiresImmediateKill
+ || exceededOutputLimit
+ terminationStartedAt = now
+ forcedCompletionDeadline = now.advanced(
+ by: terminationGrace + forcedCompletionGrace
+ )
+ signalProcessGroup(
+ processIdentifier,
+ signal: requiresImmediateKill ? SIGKILL : SIGTERM
+ )
+ sentKill = requiresImmediateKill
+ } else if exceededOutputLimit, !sentKill {
+ signalProcessGroup(processIdentifier, signal: SIGKILL)
+ sentKill = true
+ }
+
+ if let terminationStartedAt,
+ !sentKill,
+ now
+ >= terminationStartedAt.advanced(
+ by: terminationGrace
+ )
+ {
+ signalProcessGroup(processIdentifier, signal: SIGKILL)
+ sentKill = true
+ }
+
+ if sentKill, let completionReason {
+ if let forcedCompletionDeadline,
+ now >= forcedCompletionDeadline
+ {
+ signalProcessGroup(processIdentifier, signal: SIGKILL)
+ if case .leaderExited = completionReason,
+ leaderExited
+ {
+ return try completeNormally(
+ processIdentifier: processIdentifier,
+ executableURL: executableURL,
+ captureFiles: captureFiles
+ )
+ }
+ if leaderExited {
+ do {
+ _ = try reapLeader(processIdentifier)
+ } catch {
+ reapEventually(processIdentifier)
+ }
+ } else {
+ reapEventually(processIdentifier)
+ }
+ try finish(
+ reason: completionReason,
+ executableURL: executableURL
+ )
+ }
+ }
+
+ usleep(supervisionIntervalMicroseconds)
+ }
+ }
+
+ private static func completeNormally(
+ processIdentifier: pid_t,
+ executableURL: URL,
+ captureFiles: CaptureFiles
+ ) throws -> GlossCommandOutput {
+ guard !captureSizeExceedsLimit(captureFiles) else {
+ _ = try reapLeader(processIdentifier)
+ throw GlossCommandRunnerError.outputLimitExceeded(
+ executablePath: executableURL.path,
+ maximumByteCount:
+ GlossCommandRunner.maximumCapturedOutputByteCount
+ )
+ }
+ let sizes = try captureSizes(captureFiles)
+ let rawWaitStatus = try reapLeader(processIdentifier)
+ return makeOutput(
+ rawWaitStatus: rawWaitStatus,
+ standardOutput: try readCapture(
+ descriptor: captureFiles.standardOutputDescriptor,
+ byteCount: sizes.standardOutput
+ ),
+ standardError: try readCapture(
+ descriptor: captureFiles.standardErrorDescriptor,
+ byteCount: sizes.standardError
+ )
+ )
+ }
+
+ private static func finish(
+ reason: CompletionReason,
+ executableURL: URL
+ ) throws -> Never {
+ switch reason {
+ case .cancelled:
+ throw CancellationError()
+ case .failed(let code):
+ throw posixError(code)
+ case .leaderExited:
+ preconditionFailure("Leader exit completes normally.")
+ case .outputLimitExceeded:
+ throw GlossCommandRunnerError.outputLimitExceeded(
+ executablePath: executableURL.path,
+ maximumByteCount:
+ GlossCommandRunner.maximumCapturedOutputByteCount
+ )
+ case .timedOut:
+ throw GlossCommandRunnerError.timedOut(
+ executablePath: executableURL.path
+ )
+ }
+ }
+
+ private static func observeLeader(
+ _ processIdentifier: pid_t
+ ) -> LeaderState {
+ var information = siginfo_t()
+ while true {
+ let result = Darwin.waitid(
+ P_PID,
+ id_t(processIdentifier),
+ &information,
+ WEXITED | WNOHANG | WNOWAIT
+ )
+ if result == 0 {
+ return information.si_pid == processIdentifier
+ ? .exited : .running
+ }
+ if errno == EINTR {
+ continue
+ }
+ return .failed(errno)
+ }
+ }
+
+ private static func processGroupHasDescendants(
+ _ processIdentifier: pid_t
+ ) -> Bool {
+ var managementInformation = [
+ Int32(CTL_KERN),
+ Int32(KERN_PROC),
+ Int32(KERN_PROC_PGRP),
+ processIdentifier,
+ ]
+ var byteCount: size_t = 0
+ guard
+ managementInformation.withUnsafeMutableBufferPointer({
+ sysctl(
+ $0.baseAddress,
+ u_int($0.count),
+ nil,
+ &byteCount,
+ nil,
+ 0
+ )
+ }) == 0
+ else {
+ return true
+ }
+
+ let stride = MemoryLayout.stride
+ var processes = [kinfo_proc](
+ repeating: kinfo_proc(),
+ count: max(1, byteCount / stride + 8)
+ )
+ byteCount = processes.count * stride
+ let result = managementInformation.withUnsafeMutableBufferPointer {
+ managementBuffer in
+ processes.withUnsafeMutableBytes { processBuffer in
+ sysctl(
+ managementBuffer.baseAddress,
+ u_int(managementBuffer.count),
+ processBuffer.baseAddress,
+ &byteCount,
+ nil,
+ 0
+ )
+ }
+ }
+ guard result == 0 else {
+ return true
+ }
+
+ let processCount = byteCount / stride
+ var foundLeader = false
+ for process in processes.prefix(processCount) {
+ if process.kp_proc.p_pid == processIdentifier {
+ foundLeader = true
+ } else {
+ return true
+ }
+ }
+ return !foundLeader
+ }
+
+ private static func reapLeader(
+ _ processIdentifier: pid_t
+ ) throws -> Int32 {
+ var status: Int32 = 0
+ while true {
+ let result = Darwin.waitpid(
+ processIdentifier,
+ &status,
+ 0
+ )
+ if result == processIdentifier {
+ return status
+ }
+ if errno == EINTR {
+ continue
+ }
+ throw posixError(errno)
+ }
+ }
+
+ private static func reapEventually(_ processIdentifier: pid_t) {
+ Task.detached(priority: .utility) {
+ var status: Int32 = 0
+ while Darwin.waitpid(processIdentifier, &status, 0) == -1,
+ errno == EINTR
+ {}
+ }
+ }
+
+ private static func captureSizeExceedsLimit(
+ _ captureFiles: CaptureFiles
+ ) -> Bool {
+ guard let sizes = try? captureSizes(captureFiles) else {
+ return true
+ }
+ let limit = GlossCommandRunner.maximumCapturedOutputByteCount
+ return sizes.standardOutput > limit
+ || sizes.standardError > limit
+ || sizes.standardOutput > limit - sizes.standardError
+ }
+
+ private static func captureSizes(
+ _ captureFiles: CaptureFiles
+ ) throws -> (standardOutput: Int, standardError: Int) {
+ var standardOutputStatus = stat()
+ var standardErrorStatus = stat()
+ guard
+ fstat(
+ captureFiles.standardOutputDescriptor,
+ &standardOutputStatus
+ ) == 0,
+ fstat(
+ captureFiles.standardErrorDescriptor,
+ &standardErrorStatus
+ ) == 0,
+ standardOutputStatus.st_size >= 0,
+ standardErrorStatus.st_size >= 0,
+ standardOutputStatus.st_size <= off_t(Int.max),
+ standardErrorStatus.st_size <= off_t(Int.max)
+ else {
+ throw posixError(errno == 0 ? EOVERFLOW : errno)
+ }
+ return (
+ Int(standardOutputStatus.st_size),
+ Int(standardErrorStatus.st_size)
+ )
+ }
+
+ private static func readCapture(
+ descriptor: Int32,
+ byteCount: Int
+ ) throws -> Data {
+ var data = Data(count: byteCount)
+ var offset = 0
+ while offset < byteCount {
+ let count = data.withUnsafeMutableBytes { bytes in
+ Darwin.pread(
+ descriptor,
+ bytes.baseAddress!.advanced(by: offset),
+ byteCount - offset,
+ off_t(offset)
+ )
+ }
+ if count > 0 {
+ offset += count
+ } else if count == 0 {
+ data.removeSubrange(offset.. GlossCommandOutput {
+ let signal = rawWaitStatus & 0x7f
+ let terminationStatus =
+ signal == 0
+ ? (rawWaitStatus >> 8) & 0xff
+ : signal
+ return GlossCommandOutput(
+ terminationStatus: terminationStatus,
+ standardOutput: standardOutput,
+ standardError: standardError
+ )
+ }
+
+ private static func spawn(
+ executableURL: URL,
+ arguments: [String],
+ environment: [String: String],
+ standardOutputDescriptor: Int32,
+ standardErrorDescriptor: Int32
+ ) throws -> pid_t {
+ var fileActions: posix_spawn_file_actions_t?
+ var attributes: posix_spawnattr_t?
+ try check(posix_spawn_file_actions_init(&fileActions))
+ defer { posix_spawn_file_actions_destroy(&fileActions) }
+ try check(posix_spawnattr_init(&attributes))
+ defer { posix_spawnattr_destroy(&attributes) }
+
+ try "/dev/null".withCString { path in
+ try check(
+ posix_spawn_file_actions_addopen(
+ &fileActions,
+ STDIN_FILENO,
+ path,
+ O_RDONLY,
+ 0
+ )
+ )
+ }
+ try check(
+ posix_spawn_file_actions_adddup2(
+ &fileActions,
+ standardOutputDescriptor,
+ STDOUT_FILENO
+ )
+ )
+ try check(
+ posix_spawn_file_actions_adddup2(
+ &fileActions,
+ standardErrorDescriptor,
+ STDERR_FILENO
+ )
+ )
+ for descriptor in [
+ standardOutputDescriptor,
+ standardErrorDescriptor,
+ ] {
+ try check(
+ posix_spawn_file_actions_addclose(
+ &fileActions,
+ descriptor
+ )
+ )
+ }
+
+ try check(posix_spawnattr_setpgroup(&attributes, 0))
+ let flags =
+ Int16(POSIX_SPAWN_SETPGROUP)
+ | Int16(POSIX_SPAWN_CLOEXEC_DEFAULT)
+ try check(posix_spawnattr_setflags(&attributes, flags))
+
+ let executablePath = executableURL.path
+ let argumentStrings = [executablePath] + arguments
+ let mergedEnvironment = ProcessInfo.processInfo.environment
+ .merging(environment) { _, newValue in newValue }
+ let environmentStrings =
+ mergedEnvironment
+ .map { "\($0.key)=\($0.value)" }
+ .sorted()
+ var argumentPointers = try makeCStringArray(argumentStrings)
+ defer { freeCStringArray(&argumentPointers) }
+ var environmentPointers = try makeCStringArray(
+ environmentStrings
+ )
+ defer { freeCStringArray(&environmentPointers) }
+
+ var processIdentifier: pid_t = 0
+ let spawnResult = argumentPointers.withUnsafeMutableBufferPointer {
+ argumentBuffer in
+ environmentPointers.withUnsafeMutableBufferPointer {
+ environmentBuffer in
+ executablePath.withCString { path in
+ posix_spawn(
+ &processIdentifier,
+ path,
+ &fileActions,
+ &attributes,
+ argumentBuffer.baseAddress,
+ environmentBuffer.baseAddress
+ )
+ }
+ }
+ }
+ try check(spawnResult)
+ return processIdentifier
+ }
+
+ private static func makeCStringArray(
+ _ strings: [String]
+ ) throws -> [UnsafeMutablePointer?] {
+ var result: [UnsafeMutablePointer?] = []
+ result.reserveCapacity(strings.count + 1)
+ for string in strings {
+ guard let pointer = strdup(string) else {
+ freeCStringArray(&result)
+ throw posixError(ENOMEM)
+ }
+ result.append(pointer)
+ }
+ result.append(nil)
+ return result
+ }
+
+ private static func freeCStringArray(
+ _ pointers: inout [UnsafeMutablePointer?]
+ ) {
+ for pointer in pointers {
+ free(pointer)
+ }
+ pointers.removeAll(keepingCapacity: false)
+ }
+
+ private static func check(_ result: Int32) throws {
+ guard result == 0 else {
+ throw posixError(result)
+ }
+ }
+
+ private static func posixError(_ code: Int32) -> Error {
+ NSError(
+ domain: NSPOSIXErrorDomain,
+ code: Int(code),
+ userInfo: nil
+ )
+ }
+}
+
+public struct GlossHomebrewCaskRelease: Equatable, Sendable {
+ public let version: String
+ public let url: URL
+ public let sha256: String
+
+ public init(version: String, url: URL, sha256: String) {
+ self.version = version
+ self.url = url
+ self.sha256 = sha256
+ }
+}
+
+public struct GlossPathInspector: Sendable {
+ public typealias IsExecutable = @Sendable (URL) -> Bool
+ public typealias FileExists = @Sendable (URL) -> Bool
+ public typealias PathsReferToSameItem = @Sendable (URL, URL) -> Bool
+
+ private let isExecutableImplementation: IsExecutable
+ private let fileExistsImplementation: FileExists
+ private let pathsReferToSameItemImplementation: PathsReferToSameItem
+
+ public init(
+ isExecutable: @escaping IsExecutable,
+ fileExists: @escaping FileExists,
+ pathsReferToSameItem: @escaping PathsReferToSameItem
+ ) {
+ isExecutableImplementation = isExecutable
+ fileExistsImplementation = fileExists
+ pathsReferToSameItemImplementation = pathsReferToSameItem
+ }
+
+ public func isExecutable(_ url: URL) -> Bool {
+ isExecutableImplementation(url)
+ }
+
+ public func fileExists(_ url: URL) -> Bool {
+ fileExistsImplementation(url)
+ }
+
+ public func pathsReferToSameItem(_ first: URL, _ second: URL) -> Bool {
+ pathsReferToSameItemImplementation(first, second)
+ }
+
+ public static let live = Self(
+ isExecutable: { url in
+ FileManager.default.isExecutableFile(atPath: url.path)
+ },
+ fileExists: { url in
+ FileManager.default.fileExists(atPath: url.path)
+ },
+ pathsReferToSameItem: { first, second in
+ let fileManager = FileManager.default
+ guard fileManager.fileExists(atPath: first.path),
+ fileManager.fileExists(atPath: second.path)
+ else {
+ return false
+ }
+ return first.resolvingSymlinksInPath().standardizedFileURL
+ == second.resolvingSymlinksInPath().standardizedFileURL
+ }
+ )
+}
+
+public struct GlossHomebrewInstallation: Equatable, Sendable {
+ public let brewExecutableURL: URL
+ public let caskToken: String
+ public let installedVersion: String
+ public let availableVersion: String
+ public let managedAppURL: URL
+ public let installedAppTargetURL: URL
+
+ public init(
+ brewExecutableURL: URL,
+ caskToken: String,
+ installedVersion: String,
+ availableVersion: String,
+ managedAppURL: URL,
+ installedAppTargetURL: URL
+ ) {
+ self.brewExecutableURL = brewExecutableURL
+ self.caskToken = caskToken
+ self.installedVersion = installedVersion
+ self.availableVersion = availableVersion
+ self.managedAppURL = managedAppURL
+ self.installedAppTargetURL = installedAppTargetURL
+ }
+}
+
+public enum GlossHomebrewDetectionError: LocalizedError, Equatable, Sendable {
+ case malformedInfo
+ case invalidCaskIdentity
+ case invalidCaskVersion
+
+ public var errorDescription: String? {
+ switch self {
+ case .malformedInfo:
+ "Homebrew 返回了无法解析的 Gloss cask 信息。"
+ case .invalidCaskIdentity:
+ "Homebrew 返回的 cask 不是 sunchj/tap/gloss。"
+ case .invalidCaskVersion:
+ "Homebrew 返回了无效的 Gloss 版本。"
+ }
+ }
+}
+
+public struct GlossHomebrewInstallationDetector: Sendable {
+ public static let caskToken = "sunchj/tap/gloss"
+ public static let brewExecutableURLs = [
+ URL(fileURLWithPath: "/opt/homebrew/bin/brew"),
+ URL(fileURLWithPath: "/usr/local/bin/brew"),
+ ]
+ public static let infoArguments = [
+ "info",
+ "--cask",
+ "--json=v2",
+ caskToken,
+ ]
+
+ private struct InfoResponse: Decodable {
+ let casks: [Cask]
+ }
+
+ private struct Cask: Decodable {
+ let token: String
+ let fullToken: String
+ let tap: String
+ let version: String
+ let installed: String?
+ let url: URL?
+ let sha256: String?
+ let artifacts: [JSONValue]
+
+ enum CodingKeys: String, CodingKey {
+ case token
+ case fullToken = "full_token"
+ case tap
+ case version
+ case installed
+ case url
+ case sha256
+ case artifacts
+ }
+ }
+
+ public static func parseCaskRelease(
+ _ data: Data
+ ) throws -> GlossHomebrewCaskRelease {
+ let cask = try decodeCask(from: data)
+ try validateIdentity(cask)
+ guard GlossSemanticVersion(cask.version) != nil else {
+ throw GlossHomebrewDetectionError.invalidCaskVersion
+ }
+ guard let url = cask.url,
+ url.scheme == "https",
+ let sha256 = cask.sha256,
+ sha256.range(
+ of: "^[0-9a-fA-F]{64}$",
+ options: .regularExpression
+ ) != nil
+ else {
+ throw GlossHomebrewDetectionError.malformedInfo
+ }
+ return GlossHomebrewCaskRelease(
+ version: cask.version,
+ url: url,
+ sha256: sha256.lowercased()
+ )
+ }
+
+ private let commandRunner: GlossCommandRunner
+ private let pathInspector: GlossPathInspector
+
+ public init(
+ commandRunner: GlossCommandRunner,
+ pathInspector: GlossPathInspector = .live
+ ) {
+ self.commandRunner = commandRunner
+ self.pathInspector = pathInspector
+ }
+
+ public func detect(
+ currentBundleURL: URL
+ ) async throws -> GlossHomebrewInstallation? {
+ for brewURL in Self.brewExecutableURLs
+ where pathInspector.isExecutable(brewURL) {
+ if let installation = try await detect(
+ brewExecutableURL: brewURL,
+ currentBundleURL: currentBundleURL
+ ) {
+ return installation
+ }
+ }
+ return nil
+ }
+
+ public func detect(
+ brewExecutableURL: URL,
+ currentBundleURL: URL
+ ) async throws -> GlossHomebrewInstallation? {
+ guard Self.brewExecutableURLs.contains(brewExecutableURL),
+ pathInspector.isExecutable(brewExecutableURL)
+ else {
+ return nil
+ }
+ let output = try await commandRunner.run(
+ executableURL: brewExecutableURL,
+ arguments: Self.infoArguments
+ )
+ guard output.terminationStatus == 0 else {
+ return nil
+ }
+ return try parseManagedInstallation(
+ output.standardOutput,
+ brewURL: brewExecutableURL,
+ currentBundleURL: currentBundleURL
+ )
+ }
+
+ private func parseManagedInstallation(
+ _ data: Data,
+ brewURL: URL,
+ currentBundleURL: URL
+ ) throws -> GlossHomebrewInstallation? {
+ let cask = try Self.decodeCask(from: data)
+ try Self.validateIdentity(cask)
+ guard let installedVersion = cask.installed else {
+ return nil
+ }
+ guard GlossSemanticVersion(installedVersion) != nil,
+ GlossSemanticVersion(cask.version) != nil
+ else {
+ throw GlossHomebrewDetectionError.invalidCaskVersion
+ }
+
+ guard
+ let targetPath = cask.artifacts.compactMap({ artifact -> String? in
+ guard artifact["app"] != nil else {
+ return nil
+ }
+ return artifact["target"]?.stringValue
+ }).first
+ else {
+ throw GlossHomebrewDetectionError.malformedInfo
+ }
+ let targetURL = URL(fileURLWithPath: targetPath)
+ let prefixURL =
+ brewURL
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ let managedAppURL =
+ prefixURL
+ .appendingPathComponent("Caskroom", isDirectory: true)
+ .appendingPathComponent("gloss", isDirectory: true)
+ .appendingPathComponent(installedVersion, isDirectory: true)
+ .appendingPathComponent("Gloss.app", isDirectory: true)
+
+ guard pathInspector.fileExists(managedAppURL),
+ pathInspector.fileExists(targetURL),
+ pathInspector.fileExists(currentBundleURL),
+ pathInspector.pathsReferToSameItem(targetURL, currentBundleURL),
+ pathInspector.pathsReferToSameItem(managedAppURL, currentBundleURL)
+ else {
+ return nil
+ }
+
+ return GlossHomebrewInstallation(
+ brewExecutableURL: brewURL,
+ caskToken: Self.caskToken,
+ installedVersion: installedVersion,
+ availableVersion: cask.version,
+ managedAppURL: managedAppURL,
+ installedAppTargetURL: targetURL
+ )
+ }
+
+ private static func decodeCask(from data: Data) throws -> Cask {
+ let response: InfoResponse
+ do {
+ response = try JSONDecoder().decode(InfoResponse.self, from: data)
+ } catch {
+ throw GlossHomebrewDetectionError.malformedInfo
+ }
+
+ guard response.casks.count == 1, let cask = response.casks.first
+ else {
+ throw GlossHomebrewDetectionError.malformedInfo
+ }
+ return cask
+ }
+
+ private static func validateIdentity(_ cask: Cask) throws {
+ guard cask.token == "gloss",
+ cask.fullToken == Self.caskToken,
+ cask.tap == "sunchj/tap"
+ else {
+ throw GlossHomebrewDetectionError.invalidCaskIdentity
+ }
+ }
+}
diff --git a/Sources/GlossCore/GlossHomebrewUpgrade.swift b/Sources/GlossCore/GlossHomebrewUpgrade.swift
new file mode 100644
index 0000000..2c1fe23
--- /dev/null
+++ b/Sources/GlossCore/GlossHomebrewUpgrade.swift
@@ -0,0 +1,1717 @@
+import CryptoKit
+import Darwin
+import Foundation
+
+public struct GlossHomebrewUpgradeRequest: Codable, Equatable, Sendable {
+ public static let currentSchemaVersion = 2
+
+ public let schemaVersion: Int
+ public let brewExecutablePath: String
+ public let caskToken: String
+ public let previousVersion: String
+ public let expectedVersion: String
+ public let expectedReleaseTag: String
+ public let expectedArchitecture: String
+ public let expectedAssetURL: URL
+ public let expectedAssetSHA256: String
+ public let expectedAssetSize: Int64
+ public let expectedHomebrewCaskURL: URL
+ public let expectedHomebrewCaskSHA256: String
+ public let expectedHomebrewCaskSize: Int64
+ public let parentProcessIdentifier: Int32
+ public let currentBundlePath: String
+ public let resultPath: String
+ public let readinessPath: String
+ public let manifestPath: String
+ public let manifestSignaturePath: String
+ public let recoveryBundlePath: String
+ public let requestIdentifier: UUID
+
+ public init(
+ schemaVersion: Int = Self.currentSchemaVersion,
+ brewExecutablePath: String,
+ caskToken: String,
+ previousVersion: String,
+ expectedVersion: String,
+ expectedReleaseTag: String,
+ expectedArchitecture: String,
+ expectedAssetURL: URL,
+ expectedAssetSHA256: String,
+ expectedAssetSize: Int64,
+ expectedHomebrewCaskURL: URL,
+ expectedHomebrewCaskSHA256: String,
+ expectedHomebrewCaskSize: Int64,
+ parentProcessIdentifier: Int32,
+ currentBundlePath: String,
+ resultPath: String,
+ readinessPath: String,
+ manifestPath: String,
+ manifestSignaturePath: String,
+ recoveryBundlePath: String,
+ requestIdentifier: UUID = UUID()
+ ) {
+ self.schemaVersion = schemaVersion
+ self.brewExecutablePath = brewExecutablePath
+ self.caskToken = caskToken
+ self.previousVersion = previousVersion
+ self.expectedVersion = expectedVersion
+ self.expectedReleaseTag = expectedReleaseTag
+ self.expectedArchitecture = expectedArchitecture
+ self.expectedAssetURL = expectedAssetURL
+ self.expectedAssetSHA256 = expectedAssetSHA256
+ self.expectedAssetSize = expectedAssetSize
+ self.expectedHomebrewCaskURL = expectedHomebrewCaskURL
+ self.expectedHomebrewCaskSHA256 = expectedHomebrewCaskSHA256
+ self.expectedHomebrewCaskSize = expectedHomebrewCaskSize
+ self.parentProcessIdentifier = parentProcessIdentifier
+ self.currentBundlePath = currentBundlePath
+ self.resultPath = resultPath
+ self.readinessPath = readinessPath
+ self.manifestPath = manifestPath
+ self.manifestSignaturePath = manifestSignaturePath
+ self.recoveryBundlePath = recoveryBundlePath
+ self.requestIdentifier = requestIdentifier
+ }
+
+ public init(
+ installation: GlossHomebrewInstallation,
+ update: GlossAppUpdateAvailability,
+ parentProcessIdentifier: Int32,
+ currentBundleURL: URL,
+ resultURL: URL,
+ readinessURL: URL,
+ manifestURL: URL,
+ manifestSignatureURL: URL,
+ recoveryBundleURL: URL,
+ requestIdentifier: UUID = UUID()
+ ) {
+ self.init(
+ brewExecutablePath: installation.brewExecutableURL.path,
+ caskToken: installation.caskToken,
+ previousVersion: installation.installedVersion,
+ expectedVersion: update.version,
+ expectedReleaseTag: update.releaseTag,
+ expectedArchitecture: update.architecture,
+ expectedAssetURL: update.assetURL,
+ expectedAssetSHA256: update.assetSHA256,
+ expectedAssetSize: update.assetSize,
+ expectedHomebrewCaskURL: update.homebrewCask.url,
+ expectedHomebrewCaskSHA256: update.homebrewCask.sha256,
+ expectedHomebrewCaskSize: update.homebrewCask.size,
+ parentProcessIdentifier: parentProcessIdentifier,
+ currentBundlePath: currentBundleURL.path,
+ resultPath: resultURL.path,
+ readinessPath: readinessURL.path,
+ manifestPath: manifestURL.path,
+ manifestSignaturePath: manifestSignatureURL.path,
+ recoveryBundlePath: recoveryBundleURL.path,
+ requestIdentifier: requestIdentifier
+ )
+ }
+
+ public var brewExecutableURL: URL {
+ URL(fileURLWithPath: brewExecutablePath)
+ }
+
+ public var currentBundleURL: URL {
+ URL(fileURLWithPath: currentBundlePath, isDirectory: true)
+ }
+
+ public var resultURL: URL {
+ URL(fileURLWithPath: resultPath)
+ }
+
+ public var readinessURL: URL {
+ URL(fileURLWithPath: readinessPath)
+ }
+
+ public var manifestURL: URL {
+ URL(fileURLWithPath: manifestPath)
+ }
+
+ public var manifestSignatureURL: URL {
+ URL(fileURLWithPath: manifestSignaturePath)
+ }
+
+ public var recoveryBundleURL: URL {
+ URL(fileURLWithPath: recoveryBundlePath, isDirectory: true)
+ }
+
+ public func validate() throws {
+ guard schemaVersion == Self.currentSchemaVersion else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "unsupported schema version"
+ )
+ }
+ guard
+ GlossHomebrewInstallationDetector.brewExecutableURLs.map(\.path)
+ .contains(brewExecutablePath)
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "brew executable is not trusted"
+ )
+ }
+ guard caskToken == GlossHomebrewInstallationDetector.caskToken else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "cask token is not trusted"
+ )
+ }
+ guard let parsedPreviousVersion = GlossSemanticVersion(previousVersion)
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "previous version is invalid"
+ )
+ }
+ guard let parsedExpectedVersion = GlossSemanticVersion(expectedVersion)
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "expected version is invalid"
+ )
+ }
+ guard parsedPreviousVersion < parsedExpectedVersion else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "expected version must be newer than the installed version"
+ )
+ }
+ guard expectedReleaseTag == "v\(expectedVersion)" else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "release tag does not match expected version"
+ )
+ }
+ guard expectedArchitecture == GlossAppArchitecture.current else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "asset architecture does not match this Mac"
+ )
+ }
+ guard expectedAssetSize > 0,
+ expectedAssetSHA256.range(
+ of: "^[0-9a-f]{64}$",
+ options: .regularExpression
+ ) != nil
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "signed app asset metadata is invalid"
+ )
+ }
+ guard expectedHomebrewCaskSize > 0,
+ expectedHomebrewCaskSHA256.range(
+ of: "^[0-9a-f]{64}$",
+ options: .regularExpression
+ ) != nil
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "signed Homebrew cask metadata is invalid"
+ )
+ }
+ do {
+ try GlossAppReleaseManifestValidator.validateOfficialAssetURL(
+ expectedAssetURL,
+ architecture: expectedArchitecture,
+ releaseTag: expectedReleaseTag
+ )
+ try GlossAppReleaseManifestValidator
+ .validateOfficialHomebrewCaskURL(
+ expectedHomebrewCaskURL,
+ releaseTag: expectedReleaseTag
+ )
+ } catch {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "signed release URLs are invalid"
+ )
+ }
+ guard parentProcessIdentifier > 1 else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "parent process identifier is invalid"
+ )
+ }
+ guard currentBundlePath == "/Applications/Gloss.app",
+ currentBundleURL.standardizedFileURL.path == currentBundlePath,
+ currentBundleURL.lastPathComponent == "Gloss.app"
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "current bundle path is invalid"
+ )
+ }
+ guard resultPath.hasPrefix("/"),
+ resultURL.standardizedFileURL.path == resultPath,
+ resultURL.pathExtension == "json"
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "result path is invalid"
+ )
+ }
+ guard readinessPath.hasPrefix("/"),
+ readinessURL.standardizedFileURL.path == readinessPath,
+ readinessURL.pathExtension == "json",
+ readinessURL != resultURL
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "readiness path is invalid"
+ )
+ }
+ guard manifestPath.hasPrefix("/"),
+ manifestURL.standardizedFileURL.path == manifestPath,
+ manifestURL.lastPathComponent == "release-manifest.json",
+ manifestSignaturePath.hasPrefix("/"),
+ manifestSignatureURL.standardizedFileURL.path
+ == manifestSignaturePath,
+ manifestSignatureURL.lastPathComponent
+ == "release-manifest.json.sig",
+ manifestURL.deletingLastPathComponent()
+ == readinessURL.deletingLastPathComponent(),
+ manifestSignatureURL.deletingLastPathComponent()
+ == readinessURL.deletingLastPathComponent()
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "signed manifest paths are invalid"
+ )
+ }
+ let expectedRecoveryURL =
+ readinessURL.deletingLastPathComponent()
+ .appendingPathComponent("recovery", isDirectory: true)
+ .appendingPathComponent("Gloss.app", isDirectory: true)
+ guard recoveryBundlePath.hasPrefix("/"),
+ recoveryBundleURL.standardizedFileURL.path == recoveryBundlePath,
+ recoveryBundleURL == expectedRecoveryURL
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "recovery bundle path is invalid"
+ )
+ }
+ }
+}
+
+public struct GlossHomebrewUpgradeResult: Codable, Equatable, Sendable {
+ public enum Outcome: String, Codable, Sendable {
+ case succeeded
+ case failed
+ }
+
+ public static let currentSchemaVersion = 2
+
+ public let schemaVersion: Int
+ public let outcome: Outcome
+ public let expectedVersion: String
+ public let installedVersion: String?
+ public let errorCode: String?
+ public let message: String?
+ public let recoveredPreviousInstallation: Bool
+ public let recoveryError: String?
+ public let completedAt: Date
+
+ public init(
+ schemaVersion: Int = Self.currentSchemaVersion,
+ outcome: Outcome,
+ expectedVersion: String,
+ installedVersion: String? = nil,
+ errorCode: String? = nil,
+ message: String? = nil,
+ recoveredPreviousInstallation: Bool = false,
+ recoveryError: String? = nil,
+ completedAt: Date
+ ) {
+ self.schemaVersion = schemaVersion
+ self.outcome = outcome
+ self.expectedVersion = expectedVersion
+ self.installedVersion = installedVersion
+ self.errorCode = errorCode
+ self.message = message
+ self.recoveredPreviousInstallation = recoveredPreviousInstallation
+ self.recoveryError = recoveryError
+ self.completedAt = completedAt
+ }
+
+ public var succeeded: Bool {
+ outcome == .succeeded
+ }
+}
+
+public enum GlossUpdateHelperArguments {
+ public static let requestFileFlag = "--request"
+
+ public static func requestFileURL(
+ from arguments: [String]
+ ) throws -> URL {
+ guard arguments.count == 2,
+ arguments[0] == requestFileFlag,
+ arguments[1].hasPrefix("/")
+ else {
+ throw GlossHomebrewUpgradeError.invalidArguments
+ }
+ return URL(fileURLWithPath: arguments[1])
+ }
+}
+
+public enum GlossHomebrewUpgradeError: LocalizedError, Equatable, Sendable {
+ case invalidArguments
+ case invalidRequest(String)
+ case helperReadinessTimedOut
+ case helperExitedBeforeReady
+ case parentProcessDidNotExit
+ case recoveryPreparationFailed(String)
+ case recoveryFailed(String)
+ case releaseBindingFailed(String)
+ case homebrewUpdateFailed(status: Int32, detail: String)
+ case homebrewUpgradeFailed(status: Int32, detail: String)
+ case installationVerificationFailed(String)
+ case relaunchFailed(status: Int32, detail: String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .invalidArguments:
+ "更新 helper 参数无效。"
+ case .invalidRequest(let reason):
+ "更新请求无效:\(reason)"
+ case .helperReadinessTimedOut:
+ "更新 helper 未在限定时间内完成启动验证。"
+ case .helperExitedBeforeReady:
+ "更新 helper 在完成启动验证前退出。"
+ case .parentProcessDidNotExit:
+ "Gloss 未在限定时间内退出。"
+ case .recoveryPreparationFailed(let reason):
+ "无法准备 Gloss 恢复副本:\(reason)"
+ case .recoveryFailed(let reason):
+ "Gloss 更新失败,且恢复上一版本失败:\(reason)"
+ case .releaseBindingFailed(let reason):
+ "Homebrew Cask 未通过签名发行绑定验证:\(reason)"
+ case .homebrewUpdateFailed(let status, let detail):
+ "Homebrew 更新失败(\(status)):\(detail)"
+ case .homebrewUpgradeFailed(let status, let detail):
+ "Gloss 的 Homebrew 升级失败(\(status)):\(detail)"
+ case .installationVerificationFailed(let reason):
+ "升级后的 Gloss 未通过验证:\(reason)"
+ case .relaunchFailed(let status, let detail):
+ "Gloss 升级完成,但重新打开失败(\(status)):\(detail)"
+ }
+ }
+
+ public var resultCode: String {
+ switch self {
+ case .invalidArguments:
+ "invalid_arguments"
+ case .invalidRequest:
+ "invalid_request"
+ case .helperReadinessTimedOut:
+ "helper_readiness_timeout"
+ case .helperExitedBeforeReady:
+ "helper_exited_before_ready"
+ case .parentProcessDidNotExit:
+ "parent_exit_timeout"
+ case .recoveryPreparationFailed:
+ "recovery_preparation_failed"
+ case .recoveryFailed:
+ "recovery_failed"
+ case .releaseBindingFailed:
+ "release_binding_failed"
+ case .homebrewUpdateFailed:
+ "homebrew_update_failed"
+ case .homebrewUpgradeFailed:
+ "homebrew_upgrade_failed"
+ case .installationVerificationFailed:
+ "installation_verification_failed"
+ case .relaunchFailed:
+ "relaunch_failed"
+ }
+ }
+}
+
+public struct GlossUpdateHelperReadiness: Codable, Equatable, Sendable {
+ public static let currentSchemaVersion = 1
+
+ public let schemaVersion: Int
+ public let requestIdentifier: UUID
+ public let helperProcessIdentifier: Int32
+
+ public init(
+ schemaVersion: Int = Self.currentSchemaVersion,
+ requestIdentifier: UUID,
+ helperProcessIdentifier: Int32
+ ) {
+ self.schemaVersion = schemaVersion
+ self.requestIdentifier = requestIdentifier
+ self.helperProcessIdentifier = helperProcessIdentifier
+ }
+}
+
+public enum GlossUpdateHelperReadinessStore {
+ public static func load(from url: URL) throws -> GlossUpdateHelperReadiness {
+ try JSONDecoder().decode(
+ GlossUpdateHelperReadiness.self,
+ from: Data(contentsOf: url)
+ )
+ }
+
+ public static func write(
+ _ readiness: GlossUpdateHelperReadiness,
+ to url: URL
+ ) throws {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ let data = try encoder.encode(readiness)
+ try FileManager.default.createDirectory(
+ at: url.deletingLastPathComponent(),
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ try data.write(to: url, options: .atomic)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: url.path
+ )
+ }
+}
+
+public struct GlossSignedAppUpdateRequestVerifier: Sendable {
+ private let validator: GlossAppReleaseManifestValidator
+
+ public init(
+ manifestSigningPublicKey: Data =
+ GlossAppReleaseManifestValidator.pinnedManifestSigningPublicKey
+ ) throws {
+ validator = try GlossAppReleaseManifestValidator(
+ manifestSigningPublicKey: manifestSigningPublicKey
+ )
+ }
+
+ public func verify(_ request: GlossHomebrewUpgradeRequest) throws {
+ let manifest: GlossAppReleaseManifest
+ do {
+ manifest = try validator.validate(
+ manifestData: Data(contentsOf: request.manifestURL),
+ detachedSignatureData: Data(
+ contentsOf: request.manifestSignatureURL
+ )
+ )
+ } catch {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "signed release manifest could not be reverified"
+ )
+ }
+ guard manifest.version == request.expectedVersion,
+ manifest.releaseTag == request.expectedReleaseTag,
+ let asset = manifest.assets.first(where: {
+ $0.operatingSystem == "macos"
+ && $0.architecture == request.expectedArchitecture
+ }),
+ asset.url == request.expectedAssetURL,
+ asset.sha256 == request.expectedAssetSHA256,
+ asset.size == request.expectedAssetSize,
+ manifest.homebrewCask.token == request.caskToken,
+ manifest.homebrewCask.url
+ == request.expectedHomebrewCaskURL,
+ manifest.homebrewCask.sha256
+ == request.expectedHomebrewCaskSHA256,
+ manifest.homebrewCask.size
+ == request.expectedHomebrewCaskSize
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "update request does not match the signed release manifest"
+ )
+ }
+ }
+}
+
+public enum GlossHomebrewUpgradeRequestStore {
+ public static func load(from url: URL) throws -> GlossHomebrewUpgradeRequest {
+ try JSONDecoder().decode(
+ GlossHomebrewUpgradeRequest.self,
+ from: Data(contentsOf: url)
+ )
+ }
+
+ public static func write(
+ _ request: GlossHomebrewUpgradeRequest,
+ to url: URL
+ ) throws {
+ try writeJSON(request, to: url)
+ }
+
+ private static func writeJSON(
+ _ value: T,
+ to url: URL
+ ) throws {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ let data = try encoder.encode(value)
+ try FileManager.default.createDirectory(
+ at: url.deletingLastPathComponent(),
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ try data.write(to: url, options: .atomic)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: url.path
+ )
+ }
+}
+
+public enum GlossHomebrewUpgradeResultStore {
+ public static func load(from url: URL) throws -> GlossHomebrewUpgradeResult {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return try decoder.decode(
+ GlossHomebrewUpgradeResult.self,
+ from: Data(contentsOf: url)
+ )
+ }
+
+ public static func write(
+ _ result: GlossHomebrewUpgradeResult,
+ to url: URL
+ ) throws {
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ let data = try encoder.encode(result)
+ try FileManager.default.createDirectory(
+ at: url.deletingLastPathComponent(),
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ try data.write(to: url, options: .atomic)
+ try FileManager.default.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: url.path
+ )
+ }
+}
+
+public struct GlossParentProcessWaiter: Sendable {
+ public typealias Wait = @Sendable (Int32, Duration) async throws -> Void
+
+ private let waitImplementation: Wait
+
+ public init(wait: @escaping Wait) {
+ waitImplementation = wait
+ }
+
+ public func wait(
+ for processIdentifier: Int32,
+ timeout: Duration
+ ) async throws {
+ try await waitImplementation(processIdentifier, timeout)
+ }
+
+ public static let live = Self { processIdentifier, timeout in
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: timeout)
+ while Self.isRunning(processIdentifier) {
+ guard clock.now < deadline else {
+ throw GlossHomebrewUpgradeError.parentProcessDidNotExit
+ }
+ try await Task.sleep(for: .milliseconds(250))
+ }
+ }
+
+ private static func isRunning(_ processIdentifier: Int32) -> Bool {
+ if kill(pid_t(processIdentifier), 0) == 0 {
+ return true
+ }
+ return errno == EPERM
+ }
+}
+
+public struct GlossHomebrewUpgradeVerifier: Sendable {
+ public typealias Verify =
+ @Sendable (
+ GlossHomebrewUpgradeRequest
+ ) async throws -> String
+
+ private let verifyImplementation: Verify
+
+ public init(verify: @escaping Verify) {
+ verifyImplementation = verify
+ }
+
+ public func verify(
+ _ request: GlossHomebrewUpgradeRequest
+ ) async throws -> String {
+ try await verifyImplementation(request)
+ }
+
+ public static func live(
+ commandRunner: GlossCommandRunner = .live,
+ pathInspector: GlossPathInspector = .live,
+ bundleVersionReader: GlossBundleVersionReader = .live
+ ) -> Self {
+ Self { request in
+ let detector = GlossHomebrewInstallationDetector(
+ commandRunner: commandRunner,
+ pathInspector: pathInspector
+ )
+ guard
+ let installation = try await detector.detect(
+ brewExecutableURL: request.brewExecutableURL,
+ currentBundleURL: request.currentBundleURL
+ )
+ else {
+ throw
+ GlossHomebrewUpgradeError
+ .installationVerificationFailed(
+ "Homebrew 不再管理当前 App"
+ )
+ }
+ guard installation.caskToken == request.caskToken,
+ Self.installedVersion(
+ installation.installedVersion,
+ matchesExpectedVersion: request.expectedVersion
+ )
+ else {
+ throw
+ GlossHomebrewUpgradeError
+ .installationVerificationFailed(
+ "Homebrew 安装版本与签名发行版本不一致"
+ )
+ }
+
+ let bundleVersion = try bundleVersionReader.version(
+ at: request.currentBundleURL
+ )
+ guard bundleVersion == installation.installedVersion else {
+ throw
+ GlossHomebrewUpgradeError
+ .installationVerificationFailed(
+ "App bundle 与 Homebrew 安装版本不一致"
+ )
+ }
+
+ let appExecutableURL =
+ request.currentBundleURL
+ .appendingPathComponent("Contents", isDirectory: true)
+ .appendingPathComponent("MacOS", isDirectory: true)
+ .appendingPathComponent("Gloss")
+ let architecture = try await commandRunner.run(
+ executableURL: URL(fileURLWithPath: "/usr/bin/lipo"),
+ arguments: [
+ appExecutableURL.path,
+ "-verify_arch",
+ Self.runningArchitecture,
+ ]
+ )
+ guard architecture.terminationStatus == 0 else {
+ throw
+ GlossHomebrewUpgradeError
+ .installationVerificationFailed(
+ "App 不包含当前 Mac 所需的架构"
+ )
+ }
+
+ let verification = try await commandRunner.run(
+ executableURL: URL(fileURLWithPath: "/usr/bin/codesign"),
+ arguments: [
+ "--verify",
+ "--deep",
+ "--strict",
+ request.currentBundlePath,
+ ]
+ )
+ guard verification.terminationStatus == 0 else {
+ throw
+ GlossHomebrewUpgradeError
+ .installationVerificationFailed(
+ "代码签名完整性检查失败"
+ )
+ }
+
+ let signatureDetails = try await commandRunner.run(
+ executableURL: URL(fileURLWithPath: "/usr/bin/codesign"),
+ arguments: [
+ "--display",
+ "--verbose=4",
+ request.currentBundlePath,
+ ]
+ )
+ let signatureText = Self.commandText(signatureDetails)
+ guard signatureDetails.terminationStatus == 0,
+ signatureText.split(separator: "\n").contains(
+ where: { $0.trimmingCharacters(in: .whitespaces) == "Signature=adhoc" }
+ )
+ else {
+ throw
+ GlossHomebrewUpgradeError
+ .installationVerificationFailed(
+ "App 不是预期的 ad-hoc 签名"
+ )
+ }
+
+ let quarantine = try await commandRunner.run(
+ executableURL: URL(fileURLWithPath: "/usr/bin/xattr"),
+ arguments: [
+ "-p",
+ "com.apple.quarantine",
+ request.currentBundlePath,
+ ]
+ )
+ let quarantineText = Self.commandText(quarantine)
+ guard quarantine.terminationStatus != 0,
+ quarantineText.contains("No such xattr")
+ else {
+ throw
+ GlossHomebrewUpgradeError
+ .installationVerificationFailed(
+ quarantine.terminationStatus == 0
+ ? "App 仍带有 quarantine 属性"
+ : "无法确认 App 的 quarantine 状态"
+ )
+ }
+ return installation.installedVersion
+ }
+ }
+
+ private static func commandText(_ output: GlossCommandOutput) -> String {
+ String(
+ decoding: output.standardOutput + output.standardError,
+ as: UTF8.self
+ )
+ }
+
+ static func installedVersion(
+ _ installedVersion: String,
+ matchesExpectedVersion expectedVersion: String
+ ) -> Bool {
+ guard let installed = GlossSemanticVersion(installedVersion),
+ let expected = GlossSemanticVersion(expectedVersion)
+ else {
+ return false
+ }
+ return installed == expected
+ }
+
+ private static var runningArchitecture: String {
+ #if arch(arm64)
+ "arm64"
+ #elseif arch(x86_64)
+ "x86_64"
+ #else
+ "unsupported"
+ #endif
+ }
+}
+
+public struct GlossBundleVersionReader: Sendable {
+ public typealias Read = @Sendable (URL) throws -> String
+
+ private let readImplementation: Read
+
+ public init(read: @escaping Read) {
+ readImplementation = read
+ }
+
+ public func version(at bundleURL: URL) throws -> String {
+ try readImplementation(bundleURL)
+ }
+
+ public static let live = Self { bundleURL in
+ let infoURL =
+ bundleURL
+ .appendingPathComponent("Contents", isDirectory: true)
+ .appendingPathComponent("Info.plist")
+ let data = try Data(contentsOf: infoURL)
+ guard
+ let propertyList = try PropertyListSerialization.propertyList(
+ from: data,
+ options: [],
+ format: nil
+ ) as? [String: Any],
+ let version = propertyList["CFBundleShortVersionString"] as? String,
+ GlossSemanticVersion(version) != nil
+ else {
+ throw GlossHomebrewUpgradeError.installationVerificationFailed(
+ "无法读取 App 版本"
+ )
+ }
+ return version
+ }
+}
+
+public struct GlossRecoveryBundleValidator: Sendable {
+ public typealias Validate =
+ @Sendable (URL, Set) async throws -> Void
+
+ private let validateImplementation: Validate
+
+ public init(validate: @escaping Validate) {
+ validateImplementation = validate
+ }
+
+ public func validate(
+ _ bundleURL: URL,
+ allowedVersions: Set
+ ) async throws {
+ try await validateImplementation(bundleURL, allowedVersions)
+ }
+
+ public static func live(
+ commandRunner: GlossCommandRunner = .live,
+ bundleVersionReader: GlossBundleVersionReader = .live
+ ) -> Self {
+ Self { bundleURL, allowedVersions in
+ guard Self.isDirectoryWithoutSymlinks(bundleURL) else {
+ throw GlossHomebrewUpgradeError.recoveryFailed(
+ "恢复 App 不是常规目录,或包含符号链接路径"
+ )
+ }
+ let version = try bundleVersionReader.version(at: bundleURL)
+ guard allowedVersions.contains(version) else {
+ throw GlossHomebrewUpgradeError.recoveryFailed(
+ "恢复 App 版本不在允许范围内"
+ )
+ }
+ let executableURL =
+ bundleURL
+ .appendingPathComponent("Contents/MacOS/Gloss")
+ let architecture = try await commandRunner.run(
+ executableURL: URL(fileURLWithPath: "/usr/bin/lipo"),
+ arguments: [
+ executableURL.path,
+ "-verify_arch",
+ GlossAppArchitecture.current,
+ ],
+ timeout: .seconds(60)
+ )
+ guard architecture.terminationStatus == 0 else {
+ throw GlossHomebrewUpgradeError.recoveryFailed(
+ "恢复 App 不包含当前 Mac 架构"
+ )
+ }
+ let signature = try await commandRunner.run(
+ executableURL: URL(fileURLWithPath: "/usr/bin/codesign"),
+ arguments: [
+ "--verify",
+ "--deep",
+ "--strict",
+ bundleURL.path,
+ ],
+ timeout: .seconds(60)
+ )
+ guard signature.terminationStatus == 0 else {
+ throw GlossHomebrewUpgradeError.recoveryFailed(
+ "恢复 App 的代码签名无效"
+ )
+ }
+ guard try !Self.hasQuarantineAttribute(bundleURL) else {
+ throw GlossHomebrewUpgradeError.recoveryFailed(
+ "恢复 App 仍带有 quarantine 属性"
+ )
+ }
+ }
+ }
+
+ private static func isDirectoryWithoutSymlinks(_ url: URL) -> Bool {
+ guard url.resolvingSymlinksInPath() == url.standardizedFileURL else {
+ return false
+ }
+ var metadata = stat()
+ guard lstat(url.path, &metadata) == 0 else {
+ return false
+ }
+ return metadata.st_mode & S_IFMT == S_IFDIR
+ }
+
+ private static func hasQuarantineAttribute(_ url: URL) throws -> Bool {
+ let result = url.path.withCString { path in
+ "com.apple.quarantine".withCString { name in
+ getxattr(path, name, nil, 0, 0, 0)
+ }
+ }
+ if result >= 0 {
+ return true
+ }
+ if errno == ENOATTR {
+ return false
+ }
+ throw GlossHomebrewUpgradeError.recoveryFailed(
+ "无法确认恢复 App 的 quarantine 状态"
+ )
+ }
+}
+
+public struct GlossAppUpdateRecoveryManager: Sendable {
+ public typealias Prepare =
+ @Sendable (GlossHomebrewUpgradeRequest) async throws -> Void
+ public typealias Restore =
+ @Sendable (GlossHomebrewUpgradeRequest) async throws -> Bool
+
+ private let prepareImplementation: Prepare
+ private let restoreImplementation: Restore
+
+ public init(
+ prepare: @escaping Prepare,
+ restoreIfNeeded: @escaping Restore
+ ) {
+ prepareImplementation = prepare
+ restoreImplementation = restoreIfNeeded
+ }
+
+ public func prepare(_ request: GlossHomebrewUpgradeRequest) async throws {
+ try await prepareImplementation(request)
+ }
+
+ public func restoreIfNeeded(
+ _ request: GlossHomebrewUpgradeRequest
+ ) async throws -> Bool {
+ try await restoreImplementation(request)
+ }
+
+ public static func live(
+ validator: GlossRecoveryBundleValidator = .live()
+ ) -> Self {
+ Self(
+ prepare: { request in
+ let fileManager = FileManager.default
+ do {
+ try await validator.validate(
+ request.currentBundleURL,
+ allowedVersions: [request.previousVersion]
+ )
+ let recoveryRoot =
+ request.recoveryBundleURL.deletingLastPathComponent()
+ try fileManager.createDirectory(
+ at: recoveryRoot,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ if fileManager.fileExists(
+ atPath: request.recoveryBundlePath
+ ) {
+ try fileManager.removeItem(
+ at: request.recoveryBundleURL
+ )
+ }
+ try fileManager.copyItem(
+ at: request.currentBundleURL,
+ to: request.recoveryBundleURL
+ )
+ try await validator.validate(
+ request.recoveryBundleURL,
+ allowedVersions: [request.previousVersion]
+ )
+ } catch {
+ throw
+ GlossHomebrewUpgradeError
+ .recoveryPreparationFailed(
+ error.localizedDescription
+ )
+ }
+ },
+ restoreIfNeeded: { request in
+ let fileManager = FileManager.default
+ if fileManager.fileExists(
+ atPath: request.currentBundlePath
+ ) {
+ do {
+ try await validator.validate(
+ request.currentBundleURL,
+ allowedVersions: [
+ request.previousVersion,
+ request.expectedVersion,
+ ]
+ )
+ return false
+ } catch {
+ // The installed target is partial or invalid; restore
+ // only from the already-validated private backup.
+ }
+ }
+ do {
+ try await validator.validate(
+ request.recoveryBundleURL,
+ allowedVersions: [request.previousVersion]
+ )
+ let replacementURL =
+ request.currentBundleURL.deletingLastPathComponent()
+ .appendingPathComponent(
+ ".Gloss-recovery-\(UUID().uuidString).app",
+ isDirectory: true
+ )
+ defer {
+ try? fileManager.removeItem(at: replacementURL)
+ }
+ try fileManager.copyItem(
+ at: request.recoveryBundleURL,
+ to: replacementURL
+ )
+ try await validator.validate(
+ replacementURL,
+ allowedVersions: [request.previousVersion]
+ )
+ if fileManager.fileExists(
+ atPath: request.currentBundlePath
+ ) {
+ try fileManager.removeItem(
+ at: request.currentBundleURL
+ )
+ }
+ try fileManager.moveItem(
+ at: replacementURL,
+ to: request.currentBundleURL
+ )
+ try await validator.validate(
+ request.currentBundleURL,
+ allowedVersions: [request.previousVersion]
+ )
+ return true
+ } catch {
+ throw GlossHomebrewUpgradeError.recoveryFailed(
+ error.localizedDescription
+ )
+ }
+ }
+ )
+ }
+}
+
+public struct GlossHomebrewReleaseBindingVerifier: Sendable {
+ public static let repositoryArguments = [
+ "--repository",
+ "sunchj/tap",
+ ]
+
+ public typealias Verify =
+ @Sendable (GlossHomebrewUpgradeRequest) async throws -> Void
+
+ private let verifyImplementation: Verify
+
+ public init(verify: @escaping Verify) {
+ verifyImplementation = verify
+ }
+
+ public func verify(_ request: GlossHomebrewUpgradeRequest) async throws {
+ try await verifyImplementation(request)
+ }
+
+ public static func live(
+ commandRunner: GlossCommandRunner = .live,
+ commandTimeout: Duration = .seconds(60),
+ fileLoader: @escaping @Sendable (URL) throws -> Data = {
+ try Data(contentsOf: $0, options: [.mappedIfSafe])
+ }
+ ) -> Self {
+ Self { request in
+ let repository = try await commandRunner.run(
+ executableURL: request.brewExecutableURL,
+ arguments: Self.repositoryArguments,
+ environment: GlossHomebrewUpgradeWorkflow
+ .noAutomaticUpdateEnvironment,
+ timeout: commandTimeout
+ )
+ guard repository.terminationStatus == 0 else {
+ throw GlossHomebrewUpgradeError.releaseBindingFailed(
+ "无法定位 sunchj/tap"
+ )
+ }
+ let repositoryText = String(
+ decoding: repository.standardOutput,
+ as: UTF8.self
+ ).trimmingCharacters(in: .whitespacesAndNewlines)
+ let repositoryLines = repositoryText.split(whereSeparator: \.isNewline)
+ guard repositoryLines.count == 1,
+ repositoryText.hasPrefix("/")
+ else {
+ throw GlossHomebrewUpgradeError.releaseBindingFailed(
+ "tap 仓库路径无效"
+ )
+ }
+ let repositoryURL = URL(fileURLWithPath: repositoryText)
+ .standardizedFileURL
+ guard repositoryURL.lastPathComponent == "homebrew-tap",
+ repositoryURL.deletingLastPathComponent().lastPathComponent
+ == "sunchj",
+ repositoryURL.resolvingSymlinksInPath() == repositoryURL
+ else {
+ throw GlossHomebrewUpgradeError.releaseBindingFailed(
+ "tap 仓库身份无效"
+ )
+ }
+
+ let caskURL =
+ repositoryURL
+ .appendingPathComponent("Casks", isDirectory: true)
+ .appendingPathComponent("gloss.rb")
+ guard
+ caskURL.standardizedFileURL.path.hasPrefix(
+ repositoryURL.path + "/"
+ ),
+ caskURL.resolvingSymlinksInPath() == caskURL,
+ Self.isRegularFile(caskURL)
+ else {
+ throw GlossHomebrewUpgradeError.releaseBindingFailed(
+ "tap 中的 Casks/gloss.rb 不是受限目录内的常规文件"
+ )
+ }
+ let caskData: Data
+ do {
+ caskData = try fileLoader(caskURL)
+ } catch {
+ throw GlossHomebrewUpgradeError.releaseBindingFailed(
+ "无法读取 tap 中的 Casks/gloss.rb"
+ )
+ }
+ let caskSHA256 = SHA256.hash(data: caskData)
+ .map { String(format: "%02x", $0) }
+ .joined()
+ guard caskData.count == request.expectedHomebrewCaskSize,
+ caskSHA256 == request.expectedHomebrewCaskSHA256
+ else {
+ throw GlossHomebrewUpgradeError.releaseBindingFailed(
+ "tap 中的 Casks/gloss.rb 与签名 manifest 不一致"
+ )
+ }
+
+ let info = try await commandRunner.run(
+ executableURL: request.brewExecutableURL,
+ arguments: GlossHomebrewInstallationDetector.infoArguments,
+ environment: GlossHomebrewUpgradeWorkflow
+ .noAutomaticUpdateEnvironment,
+ timeout: commandTimeout
+ )
+ guard info.terminationStatus == 0 else {
+ throw GlossHomebrewUpgradeError.releaseBindingFailed(
+ "无法读取 Gloss cask metadata"
+ )
+ }
+ let release: GlossHomebrewCaskRelease
+ do {
+ release =
+ try GlossHomebrewInstallationDetector
+ .parseCaskRelease(info.standardOutput)
+ } catch {
+ throw GlossHomebrewUpgradeError.releaseBindingFailed(
+ "Gloss cask metadata 无效"
+ )
+ }
+ guard release.version == request.expectedVersion else {
+ throw GlossHomebrewUpgradeError.releaseBindingFailed(
+ "cask version 与签名 manifest 不一致"
+ )
+ }
+ guard release.url == request.expectedAssetURL else {
+ throw GlossHomebrewUpgradeError.releaseBindingFailed(
+ "当前架构 cask URL 与签名 manifest 不一致"
+ )
+ }
+ guard
+ release.sha256
+ == request.expectedAssetSHA256.lowercased()
+ else {
+ throw GlossHomebrewUpgradeError.releaseBindingFailed(
+ "当前架构 cask SHA-256 与签名 manifest 不一致"
+ )
+ }
+ }
+ }
+
+ private static func isRegularFile(_ url: URL) -> Bool {
+ var metadata = stat()
+ guard lstat(url.path, &metadata) == 0 else {
+ return false
+ }
+ return metadata.st_mode & S_IFMT == S_IFREG
+ }
+}
+
+public struct GlossHomebrewUpgradeWorkflow: Sendable {
+ public static let noAutomaticUpdateEnvironment = [
+ "HOMEBREW_NO_AUTO_UPDATE": "1"
+ ]
+ public static let homebrewUpdateArguments = ["update", "--quiet"]
+ public static let homebrewUpgradeArguments = [
+ "upgrade",
+ "--cask",
+ "--require-sha",
+ GlossHomebrewInstallationDetector.caskToken,
+ ]
+ public static let openExecutableURL = URL(fileURLWithPath: "/usr/bin/open")
+
+ private let commandRunner: GlossCommandRunner
+ private let parentProcessWaiter: GlossParentProcessWaiter
+ private let recoveryManager: GlossAppUpdateRecoveryManager
+ private let releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier
+ private let verifier: GlossHomebrewUpgradeVerifier
+ private let parentExitTimeout: Duration
+ private let commandTimeout: Duration
+ private let now: @Sendable () -> Date
+
+ public init(
+ commandRunner: GlossCommandRunner,
+ parentProcessWaiter: GlossParentProcessWaiter,
+ recoveryManager: GlossAppUpdateRecoveryManager = .live(),
+ releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier? = nil,
+ verifier: GlossHomebrewUpgradeVerifier,
+ parentExitTimeout: Duration = .seconds(120),
+ commandTimeout: Duration = .seconds(10 * 60),
+ now: @escaping @Sendable () -> Date = Date.init
+ ) {
+ self.commandRunner = commandRunner
+ self.parentProcessWaiter = parentProcessWaiter
+ self.recoveryManager = recoveryManager
+ self.releaseBindingVerifier =
+ releaseBindingVerifier
+ ?? .live(
+ commandRunner: commandRunner,
+ commandTimeout: commandTimeout
+ )
+ self.verifier = verifier
+ self.parentExitTimeout = parentExitTimeout
+ self.commandTimeout = commandTimeout
+ self.now = now
+ }
+
+ public static func live() -> Self {
+ let commandRunner = GlossCommandRunner.live
+ return Self(
+ commandRunner: commandRunner,
+ parentProcessWaiter: .live,
+ recoveryManager: .live(),
+ releaseBindingVerifier: .live(
+ commandRunner: commandRunner
+ ),
+ verifier: .live(commandRunner: commandRunner)
+ )
+ }
+
+ @discardableResult
+ public func runAndPersist(
+ _ request: GlossHomebrewUpgradeRequest,
+ afterValidation: @escaping @Sendable () throws -> Void = {}
+ ) async throws -> GlossHomebrewUpgradeResult {
+ var installedVersion: String?
+ var requestIsValid = false
+ var recoveryPrepared = false
+ do {
+ try request.validate()
+ requestIsValid = true
+ guard
+ request.parentProcessIdentifier
+ != Int32(ProcessInfo.processInfo.processIdentifier)
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "helper cannot wait for itself"
+ )
+ }
+ try await recoveryManager.prepare(request)
+ recoveryPrepared = true
+ try afterValidation()
+ try await parentProcessWaiter.wait(
+ for: request.parentProcessIdentifier,
+ timeout: parentExitTimeout
+ )
+
+ let update: GlossCommandOutput
+ do {
+ update = try await commandRunner.run(
+ executableURL: request.brewExecutableURL,
+ arguments: Self.homebrewUpdateArguments,
+ timeout: commandTimeout
+ )
+ } catch {
+ throw GlossHomebrewUpgradeError.homebrewUpdateFailed(
+ status: -1,
+ detail: error.localizedDescription
+ )
+ }
+ guard update.terminationStatus == 0 else {
+ throw GlossHomebrewUpgradeError.homebrewUpdateFailed(
+ status: update.terminationStatus,
+ detail: Self.failureDetail(update)
+ )
+ }
+
+ try await releaseBindingVerifier.verify(request)
+
+ let upgrade: GlossCommandOutput
+ do {
+ upgrade = try await commandRunner.run(
+ executableURL: request.brewExecutableURL,
+ arguments: Self.homebrewUpgradeArguments,
+ environment: Self.noAutomaticUpdateEnvironment,
+ timeout: commandTimeout
+ )
+ } catch {
+ throw GlossHomebrewUpgradeError.homebrewUpgradeFailed(
+ status: -1,
+ detail: error.localizedDescription
+ )
+ }
+ guard upgrade.terminationStatus == 0 else {
+ throw GlossHomebrewUpgradeError.homebrewUpgradeFailed(
+ status: upgrade.terminationStatus,
+ detail: Self.failureDetail(upgrade)
+ )
+ }
+
+ installedVersion = try await verifier.verify(request)
+ let success = GlossHomebrewUpgradeResult(
+ outcome: .succeeded,
+ expectedVersion: request.expectedVersion,
+ installedVersion: installedVersion,
+ completedAt: now()
+ )
+ try GlossHomebrewUpgradeResultStore.write(
+ success,
+ to: request.resultURL
+ )
+
+ try await relaunch(request)
+ return success
+ } catch {
+ guard requestIsValid else {
+ throw error
+ }
+ var recoveredPreviousInstallation = false
+ var recoveryError: String?
+ var reportedError = error
+ if recoveryPrepared {
+ do {
+ recoveredPreviousInstallation =
+ try await recoveryManager.restoreIfNeeded(request)
+ } catch {
+ recoveryError = error.localizedDescription
+ reportedError = GlossHomebrewUpgradeError.recoveryFailed(
+ "原始错误:\(reportedError.localizedDescription);\(error.localizedDescription)"
+ )
+ }
+ }
+ var failure = failureResult(
+ request: request,
+ installedVersion: installedVersion,
+ error: reportedError,
+ recoveredPreviousInstallation:
+ recoveredPreviousInstallation,
+ recoveryError: recoveryError
+ )
+ var resultWriteError: Error?
+ do {
+ try GlossHomebrewUpgradeResultStore.write(
+ failure,
+ to: request.resultURL
+ )
+ } catch {
+ resultWriteError = error
+ }
+ do {
+ try await relaunch(request)
+ } catch {
+ let originalMessage =
+ failure.message ?? "原始更新失败原因未知"
+ let relaunchError = GlossHomebrewUpgradeError.relaunchFailed(
+ status: (error as? GlossHomebrewUpgradeError)
+ .flatMap { upgradeError -> Int32? in
+ if case .relaunchFailed(let status, _) = upgradeError {
+ return status
+ }
+ return nil
+ } ?? -1,
+ detail:
+ "\(originalMessage);重新打开也失败:\(error.localizedDescription)"
+ )
+ failure = failureResult(
+ request: request,
+ installedVersion: installedVersion,
+ error: relaunchError,
+ recoveredPreviousInstallation:
+ recoveredPreviousInstallation,
+ recoveryError: recoveryError
+ )
+ do {
+ try GlossHomebrewUpgradeResultStore.write(
+ failure,
+ to: request.resultURL
+ )
+ } catch {
+ resultWriteError = resultWriteError ?? error
+ }
+ }
+ if let resultWriteError {
+ throw resultWriteError
+ }
+ return failure
+ }
+ }
+
+ private func failureResult(
+ request: GlossHomebrewUpgradeRequest,
+ installedVersion: String?,
+ error: Error,
+ recoveredPreviousInstallation: Bool = false,
+ recoveryError: String? = nil
+ ) -> GlossHomebrewUpgradeResult {
+ let upgradeError = error as? GlossHomebrewUpgradeError
+ return GlossHomebrewUpgradeResult(
+ outcome: .failed,
+ expectedVersion: request.expectedVersion,
+ installedVersion: installedVersion,
+ errorCode: upgradeError?.resultCode ?? "unexpected_error",
+ message: error.localizedDescription,
+ recoveredPreviousInstallation:
+ recoveredPreviousInstallation,
+ recoveryError: recoveryError,
+ completedAt: now()
+ )
+ }
+
+ private static func failureDetail(
+ _ output: GlossCommandOutput
+ ) -> String {
+ let data =
+ output.standardError.isEmpty
+ ? output.standardOutput
+ : output.standardError
+ let text = String(decoding: data, as: UTF8.self)
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else {
+ return "no diagnostic output"
+ }
+ return String(text.prefix(2_000))
+ }
+
+ private func relaunch(
+ _ request: GlossHomebrewUpgradeRequest
+ ) async throws {
+ let relaunch: GlossCommandOutput
+ do {
+ relaunch = try await commandRunner.run(
+ executableURL: Self.openExecutableURL,
+ arguments: [request.currentBundlePath],
+ timeout: .seconds(30)
+ )
+ } catch {
+ throw GlossHomebrewUpgradeError.relaunchFailed(
+ status: -1,
+ detail: error.localizedDescription
+ )
+ }
+ guard relaunch.terminationStatus == 0 else {
+ throw GlossHomebrewUpgradeError.relaunchFailed(
+ status: relaunch.terminationStatus,
+ detail: Self.failureDetail(relaunch)
+ )
+ }
+ }
+}
+
+public struct GlossUpdateHelperLaunch: Equatable, Sendable {
+ public let processIdentifier: Int32
+ public let stagingDirectoryURL: URL
+ public let requestURL: URL
+ public let resultURL: URL
+ public let readinessURL: URL
+
+ public init(
+ processIdentifier: Int32,
+ stagingDirectoryURL: URL,
+ requestURL: URL,
+ resultURL: URL,
+ readinessURL: URL
+ ) {
+ self.processIdentifier = processIdentifier
+ self.stagingDirectoryURL = stagingDirectoryURL
+ self.requestURL = requestURL
+ self.resultURL = resultURL
+ self.readinessURL = readinessURL
+ }
+}
+
+public struct GlossUpdateHelperReadinessWaiter: Sendable {
+ public typealias Wait =
+ @Sendable (URL, UUID, Int32, Duration) async throws -> Void
+
+ private let waitImplementation: Wait
+
+ public init(wait: @escaping Wait) {
+ waitImplementation = wait
+ }
+
+ public func wait(
+ for readinessURL: URL,
+ requestIdentifier: UUID,
+ helperProcessIdentifier: Int32,
+ timeout: Duration
+ ) async throws {
+ try await waitImplementation(
+ readinessURL,
+ requestIdentifier,
+ helperProcessIdentifier,
+ timeout
+ )
+ }
+
+ public static let live = Self {
+ readinessURL,
+ requestIdentifier,
+ helperProcessIdentifier,
+ timeout in
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: timeout)
+ while clock.now < deadline {
+ if FileManager.default.fileExists(atPath: readinessURL.path) {
+ let readiness = try GlossUpdateHelperReadinessStore.load(
+ from: readinessURL
+ )
+ guard
+ readiness.schemaVersion
+ == GlossUpdateHelperReadiness.currentSchemaVersion,
+ readiness.requestIdentifier == requestIdentifier,
+ readiness.helperProcessIdentifier
+ == helperProcessIdentifier
+ else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "helper readiness marker does not match the request"
+ )
+ }
+ return
+ }
+ guard Self.isRunning(helperProcessIdentifier) else {
+ throw GlossHomebrewUpgradeError.helperExitedBeforeReady
+ }
+ try await Task.sleep(for: .milliseconds(50))
+ }
+ if Self.isRunning(helperProcessIdentifier) {
+ kill(pid_t(helperProcessIdentifier), SIGTERM)
+ }
+ throw GlossHomebrewUpgradeError.helperReadinessTimedOut
+ }
+
+ private static func isRunning(_ processIdentifier: Int32) -> Bool {
+ if kill(pid_t(processIdentifier), 0) == 0 {
+ return true
+ }
+ return errno == EPERM
+ }
+}
+
+public struct GlossAppUpdateHelperLauncher: Sendable {
+ public static let helperName = "gloss-update-helper"
+
+ private let readinessWaiter: GlossUpdateHelperReadinessWaiter
+
+ public init(
+ readinessWaiter: GlossUpdateHelperReadinessWaiter = .live
+ ) {
+ self.readinessWaiter = readinessWaiter
+ }
+
+ public func launch(
+ bundledHelperURL: URL,
+ installation: GlossHomebrewInstallation,
+ update: GlossAppUpdateAvailability,
+ parentProcessIdentifier: Int32,
+ currentBundleURL: URL,
+ cacheRootURL: URL,
+ resultURL: URL,
+ readinessTimeout: Duration = .seconds(30)
+ ) async throws -> GlossUpdateHelperLaunch {
+ let fileManager = FileManager.default
+ guard fileManager.isExecutableFile(atPath: bundledHelperURL.path) else {
+ throw GlossHomebrewUpgradeError.invalidRequest(
+ "bundled update helper is missing"
+ )
+ }
+
+ let stagingDirectoryURL = cacheRootURL.appendingPathComponent(
+ UUID().uuidString,
+ isDirectory: true
+ )
+ let stagedHelperURL = stagingDirectoryURL.appendingPathComponent(
+ Self.helperName
+ )
+ let requestURL = stagingDirectoryURL.appendingPathComponent(
+ "request.json"
+ )
+ let readinessURL = stagingDirectoryURL.appendingPathComponent(
+ "ready.json"
+ )
+ let manifestURL = stagingDirectoryURL.appendingPathComponent(
+ "release-manifest.json"
+ )
+ let manifestSignatureURL = stagingDirectoryURL.appendingPathComponent(
+ "release-manifest.json.sig"
+ )
+ let recoveryBundleURL =
+ stagingDirectoryURL
+ .appendingPathComponent("recovery", isDirectory: true)
+ .appendingPathComponent("Gloss.app", isDirectory: true)
+ let requestIdentifier = UUID()
+ let request = GlossHomebrewUpgradeRequest(
+ installation: installation,
+ update: update,
+ parentProcessIdentifier: parentProcessIdentifier,
+ currentBundleURL: currentBundleURL,
+ resultURL: resultURL,
+ readinessURL: readinessURL,
+ manifestURL: manifestURL,
+ manifestSignatureURL: manifestSignatureURL,
+ recoveryBundleURL: recoveryBundleURL,
+ requestIdentifier: requestIdentifier
+ )
+ try request.validate()
+
+ var process: Process?
+ do {
+ try fileManager.createDirectory(
+ at: stagingDirectoryURL,
+ withIntermediateDirectories: true,
+ attributes: [.posixPermissions: 0o700]
+ )
+ try fileManager.copyItem(
+ at: bundledHelperURL,
+ to: stagedHelperURL
+ )
+ try fileManager.setAttributes(
+ [.posixPermissions: 0o700],
+ ofItemAtPath: stagedHelperURL.path
+ )
+ if fileManager.fileExists(atPath: resultURL.path) {
+ try fileManager.removeItem(at: resultURL)
+ }
+ try update.manifestData.write(to: manifestURL, options: .atomic)
+ try update.detachedSignatureData.write(
+ to: manifestSignatureURL,
+ options: .atomic
+ )
+ for protectedURL in [manifestURL, manifestSignatureURL] {
+ try fileManager.setAttributes(
+ [.posixPermissions: 0o600],
+ ofItemAtPath: protectedURL.path
+ )
+ }
+ try GlossHomebrewUpgradeRequestStore.write(
+ request,
+ to: requestURL
+ )
+
+ let helperProcess = Process()
+ process = helperProcess
+ helperProcess.executableURL = stagedHelperURL
+ helperProcess.arguments = [
+ GlossUpdateHelperArguments.requestFileFlag,
+ requestURL.path,
+ ]
+ helperProcess.standardInput = FileHandle.nullDevice
+ helperProcess.standardOutput = FileHandle.nullDevice
+ helperProcess.standardError = FileHandle.nullDevice
+ try helperProcess.run()
+ try await readinessWaiter.wait(
+ for: readinessURL,
+ requestIdentifier: requestIdentifier,
+ helperProcessIdentifier: helperProcess.processIdentifier,
+ timeout: readinessTimeout
+ )
+
+ return GlossUpdateHelperLaunch(
+ processIdentifier: helperProcess.processIdentifier,
+ stagingDirectoryURL: stagingDirectoryURL,
+ requestURL: requestURL,
+ resultURL: resultURL,
+ readinessURL: readinessURL
+ )
+ } catch {
+ if let process, process.isRunning {
+ process.terminate()
+ }
+ try? fileManager.removeItem(at: stagingDirectoryURL)
+ throw error
+ }
+ }
+}
diff --git a/Sources/GlossUpdateHelper/main.swift b/Sources/GlossUpdateHelper/main.swift
new file mode 100644
index 0000000..123f39d
--- /dev/null
+++ b/Sources/GlossUpdateHelper/main.swift
@@ -0,0 +1,36 @@
+import Foundation
+import GlossCore
+
+@main
+struct GlossUpdateHelperMain {
+ static func main() async {
+ do {
+ let requestURL = try GlossUpdateHelperArguments.requestFileURL(
+ from: Array(CommandLine.arguments.dropFirst())
+ )
+ let request = try GlossHomebrewUpgradeRequestStore.load(
+ from: requestURL
+ )
+ let signedRequestVerifier =
+ try GlossSignedAppUpdateRequestVerifier()
+ let result = try await GlossHomebrewUpgradeWorkflow.live()
+ .runAndPersist(request) {
+ try signedRequestVerifier.verify(request)
+ try GlossUpdateHelperReadinessStore.write(
+ GlossUpdateHelperReadiness(
+ requestIdentifier: request.requestIdentifier,
+ helperProcessIdentifier:
+ Int32(ProcessInfo.processInfo.processIdentifier)
+ ),
+ to: request.readinessURL
+ )
+ }
+ exit(result.succeeded ? EXIT_SUCCESS : EXIT_FAILURE)
+ } catch {
+ FileHandle.standardError.write(
+ Data("gloss-update-helper: \(error.localizedDescription)\n".utf8)
+ )
+ exit(EXIT_FAILURE)
+ }
+ }
+}
diff --git a/Tests/GlossAppTests/AppUpdateControllerTests.swift b/Tests/GlossAppTests/AppUpdateControllerTests.swift
new file mode 100644
index 0000000..e3c2a9d
--- /dev/null
+++ b/Tests/GlossAppTests/AppUpdateControllerTests.swift
@@ -0,0 +1,278 @@
+import Foundation
+import GlossCore
+import XCTest
+
+@testable import Gloss
+
+@MainActor
+final class AppUpdateControllerTests: XCTestCase {
+ func testAutomaticThrottleReturnsToIdleWithoutUserFacingFailure() async {
+ let controller = makeController(
+ check: { _ in
+ .throttled(nextCheckAt: Date(timeIntervalSince1970: 2_000))
+ }
+ )
+
+ await controller.check(mode: .automatic)
+
+ XCTAssertEqual(
+ controller.state,
+ .idle(currentVersion: "0.8.2")
+ )
+ }
+
+ func testManagedInstallIsOfferedAfterSignedUpdateDiscovery() async {
+ let expectedInstallation = installation()
+ let controller = makeController(
+ check: { _ in .updateAvailable(self.updateAvailability()) },
+ detect: { expectedInstallation }
+ )
+
+ await controller.check(mode: .manual)
+
+ XCTAssertEqual(
+ controller.state,
+ .updateAvailable(
+ updateAvailability(),
+ delivery: .homebrew(expectedInstallation)
+ )
+ )
+ }
+
+ func testUnmanagedInstallOnlyOpensOfficialReleasePage() async {
+ var openedURL: URL?
+ let controller = makeController(
+ check: { _ in .updateAvailable(self.updateAvailability()) },
+ detect: { nil },
+ openReleasePage: {
+ openedURL = $0
+ return true
+ }
+ )
+
+ await controller.check(mode: .manual)
+ await controller.performPrimaryAction()
+
+ XCTAssertEqual(openedURL, updateAvailability().releasePageURL)
+ }
+
+ func testActivePDFDefersInstallWithoutLaunchingHelper() async {
+ var helperLaunchCount = 0
+ var terminationCount = 0
+ let controller = makeController(
+ check: { _ in .updateAvailable(self.updateAvailability()) },
+ detect: { self.installation() },
+ launch: { _, _ in helperLaunchCount += 1 },
+ isPDFActive: { true },
+ terminate: { terminationCount += 1 }
+ )
+
+ await controller.check(mode: .manual)
+ await controller.performPrimaryAction()
+
+ XCTAssertEqual(helperLaunchCount, 0)
+ XCTAssertEqual(terminationCount, 0)
+ guard case .blockedByBusinessTask = controller.state else {
+ return XCTFail("Expected business-task blocked state")
+ }
+ }
+
+ func testVerifiedHelperLaunchRequestsTermination() async {
+ var launchedInstallation: GlossHomebrewInstallation?
+ var launchedUpdate: GlossAppUpdateAvailability?
+ var terminationCount = 0
+ let controller = makeController(
+ check: { _ in .updateAvailable(self.updateAvailability()) },
+ detect: { self.installation() },
+ launch: { installation, update in
+ launchedInstallation = installation
+ launchedUpdate = update
+ },
+ terminate: { terminationCount += 1 }
+ )
+
+ await controller.check(mode: .manual)
+ await controller.performPrimaryAction()
+
+ XCTAssertEqual(launchedInstallation, installation())
+ XCTAssertEqual(launchedUpdate, updateAvailability())
+ XCTAssertEqual(terminationCount, 1)
+ XCTAssertEqual(
+ controller.state,
+ .preparingInstall(version: "0.8.3")
+ )
+ }
+
+ func testUnsupportedMinimumSystemDoesNotInspectHomebrew() async {
+ var detectionCount = 0
+ let controller = makeController(
+ check: { _ in .updateAvailable(self.updateAvailability(minimum: "15.0")) },
+ detect: {
+ detectionCount += 1
+ return self.installation()
+ },
+ operatingSystemVersion: OperatingSystemVersion(
+ majorVersion: 14,
+ minorVersion: 7,
+ patchVersion: 0
+ )
+ )
+
+ await controller.check(mode: .manual)
+
+ XCTAssertEqual(detectionCount, 0)
+ guard case .unavailable(_, let reason) = controller.state else {
+ return XCTFail("Expected unavailable state")
+ }
+ XCTAssertTrue(reason.contains("macOS 15.0"))
+ }
+
+ func testSystemVersionComparisonUsesAllComponents() {
+ XCTAssertTrue(
+ AppUpdateController.supports(
+ minimumMacOSVersion: "14.1",
+ current: OperatingSystemVersion(
+ majorVersion: 14,
+ minorVersion: 1,
+ patchVersion: 0
+ )
+ )
+ )
+ XCTAssertFalse(
+ AppUpdateController.supports(
+ minimumMacOSVersion: "14.1.1",
+ current: OperatingSystemVersion(
+ majorVersion: 14,
+ minorVersion: 1,
+ patchVersion: 0
+ )
+ )
+ )
+ }
+
+ func testStagingCleanupRemovesOnlyTheScopedUpdaterDirectory() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(
+ "gloss-app-update-cleanup-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ .appendingPathComponent("AppUpdater", isDirectory: true)
+ let staging = root.appendingPathComponent(
+ "staging",
+ isDirectory: true
+ )
+ let helper =
+ staging
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ .appendingPathComponent("gloss-update-helper")
+ try FileManager.default.createDirectory(
+ at: helper.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try Data("helper".utf8).write(to: helper)
+ defer {
+ try? FileManager.default.removeItem(
+ at: root.deletingLastPathComponent()
+ )
+ }
+
+ try AppUpdateStagingCleaner.removeStagedHelpers(at: staging)
+
+ XCTAssertFalse(
+ FileManager.default.fileExists(atPath: staging.path)
+ )
+ XCTAssertThrowsError(
+ try AppUpdateStagingCleaner.removeStagedHelpers(
+ at: root.deletingLastPathComponent()
+ )
+ ) { error in
+ XCTAssertEqual(
+ error as? AppUpdateStagingCleaner.CleanupError,
+ .invalidDirectory
+ )
+ }
+ }
+
+ private func makeController(
+ check:
+ @escaping (GlossAppUpdateCheckMode) async throws
+ -> GlossAppUpdateCheckResult,
+ detect: @escaping () async throws -> GlossHomebrewInstallation? = {
+ nil
+ },
+ launch:
+ @escaping (
+ GlossHomebrewInstallation,
+ GlossAppUpdateAvailability
+ ) async throws -> Void = { _, _ in },
+ openReleasePage: @escaping (URL) -> Bool = { _ in true },
+ isPDFActive: @escaping () -> Bool = { false },
+ terminate: @escaping () -> Void = {},
+ operatingSystemVersion: OperatingSystemVersion = OperatingSystemVersion(
+ majorVersion: 14,
+ minorVersion: 0,
+ patchVersion: 0
+ )
+ ) -> AppUpdateController {
+ AppUpdateController(
+ currentVersion: "0.8.2",
+ dependencies: AppUpdateController.Dependencies(
+ check: check,
+ detectHomebrewInstallation: detect,
+ launchHomebrewUpdate: launch,
+ openReleasePage: openReleasePage,
+ isBusinessTaskActive: isPDFActive,
+ requestApplicationTermination: terminate,
+ operatingSystemVersion: { operatingSystemVersion }
+ )
+ )
+ }
+
+ private func updateAvailability(
+ minimum: String = "14.0"
+ ) -> GlossAppUpdateAvailability {
+ GlossAppUpdateAvailability(
+ version: "0.8.3",
+ releaseTag: "v0.8.3",
+ publishedAt: Date(timeIntervalSince1970: 1_000),
+ minimumMacOSVersion: minimum,
+ releasePageURL: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/tag/v0.8.3"
+ )!,
+ architecture: GlossAppArchitecture.current,
+ assetURL: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/download/v0.8.3/Gloss-macos-\(GlossAppArchitecture.current).zip"
+ )!,
+ assetSHA256: String(repeating: "a", count: 64),
+ assetSize: 100,
+ homebrewCask: GlossAppReleaseManifest.HomebrewCask(
+ url: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/download/v0.8.3/gloss.rb"
+ )!,
+ sha256: String(repeating: "b", count: 64),
+ size: 200
+ ),
+ manifestData: Data("manifest".utf8),
+ detachedSignatureData: Data("signature".utf8)
+ )
+ }
+
+ private func installation() -> GlossHomebrewInstallation {
+ GlossHomebrewInstallation(
+ brewExecutableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"),
+ caskToken: "sunchj/tap/gloss",
+ installedVersion: "0.8.2",
+ availableVersion: "0.8.2",
+ managedAppURL: URL(
+ fileURLWithPath:
+ "/opt/homebrew/Caskroom/gloss/0.8.2/Gloss.app"
+ ),
+ installedAppTargetURL: URL(
+ fileURLWithPath: "/Applications/Gloss.app"
+ )
+ )
+ }
+}
diff --git a/Tests/GlossAppTests/AppUpdateDashboardStateTests.swift b/Tests/GlossAppTests/AppUpdateDashboardStateTests.swift
new file mode 100644
index 0000000..c29f342
--- /dev/null
+++ b/Tests/GlossAppTests/AppUpdateDashboardStateTests.swift
@@ -0,0 +1,160 @@
+import GlossCore
+import XCTest
+
+@testable import Gloss
+
+final class AppUpdateDashboardStateTests: XCTestCase {
+ func testManagedUpdateOffersHomebrewRestart() {
+ let update = updateAvailability()
+ let state = AppUpdateDashboardState.updateAvailable(
+ update,
+ delivery: .homebrew(installation())
+ )
+
+ XCTAssertEqual(state.action, .install)
+ XCTAssertEqual(
+ state.presentation.actionTitle,
+ "更新并重新启动"
+ )
+ XCTAssertTrue(state.presentation.detail.contains("Homebrew"))
+ XCTAssertEqual(
+ state.menuPresentation.title,
+ "更新 Gloss 到 0.8.3…"
+ )
+ }
+
+ func testUnmanagedUpdateOnlyOffersOfficialReleasePage() {
+ let state = AppUpdateDashboardState.updateAvailable(
+ updateAvailability(),
+ delivery: .releasePage
+ )
+
+ XCTAssertEqual(state.action, .openReleasePage)
+ XCTAssertEqual(state.presentation.actionTitle, "查看下载")
+ XCTAssertTrue(state.presentation.detail.contains("sunchj/tap/gloss"))
+ XCTAssertEqual(
+ state.menuPresentation.title,
+ "下载 Gloss 0.8.3…"
+ )
+ }
+
+ func testBusinessTaskBlockKeepsVerifiedInstallationForRetry() {
+ let expectedInstallation = installation()
+ let state = AppUpdateDashboardState.blockedByBusinessTask(
+ updateAvailability(),
+ installation: expectedInstallation
+ )
+
+ XCTAssertEqual(state.action, .install)
+ XCTAssertEqual(state.presentation.headline, "等待当前翻译任务完成")
+ XCTAssertTrue(state.menuPresentation.title.contains("0.8.3"))
+ guard case .blockedByBusinessTask(_, let actualInstallation) = state else {
+ return XCTFail("Expected blocked business-task state")
+ }
+ XCTAssertEqual(actualInstallation, expectedInstallation)
+ }
+
+ func testCheckingAndPreparingStatesDisableRepeatedActions() {
+ let checking = AppUpdateDashboardState.checking(
+ currentVersion: "0.8.2"
+ )
+ let preparing = AppUpdateDashboardState.preparingInstall(
+ version: "0.8.3"
+ )
+
+ XCTAssertNil(checking.action)
+ XCTAssertFalse(checking.presentation.actionEnabled)
+ XCTAssertTrue(checking.presentation.showsProgress)
+ XCTAssertNil(preparing.action)
+ XCTAssertFalse(preparing.menuPresentation.isEnabled)
+ }
+
+ func testFailedHelperResultIsVisibleAndRetryableOnRelaunch() {
+ let state = AppUpdateDashboardState.fromHomebrewResult(
+ GlossHomebrewUpgradeResult(
+ outcome: .failed,
+ expectedVersion: "0.8.3",
+ errorCode: "homebrew_upgrade_failed",
+ message: "tap 尚未同步",
+ completedAt: Date(timeIntervalSince1970: 2_000)
+ ),
+ currentVersion: "0.8.2"
+ )
+
+ XCTAssertEqual(
+ state,
+ .failed(
+ currentVersion: "0.8.2",
+ message: "tap 尚未同步"
+ )
+ )
+ XCTAssertEqual(state.action, .check)
+ XCTAssertEqual(state.presentation.actionTitle, "重试")
+ }
+
+ func testSuccessfulHelperResultUsesInstalledVersion() {
+ let state = AppUpdateDashboardState.fromHomebrewResult(
+ GlossHomebrewUpgradeResult(
+ outcome: .succeeded,
+ expectedVersion: "0.8.3",
+ installedVersion: "0.8.4",
+ completedAt: Date(timeIntervalSince1970: 2_000)
+ ),
+ currentVersion: "0.8.4"
+ )
+
+ XCTAssertEqual(
+ state,
+ .upToDate(
+ currentVersion: "0.8.4",
+ latestVersion: "0.8.4"
+ )
+ )
+ }
+
+ private func updateAvailability() -> GlossAppUpdateAvailability {
+ GlossAppUpdateAvailability(
+ version: "0.8.3",
+ releaseTag: "v0.8.3",
+ publishedAt: Date(timeIntervalSince1970: 1_000),
+ minimumMacOSVersion: "14.0",
+ releasePageURL: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/tag/v0.8.3"
+ )!,
+ architecture: GlossAppArchitecture.current,
+ assetURL: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/download/v0.8.3/Gloss-macos-\(GlossAppArchitecture.current).zip"
+ )!,
+ assetSHA256: String(repeating: "a", count: 64),
+ assetSize: 100,
+ homebrewCask: GlossAppReleaseManifest.HomebrewCask(
+ url: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/download/v0.8.3/gloss.rb"
+ )!,
+ sha256: String(repeating: "b", count: 64),
+ size: 200
+ ),
+ manifestData: Data("manifest".utf8),
+ detachedSignatureData: Data("signature".utf8)
+ )
+ }
+
+ private func installation() -> GlossHomebrewInstallation {
+ GlossHomebrewInstallation(
+ brewExecutableURL: URL(fileURLWithPath: "/opt/homebrew/bin/brew"),
+ caskToken: "sunchj/tap/gloss",
+ installedVersion: "0.8.2",
+ availableVersion: "0.8.2",
+ managedAppURL: URL(
+ fileURLWithPath:
+ "/opt/homebrew/Caskroom/gloss/0.8.2/Gloss.app"
+ ),
+ installedAppTargetURL: URL(
+ fileURLWithPath: "/Applications/Gloss.app"
+ )
+ )
+ }
+}
diff --git a/Tests/GlossAppTests/GlossAppScenarioActivationTests.swift b/Tests/GlossAppTests/GlossAppScenarioActivationTests.swift
new file mode 100644
index 0000000..881c69f
--- /dev/null
+++ b/Tests/GlossAppTests/GlossAppScenarioActivationTests.swift
@@ -0,0 +1,79 @@
+import GlossCore
+import XCTest
+
+@testable import Gloss
+
+final class GlossAppScenarioActivationTests: XCTestCase {
+ func testDefaultScenariosStartBrowserAndPDFInfrastructure() {
+ let activation = GlossAppScenarioActivation(registry: .current)
+
+ XCTAssertTrue(activation.startsTranslationBridge)
+ XCTAssertTrue(activation.preparesBrowserExtensions)
+ XCTAssertTrue(activation.preparesPDFRuntime)
+ XCTAssertTrue(activation.prewarmsTranslationProvider)
+ XCTAssertTrue(activation.acceptsPDFOpenRequests)
+ XCTAssertFalse(activation.configuresSystemServices)
+ XCTAssertFalse(activation.observesWorkspaceApplications)
+ }
+
+ func testBrowserOnlyDoesNotStartPDFInfrastructure() {
+ let activation = GlossAppScenarioActivation(
+ registry: GlossCapabilityRegistry(
+ enabledScenarios: [.browserTranslation]
+ )
+ )
+
+ XCTAssertTrue(activation.startsTranslationBridge)
+ XCTAssertTrue(activation.preparesBrowserExtensions)
+ XCTAssertTrue(activation.prewarmsTranslationProvider)
+ XCTAssertFalse(activation.preparesPDFRuntime)
+ XCTAssertFalse(activation.acceptsPDFOpenRequests)
+ }
+
+ func testPDFOnlyStillStartsTranslationBridge() {
+ let activation = GlossAppScenarioActivation(
+ registry: GlossCapabilityRegistry(
+ enabledScenarios: [.pdfTranslation]
+ )
+ )
+
+ XCTAssertTrue(activation.startsTranslationBridge)
+ XCTAssertFalse(activation.preparesBrowserExtensions)
+ XCTAssertTrue(activation.preparesPDFRuntime)
+ XCTAssertTrue(activation.prewarmsTranslationProvider)
+ XCTAssertTrue(activation.acceptsPDFOpenRequests)
+ }
+
+ func testNoBusinessScenariosStartNoTranslationServices() {
+ let activation = GlossAppScenarioActivation(
+ registry: GlossCapabilityRegistry(enabledScenarios: [])
+ )
+
+ XCTAssertFalse(activation.startsTranslationBridge)
+ XCTAssertFalse(activation.preparesBrowserExtensions)
+ XCTAssertFalse(activation.preparesPDFRuntime)
+ XCTAssertFalse(activation.prewarmsTranslationProvider)
+ XCTAssertFalse(activation.acceptsPDFOpenRequests)
+ XCTAssertFalse(activation.configuresSystemServices)
+ XCTAssertFalse(activation.observesWorkspaceApplications)
+ }
+
+ func testSecondaryScenariosStartOnlyTheirSupportingLifecycleWork() {
+ let activation = GlossAppScenarioActivation(
+ registry: GlossCapabilityRegistry(
+ enabledScenarios: [
+ .clipboardTranslation,
+ .imageTranslation,
+ .selectionTranslation,
+ ]
+ )
+ )
+
+ XCTAssertTrue(activation.configuresSystemServices)
+ XCTAssertTrue(activation.observesWorkspaceApplications)
+ XCTAssertTrue(activation.prewarmsTranslationProvider)
+ XCTAssertFalse(activation.startsTranslationBridge)
+ XCTAssertFalse(activation.preparesBrowserExtensions)
+ XCTAssertFalse(activation.preparesPDFRuntime)
+ }
+}
diff --git a/Tests/GlossAppTests/PDFRuntimeLifecycleTests.swift b/Tests/GlossAppTests/PDFRuntimeLifecycleTests.swift
index 0529291..ced1c56 100644
--- a/Tests/GlossAppTests/PDFRuntimeLifecycleTests.swift
+++ b/Tests/GlossAppTests/PDFRuntimeLifecycleTests.swift
@@ -168,4 +168,141 @@ final class PDFRuntimeLifecycleTests: XCTestCase {
)
)
}
+
+ func testIncompatibleManagedRuntimeConsumesVerifiedUpdateBeforeStarting() {
+ let checked = runtimeSnapshot(
+ currentVersion: "0.6.4+gloss.4",
+ availableVersion: "0.6.4+gloss.5",
+ updateAvailable: true,
+ operation: .ready
+ )
+
+ XCTAssertEqual(
+ PDFRuntimeController.managedRuntimePreparation(for: checked),
+ .installAvailableUpdate
+ )
+ }
+
+ func testCompatibleManagedRuntimeStartsWhileUpdateCheckRemainsAdvisory() {
+ let checked = runtimeSnapshot(
+ currentVersion: "0.6.4+gloss.5",
+ availableVersion: "0.6.4+gloss.6",
+ updateAvailable: true,
+ operation: .ready
+ )
+
+ XCTAssertEqual(
+ PDFRuntimeController.managedRuntimePreparation(for: checked),
+ .startCurrent
+ )
+ XCTAssertEqual(
+ PDFRuntimeController.compatibleManagedRuntimeLaunch(
+ for: checked
+ )?.source,
+ "Gloss runtime 0.6.4+gloss.5"
+ )
+ }
+
+ func testCompatibleManagedRuntimeRemainsAvailableOffline() {
+ let offline = runtimeSnapshot(
+ currentVersion: "0.6.4+gloss.5",
+ availableVersion: nil,
+ updateAvailable: false,
+ operation: .failed
+ )
+
+ XCTAssertEqual(
+ PDFRuntimeController.managedRuntimePreparation(for: offline),
+ .startCurrent
+ )
+ XCTAssertTrue(
+ PDFRuntimeController.isCompatibleManagedRuntimeVersion(
+ "0.6.4+gloss.10"
+ )
+ )
+ }
+
+ func testMissingAndUnversionedManagedRuntimesCannotLaunch() {
+ let missing = runtimeSnapshot(
+ currentVersion: nil,
+ availableVersion: nil,
+ updateAvailable: false,
+ operation: .idle,
+ executableURL: nil
+ )
+ let unversioned = runtimeSnapshot(
+ currentVersion: nil,
+ availableVersion: nil,
+ updateAvailable: false,
+ operation: .ready
+ )
+
+ XCTAssertEqual(
+ PDFRuntimeController.managedRuntimePreparation(for: missing),
+ .install
+ )
+ XCTAssertEqual(
+ PDFRuntimeController.managedRuntimePreparation(for: unversioned),
+ .updateRequired(currentVersion: "未知版本")
+ )
+ XCTAssertNil(
+ PDFRuntimeController.compatibleManagedRuntimeLaunch(for: missing)
+ )
+ XCTAssertNil(
+ PDFRuntimeController.compatibleManagedRuntimeLaunch(for: unversioned)
+ )
+ }
+
+ func testControllerDoesNotFallBackToUnverifiedExternalRuntime() {
+ let controller = PDFRuntimeController(
+ service: BabelDOCServiceSession(
+ persistedStateDirectoryURL: FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ ),
+ runtimeManager: nil
+ )
+
+ XCTAssertNil(controller.currentRuntimeLaunch)
+ }
+
+ func testIncompatibleManagedRuntimeRequiresUpdateWhenOffline() {
+ let offline = runtimeSnapshot(
+ currentVersion: "0.6.4+gloss.4",
+ availableVersion: nil,
+ updateAvailable: false,
+ operation: .failed
+ )
+
+ XCTAssertEqual(
+ PDFRuntimeController.managedRuntimePreparation(for: offline),
+ .updateRequired(currentVersion: "0.6.4+gloss.4")
+ )
+ XCTAssertFalse(
+ PDFRuntimeController.isCompatibleManagedRuntimeVersion(
+ "0.6.4+gloss.4"
+ )
+ )
+ }
+
+ private func runtimeSnapshot(
+ currentVersion: String?,
+ availableVersion: String?,
+ updateAvailable: Bool,
+ operation: BabelDOCRuntimeOperation,
+ executableURL: URL? = URL(
+ fileURLWithPath: "/runtime/gloss-babeldoc"
+ )
+ ) -> BabelDOCRuntimeSnapshot {
+ BabelDOCRuntimeSnapshot(
+ channel: .stable,
+ pinnedVersion: nil,
+ currentVersion: currentVersion,
+ previousVersion: nil,
+ availableVersion: availableVersion,
+ currentExecutableURL: executableURL,
+ updateAvailable: updateAvailable,
+ operation: operation,
+ lastError: operation == .failed ? "offline" : nil
+ )
+ }
}
diff --git a/Tests/GlossCoreTests/BabelDOCRuntimeCompatibilityTests.swift b/Tests/GlossCoreTests/BabelDOCRuntimeCompatibilityTests.swift
new file mode 100644
index 0000000..6e3754a
--- /dev/null
+++ b/Tests/GlossCoreTests/BabelDOCRuntimeCompatibilityTests.swift
@@ -0,0 +1,124 @@
+import Foundation
+import XCTest
+
+@testable import GlossCore
+
+final class BabelDOCRuntimeCompatibilityTests: XCTestCase {
+ func testMinimumCompatibleManagedRuntimeVersion() {
+ XCTAssertFalse(
+ BabelDOCRuntimeCompatibility.isCompatible(
+ "0.6.4+gloss.4"
+ )
+ )
+ XCTAssertTrue(
+ BabelDOCRuntimeCompatibility.isCompatible(
+ "0.6.4+gloss.5"
+ )
+ )
+ XCTAssertTrue(
+ BabelDOCRuntimeCompatibility.isCompatible(
+ "0.6.5+gloss.1"
+ )
+ )
+ XCTAssertTrue(
+ BabelDOCRuntimeCompatibility.isCompatible(
+ "0.6.5"
+ )
+ )
+ }
+
+ func testUnprovenRuntimeVersionsFailClosed() {
+ let unprovenVersions: [String?] = [
+ nil,
+ "",
+ "0.6.4",
+ "0.6.4+gloss.05",
+ "0.6.4+gloss.5-dev",
+ "0.6.4+gloss.5.1",
+ "0.6.4+other.9",
+ "not-a-version",
+ ]
+
+ for version in unprovenVersions {
+ XCTAssertFalse(
+ BabelDOCRuntimeCompatibility.isCompatible(version),
+ "\(version ?? "nil") should not be trusted"
+ )
+ }
+ }
+
+ func testStandaloneHelperResolvesEnclosingAppVersion() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(
+ "gloss-version-test-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ defer { try? FileManager.default.removeItem(at: root) }
+ let contents = root.appendingPathComponent(
+ "Gloss.app/Contents",
+ isDirectory: true
+ )
+ let helper = contents.appendingPathComponent(
+ "Helpers/gloss-cli"
+ )
+ try FileManager.default.createDirectory(
+ at: helper.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ let plist = try PropertyListSerialization.data(
+ fromPropertyList: [
+ "CFBundleShortVersionString": "9.8.7"
+ ],
+ format: .xml,
+ options: 0
+ )
+ try plist.write(
+ to: contents.appendingPathComponent("Info.plist")
+ )
+
+ XCTAssertEqual(
+ GlossProductVersionResolver.resolve(
+ bundleVersion: nil,
+ executableURL: helper,
+ workingDirectoryURL: root
+ ),
+ "9.8.7"
+ )
+ }
+
+ func testDevelopmentCheckoutResolvesResourcesPlist() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(
+ "gloss-checkout-version-test-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ defer { try? FileManager.default.removeItem(at: root) }
+ let resources = root.appendingPathComponent(
+ "Resources",
+ isDirectory: true
+ )
+ try FileManager.default.createDirectory(
+ at: resources,
+ withIntermediateDirectories: true
+ )
+ let plist = try PropertyListSerialization.data(
+ fromPropertyList: [
+ "CFBundleShortVersionString": "1.2.3"
+ ],
+ format: .xml,
+ options: 0
+ )
+ try plist.write(
+ to: resources.appendingPathComponent("Info.plist")
+ )
+
+ XCTAssertEqual(
+ GlossProductVersionResolver.resolve(
+ bundleVersion: nil,
+ executableURL: nil,
+ workingDirectoryURL: root
+ ),
+ "1.2.3"
+ )
+ }
+}
diff --git a/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift b/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift
index 95c4a1b..04f9315 100644
--- a/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift
+++ b/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift
@@ -544,8 +544,8 @@ struct BabelDOCRuntimeDistributionTests {
#expect(try Data(contentsOf: executable) == firstPayload)
}
- @Test("update check uses an injected URL and transport")
- func updateCheckAndInstallUseInjectedURL() async throws {
+ @Test("update fetches signed metadata once and installs it")
+ func updateUsesInjectedURLAndTransport() async throws {
let root = try temporaryDirectory()
let payload = Data("runtime".utf8)
let manifestURL = try #require(URL(string: "https://updates.example.test/custom.json"))
@@ -567,17 +567,49 @@ struct BabelDOCRuntimeDistributionTests {
manifestSigningPublicKey: signingPublicKey
)
- let checked = try await manager.checkForUpdates(manifestURL: manifestURL)
- #expect(checked.availableVersion == "2.0.0")
- #expect(checked.updateAvailable)
+ let installed = try await manager.update(manifestURL: manifestURL)
+ #expect(installed.currentVersion == "2.0.0")
+ #expect(!installed.updateAvailable)
#expect(
Set(await recorder.fetchedURLs)
== Set([manifestURL, signatureURL(for: manifestURL)])
)
+ #expect(await recorder.fetchedURLs.count == 2)
+ #expect(await recorder.downloadedURLs == [assetURL])
+ }
- let installed = try await manager.update(manifestURL: manifestURL)
+ @Test("available update installs without fetching metadata again")
+ func installAvailableUpdateReusesVerifiedManifest() async throws {
+ let root = try temporaryDirectory()
+ let payload = Data("runtime".utf8)
+ let manifestURL = try #require(URL(string: "https://updates.example.test/custom.json"))
+ let assetURL = try #require(URL(string: "https://updates.example.test/runtime"))
+ let runtimeManifest = manifest(
+ version: "2.0.0",
+ assets: [asset(url: assetURL, payload: payload)]
+ )
+ let manifestData = try JSONEncoder().encode(runtimeManifest)
+ let recorder = URLRecorder()
+ let manager = try BabelDOCRuntimeManager(
+ rootDirectory: root,
+ platform: platform,
+ transport: transport(
+ payloads: [assetURL: payload],
+ manifestData: manifestData,
+ recorder: recorder
+ ),
+ manifestSigningPublicKey: signingPublicKey
+ )
+
+ let checked = try await manager.checkForUpdates(manifestURL: manifestURL)
+ #expect(checked.availableVersion == "2.0.0")
+ #expect(checked.updateAvailable)
+ let fetchesAfterCheck = await recorder.fetchedURLs
+
+ let installed = try await manager.installAvailableUpdate()
#expect(installed.currentVersion == "2.0.0")
#expect(!installed.updateAvailable)
+ #expect(await recorder.fetchedURLs == fetchesAfterCheck)
#expect(await recorder.downloadedURLs == [assetURL])
}
diff --git a/Tests/GlossCoreTests/CodexAppServerClientTests.swift b/Tests/GlossCoreTests/CodexAppServerClientTests.swift
index 977c3d4..131c472 100644
--- a/Tests/GlossCoreTests/CodexAppServerClientTests.swift
+++ b/Tests/GlossCoreTests/CodexAppServerClientTests.swift
@@ -196,6 +196,47 @@ final class CodexAppServerClientTests: XCTestCase {
XCTAssertEqual(launch.source, "external-cli")
}
+ func testHelperExecutableFindsSiblingBundledAppServer() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let helpers = root.appendingPathComponent(
+ "Gloss.app/Contents/Helpers",
+ isDirectory: true
+ )
+ let cli = helpers.appendingPathComponent("gloss-cli")
+ let runtime = helpers.appendingPathComponent(
+ "gloss-codex-app-server"
+ )
+ try makeExecutable(at: cli)
+ try makeExecutable(at: runtime)
+ let homebrewBin = root.appendingPathComponent(
+ "homebrew-bin",
+ isDirectory: true
+ )
+ try FileManager.default.createDirectory(
+ at: homebrewBin,
+ withIntermediateDirectories: true
+ )
+ let symlink = homebrewBin.appendingPathComponent("gloss-cli")
+ try FileManager.default.createSymbolicLink(
+ at: symlink,
+ withDestinationURL: cli
+ )
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ let launch = try XCTUnwrap(
+ CodexAppServerClient.resolveRuntime(
+ environment: ["GLOSS_CODEX_BIN": "/unavailable/codex"],
+ bundleURL: root.appendingPathComponent("NotAnApp"),
+ executableURL: symlink
+ )
+ )
+
+ XCTAssertEqual(launch.executable, runtime.path)
+ XCTAssertEqual(launch.argumentPrefix, [])
+ XCTAssertEqual(launch.source, "sibling-app-server")
+ }
+
func testFastLaunchArgumentsDisableUnusedCodexSubsystems() {
let arguments = CodexAppServerClient.fastLaunchArguments(
reasoningEffort: .low,
diff --git a/Tests/GlossCoreTests/GlossAppUpdateDiscoveryTests.swift b/Tests/GlossCoreTests/GlossAppUpdateDiscoveryTests.swift
new file mode 100644
index 0000000..6618831
--- /dev/null
+++ b/Tests/GlossCoreTests/GlossAppUpdateDiscoveryTests.swift
@@ -0,0 +1,417 @@
+import CryptoKit
+import Foundation
+import Testing
+
+@testable import GlossCore
+
+@Suite("Gloss app update discovery")
+struct GlossAppUpdateDiscoveryTests {
+ private enum TestError: Error {
+ case offline
+ }
+
+ private actor FetchRecorder {
+ var payloads: [URL: Data]
+ var offline = false
+ var requestedURLs: [URL] = []
+
+ init(payloads: [URL: Data] = [:]) {
+ self.payloads = payloads
+ }
+
+ func fetch(_ url: URL) throws -> Data {
+ requestedURLs.append(url)
+ if offline {
+ throw TestError.offline
+ }
+ return payloads[url] ?? Data()
+ }
+
+ func setOffline() {
+ offline = true
+ }
+ }
+
+ private actor CheckHistory {
+ var lastCheck: Date?
+ var recordedChecks: [Date] = []
+
+ init(lastCheck: Date? = nil) {
+ self.lastCheck = lastCheck
+ }
+
+ func record(_ date: Date) {
+ lastCheck = date
+ recordedChecks.append(date)
+ }
+ }
+
+ @Test("semantic version precedence compares numeric components")
+ func comparesSemanticVersions() throws {
+ let newerPatch = try #require(GlossSemanticVersion("0.8.10"))
+ let olderPatch = try #require(GlossSemanticVersion("0.8.9"))
+ let prerelease = try #require(GlossSemanticVersion("0.8.10-rc.2"))
+ let laterPrerelease = try #require(GlossSemanticVersion("0.8.10-rc.10"))
+ let buildOne = try #require(GlossSemanticVersion("0.8.10+build.1"))
+ let buildTwo = try #require(GlossSemanticVersion("0.8.10+build.2"))
+
+ #expect(newerPatch > olderPatch)
+ #expect(prerelease < laterPrerelease)
+ #expect(laterPrerelease < newerPatch)
+ #expect(buildOne == buildTwo)
+ #expect(GlossSemanticVersion("0.08.10") == nil)
+ #expect(GlossSemanticVersion("0.8") == nil)
+ #expect(GlossSemanticVersion("0.8.10-01") == nil)
+ }
+
+ @Test("official endpoints cannot be redirected by callers")
+ func usesFixedOfficialEndpoints() {
+ #expect(
+ GlossAppReleaseEndpoint.manifestURL.absoluteString
+ == "https://github.com/SunChJ/gloss-releases/releases/latest/download/gloss-release-manifest.json"
+ )
+ #expect(
+ GlossAppReleaseEndpoint.signatureURL.absoluteString
+ == "https://github.com/SunChJ/gloss-releases/releases/latest/download/gloss-release-manifest.json.sig"
+ )
+ }
+
+ @Test("valid signed manifest reports only advisory release metadata")
+ func discoversSignedUpdate() async throws {
+ let signed = try signedManifest(version: "0.8.10")
+ let fetchRecorder = FetchRecorder(
+ payloads: [
+ GlossAppReleaseEndpoint.manifestURL: signed.manifest,
+ GlossAppReleaseEndpoint.signatureURL: signed.signature,
+ ]
+ )
+ let discovery = try GlossAppUpdateDiscovery(
+ currentVersion: "0.8.9",
+ fetcher: GlossAppUpdateDataFetcher { url in
+ try await fetchRecorder.fetch(url)
+ },
+ manifestSigningPublicKey: signingPublicKey
+ )
+
+ let result = try await discovery.check(
+ mode: .manual,
+ now: Date(timeIntervalSince1970: 1_800_000_000)
+ )
+
+ guard case .updateAvailable(let update) = result else {
+ Issue.record("Expected an available update")
+ return
+ }
+ #expect(update.version == "0.8.10")
+ #expect(update.releaseTag == "v0.8.10")
+ #expect(update.architecture == GlossAppArchitecture.current)
+ #expect(
+ update.assetURL.lastPathComponent
+ == "Gloss-macos-\(GlossAppArchitecture.current).zip"
+ )
+ #expect(
+ update.homebrewCask.token
+ == GlossHomebrewInstallationDetector.caskToken
+ )
+ #expect(update.manifestData == signed.manifest)
+ #expect(update.detachedSignatureData == signed.signature)
+ #expect(
+ update.releasePageURL.absoluteString
+ == "https://github.com/SunChJ/gloss-releases/releases/tag/v0.8.10"
+ )
+ let requestedURLs = await fetchRecorder.requestedURLs
+ #expect(
+ requestedURLs == [
+ GlossAppReleaseEndpoint.manifestURL,
+ GlossAppReleaseEndpoint.signatureURL,
+ ]
+ )
+ }
+
+ @Test("invalid signature fails closed")
+ func rejectsInvalidSignature() async throws {
+ let signed = try signedManifest(version: "0.8.10")
+ var invalidSignature = signed.signature
+ invalidSignature[invalidSignature.startIndex] ^= 0xff
+ let discovery = try discovery(
+ currentVersion: "0.8.9",
+ manifestData: signed.manifest,
+ signatureData: invalidSignature
+ )
+
+ await #expect(throws: GlossAppUpdateError.invalidManifestSignature) {
+ try await discovery.check(mode: .manual)
+ }
+ }
+
+ @Test("signed manifest with a foreign asset URL fails closed")
+ func rejectsForeignManifest() async throws {
+ var manifest = manifest(version: "0.8.10")
+ manifest = GlossAppReleaseManifest(
+ version: manifest.version,
+ releaseTag: manifest.releaseTag,
+ publishedAt: manifest.publishedAt,
+ minimumMacOSVersion: manifest.minimumMacOSVersion,
+ assets: [
+ GlossAppReleaseManifest.Asset(
+ operatingSystem: "macos",
+ architecture: "arm64",
+ url: URL(
+ string:
+ "https://attacker.example/Gloss-macos-arm64.zip"
+ )!,
+ sha256: String(repeating: "a", count: 64),
+ size: 100
+ ),
+ manifest.assets[1],
+ ],
+ homebrewCask: manifest.homebrewCask
+ )
+ let signed = try sign(manifest)
+ let discovery = try discovery(
+ currentVersion: "0.8.9",
+ manifestData: signed.manifest,
+ signatureData: signed.signature
+ )
+
+ await #expect(throws: GlossAppUpdateError.self) {
+ try await discovery.check(mode: .manual)
+ }
+ }
+
+ @Test("malformed and mismatched official manifests fail closed")
+ func rejectsMalformedManifest() async throws {
+ let malformed = Data(#"{"schemaVersion":1}"#.utf8)
+ let malformedSignature = try signingPrivateKey.signature(for: malformed)
+ let malformedDiscovery = try discovery(
+ currentVersion: "0.8.9",
+ manifestData: malformed,
+ signatureData: malformedSignature
+ )
+ await #expect(throws: GlossAppUpdateError.self) {
+ try await malformedDiscovery.check(mode: .manual)
+ }
+
+ let mismatched = GlossAppReleaseManifest(
+ version: "0.8.10",
+ releaseTag: "v9.9.9",
+ publishedAt: "2026-07-27T00:00:00Z",
+ minimumMacOSVersion: "14.0",
+ assets: manifest(version: "0.8.10").assets,
+ homebrewCask: manifest(version: "0.8.10").homebrewCask
+ )
+ let signedMismatch = try sign(mismatched)
+ let mismatchDiscovery = try discovery(
+ currentVersion: "0.8.9",
+ manifestData: signedMismatch.manifest,
+ signatureData: signedMismatch.signature
+ )
+ await #expect(throws: GlossAppUpdateError.self) {
+ try await mismatchDiscovery.check(mode: .manual)
+ }
+ }
+
+ @Test("signed manifest cannot redirect the Homebrew cask token")
+ func rejectsForeignCaskToken() async throws {
+ let original = manifest(version: "0.8.10")
+ let redirected = GlossAppReleaseManifest(
+ version: original.version,
+ releaseTag: original.releaseTag,
+ publishedAt: original.publishedAt,
+ minimumMacOSVersion: original.minimumMacOSVersion,
+ assets: original.assets,
+ homebrewCask: GlossAppReleaseManifest.HomebrewCask(
+ token: "attacker/tap/gloss",
+ url: original.homebrewCask.url,
+ sha256: original.homebrewCask.sha256,
+ size: original.homebrewCask.size
+ )
+ )
+ let signed = try sign(redirected)
+ let discovery = try discovery(
+ currentVersion: "0.8.9",
+ manifestData: signed.manifest,
+ signatureData: signed.signature
+ )
+
+ await #expect(throws: GlossAppUpdateError.self) {
+ try await discovery.check(mode: .manual)
+ }
+ }
+
+ @Test("offline failure is surfaced and records the automatic attempt")
+ func surfacesOfflineFailure() async throws {
+ let fetchRecorder = FetchRecorder()
+ await fetchRecorder.setOffline()
+ let history = CheckHistory()
+ let discovery = try GlossAppUpdateDiscovery(
+ currentVersion: "0.8.9",
+ fetcher: GlossAppUpdateDataFetcher { url in
+ try await fetchRecorder.fetch(url)
+ },
+ history: makeHistory(history),
+ manifestSigningPublicKey: signingPublicKey
+ )
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+
+ await #expect(throws: TestError.offline) {
+ try await discovery.check(mode: .automatic, now: now)
+ }
+ #expect(await history.recordedChecks == [now])
+ }
+
+ @Test("automatic checks are throttled for 24 hours")
+ func throttlesAutomaticChecks() async throws {
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let previous = now.addingTimeInterval(-60)
+ let history = CheckHistory(lastCheck: previous)
+ let fetchRecorder = FetchRecorder()
+ let discovery = try GlossAppUpdateDiscovery(
+ currentVersion: "0.8.9",
+ fetcher: GlossAppUpdateDataFetcher { url in
+ try await fetchRecorder.fetch(url)
+ },
+ history: makeHistory(history),
+ manifestSigningPublicKey: signingPublicKey
+ )
+
+ let result = try await discovery.check(mode: .automatic, now: now)
+
+ #expect(
+ result
+ == .throttled(
+ nextCheckAt: previous.addingTimeInterval(24 * 60 * 60)
+ )
+ )
+ #expect(await fetchRecorder.requestedURLs.isEmpty)
+ #expect(await history.recordedChecks.isEmpty)
+ }
+
+ @Test("manual checks bypass the 24-hour throttle")
+ func manualCheckBypassesThrottle() async throws {
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let history = CheckHistory(lastCheck: now.addingTimeInterval(-60))
+ let signed = try signedManifest(version: "0.8.10")
+ let fetchRecorder = FetchRecorder(
+ payloads: [
+ GlossAppReleaseEndpoint.manifestURL: signed.manifest,
+ GlossAppReleaseEndpoint.signatureURL: signed.signature,
+ ]
+ )
+ let discovery = try GlossAppUpdateDiscovery(
+ currentVersion: "0.8.9",
+ fetcher: GlossAppUpdateDataFetcher { url in
+ try await fetchRecorder.fetch(url)
+ },
+ history: makeHistory(history),
+ manifestSigningPublicKey: signingPublicKey
+ )
+
+ let result = try await discovery.check(mode: .manual, now: now)
+
+ guard case .updateAvailable = result else {
+ Issue.record("Expected manual check to bypass throttling")
+ return
+ }
+ #expect(await fetchRecorder.requestedURLs.count == 2)
+ #expect(await history.recordedChecks == [now])
+ }
+
+ @Test("same or older official version is up to date")
+ func reportsUpToDate() async throws {
+ let signed = try signedManifest(version: "0.8.9")
+ let discovery = try discovery(
+ currentVersion: "0.8.10",
+ manifestData: signed.manifest,
+ signatureData: signed.signature
+ )
+
+ #expect(
+ try await discovery.check(mode: .manual)
+ == .upToDate(latestVersion: "0.8.9")
+ )
+ }
+
+ private func discovery(
+ currentVersion: String,
+ manifestData: Data,
+ signatureData: Data
+ ) throws -> GlossAppUpdateDiscovery {
+ try GlossAppUpdateDiscovery(
+ currentVersion: currentVersion,
+ fetcher: GlossAppUpdateDataFetcher { url in
+ switch url {
+ case GlossAppReleaseEndpoint.manifestURL:
+ manifestData
+ case GlossAppReleaseEndpoint.signatureURL:
+ signatureData
+ default:
+ throw TestError.offline
+ }
+ },
+ manifestSigningPublicKey: signingPublicKey
+ )
+ }
+
+ private func makeHistory(
+ _ history: CheckHistory
+ ) -> GlossAppUpdateCheckHistory {
+ GlossAppUpdateCheckHistory(
+ lastCheck: { await history.lastCheck },
+ recordCheck: { date in await history.record(date) }
+ )
+ }
+
+ private func signedManifest(
+ version: String
+ ) throws -> (manifest: Data, signature: Data) {
+ try sign(manifest(version: version))
+ }
+
+ private func sign(
+ _ manifest: GlossAppReleaseManifest
+ ) throws -> (manifest: Data, signature: Data) {
+ let data = try JSONEncoder().encode(manifest)
+ return (data, try signingPrivateKey.signature(for: data))
+ }
+
+ private func manifest(version: String) -> GlossAppReleaseManifest {
+ let releaseTag = "v\(version)"
+ return GlossAppReleaseManifest(
+ version: version,
+ releaseTag: releaseTag,
+ publishedAt: "2026-07-27T00:00:00Z",
+ minimumMacOSVersion: "14.0",
+ assets: ["arm64", "x86_64"].map { architecture in
+ GlossAppReleaseManifest.Asset(
+ operatingSystem: "macos",
+ architecture: architecture,
+ url: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/download/\(releaseTag)/Gloss-macos-\(architecture).zip"
+ )!,
+ sha256: String(repeating: "a", count: 64),
+ size: 100
+ )
+ },
+ homebrewCask: GlossAppReleaseManifest.HomebrewCask(
+ url: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/download/\(releaseTag)/gloss.rb"
+ )!,
+ sha256: String(repeating: "b", count: 64),
+ size: 200
+ )
+ )
+ }
+
+ private var signingPrivateKey: Curve25519.Signing.PrivateKey {
+ try! Curve25519.Signing.PrivateKey(
+ rawRepresentation: Data(0..<32)
+ )
+ }
+
+ private var signingPublicKey: Data {
+ signingPrivateKey.publicKey.rawRepresentation
+ }
+}
diff --git a/Tests/GlossCoreTests/GlossCLIInvocationTests.swift b/Tests/GlossCoreTests/GlossCLIInvocationTests.swift
new file mode 100644
index 0000000..03eb69e
--- /dev/null
+++ b/Tests/GlossCoreTests/GlossCLIInvocationTests.swift
@@ -0,0 +1,164 @@
+import Foundation
+import XCTest
+
+@testable import GlossCore
+
+final class GlossCLIInvocationTests: XCTestCase {
+ func testLegacyFlatTextInvocationRemainsCompatible() throws {
+ let invocation = try GlossCLIInvocationParser.parse([
+ "--target", "Japanese",
+ "--profile", "technical",
+ "Hello", "world",
+ ])
+ guard case .legacyText(let options) = invocation else {
+ return XCTFail("Expected legacy text invocation")
+ }
+
+ XCTAssertEqual(options.targetLanguage, "Japanese")
+ XCTAssertEqual(options.profile, .technical)
+ XCTAssertEqual(options.contentKind, .selection)
+ XCTAssertEqual(options.textParts, ["Hello", "world"])
+ }
+
+ func testBrowserCommandMapsToWebpageScenario() throws {
+ let invocation = try GlossCLIInvocationParser.parse([
+ GlossCLICommand.browser.rawValue,
+ "--provider", "llama",
+ "Browser text",
+ ])
+ guard case .browser(let options) = invocation else {
+ return XCTFail("Expected browser invocation")
+ }
+
+ XCTAssertEqual(options.contentKind, .webpage)
+ XCTAssertEqual(options.provider, .llama)
+ XCTAssertEqual(options.textParts, ["Browser text"])
+ }
+
+ func testExplicitTextCommandMapsToCoreTranslationCapability() throws {
+ let invocation = try GlossCLIInvocationParser.parse([
+ GlossCLICommand.text.rawValue,
+ "--kind", "document",
+ "Document text",
+ ])
+ guard case .text(let options) = invocation else {
+ return XCTFail("Expected text invocation")
+ }
+
+ XCTAssertEqual(options.contentKind, .document)
+ XCTAssertEqual(options.textParts, ["Document text"])
+ }
+
+ func testBrowserRejectsNonWebpageKind() {
+ XCTAssertThrowsError(
+ try GlossCLIInvocationParser.parse([
+ GlossCLICommand.browser.rawValue,
+ "--kind", "selection",
+ "Text",
+ ])
+ )
+ }
+
+ func testPDFCommandAcceptsBatchAndUsesDocumentDefaults() throws {
+ let invocation = try GlossCLIInvocationParser.parse([
+ GlossCLICommand.pdf.rawValue,
+ "one.pdf",
+ "two.pdf",
+ "--output", "/tmp/output",
+ ])
+ guard case .pdf(let options) = invocation else {
+ return XCTFail("Expected PDF invocation")
+ }
+
+ XCTAssertEqual(options.inputPaths, ["one.pdf", "two.pdf"])
+ XCTAssertEqual(options.outputDirectoryPath, "/tmp/output")
+ XCTAssertEqual(options.sourceLanguageCode, "en")
+ XCTAssertEqual(options.targetLanguage, "Chinese (Simplified)")
+ XCTAssertEqual(options.outputMode, .monolingual)
+ }
+
+ func testCapabilitiesJSONCommandIsExplicit() throws {
+ let invocation = try GlossCLIInvocationParser.parse([
+ GlossCLICommand.capabilities.rawValue,
+ "--json",
+ ])
+ guard case .capabilitiesJSON = invocation else {
+ return XCTFail("Expected capabilities JSON invocation")
+ }
+ }
+
+ func testDisabledScenarioCannotBeDispatched() {
+ let registry = GlossCapabilityRegistry(
+ enabledScenarios: [.pdfTranslation]
+ )
+
+ XCTAssertThrowsError(
+ try GlossCLIInvocationParser.parse(
+ [GlossCLICommand.browser.rawValue, "Text"],
+ registry: registry
+ )
+ )
+ XCTAssertNoThrow(
+ try GlossCLIInvocationParser.parse(
+ [
+ GlossCLICommand.pdf.rawValue,
+ "one.pdf",
+ "--output", "/tmp/output",
+ ],
+ registry: registry
+ )
+ )
+ }
+
+ func testLegacyTextInvocationCannotBypassCapabilityRegistry() {
+ let registry = GlossCapabilityRegistry(enabledScenarios: [])
+
+ XCTAssertThrowsError(
+ try GlossCLIInvocationParser.parse(
+ ["Legacy text"],
+ registry: registry
+ )
+ )
+ XCTAssertNoThrow(
+ try GlossCLIInvocationParser.parse(
+ ["--help"],
+ registry: registry
+ )
+ )
+ }
+
+ func testCapabilityReportUsesStableSortedCollections() {
+ let report = GlossCapabilitiesReport()
+
+ XCTAssertEqual(
+ report.availableCoreCapabilities.map(\.rawValue),
+ report.availableCoreCapabilities.map(\.rawValue).sorted()
+ )
+ XCTAssertEqual(
+ report.enabledCoreCapabilities.map(\.rawValue),
+ report.enabledCoreCapabilities.map(\.rawValue).sorted()
+ )
+ XCTAssertEqual(
+ report.availableScenarios.map(\.rawValue),
+ report.availableScenarios.map(\.rawValue).sorted()
+ )
+ XCTAssertEqual(
+ report.enabledScenarios.map(\.rawValue),
+ report.enabledScenarios.map(\.rawValue).sorted()
+ )
+ XCTAssertEqual(
+ report.commandMappings.map(\.command.rawValue),
+ report.commandMappings.map(\.command.rawValue).sorted()
+ )
+ for mapping in report.commandMappings {
+ XCTAssertEqual(
+ mapping.requiredCapabilities.map(\.rawValue),
+ mapping.requiredCapabilities.map(\.rawValue).sorted()
+ )
+ XCTAssertEqual(
+ mapping.scenarioCapabilities.map(\.rawValue),
+ mapping.scenarioCapabilities.map(\.rawValue).sorted()
+ )
+ }
+ }
+}
diff --git a/Tests/GlossCoreTests/GlossCapabilityRegistryTests.swift b/Tests/GlossCoreTests/GlossCapabilityRegistryTests.swift
new file mode 100644
index 0000000..0a29919
--- /dev/null
+++ b/Tests/GlossCoreTests/GlossCapabilityRegistryTests.swift
@@ -0,0 +1,164 @@
+import XCTest
+
+@testable import GlossCore
+
+final class GlossCapabilityRegistryTests: XCTestCase {
+ func testDefaultRegistryEnablesBrowserAndPDFScenariosOnly() {
+ let registry = GlossCapabilityRegistry()
+
+ XCTAssertEqual(
+ registry.enabledScenarios,
+ [.browserTranslation, .pdfTranslation]
+ )
+ }
+
+ func testDefaultBrowserScenarioSupportsBothExtensionFamilies() {
+ let registry = GlossCapabilityRegistry()
+
+ XCTAssertTrue(registry.supports(.textTranslation))
+ XCTAssertTrue(registry.supports(.translationLoopbackBridge))
+ XCTAssertTrue(registry.supports(.browserBridge))
+ XCTAssertTrue(registry.supports(.chromeExtension))
+ XCTAssertTrue(registry.supports(.safariExtension))
+ }
+
+ func testAdHocDistributionKeepsBrowserScenarioWithChromeOnly() {
+ let registry = GlossCapabilityRegistry(
+ distributionProfile: GlossDistributionProfile(
+ safariExtensionAvailable: false
+ )
+ )
+
+ XCTAssertTrue(registry.isEnabled(.browserTranslation))
+ XCTAssertTrue(registry.supports(.browserBridge))
+ XCTAssertTrue(registry.supports(.chromeExtension))
+ XCTAssertFalse(registry.supports(.safariExtension))
+ XCTAssertFalse(
+ registry.availableCoreCapabilities.contains(.safariExtension)
+ )
+
+ let browser = registry.commandMappings.first {
+ $0.command == .browser
+ }
+ XCTAssertEqual(browser?.enabled, true)
+ XCTAssertEqual(
+ browser?.scenarioCapabilities.contains(.chromeExtension),
+ true
+ )
+ XCTAssertEqual(
+ browser?.scenarioCapabilities.contains(.safariExtension),
+ false
+ )
+
+ let report = GlossCapabilitiesReport(registry: registry)
+ XCTAssertTrue(
+ report.enabledScenarios.contains(.browserTranslation)
+ )
+ XCTAssertFalse(
+ report.availableCoreCapabilities.contains(.safariExtension)
+ )
+ }
+
+ func testDefaultPDFScenarioSupportsRuntimeAndBatchQueue() {
+ let registry = GlossCapabilityRegistry()
+
+ XCTAssertTrue(registry.supports(.textTranslation))
+ XCTAssertTrue(registry.supports(.documentTranslation))
+ XCTAssertTrue(registry.supports(.translationLoopbackBridge))
+ XCTAssertTrue(registry.supports(.pdfLayoutAnalysis))
+ XCTAssertTrue(registry.supports(.pdfRuntime))
+ XCTAssertTrue(registry.supports(.pdfBatchQueue))
+ XCTAssertTrue(registry.supports(.pdfExport))
+ }
+
+ func testDefaultRegistryKeepsInfrastructureButHidesSecondaryTools() {
+ let registry = GlossCapabilityRegistry()
+
+ XCTAssertTrue(registry.supports(.providerConfiguration))
+ XCTAssertTrue(registry.supports(.languageConfiguration))
+ XCTAssertTrue(registry.supports(.appUpdates))
+ XCTAssertTrue(registry.supports(.diagnostics))
+ XCTAssertTrue(registry.supports(.launchAtLogin))
+ XCTAssertFalse(registry.supports(.clipboardText))
+ XCTAssertFalse(registry.supports(.clipboardImage))
+ XCTAssertFalse(registry.supports(.screenshotCapture))
+ XCTAssertFalse(registry.supports(.selectionCapture))
+ XCTAssertFalse(registry.supports(.automaticSelection))
+ XCTAssertFalse(registry.supports(.appExclusions))
+ XCTAssertFalse(registry.supports(.glossaryManagement))
+ XCTAssertFalse(registry.supports(.translationHistory))
+ }
+
+ func testSecondaryScenarioCanBeRestoredWithoutChangingTheRegistry() {
+ let registry = GlossCapabilityRegistry(
+ enabledScenarios: [
+ .browserTranslation,
+ .pdfTranslation,
+ .selectionTranslation,
+ ]
+ )
+
+ XCTAssertTrue(registry.supports(.selectionCapture))
+ XCTAssertTrue(registry.supports(.automaticSelection))
+ XCTAssertTrue(registry.supports(.appExclusions))
+ }
+
+ func testCoreCapabilitiesAndCommandMappingsAreStable() {
+ let registry = GlossCapabilityRegistry()
+
+ XCTAssertEqual(
+ registry.enabledCoreCapabilities,
+ [
+ .browserBridge,
+ .chromeExtension,
+ .documentTranslation,
+ .pdfBatchQueue,
+ .pdfExport,
+ .pdfLayoutAnalysis,
+ .pdfRuntime,
+ .safariExtension,
+ .textTranslation,
+ .translationLoopbackBridge,
+ ]
+ )
+ XCTAssertEqual(
+ registry.commandMappings.map(\.command),
+ [.capabilities, .text, .browser, .pdf]
+ )
+ XCTAssertEqual(
+ registry.commandMappings.map(\.enabled),
+ [true, true, true, true]
+ )
+ let browser = registry.commandMappings.first {
+ $0.command == .browser
+ }
+ XCTAssertEqual(
+ browser?.requiredCapabilities,
+ [
+ .languageConfiguration,
+ .providerConfiguration,
+ .textTranslation,
+ ]
+ )
+ XCTAssertEqual(
+ browser?.scenarioCapabilities,
+ GlossBusinessScenario.browserTranslation.requiredCapabilities.sorted {
+ $0.rawValue < $1.rawValue
+ }
+ )
+ let pdf = registry.commandMappings.first {
+ $0.command == .pdf
+ }
+ XCTAssertEqual(pdf?.scenario, .pdfTranslation)
+ XCTAssertEqual(
+ pdf?.requiredCapabilities,
+ GlossBusinessScenario.pdfTranslation.requiredCapabilities.sorted {
+ $0.rawValue < $1.rawValue
+ }
+ )
+ XCTAssertEqual(
+ pdf?.scenarioCapabilities,
+ pdf?.requiredCapabilities
+ )
+ }
+}
diff --git a/Tests/GlossCoreTests/GlossDistributionProfileTests.swift b/Tests/GlossCoreTests/GlossDistributionProfileTests.swift
new file mode 100644
index 0000000..71a3709
--- /dev/null
+++ b/Tests/GlossCoreTests/GlossDistributionProfileTests.swift
@@ -0,0 +1,74 @@
+import Foundation
+import XCTest
+
+@testable import GlossCore
+
+final class GlossDistributionProfileTests: XCTestCase {
+ func testExplicitBundleValueTakesPriority() {
+ let profile = GlossDistributionProfile.resolve(
+ bundleValue: false,
+ executableURL: nil
+ )
+
+ XCTAssertFalse(profile.safariExtensionAvailable)
+ }
+
+ func testStandaloneHelperReadsEnclosingAppProfile() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(
+ "gloss-distribution-test-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ defer { try? FileManager.default.removeItem(at: root) }
+ let contents = root.appendingPathComponent(
+ "Gloss.app/Contents",
+ isDirectory: true
+ )
+ let helper = contents.appendingPathComponent(
+ "Helpers/gloss-cli"
+ )
+ try FileManager.default.createDirectory(
+ at: helper.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ XCTAssertTrue(
+ FileManager.default.createFile(
+ atPath: helper.path,
+ contents: Data()
+ )
+ )
+ let plist = try PropertyListSerialization.data(
+ fromPropertyList: [
+ "GlossSafariExtensionAvailable": false
+ ],
+ format: .xml,
+ options: 0
+ )
+ try plist.write(
+ to: contents.appendingPathComponent("Info.plist")
+ )
+
+ let profile = GlossDistributionProfile.resolve(
+ bundleValue: nil,
+ executableURL: helper,
+ workingDirectoryURL: root
+ )
+
+ XCTAssertFalse(profile.safariExtensionAvailable)
+ }
+
+ func testMissingDistributionFlagFailsClosed() {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent(
+ "gloss-missing-distribution-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ let profile = GlossDistributionProfile.resolve(
+ bundleValue: nil,
+ executableURL: nil,
+ workingDirectoryURL: root
+ )
+
+ XCTAssertFalse(profile.safariExtensionAvailable)
+ }
+}
diff --git a/Tests/GlossCoreTests/GlossHomebrewInstallationTests.swift b/Tests/GlossCoreTests/GlossHomebrewInstallationTests.swift
new file mode 100644
index 0000000..3f5c8fc
--- /dev/null
+++ b/Tests/GlossCoreTests/GlossHomebrewInstallationTests.swift
@@ -0,0 +1,366 @@
+import Foundation
+import Testing
+
+@testable import GlossCore
+
+@Suite("Gloss Homebrew installation detection")
+struct GlossHomebrewInstallationTests {
+ private actor RunnerRecorder {
+ struct Invocation: Equatable, Sendable {
+ let executableURL: URL
+ let arguments: [String]
+ }
+
+ var outputs: [URL: GlossCommandOutput]
+ var invocations: [Invocation] = []
+
+ init(outputs: [URL: GlossCommandOutput]) {
+ self.outputs = outputs
+ }
+
+ func run(
+ executableURL: URL,
+ arguments: [String]
+ ) -> GlossCommandOutput {
+ invocations.append(
+ Invocation(
+ executableURL: executableURL,
+ arguments: arguments
+ )
+ )
+ return outputs[executableURL]
+ ?? GlossCommandOutput(terminationStatus: 127, standardOutput: Data())
+ }
+ }
+
+ @Test("detects an arm64 Homebrew-managed app from cask JSON")
+ func detectsManagedArmInstallation() async throws {
+ let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew")
+ let currentBundleURL = URL(fileURLWithPath: "/Applications/Gloss.app")
+ let managedURL = URL(
+ fileURLWithPath:
+ "/opt/homebrew/Caskroom/gloss/0.8.2/Gloss.app"
+ )
+ let runner = RunnerRecorder(
+ outputs: [
+ brewURL: GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: infoJSON()
+ )
+ ]
+ )
+ let detector = GlossHomebrewInstallationDetector(
+ commandRunner: GlossCommandRunner { executableURL, arguments in
+ await runner.run(
+ executableURL: executableURL,
+ arguments: arguments
+ )
+ },
+ pathInspector: pathInspector(
+ executableURLs: [brewURL],
+ existingURLs: [currentBundleURL, managedURL],
+ canonicalPaths: [
+ currentBundleURL.path: currentBundleURL.path,
+ managedURL.path: currentBundleURL.path,
+ ]
+ )
+ )
+
+ let installation = try #require(
+ try await detector.detect(currentBundleURL: currentBundleURL)
+ )
+
+ #expect(installation.brewExecutableURL == brewURL)
+ #expect(installation.caskToken == "sunchj/tap/gloss")
+ #expect(installation.installedVersion == "0.8.2")
+ #expect(installation.availableVersion == "0.8.10")
+ #expect(installation.managedAppURL.path == managedURL.path)
+ #expect(installation.installedAppTargetURL == currentBundleURL)
+ #expect(
+ await runner.invocations == [
+ RunnerRecorder.Invocation(
+ executableURL: brewURL,
+ arguments: [
+ "info",
+ "--cask",
+ "--json=v2",
+ "sunchj/tap/gloss",
+ ]
+ )
+ ]
+ )
+ }
+
+ @Test("only the two fixed Homebrew executable paths are eligible")
+ func usesOnlyFixedBrewPaths() async throws {
+ #expect(
+ GlossHomebrewInstallationDetector.brewExecutableURLs.map(\.path)
+ == [
+ "/opt/homebrew/bin/brew",
+ "/usr/local/bin/brew",
+ ]
+ )
+
+ let runner = RunnerRecorder(outputs: [:])
+ let detector = GlossHomebrewInstallationDetector(
+ commandRunner: GlossCommandRunner { executableURL, arguments in
+ await runner.run(
+ executableURL: executableURL,
+ arguments: arguments
+ )
+ },
+ pathInspector: pathInspector(
+ executableURLs: [
+ URL(fileURLWithPath: "/tmp/untrusted/bin/brew")
+ ]
+ )
+ )
+
+ #expect(
+ try await detector.detect(
+ currentBundleURL: URL(
+ fileURLWithPath: "/Applications/Gloss.app"
+ )
+ ) == nil
+ )
+ #expect(await runner.invocations.isEmpty)
+ }
+
+ @Test("falls back to the fixed Intel Homebrew path")
+ func detectsManagedIntelInstallation() async throws {
+ let brewURL = URL(fileURLWithPath: "/usr/local/bin/brew")
+ let currentBundleURL = URL(fileURLWithPath: "/Applications/Gloss.app")
+ let managedURL = URL(
+ fileURLWithPath:
+ "/usr/local/Caskroom/gloss/0.8.2/Gloss.app"
+ )
+ let runner = RunnerRecorder(
+ outputs: [
+ brewURL: GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: infoJSON()
+ )
+ ]
+ )
+ let detector = GlossHomebrewInstallationDetector(
+ commandRunner: GlossCommandRunner { executableURL, arguments in
+ await runner.run(
+ executableURL: executableURL,
+ arguments: arguments
+ )
+ },
+ pathInspector: pathInspector(
+ executableURLs: [brewURL],
+ existingURLs: [currentBundleURL, managedURL],
+ canonicalPaths: [
+ currentBundleURL.path: currentBundleURL.path,
+ managedURL.path: currentBundleURL.path,
+ ]
+ )
+ )
+
+ let installation = try #require(
+ try await detector.detect(currentBundleURL: currentBundleURL)
+ )
+
+ #expect(installation.brewExecutableURL == brewURL)
+ #expect(installation.managedAppURL.path == managedURL.path)
+ }
+
+ @Test("malformed brew JSON fails explicitly")
+ func rejectsMalformedJSON() async throws {
+ let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew")
+ let detector = detector(
+ brewURL: brewURL,
+ output: GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: Data("not JSON".utf8)
+ )
+ )
+
+ await #expect(throws: GlossHomebrewDetectionError.malformedInfo) {
+ try await detector.detect(
+ currentBundleURL: URL(
+ fileURLWithPath: "/Applications/Gloss.app"
+ )
+ )
+ }
+ }
+
+ @Test("foreign cask identity fails explicitly")
+ func rejectsForeignCaskIdentity() async throws {
+ let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew")
+ let foreignJSON = infoJSON(
+ fullToken: "attacker/tap/gloss",
+ tap: "attacker/tap"
+ )
+ let detector = detector(
+ brewURL: brewURL,
+ output: GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: foreignJSON
+ )
+ )
+
+ await #expect(
+ throws: GlossHomebrewDetectionError.invalidCaskIdentity
+ ) {
+ try await detector.detect(
+ currentBundleURL: URL(
+ fileURLWithPath: "/Applications/Gloss.app"
+ )
+ )
+ }
+ }
+
+ @Test("a copied app is not treated as the Homebrew-managed instance")
+ func rejectsNonManagedCopy() async throws {
+ let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew")
+ let copiedBundleURL = URL(
+ fileURLWithPath: "/Users/test/Applications/Gloss.app"
+ )
+ let installedTargetURL = URL(
+ fileURLWithPath: "/Applications/Gloss.app"
+ )
+ let managedURL = URL(
+ fileURLWithPath:
+ "/opt/homebrew/Caskroom/gloss/0.8.2/Gloss.app"
+ )
+ let runner = RunnerRecorder(
+ outputs: [
+ brewURL: GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: infoJSON()
+ )
+ ]
+ )
+ let detector = GlossHomebrewInstallationDetector(
+ commandRunner: GlossCommandRunner { executableURL, arguments in
+ await runner.run(
+ executableURL: executableURL,
+ arguments: arguments
+ )
+ },
+ pathInspector: pathInspector(
+ executableURLs: [brewURL],
+ existingURLs: [
+ copiedBundleURL,
+ installedTargetURL,
+ managedURL,
+ ],
+ canonicalPaths: [
+ copiedBundleURL.path: copiedBundleURL.path,
+ installedTargetURL.path: installedTargetURL.path,
+ managedURL.path: installedTargetURL.path,
+ ]
+ )
+ )
+
+ #expect(
+ try await detector.detect(currentBundleURL: copiedBundleURL) == nil
+ )
+ }
+
+ @Test("an available but uninstalled cask is not a managed install")
+ func ignoresUninstalledCask() async throws {
+ let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew")
+ let detector = detector(
+ brewURL: brewURL,
+ output: GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: infoJSON(installedVersion: nil)
+ )
+ )
+
+ #expect(
+ try await detector.detect(
+ currentBundleURL: URL(
+ fileURLWithPath: "/Applications/Gloss.app"
+ )
+ ) == nil
+ )
+ }
+
+ @Test("a failed brew info command does not claim ownership")
+ func handlesBrewInfoFailure() async throws {
+ let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew")
+ let detector = detector(
+ brewURL: brewURL,
+ output: GlossCommandOutput(
+ terminationStatus: 1,
+ standardOutput: Data(),
+ standardError: Data("not installed".utf8)
+ )
+ )
+
+ #expect(
+ try await detector.detect(
+ currentBundleURL: URL(
+ fileURLWithPath: "/Applications/Gloss.app"
+ )
+ ) == nil
+ )
+ }
+
+ private func detector(
+ brewURL: URL,
+ output: GlossCommandOutput
+ ) -> GlossHomebrewInstallationDetector {
+ GlossHomebrewInstallationDetector(
+ commandRunner: GlossCommandRunner { _, _ in output },
+ pathInspector: pathInspector(
+ executableURLs: [brewURL]
+ )
+ )
+ }
+
+ private func pathInspector(
+ executableURLs: Set = [],
+ existingURLs: Set = [],
+ canonicalPaths: [String: String] = [:]
+ ) -> GlossPathInspector {
+ let executablePaths = Set(executableURLs.map(\.path))
+ let existingPaths = Set(existingURLs.map(\.path))
+ return GlossPathInspector(
+ isExecutable: { executablePaths.contains($0.path) },
+ fileExists: { existingPaths.contains($0.path) },
+ pathsReferToSameItem: { first, second in
+ let firstPath = canonicalPaths[first.path] ?? first.path
+ let secondPath = canonicalPaths[second.path] ?? second.path
+ return firstPath == secondPath
+ }
+ )
+ }
+
+ private func infoJSON(
+ token: String = "gloss",
+ fullToken: String = "sunchj/tap/gloss",
+ tap: String = "sunchj/tap",
+ version: String = "0.8.10",
+ installedVersion: String? = "0.8.2"
+ ) -> Data {
+ let installedJSON = installedVersion.map { "\"\($0)\"" } ?? "null"
+ return Data(
+ """
+ {
+ "formulae": [],
+ "casks": [
+ {
+ "token": "\(token)",
+ "full_token": "\(fullToken)",
+ "tap": "\(tap)",
+ "version": "\(version)",
+ "installed": \(installedJSON),
+ "artifacts": [
+ {
+ "app": ["Gloss.app"],
+ "target": "/Applications/Gloss.app"
+ }
+ ]
+ }
+ ]
+ }
+ """.utf8
+ )
+ }
+}
diff --git a/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift b/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift
new file mode 100644
index 0000000..8b554b3
--- /dev/null
+++ b/Tests/GlossCoreTests/GlossHomebrewUpgradeTests.swift
@@ -0,0 +1,1310 @@
+import CryptoKit
+import Darwin
+import Foundation
+import Testing
+
+@testable import GlossCore
+
+@Suite("Gloss Homebrew upgrade helper")
+struct GlossHomebrewUpgradeTests {
+ private actor CommandPolicyRecorder {
+ var updateTimeout: Duration?
+ var upgradeTimeout: Duration?
+ var upgradeEnvironment: [String: String]?
+
+ func record(
+ arguments: [String],
+ environment: [String: String],
+ timeout: Duration?
+ ) {
+ if arguments == GlossHomebrewUpgradeWorkflow.homebrewUpdateArguments {
+ updateTimeout = timeout
+ } else if arguments
+ == GlossHomebrewUpgradeWorkflow.homebrewUpgradeArguments
+ {
+ upgradeTimeout = timeout
+ upgradeEnvironment = environment
+ }
+ }
+ }
+
+ private actor Recorder {
+ struct Invocation: Equatable, Sendable {
+ let executableURL: URL
+ let arguments: [String]
+ }
+
+ var invocations: [Invocation] = []
+ var output: @Sendable (URL, [String]) -> GlossCommandOutput
+
+ init(
+ output:
+ @escaping @Sendable (
+ URL,
+ [String]
+ ) -> GlossCommandOutput
+ ) {
+ self.output = output
+ }
+
+ func run(
+ executableURL: URL,
+ arguments: [String]
+ ) -> GlossCommandOutput {
+ invocations.append(
+ Invocation(
+ executableURL: executableURL,
+ arguments: arguments
+ )
+ )
+ return output(executableURL, arguments)
+ }
+ }
+
+ @Test("request and result stores round-trip their wire formats")
+ func storesRoundTrip() throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let requestURL = directory.appendingPathComponent("request.json")
+ let resultURL = directory.appendingPathComponent("result.json")
+ let request = makeRequest(resultURL: resultURL)
+ let completedAt = Date(timeIntervalSince1970: 1_785_160_000)
+ let result = GlossHomebrewUpgradeResult(
+ outcome: .succeeded,
+ expectedVersion: "0.8.3",
+ installedVersion: "0.8.4",
+ completedAt: completedAt
+ )
+
+ try GlossHomebrewUpgradeRequestStore.write(request, to: requestURL)
+ try GlossHomebrewUpgradeResultStore.write(result, to: resultURL)
+
+ #expect(
+ try GlossHomebrewUpgradeRequestStore.load(from: requestURL)
+ == request
+ )
+ #expect(
+ try GlossHomebrewUpgradeResultStore.load(from: resultURL)
+ == result
+ )
+ }
+
+ @Test("workflow runs only the fixed Homebrew commands and relaunches")
+ func workflowSucceeds() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let resultURL = directory.appendingPathComponent("result.json")
+ let request = makeRequest(resultURL: resultURL)
+ let brewURL = request.brewExecutableURL
+ let recorder = Recorder { _, _ in
+ GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: Data()
+ )
+ }
+ let runner = GlossCommandRunner { executableURL, arguments in
+ await recorder.run(
+ executableURL: executableURL,
+ arguments: arguments
+ )
+ }
+ let workflow = GlossHomebrewUpgradeWorkflow(
+ commandRunner: runner,
+ parentProcessWaiter: GlossParentProcessWaiter { processID, timeout in
+ #expect(processID == 98_765)
+ #expect(timeout == .seconds(120))
+ },
+ recoveryManager: noOpRecoveryManager(),
+ releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier { _ in },
+ verifier: GlossHomebrewUpgradeVerifier { _ in "0.8.4" },
+ now: { Date(timeIntervalSince1970: 1_785_160_000) }
+ )
+
+ let result = try await workflow.runAndPersist(request)
+
+ #expect(result.succeeded)
+ #expect(result.installedVersion == "0.8.4")
+ #expect(
+ await recorder.invocations == [
+ Recorder.Invocation(
+ executableURL: brewURL,
+ arguments: ["update", "--quiet"]
+ ),
+ Recorder.Invocation(
+ executableURL: brewURL,
+ arguments: [
+ "upgrade",
+ "--cask",
+ "--require-sha",
+ "sunchj/tap/gloss",
+ ]
+ ),
+ Recorder.Invocation(
+ executableURL: URL(fileURLWithPath: "/usr/bin/open"),
+ arguments: ["/Applications/Gloss.app"]
+ ),
+ ]
+ )
+ #expect(
+ try GlossHomebrewUpgradeResultStore.load(from: resultURL)
+ == result
+ )
+ }
+
+ @Test("a failed Homebrew command persists failure and reopens Gloss")
+ func workflowPersistsFailureAndRelaunches() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let resultURL = directory.appendingPathComponent("result.json")
+ let request = makeRequest(resultURL: resultURL)
+ let recorder = Recorder { executableURL, arguments in
+ if executableURL == request.brewExecutableURL,
+ arguments == GlossHomebrewUpgradeWorkflow.homebrewUpgradeArguments
+ {
+ return GlossCommandOutput(
+ terminationStatus: 1,
+ standardOutput: Data(),
+ standardError: Data("upgrade failed".utf8)
+ )
+ }
+ return GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: Data()
+ )
+ }
+ let runner = GlossCommandRunner { executableURL, arguments in
+ await recorder.run(
+ executableURL: executableURL,
+ arguments: arguments
+ )
+ }
+ let workflow = GlossHomebrewUpgradeWorkflow(
+ commandRunner: runner,
+ parentProcessWaiter: GlossParentProcessWaiter { _, _ in },
+ recoveryManager: noOpRecoveryManager(),
+ releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier { _ in },
+ verifier: GlossHomebrewUpgradeVerifier { _ in
+ Issue.record("verifier must not run after a failed upgrade")
+ return "0.8.3"
+ },
+ now: { Date(timeIntervalSince1970: 1_785_160_000) }
+ )
+
+ let result = try await workflow.runAndPersist(request)
+
+ #expect(!result.succeeded)
+ #expect(result.errorCode == "homebrew_upgrade_failed")
+ #expect(
+ await recorder.invocations.last
+ == Recorder.Invocation(
+ executableURL: URL(fileURLWithPath: "/usr/bin/open"),
+ arguments: ["/Applications/Gloss.app"]
+ )
+ )
+ #expect(
+ try GlossHomebrewUpgradeResultStore.load(from: resultURL)
+ == result
+ )
+ }
+
+ @Test("a damaged upgrade restores the validated previous App")
+ func workflowRecordsSuccessfulRecovery() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let request = makeRequest(
+ resultURL: directory.appendingPathComponent("result.json")
+ )
+ let runner = GlossCommandRunner { executableURL, arguments in
+ if executableURL == request.brewExecutableURL,
+ arguments == GlossHomebrewUpgradeWorkflow.homebrewUpgradeArguments
+ {
+ return GlossCommandOutput(
+ terminationStatus: 1,
+ standardOutput: Data()
+ )
+ }
+ return GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: Data()
+ )
+ }
+ let workflow = GlossHomebrewUpgradeWorkflow(
+ commandRunner: runner,
+ parentProcessWaiter: GlossParentProcessWaiter { _, _ in },
+ recoveryManager: GlossAppUpdateRecoveryManager(
+ prepare: { _ in },
+ restoreIfNeeded: { _ in true }
+ ),
+ releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier { _ in },
+ verifier: GlossHomebrewUpgradeVerifier { _ in "0.8.3" }
+ )
+
+ let result = try await workflow.runAndPersist(request)
+
+ #expect(!result.succeeded)
+ #expect(result.recoveredPreviousInstallation)
+ #expect(result.recoveryError == nil)
+ #expect(result.errorCode == "homebrew_upgrade_failed")
+ }
+
+ @Test("recovery failure is persisted and still attempts relaunch")
+ func workflowRecordsRecoveryFailure() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let request = makeRequest(
+ resultURL: directory.appendingPathComponent("result.json")
+ )
+ let recorder = Recorder { executableURL, arguments in
+ if executableURL == request.brewExecutableURL,
+ arguments == GlossHomebrewUpgradeWorkflow.homebrewUpgradeArguments
+ {
+ return GlossCommandOutput(
+ terminationStatus: 1,
+ standardOutput: Data()
+ )
+ }
+ return GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: Data()
+ )
+ }
+ let runner = GlossCommandRunner { executableURL, arguments in
+ await recorder.run(
+ executableURL: executableURL,
+ arguments: arguments
+ )
+ }
+ let workflow = GlossHomebrewUpgradeWorkflow(
+ commandRunner: runner,
+ parentProcessWaiter: GlossParentProcessWaiter { _, _ in },
+ recoveryManager: GlossAppUpdateRecoveryManager(
+ prepare: { _ in },
+ restoreIfNeeded: { _ in
+ throw GlossHomebrewUpgradeError.recoveryFailed(
+ "backup unavailable"
+ )
+ }
+ ),
+ releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier { _ in },
+ verifier: GlossHomebrewUpgradeVerifier { _ in "0.8.3" }
+ )
+
+ let result = try await workflow.runAndPersist(request)
+
+ #expect(result.errorCode == "recovery_failed")
+ #expect(result.recoveryError?.contains("backup unavailable") == true)
+ #expect(
+ await recorder.invocations.last
+ == Recorder.Invocation(
+ executableURL: URL(fileURLWithPath: "/usr/bin/open"),
+ arguments: ["/Applications/Gloss.app"]
+ )
+ )
+ }
+
+ @Test("brew commands are bounded and upgrade cannot implicitly update")
+ func workflowPinsCommandPolicy() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let request = makeRequest(
+ resultURL: directory.appendingPathComponent("result.json")
+ )
+ let recorder = CommandPolicyRecorder()
+ let runner = GlossCommandRunner(runWithTimeout: {
+ _,
+ arguments,
+ environment,
+ timeout in
+ await recorder.record(
+ arguments: arguments,
+ environment: environment,
+ timeout: timeout
+ )
+ return GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: Data()
+ )
+ })
+ let workflow = GlossHomebrewUpgradeWorkflow(
+ commandRunner: runner,
+ parentProcessWaiter: GlossParentProcessWaiter { _, _ in },
+ recoveryManager: noOpRecoveryManager(),
+ releaseBindingVerifier: GlossHomebrewReleaseBindingVerifier { _ in },
+ verifier: GlossHomebrewUpgradeVerifier { _ in "0.8.3" },
+ commandTimeout: .seconds(42)
+ )
+
+ #expect(try await workflow.runAndPersist(request).succeeded)
+ #expect(await recorder.updateTimeout == .seconds(42))
+ #expect(await recorder.upgradeTimeout == .seconds(42))
+ #expect(
+ await recorder.upgradeEnvironment
+ == GlossHomebrewUpgradeWorkflow.noAutomaticUpdateEnvironment
+ )
+ }
+
+ @Test("live verifier accepts the exact managed ad-hoc build")
+ func liveVerifierAcceptsExactVersion() async throws {
+ let brewURL = URL(fileURLWithPath: "/opt/homebrew/bin/brew")
+ let currentBundleURL = URL(fileURLWithPath: "/Applications/Gloss.app")
+ let managedBundleURL = URL(
+ fileURLWithPath: "/opt/homebrew/Caskroom/gloss/0.8.4/Gloss.app"
+ )
+ let resultURL = temporaryDirectory()
+ .appendingPathComponent("result.json")
+ let request = makeRequest(
+ resultURL: resultURL,
+ expectedVersion: "0.8.4"
+ )
+ #if arch(arm64)
+ let runningArchitecture = "arm64"
+ #else
+ let runningArchitecture = "x86_64"
+ #endif
+ let recorder = Recorder { executableURL, arguments in
+ switch (executableURL.path, arguments) {
+ case (
+ brewURL.path,
+ GlossHomebrewInstallationDetector.infoArguments
+ ):
+ return GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: Self.infoJSON(version: "0.8.4")
+ )
+ case (
+ "/usr/bin/lipo",
+ [
+ currentBundleURL
+ .appendingPathComponent("Contents/MacOS/Gloss").path,
+ "-verify_arch",
+ runningArchitecture,
+ ]
+ ):
+ return GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: Data()
+ )
+ case (
+ "/usr/bin/codesign",
+ ["--verify", "--deep", "--strict", currentBundleURL.path]
+ ):
+ return GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: Data()
+ )
+ case (
+ "/usr/bin/codesign",
+ ["--display", "--verbose=4", currentBundleURL.path]
+ ):
+ return GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: Data(),
+ standardError: Data(
+ "Executable=Gloss\nSignature=adhoc\n".utf8
+ )
+ )
+ case (
+ "/usr/bin/xattr",
+ [
+ "-p",
+ "com.apple.quarantine",
+ currentBundleURL.path,
+ ]
+ ):
+ return GlossCommandOutput(
+ terminationStatus: 1,
+ standardOutput: Data(),
+ standardError: Data(
+ "No such xattr: com.apple.quarantine".utf8
+ )
+ )
+ default:
+ return GlossCommandOutput(
+ terminationStatus: 127,
+ standardOutput: Data()
+ )
+ }
+ }
+ let runner = GlossCommandRunner { executableURL, arguments in
+ await recorder.run(
+ executableURL: executableURL,
+ arguments: arguments
+ )
+ }
+ let existingPaths = Set([
+ brewURL.path,
+ currentBundleURL.path,
+ managedBundleURL.path,
+ ])
+ let verifier = GlossHomebrewUpgradeVerifier.live(
+ commandRunner: runner,
+ pathInspector: GlossPathInspector(
+ isExecutable: { $0 == brewURL },
+ fileExists: { existingPaths.contains($0.path) },
+ pathsReferToSameItem: { first, second in
+ let canonical: [String: String] = [
+ currentBundleURL.path: currentBundleURL.path,
+ managedBundleURL.path: currentBundleURL.path,
+ ]
+ return (canonical[first.path] ?? first.path)
+ == (canonical[second.path] ?? second.path)
+ }
+ ),
+ bundleVersionReader: GlossBundleVersionReader { _ in "0.8.4" }
+ )
+
+ #expect(try await verifier.verify(request) == "0.8.4")
+ #expect(await recorder.invocations.count == 5)
+ }
+
+ @Test("post-upgrade verification requires the exact signed version")
+ func postUpgradeVersionMustMatchExactly() {
+ #expect(
+ GlossHomebrewUpgradeVerifier.installedVersion(
+ "0.8.3",
+ matchesExpectedVersion: "0.8.3"
+ )
+ )
+ #expect(
+ !GlossHomebrewUpgradeVerifier.installedVersion(
+ "0.8.4",
+ matchesExpectedVersion: "0.8.3"
+ )
+ )
+ }
+
+ @Test("helper request rejects same-version updates and downgrades")
+ func requestRequiresStrictlyNewerVersion() {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+
+ for version in ["0.8.2", "0.8.1"] {
+ let request = makeRequest(
+ resultURL: directory.appendingPathComponent(
+ "\(version)-result.json"
+ ),
+ expectedVersion: version
+ )
+ #expect(throws: GlossHomebrewUpgradeError.self) {
+ try request.validate()
+ }
+ }
+ }
+
+ @Test("signed cask and current architecture metadata bind before upgrade")
+ func releaseBindingSucceeds() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let repository =
+ directory
+ .appendingPathComponent("sunchj/homebrew-tap", isDirectory: true)
+ let caskURL = repository.appendingPathComponent("Casks/gloss.rb")
+ let caskData = Data("trusted cask".utf8)
+ try FileManager.default.createDirectory(
+ at: caskURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try caskData.write(to: caskURL)
+ let request = makeRequest(
+ resultURL: directory.appendingPathComponent("result.json"),
+ expectedHomebrewCaskSHA256: Self.sha256(caskData),
+ expectedHomebrewCaskSize: Int64(caskData.count)
+ )
+ let runner = bindingRunner(
+ repository: repository,
+ version: request.expectedVersion,
+ assetURL: request.expectedAssetURL,
+ assetSHA256: request.expectedAssetSHA256
+ )
+
+ try await GlossHomebrewReleaseBindingVerifier.live(
+ commandRunner: runner
+ ).verify(request)
+ }
+
+ @Test("cask version URL and SHA mismatches fail closed")
+ func releaseBindingRejectsMetadataMismatches() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let repository =
+ directory
+ .appendingPathComponent("sunchj/homebrew-tap", isDirectory: true)
+ let caskURL = repository.appendingPathComponent("Casks/gloss.rb")
+ let caskData = Data("trusted cask".utf8)
+ try FileManager.default.createDirectory(
+ at: caskURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try caskData.write(to: caskURL)
+ let request = makeRequest(
+ resultURL: directory.appendingPathComponent("result.json"),
+ expectedHomebrewCaskSHA256: Self.sha256(caskData),
+ expectedHomebrewCaskSize: Int64(caskData.count)
+ )
+ let cases: [(String, URL, String)] = [
+ ("0.8.4", request.expectedAssetURL, request.expectedAssetSHA256),
+ (
+ request.expectedVersion,
+ URL(string: "https://attacker.example/Gloss.zip")!,
+ request.expectedAssetSHA256
+ ),
+ (
+ request.expectedVersion,
+ request.expectedAssetURL,
+ String(repeating: "c", count: 64)
+ ),
+ ]
+
+ for (version, assetURL, assetSHA256) in cases {
+ let verifier = GlossHomebrewReleaseBindingVerifier.live(
+ commandRunner: bindingRunner(
+ repository: repository,
+ version: version,
+ assetURL: assetURL,
+ assetSHA256: assetSHA256
+ )
+ )
+ await #expect(throws: GlossHomebrewUpgradeError.self) {
+ try await verifier.verify(request)
+ }
+ }
+ }
+
+ @Test("tampered tap cask fails signed hash binding")
+ func releaseBindingRejectsTamperedCask() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let repository =
+ directory
+ .appendingPathComponent("sunchj/homebrew-tap", isDirectory: true)
+ let caskURL = repository.appendingPathComponent("Casks/gloss.rb")
+ try FileManager.default.createDirectory(
+ at: caskURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try Data("tampered".utf8).write(to: caskURL)
+ let request = makeRequest(
+ resultURL: directory.appendingPathComponent("result.json"),
+ expectedHomebrewCaskSHA256: Self.sha256(Data("trusted".utf8)),
+ expectedHomebrewCaskSize: Int64(Data("trusted".utf8).count)
+ )
+
+ await #expect(throws: GlossHomebrewUpgradeError.self) {
+ try await GlossHomebrewReleaseBindingVerifier.live(
+ commandRunner: bindingRunner(
+ repository: repository,
+ version: request.expectedVersion,
+ assetURL: request.expectedAssetURL,
+ assetSHA256: request.expectedAssetSHA256
+ )
+ ).verify(request)
+ }
+ }
+
+ @Test("tap cask must be a regular in-repository file")
+ func releaseBindingRejectsSymlinkedCask() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let repository =
+ directory
+ .appendingPathComponent("sunchj/homebrew-tap", isDirectory: true)
+ let caskURL = repository.appendingPathComponent("Casks/gloss.rb")
+ let outsideURL = directory.appendingPathComponent("outside.rb")
+ let caskData = Data("trusted cask".utf8)
+ try FileManager.default.createDirectory(
+ at: caskURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try caskData.write(to: outsideURL)
+ try FileManager.default.createSymbolicLink(
+ at: caskURL,
+ withDestinationURL: outsideURL
+ )
+ let request = makeRequest(
+ resultURL: directory.appendingPathComponent("result.json"),
+ expectedHomebrewCaskSHA256: Self.sha256(caskData),
+ expectedHomebrewCaskSize: Int64(caskData.count)
+ )
+
+ await #expect(throws: GlossHomebrewUpgradeError.self) {
+ try await GlossHomebrewReleaseBindingVerifier.live(
+ commandRunner: bindingRunner(
+ repository: repository,
+ version: request.expectedVersion,
+ assetURL: request.expectedAssetURL,
+ assetSHA256: request.expectedAssetSHA256
+ )
+ ).verify(request)
+ }
+ }
+
+ @Test("helper independently reverifies the raw signed manifest")
+ func signedRequestIsReverified() throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let resultURL = directory.appendingPathComponent("result.json")
+ let request = makeRequest(resultURL: resultURL)
+ let manifest = GlossAppReleaseManifest(
+ version: request.expectedVersion,
+ releaseTag: request.expectedReleaseTag,
+ publishedAt: "2026-07-27T00:00:00Z",
+ minimumMacOSVersion: "14.0",
+ assets: ["arm64", "x86_64"].map { architecture in
+ GlossAppReleaseManifest.Asset(
+ operatingSystem: "macos",
+ architecture: architecture,
+ url: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/download/\(request.expectedReleaseTag)/Gloss-macos-\(architecture).zip"
+ )!,
+ sha256: String(repeating: "a", count: 64),
+ size: 100
+ )
+ },
+ homebrewCask: GlossAppReleaseManifest.HomebrewCask(
+ url: request.expectedHomebrewCaskURL,
+ sha256: request.expectedHomebrewCaskSHA256,
+ size: request.expectedHomebrewCaskSize
+ )
+ )
+ let privateKey = Curve25519.Signing.PrivateKey()
+ let manifestData = try JSONEncoder().encode(manifest)
+ try manifestData.write(to: request.manifestURL)
+ try privateKey.signature(for: manifestData).write(
+ to: request.manifestSignatureURL
+ )
+
+ try GlossSignedAppUpdateRequestVerifier(
+ manifestSigningPublicKey: privateKey.publicKey.rawRepresentation
+ ).verify(request)
+
+ var tampered = manifestData
+ tampered[tampered.startIndex] ^= 0x01
+ try tampered.write(to: request.manifestURL)
+ #expect(throws: GlossHomebrewUpgradeError.self) {
+ try GlossSignedAppUpdateRequestVerifier(
+ manifestSigningPublicKey:
+ privateKey.publicKey.rawRepresentation
+ ).verify(request)
+ }
+ }
+
+ @Test("readiness marker is required before the App can exit")
+ func readinessHandshakeSucceedsAndRejectsEarlyExit() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let readinessURL = directory.appendingPathComponent("ready.json")
+ let requestIdentifier = UUID()
+ let processIdentifier =
+ Int32(ProcessInfo.processInfo.processIdentifier)
+ try GlossUpdateHelperReadinessStore.write(
+ GlossUpdateHelperReadiness(
+ requestIdentifier: requestIdentifier,
+ helperProcessIdentifier: processIdentifier
+ ),
+ to: readinessURL
+ )
+ try await GlossUpdateHelperReadinessWaiter.live.wait(
+ for: readinessURL,
+ requestIdentifier: requestIdentifier,
+ helperProcessIdentifier: processIdentifier,
+ timeout: .seconds(1)
+ )
+
+ let exited = Process()
+ exited.executableURL = URL(fileURLWithPath: "/usr/bin/true")
+ try exited.run()
+ exited.waitUntilExit()
+ await #expect(throws: GlossHomebrewUpgradeError.helperExitedBeforeReady) {
+ try await GlossUpdateHelperReadinessWaiter.live.wait(
+ for: directory.appendingPathComponent("missing.json"),
+ requestIdentifier: UUID(),
+ helperProcessIdentifier: exited.processIdentifier,
+ timeout: .seconds(1)
+ )
+ }
+ }
+
+ @Test("readiness and command execution have bounded timeouts")
+ func helperAndCommandTimeouts() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let helper = Process()
+ helper.executableURL = URL(fileURLWithPath: "/bin/sleep")
+ helper.arguments = ["10"]
+ try helper.run()
+ await #expect(throws: GlossHomebrewUpgradeError.helperReadinessTimedOut) {
+ try await GlossUpdateHelperReadinessWaiter.live.wait(
+ for: directory.appendingPathComponent("missing.json"),
+ requestIdentifier: UUID(),
+ helperProcessIdentifier: helper.processIdentifier,
+ timeout: .milliseconds(50)
+ )
+ }
+
+ await #expect(throws: GlossCommandRunnerError.self) {
+ try await GlossCommandRunner.live.run(
+ executableURL: URL(fileURLWithPath: "/bin/sleep"),
+ arguments: ["10"],
+ timeout: .milliseconds(50)
+ )
+ }
+ }
+
+ @Test(
+ "command timeout kills descendants that ignore TERM and hold output"
+ )
+ func commandTimeoutKillsProcessGroup() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let processIdentifierURL = directory.appendingPathComponent(
+ "timeout-child.pid"
+ )
+ var fixtureIdentity: ProcessGroupFixtureIdentity?
+ defer {
+ if let fixtureIdentity {
+ cleanup(fixtureIdentity)
+ }
+ }
+
+ let clock = ContinuousClock()
+ let startedAt = clock.now
+ let command = Task {
+ try await GlossCommandRunner.live.run(
+ executableURL: URL(fileURLWithPath: "/usr/bin/python3"),
+ arguments: [
+ "-c",
+ Self.processGroupFixture,
+ processIdentifierURL.path,
+ "wait",
+ ],
+ timeout: .seconds(1)
+ )
+ }
+ defer { command.cancel() }
+ fixtureIdentity = try await waitForProcessIdentity(
+ at: processIdentifierURL
+ )
+ await #expect(throws: GlossCommandRunnerError.self) {
+ try await command.value
+ }
+ let elapsed = startedAt.duration(to: clock.now)
+
+ #expect(elapsed < .seconds(3))
+ let childExited = await waitForProcessToExit(
+ fixtureIdentity!
+ )
+ #expect(childExited)
+ if childExited {
+ fixtureIdentity = nil
+ }
+ }
+
+ @Test(
+ "normal leader exit cleans descendants that keep output handles open"
+ )
+ func commandCompletionCleansOutputHoldingDescendants() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let processIdentifierURL = directory.appendingPathComponent(
+ "completed-child.pid"
+ )
+ var fixtureIdentity: ProcessGroupFixtureIdentity?
+ defer {
+ if let fixtureIdentity {
+ cleanup(fixtureIdentity)
+ }
+ }
+
+ let clock = ContinuousClock()
+ let startedAt = clock.now
+ let command = Task {
+ try await GlossCommandRunner.live.run(
+ executableURL: URL(fileURLWithPath: "/usr/bin/python3"),
+ arguments: [
+ "-c",
+ Self.processGroupFixture,
+ processIdentifierURL.path,
+ "exit",
+ ],
+ timeout: .seconds(3)
+ )
+ }
+ defer { command.cancel() }
+ fixtureIdentity = try await waitForProcessIdentity(
+ at: processIdentifierURL
+ )
+ let output = try await command.value
+ let elapsed = startedAt.duration(to: clock.now)
+ #expect(output.terminationStatus == 0)
+ #expect(
+ String(decoding: output.standardOutput, as: UTF8.self)
+ .contains("child-ready")
+ )
+ #expect(elapsed < .seconds(2))
+ let childExited = await waitForProcessToExit(
+ fixtureIdentity!
+ )
+ #expect(childExited)
+ if childExited {
+ fixtureIdentity = nil
+ }
+ }
+
+ @Test("command cancellation kills its entire process group")
+ func commandCancellationKillsProcessGroup() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let processIdentifierURL = directory.appendingPathComponent(
+ "cancelled-child.pid"
+ )
+ var fixtureIdentity: ProcessGroupFixtureIdentity?
+ defer {
+ if let fixtureIdentity {
+ cleanup(fixtureIdentity)
+ }
+ }
+
+ let command = Task {
+ try await GlossCommandRunner.live.run(
+ executableURL: URL(fileURLWithPath: "/usr/bin/python3"),
+ arguments: [
+ "-c",
+ Self.processGroupFixture,
+ processIdentifierURL.path,
+ "wait",
+ ]
+ )
+ }
+ fixtureIdentity = try await waitForProcessIdentity(
+ at: processIdentifierURL
+ )
+ command.cancel()
+
+ await #expect(throws: CancellationError.self) {
+ try await command.value
+ }
+ let childExited = await waitForProcessToExit(
+ fixtureIdentity!
+ )
+ #expect(childExited)
+ if childExited {
+ fixtureIdentity = nil
+ }
+ }
+
+ @Test("continuous output still observes the command timeout")
+ func continuousOutputStillTimesOut() async {
+ await #expect(
+ throws: GlossCommandRunnerError.timedOut(
+ executablePath: "/usr/bin/python3"
+ )
+ ) {
+ try await GlossCommandRunner.live.run(
+ executableURL: URL(fileURLWithPath: "/usr/bin/python3"),
+ arguments: [
+ "-c",
+ """
+ import os
+ import time
+ while True:
+ os.write(1, b"x" * 1024)
+ time.sleep(0.005)
+ """,
+ ],
+ timeout: .milliseconds(100)
+ )
+ }
+ }
+
+ @Test("unbounded command output fails at the capture limit")
+ func commandOutputLimitIsEnforced() async {
+ await #expect(
+ throws: GlossCommandRunnerError.outputLimitExceeded(
+ executablePath: "/usr/bin/python3",
+ maximumByteCount:
+ GlossCommandRunner.maximumCapturedOutputByteCount
+ )
+ ) {
+ try await GlossCommandRunner.live.run(
+ executableURL: URL(fileURLWithPath: "/usr/bin/python3"),
+ arguments: [
+ "-c",
+ """
+ import os
+ chunk = b"x" * 65536
+ while True:
+ os.write(1, chunk)
+ """,
+ ],
+ timeout: .seconds(5)
+ )
+ }
+ }
+
+ @Test("short commands complete without process-group cleanup delay")
+ func shortCommandsCompleteWithoutCleanupDelay() async throws {
+ let clock = ContinuousClock()
+ let startedAt = clock.now
+
+ // A smaller sample still catches the historical per-command cleanup
+ // delay while avoiding a wall-clock assertion dominated by process
+ // launch contention on shared CI runners.
+ for value in 0..<16 {
+ let output = try await GlossCommandRunner.live.run(
+ executableURL: URL(fileURLWithPath: "/usr/bin/printf"),
+ arguments: ["%d", String(value)],
+ timeout: .seconds(1)
+ )
+ #expect(output.terminationStatus == 0)
+ #expect(
+ String(decoding: output.standardOutput, as: UTF8.self)
+ == String(value)
+ )
+ }
+
+ #expect(startedAt.duration(to: clock.now) < .seconds(3))
+ }
+
+ @Test("launcher stages an independent helper and canonical request")
+ func launcherStagesHelper() async throws {
+ let directory = temporaryDirectory()
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let cacheURL = directory.appendingPathComponent(
+ "AppUpdater",
+ isDirectory: true
+ )
+ let resultURL = cacheURL.appendingPathComponent("latest-result.json")
+ try FileManager.default.createDirectory(
+ at: cacheURL,
+ withIntermediateDirectories: true
+ )
+ try Data("old".utf8).write(to: resultURL, options: .atomic)
+ let installation = GlossHomebrewInstallation(
+ brewExecutableURL: URL(
+ fileURLWithPath: "/opt/homebrew/bin/brew"
+ ),
+ caskToken: "sunchj/tap/gloss",
+ installedVersion: "0.8.2",
+ availableVersion: "0.8.3",
+ managedAppURL: URL(
+ fileURLWithPath:
+ "/opt/homebrew/Caskroom/gloss/0.8.2/Gloss.app"
+ ),
+ installedAppTargetURL: URL(
+ fileURLWithPath: "/Applications/Gloss.app"
+ )
+ )
+
+ let launch = try await GlossAppUpdateHelperLauncher(
+ readinessWaiter: GlossUpdateHelperReadinessWaiter {
+ _,
+ _,
+ _,
+ _ in
+ }
+ ).launch(
+ bundledHelperURL: URL(fileURLWithPath: "/usr/bin/true"),
+ installation: installation,
+ update: updateAvailability(),
+ parentProcessIdentifier: 98_765,
+ currentBundleURL: URL(
+ fileURLWithPath: "/Applications/Gloss.app"
+ ),
+ cacheRootURL: cacheURL,
+ resultURL: resultURL
+ )
+
+ #expect(launch.processIdentifier > 1)
+ #expect(
+ FileManager.default.isExecutableFile(
+ atPath:
+ launch.stagingDirectoryURL
+ .appendingPathComponent("gloss-update-helper").path
+ )
+ )
+ #expect(!FileManager.default.fileExists(atPath: resultURL.path))
+ let request = try GlossHomebrewUpgradeRequestStore.load(
+ from: launch.requestURL
+ )
+ #expect(request.expectedVersion == "0.8.3")
+ #expect(request.resultPath == resultURL.path)
+ #expect(request.expectedArchitecture == GlossAppArchitecture.current)
+ #expect(
+ try Data(contentsOf: request.manifestURL)
+ == updateAvailability().manifestData
+ )
+ }
+
+ private func makeRequest(
+ resultURL: URL,
+ expectedVersion: String = "0.8.3",
+ expectedHomebrewCaskSHA256: String = String(
+ repeating: "b",
+ count: 64
+ ),
+ expectedHomebrewCaskSize: Int64 = 200
+ ) -> GlossHomebrewUpgradeRequest {
+ GlossHomebrewUpgradeRequest(
+ brewExecutablePath: "/opt/homebrew/bin/brew",
+ caskToken: "sunchj/tap/gloss",
+ previousVersion: "0.8.2",
+ expectedVersion: expectedVersion,
+ expectedReleaseTag: "v\(expectedVersion)",
+ expectedArchitecture: GlossAppArchitecture.current,
+ expectedAssetURL: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/download/v\(expectedVersion)/Gloss-macos-\(GlossAppArchitecture.current).zip"
+ )!,
+ expectedAssetSHA256: String(repeating: "a", count: 64),
+ expectedAssetSize: 100,
+ expectedHomebrewCaskURL: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/download/v\(expectedVersion)/gloss.rb"
+ )!,
+ expectedHomebrewCaskSHA256: expectedHomebrewCaskSHA256,
+ expectedHomebrewCaskSize: expectedHomebrewCaskSize,
+ parentProcessIdentifier: 98_765,
+ currentBundlePath: "/Applications/Gloss.app",
+ resultPath: resultURL.path,
+ readinessPath:
+ resultURL.deletingLastPathComponent()
+ .appendingPathComponent("ready.json").path,
+ manifestPath:
+ resultURL.deletingLastPathComponent()
+ .appendingPathComponent("release-manifest.json").path,
+ manifestSignaturePath:
+ resultURL.deletingLastPathComponent()
+ .appendingPathComponent("release-manifest.json.sig").path,
+ recoveryBundlePath:
+ resultURL.deletingLastPathComponent()
+ .appendingPathComponent("recovery/Gloss.app").path
+ )
+ }
+
+ private func updateAvailability() -> GlossAppUpdateAvailability {
+ GlossAppUpdateAvailability(
+ version: "0.8.3",
+ releaseTag: "v0.8.3",
+ publishedAt: Date(timeIntervalSince1970: 1_000),
+ minimumMacOSVersion: "14.0",
+ releasePageURL: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/tag/v0.8.3"
+ )!,
+ architecture: GlossAppArchitecture.current,
+ assetURL: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/download/v0.8.3/Gloss-macos-\(GlossAppArchitecture.current).zip"
+ )!,
+ assetSHA256: String(repeating: "a", count: 64),
+ assetSize: 100,
+ homebrewCask: GlossAppReleaseManifest.HomebrewCask(
+ url: URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/download/v0.8.3/gloss.rb"
+ )!,
+ sha256: String(repeating: "b", count: 64),
+ size: 200
+ ),
+ manifestData: Data("signed manifest".utf8),
+ detachedSignatureData: Data("signature".utf8)
+ )
+ }
+
+ private func noOpRecoveryManager() -> GlossAppUpdateRecoveryManager {
+ GlossAppUpdateRecoveryManager(
+ prepare: { _ in },
+ restoreIfNeeded: { _ in false }
+ )
+ }
+
+ private func bindingRunner(
+ repository: URL,
+ version: String,
+ assetURL: URL,
+ assetSHA256: String
+ ) -> GlossCommandRunner {
+ GlossCommandRunner { executableURL, arguments in
+ guard executableURL.path == "/opt/homebrew/bin/brew" else {
+ return GlossCommandOutput(
+ terminationStatus: 127,
+ standardOutput: Data()
+ )
+ }
+ if arguments
+ == GlossHomebrewReleaseBindingVerifier.repositoryArguments
+ {
+ return GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: Data("\(repository.path)\n".utf8)
+ )
+ }
+ if arguments == GlossHomebrewInstallationDetector.infoArguments {
+ return GlossCommandOutput(
+ terminationStatus: 0,
+ standardOutput: Self.infoJSON(
+ version: version,
+ assetURL: assetURL,
+ assetSHA256: assetSHA256
+ )
+ )
+ }
+ return GlossCommandOutput(
+ terminationStatus: 127,
+ standardOutput: Data()
+ )
+ }
+ }
+
+ private static func sha256(_ data: Data) -> String {
+ SHA256.hash(data: data)
+ .map { String(format: "%02x", $0) }
+ .joined()
+ }
+
+ private func temporaryDirectory() -> URL {
+ let url = FileManager.default.temporaryDirectory.appendingPathComponent(
+ "gloss-homebrew-upgrade-tests-\(UUID().uuidString)",
+ isDirectory: true
+ )
+ try? FileManager.default.createDirectory(
+ at: url,
+ withIntermediateDirectories: true
+ )
+ return url
+ }
+
+ private static let processGroupFixture = """
+ import os
+ import signal
+ import sys
+ import time
+
+ pid_path = sys.argv[1]
+ child = os.fork()
+ if child == 0:
+ signal.signal(signal.SIGTERM, signal.SIG_IGN)
+ with open(pid_path, "w") as pid_file:
+ pid_file.write(f"{os.getpid()} {os.getpgrp()}")
+ print("child-ready", flush=True)
+ while True:
+ time.sleep(1)
+
+ while not os.path.exists(pid_path):
+ time.sleep(0.01)
+ if sys.argv[2] == "exit":
+ os._exit(0)
+ while True:
+ time.sleep(1)
+ """
+
+ private enum ProcessGroupFixtureError: Error {
+ case didNotStart
+ }
+
+ private struct ProcessGroupFixtureIdentity {
+ let childProcessIdentifier: pid_t
+ let processGroupIdentifier: pid_t
+ let processStartTime: Double
+ }
+
+ private func waitForProcessIdentity(
+ at url: URL,
+ timeout: Duration = .seconds(2)
+ ) async throws -> ProcessGroupFixtureIdentity {
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: timeout)
+ while clock.now < deadline {
+ if let data = try? Data(contentsOf: url),
+ let value = String(data: data, encoding: .utf8),
+ case let components = value.split(separator: " "),
+ components.count == 2,
+ let childIdentifier = pid_t(components[0]),
+ let groupIdentifier = pid_t(components[1]),
+ let processStartTime =
+ BabelDOCServiceSession.processStartTime(
+ childIdentifier
+ )
+ {
+ return ProcessGroupFixtureIdentity(
+ childProcessIdentifier: childIdentifier,
+ processGroupIdentifier: groupIdentifier,
+ processStartTime: processStartTime
+ )
+ }
+ try await Task.sleep(for: .milliseconds(20))
+ }
+ throw ProcessGroupFixtureError.didNotStart
+ }
+
+ private func cleanup(_ identity: ProcessGroupFixtureIdentity) {
+ guard
+ Darwin.kill(identity.childProcessIdentifier, 0) == 0,
+ getpgid(identity.childProcessIdentifier)
+ == identity.processGroupIdentifier,
+ BabelDOCServiceSession.processStartTime(
+ identity.childProcessIdentifier
+ ) == identity.processStartTime
+ else {
+ return
+ }
+ _ = Darwin.kill(-identity.processGroupIdentifier, SIGKILL)
+ }
+
+ private func waitForProcessToExit(
+ _ identity: ProcessGroupFixtureIdentity,
+ timeout: Duration = .seconds(2)
+ ) async -> Bool {
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: timeout)
+ while clock.now < deadline {
+ if BabelDOCServiceSession.processStartTime(
+ identity.childProcessIdentifier
+ ) != identity.processStartTime {
+ return true
+ }
+ try? await Task.sleep(for: .milliseconds(20))
+ }
+ return BabelDOCServiceSession.processStartTime(
+ identity.childProcessIdentifier
+ ) != identity.processStartTime
+ }
+
+ private static func infoJSON(
+ version: String,
+ assetURL: URL? = nil,
+ assetSHA256: String = String(repeating: "a", count: 64)
+ ) -> Data {
+ let resolvedAssetURL =
+ assetURL
+ ?? URL(
+ string:
+ "https://github.com/SunChJ/gloss-releases/releases/download/v\(version)/Gloss-macos-\(GlossAppArchitecture.current).zip"
+ )!
+ return Data(
+ """
+ {
+ "formulae": [],
+ "casks": [
+ {
+ "token": "gloss",
+ "full_token": "sunchj/tap/gloss",
+ "tap": "sunchj/tap",
+ "version": "\(version)",
+ "installed": "\(version)",
+ "url": "\(resolvedAssetURL.absoluteString)",
+ "sha256": "\(assetSHA256)",
+ "artifacts": [
+ {
+ "app": ["Gloss.app"],
+ "target": "/Applications/Gloss.app"
+ }
+ ]
+ }
+ ]
+ }
+ """.utf8
+ )
+ }
+}
diff --git a/docs/release-notes/v0.8.3.md b/docs/release-notes/v0.8.3.md
new file mode 100644
index 0000000..d06a1ea
--- /dev/null
+++ b/docs/release-notes/v0.8.3.md
@@ -0,0 +1,81 @@
+# Gloss 0.8.3
+
+Gloss 0.8.3 focuses the product on browser and PDF translation, makes the same
+scenario model available to automation, and closes the Homebrew update loop.
+
+## Capability and scenario model
+
+- Composes user-facing workflows from a shared capability registry instead of
+ starting every implemented feature.
+- Enables Chrome browser translation and PDF translation by default; an
+ Apple-signed build also enables Safari.
+- Keeps clipboard, screenshot/OCR, system selection, glossary, and history
+ implementations available in source without exposing their UI or starting
+ their listeners.
+- Uses the same registry for App lifecycle decisions, menus, settings, external
+ PDF-open requests, and CLI dispatch.
+- Reads a signed-build capability flag so Homebrew's ad-hoc package does not
+ expose an unusable Safari settings entry or claim Safari in CLI output.
+- Omits the Safari App Extension from ad-hoc/Homebrew artifacts so PlugInKit
+ cannot register an unusable system extension.
+- Removes dormant text and image Services from the focused App bundle while
+ retaining their implementation in source.
+
+## CLI automation
+
+- Adds `gloss-cli capabilities --json` for stable, machine-readable capability,
+ scenario, dependency, and command mappings.
+- Adds `gloss-cli browser` with webpage translation semantics.
+- Adds an explicit `gloss-cli text` mapping for the reusable translation core;
+ the original flat invocation remains an alias.
+- Adds `gloss-cli pdf INPUT... --output DIR` for sequential PDF batches that
+ reuse one isolated BabelDOC session without taking over the App service.
+- Keeps the existing flat text command for compatibility while requiring the
+ shared text-translation capability.
+
+## PDF reliability
+
+- Requires the signed BabelDOC `0.6.4+gloss.5` runtime, which includes the
+ `o200k_base` tokenizer needed by current translation models.
+- Installs a compatible available runtime after the launch-time check instead
+ of continuing with an incompatible cached version.
+- Extends the bounded cold-start window to 180 seconds for a first DocLayout
+ initialization.
+
+## Homebrew updates
+
+- Checks the signed public release manifest silently after launch and at most
+ once every 24 hours, with manual check and update controls in the App.
+- Offers in-App installation only when the running bundle is verified as the
+ official `sunchj/tap/gloss` Homebrew cask.
+- Runs fixed Homebrew commands from a staged helper after Gloss exits, without
+ a shell or `sudo`, then verifies the version, architecture, ad-hoc signature,
+ and quarantine state before reopening the App.
+- Binds the exact Homebrew Cask bytes and current-architecture App asset to the
+ signed manifest, waits for a helper readiness acknowledgement, and restores a
+ verified previous App if an upgrade damages the installed bundle.
+- Defers installation while a browser or PDF translation task is active and
+ surfaces failure results after restart.
+
+## Release integrity
+
+- Signs the App update manifest with Ed25519 and publishes its detached
+ signature with both architecture-specific release archives.
+- Packages and signs the CLI, bundled Codex runtime, and update helper as
+ explicit Homebrew cask components.
+- Links the bundled `gloss-cli` into Homebrew's `bin` directory and validates
+ its capability report in both architecture install smokes.
+
+## Validation
+
+- The complete test suite passes: 290 tests, 5 conditionally skipped, and no
+ failures.
+- All three products (`Gloss`, `gloss-cli`, and `gloss-update-helper`) build.
+- A real CLI translation of the 15-page *Attention Is All You Need* PDF
+ completed in 31.56 seconds and produced a 15-page, 2.3 MB PDF with selectable
+ Chinese text using BabelDOC `0.6.4+gloss.5`.
+
+## Compatibility
+
+Gloss 0.8.3 requires macOS 14 or newer. Existing Homebrew installations can
+upgrade through the App or with `brew upgrade --cask sunchj/tap/gloss`.
diff --git a/docs/runtime-distribution.md b/docs/runtime-distribution.md
index 6c4eb71..e0301af 100644
--- a/docs/runtime-distribution.md
+++ b/docs/runtime-distribution.md
@@ -90,11 +90,13 @@ active runtime。
证书,也不执行 notarization 或 stapling。
2. 生成 `Gloss-macos-arm64.zip`、`Gloss-macos-x86_64.zip`、`SHA256SUMS` 和包含两个
architecture asset 的 `gloss-release-manifest.json`。
-3. 生成并校验使用 `on_arm` / `on_intel` URL 与 SHA-256 的 `Casks/gloss.rb`。Manifest 和
+3. 生成并校验使用 `on_arm` / `on_intel` URL 与 SHA-256 的根级 Release asset
+ `gloss.rb`。Manifest 还会签名绑定其固定 token、URL、SHA-256 与 size;tap workflow
+ 再将它落到 `Casks/gloss.rb`。Manifest 和
Cask 中的下载地址固定指向公开仓库
`https://github.com/SunChJ/gloss-releases/releases/download//`。
4. 始终上传私有主仓中的 Actions artifact,便于内部验证。
-5. 仅在 tag 事件中使用跨仓库 token,把 app zip、校验和、manifest 与生成的 Cask 发布到
+5. 仅在 tag 事件中使用跨仓库 token,把 app zip、校验和、manifest 与根级 `gloss.rb` 发布到
公开的 `SunChJ/gloss-releases` GitHub Release。
6. Release 上传成功后,dispatch `SunChJ/homebrew-tap` 的 `update-cask.yml`,由公开 tap
下载并二次校验 Release,再更新 `Casks/gloss.rb`。
@@ -180,7 +182,7 @@ gh workflow run update-cask.yml \
-f release_repository=SunChJ/gloss-releases
```
-公开 tap 合并生成的 Cask 更新后,用户使用标准 tap 名称安装和升级:
+公开 tap 的校验全部通过后会自动合并生成的 Cask 更新,用户使用标准 tap 名称安装和升级:
```bash
brew tap sunchj/tap
@@ -199,9 +201,11 @@ brew upgrade --cask sunchj/tap/gloss
5. 等待 Gloss Release workflow 完成 ad-hoc 签名;workflow 会先创建 draft Release,上传全部
资产后再发布,最后 dispatch tap 更新。
6. 在 `SunChJ/gloss-releases` 验证两种架构 zip、`SHA256SUMS`、
- `gloss-release-manifest.json` 与 `Casks/gloss.rb` 均存在且 URL 指向该公开 Release。
-7. 审阅并合并 `SunChJ/homebrew-tap` 生成的 Cask PR,然后在 arm64 与 x86_64 Mac 上分别执行
- `brew install --cask sunchj/tap/gloss` smoke test。
+ `gloss-release-manifest.json`、签名文件与根级 `gloss.rb` 均存在且 URL 指向该公开 Release;
+ tap 会把同一份 `gloss.rb` 落到 `Casks/gloss.rb`。
+7. 等待 `SunChJ/homebrew-tap` 生成的 Cask PR 在 required checks 通过后自动合并;workflow
+ 会在 arm64 与 x86_64 Mac 上分别执行 `brew install --cask sunchj/tap/gloss` smoke test,
+ 不要求人工批准。
`SunChJ/gloss-releases` 必须启用 GitHub release immutability。已发布 Release 的 tag 与资产
不可覆盖;相同 tag 的 workflow 重跑会 fail closed。上传中断时 Release 仍保持 draft,
@@ -226,16 +230,20 @@ Gloss 的 Actions 页面手工运行 Release,输入原 `release_tag` 并显式
- Cask 的下载 SHA-256 和公开 Release 的 `SHA256SUMS` 能证明实际下载内容与 tap 固定的内容
相同,但它们不能替代发布者身份签名;`gloss-releases`、`homebrew-tap` 或跨仓库 token
同时失守时,攻击者可能替换二进制与 checksum。
-- custom tap 的 `postflight` 按最深层优先顺序分别对 Safari `.appex`、Codex helper、CLI 和
+- custom tap 的 `postflight` 按最深层优先顺序分别对 Codex helper、CLI、更新 helper 和
最外层 `Gloss.app` 执行 `codesign --force --sign -`,不使用可能覆盖嵌套 entitlement 的
- `--deep --sign`;每一步都通过
+ `--deep --sign`;Homebrew 资产不会包含无法配对的 Safari `.appex`。每一步都通过
`--preserve-metadata=identifier,entitlements,requirements,flags,runtime` 保留已有 metadata,
- 并比较签名前后 App 与 `.appex` 的 entitlement bytes。随后只递归删除
+ 并比较签名前后 App 的 entitlement bytes。随后只递归删除
`com.apple.quarantine`、确认该属性已经不存在,最后用
`codesign --verify --deep --strict` fail closed 验证完整签名。这让正常 Homebrew 安装后的首次
启动不需要用户绕过 Gatekeeper,但也主动移除了 Gatekeeper 的隔离检查。
-- ad-hoc 签名不能完成 Safari App Extension 与宿主 App 的 Apple 身份配对,因此该发行方式不
- 承诺 Safari extension 可用;需要 Safari 配对时仍应在本地使用 Apple Development 身份构建。
+- ad-hoc 签名不能完成 Safari App Extension 与宿主 App 的 Apple 身份配对,因此构建脚本会
+ 完全省略 Safari `.appex` 并把 Safari 标记为不可用;App、设置页与
+ `gloss-cli capabilities --json` 都只声明 Chrome。需要 Safari 配对时仍应在本地使用 Apple
+ Development 或 distribution identity 构建,并确保签名允许
+ `group.com.samsoncj.gloss` App Group。构建脚本要求分别提供宿主与扩展 provisioning
+ profile,并将它们嵌入对应 bundle;运行时只会向系统实际授予的 group container 写入令牌。
因此该 Cask 只适用于用户明确信任 `SunChJ/homebrew-tap` 和
`SunChJ/gloss-releases` 的自定义分发场景,不应被描述为 Apple 已签名或已公证的软件。