Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 53 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand All @@ -155,16 +163,52 @@ 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: |
gloss/dist/release/Gloss-macos-arm64.zip
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
Expand Down Expand Up @@ -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" \
Expand Down
12 changes: 10 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -23,6 +27,10 @@ let package = Package(
name: "GlossCLI",
dependencies: ["GlossCore"]
),
.executableTarget(
name: "GlossUpdateHelper",
dependencies: ["GlossCore"]
),
.testTarget(
name: "GlossCoreTests",
dependencies: ["GlossCore"]
Expand All @@ -34,6 +42,6 @@ let package = Package(
.testTarget(
name: "GlossAppTests",
dependencies: ["Gloss"]
)
),
]
)
88 changes: 75 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 中停用;手动快捷键仍然可用
Expand All @@ -15,7 +32,7 @@ Gloss 是一款 macOS 原生、上下文感知的系统级翻译工具:选中
- 本地选项使用 `Hy-MT2-1.8B-GGUF:Q4_K_M`,通过 Metal 运行,原文和译文都留在设备上
- 使用结构化输出、只读沙盒和禁用工具的临时线程
- 译文与原文在同一结果卡片中直接对照,并支持复制译文、替换原文和追加双语
- 提供分开的 macOS 文本与图片“服务”入口,避免被系统归入错误分类
- 保留 macOS 文本与图片“服务”的处理实现,但当前聚焦发行不向系统注册入口
- 可翻译剪贴板图片、交互式截图及系统“服务”传入的图片或图片文件
- 截图写入权限隔离的临时目录并在读取后立即删除,不占用系统剪贴板
- 复制式选区与替换回退会恢复普通剪贴板;遇到密码管理器、临时内容、文件承诺或超大内容时不执行破坏性回退
Expand All @@ -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` 作为脚本与诊断入口

## 运行日志
Expand All @@ -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` 构建配置。

## 开发

Expand All @@ -70,13 +89,32 @@ 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.'
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
Expand Down Expand Up @@ -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 的轻量版本:

Expand All @@ -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 公证。
Expand All @@ -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 不会指向私有主仓。
Expand All @@ -182,19 +229,34 @@ 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。
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 页面。

## 代码结构

```text
Sources/GlossCore/ Codex/llama 客户端、provider 路由、翻译模型、缓存与并发合并
Sources/GlossOCR/ 本地 Vision OCR 与版面阅读顺序恢复
Sources/Gloss/ macOS 选区、图片、截图、结果面板、文本替换与浏览器桥接
Sources/GlossCLI/ 薄命令行入口
Sources/GlossCLI/ 浏览器/PDF 场景命令与向后兼容的文本入口
```
Loading
Loading