diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a29cee1..d165b9b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -109,6 +109,7 @@ jobs: run: | lipo dist/Gloss.app/Contents/MacOS/Gloss -verify_arch "$EXPECTED_ARCHITECTURE" test ! -e "dist/Gloss.app/Contents/PlugIns/Gloss Extension.appex" + test ! -e "dist/Gloss.app/Contents/Helpers/gloss-codex-app-server" test "$( /usr/libexec/PlistBuddy \ -c 'Print :GlossSafariExtensionAvailable' \ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..086106b --- /dev/null +++ b/LICENSE @@ -0,0 +1,75 @@ +# PolyForm Noncommercial License 1.0.0 + + + +Required Notice: Copyright 2026 SamsonLab. + +## Acceptance + +In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software. + +## Noncommercial Purposes + +Any noncommercial purpose is a permitted purpose. + +## Personal Uses + +Personal use for research, experiment, and testing for the benefit of public knowledge, personal study, private entertainment, hobby projects, amateur pursuits, or religious observance, without any anticipated commercial application, is use for a permitted purpose. + +## Noncommercial Organizations + +Use by any charitable organization, educational institution, public research organization, public safety or health organization, environmental protection organization, or government institution is use for a permitted purpose regardless of the source of funding or obligations resulting from the funding. + +## Fair Use + +You may have "fair use" rights for the software under the law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms. + +**You** refers to the individual or entity agreeing to these terms. + +**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all organizations that have control over, are under the control of, or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the software under these terms. + +**Use** means anything you do with the software requiring one of your licenses. diff --git a/README.md b/README.md index a00af03..34a7a18 100644 --- a/README.md +++ b/README.md @@ -1,262 +1,161 @@ -# Gloss - -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 中停用;手动快捷键仍然可用 -- 提供 App 例外列表,可查看、添加或移除自动出现规则 -- 支持登录时启动;辅助功能权限撤销或重新授予后会自动收敛到正确状态 -- 在选区附近显示轻量 GlossBar 与不抢焦点的自适应结果卡片;可用 `Esc` 收起 -- 内置固定版本的原生 Rust Codex app-server,不依赖 Node、Homebrew、系统 PATH 或外部 Codex CLI -- 使用 Gloss 独立的 Codex 数据目录与 ChatGPT 登录;登录后在后台并行预建翻译 thread 池 -- 可在设置中即时切换 GPT 订阅与本地 `llama.cpp` provider;GPT 可配置 model 和 reasoning -- 本地选项使用 `Hy-MT2-1.8B-GGUF:Q4_K_M`,通过 Metal 运行,原文和译文都留在设备上 -- 使用结构化输出、只读沙盒和禁用工具的临时线程 -- 译文与原文在同一结果卡片中直接对照,并支持复制译文、替换原文和追加双语 -- 保留 macOS 文本与图片“服务”的处理实现,但当前聚焦发行不向系统注册入口 -- 可翻译剪贴板图片、交互式截图及系统“服务”传入的图片或图片文件 -- 截图写入权限隔离的临时目录并在读取后立即删除,不占用系统剪贴板 -- 复制式选区与替换回退会恢复普通剪贴板;遇到密码管理器、临时内容、文件承诺或超大内容时不执行破坏性回退 -- 使用 Apple Vision 在本机完成 OCR,只把识别后的文字交给翻译引擎 -- 支持目标语言与忠实、自然、技术、学术、字幕风格 -- 使用本地语种识别,在源文与主要目标同语种时自动切换到可配置的反向语言 -- 内置 16 种常用目标语言,语种识别不会产生模型调用 -- 将最近 200 条主动翻译以 `0600` 权限保存在本机,支持搜索、复制、删除、清空和重新翻译;可随时关闭 -- 支持本地 TSV 术语表;每次只向模型加入原文实际命中的最多 40 条术语,编辑后自动清除旧缓存 -- 缓存重复内容,并合并并发的相同请求 -- 默认使用低延迟的 `gpt-5.3-codex-spark`,最多并发执行 3 个独立翻译 turn;每次完成后回滚该 turn,避免跨批上下文累积 -- 内置只监听 `127.0.0.1` 的浏览器桥接,与扩展共享同一个翻译代理和缓存 -- Chrome 扩展与 Safari Web Extension 共用 WXT 源码;Safari 入口仅在 Apple 签名构建中启用 -- 使用每机随机令牌鉴权:Chrome 自动注入 App 管理副本,Apple 签名构建中的 Safari 通过 App Group 安全配对 -- 提供 `gloss-cli` 作为脚本与诊断入口 - -## 运行日志 - -Gloss 菜单和设置页均提供“查看日志”入口,固定目录为: - -```text -~/Library/Logs/Gloss/ -├── gloss.log # 启动、桥接、RPC 与翻译批次耗时;不记录原文或译文 -└── codex-stderr.log # Codex app-server 的原始 stderr +
+ Gloss icon +

Gloss

+

Native macOS translation for the browser and layout-preserving PDFs.

+

+ English · + 简体中文 +

+

+ macOS 14+ + Swift 6 + PolyForm Noncommercial 1.0.0 +

+
+ +Gloss is a native macOS translation app built around complete workflows rather +than a collection of disconnected utilities. The current release focuses on +two scenarios: translating browser content and translating PDFs while +preserving their layout. + +The app and `gloss-cli` share the same capability registry, translation +providers, cache, and scheduling layer. You can use an installed, ChatGPT-connected +Codex CLI or keep translation on-device with `llama.cpp`. + +## Highlights + +| Workflow | What Gloss provides | +| --- | --- | +| Browser translation | A local, authenticated bridge and a bundled Chrome extension. Apple-signed builds can also include Safari. | +| PDF translation | An approximately 230 MB signed BabelDOC runtime installed on demand, with layout preservation, batch queues, rollback, and safe uninstall. | +| Translation providers | ChatGPT sign-in through an installed Codex CLI, or a local `llama.cpp` server with Hy-MT2. | +| Automation | `gloss-cli` commands for capabilities, browser text, PDFs, plain text, and diagnostics. | + +Gloss is written in Swift, uses Apple Vision for local OCR, and keeps its +browser bridge on `127.0.0.1` behind a per-device token. + +## Install + +Gloss requires **macOS 14 or newer**. + +```bash +brew install --cask sunchj/tap/gloss ``` -两份日志权限均为 `0600`,达到 5 MB 后保留一份 `.1` 轮转文件。也可以直接观察: +Upgrade from the app or with: ```bash -tail -f ~/Library/Logs/Gloss/gloss.log +brew update +brew upgrade --cask sunchj/tap/gloss ``` -## 前置条件 +The Homebrew build is ad-hoc signed rather than Apple-notarized. Its cask +re-signs the app locally, removes quarantine, and verifies the installed +bundle. Public binaries and signed update metadata are published in +[`SunChJ/gloss-releases`](https://github.com/SunChJ/gloss-releases/releases/latest). -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 使用“辅助功能”。 -4. Chrome:在 Gloss 设置中点“显示扩展”,从 `chrome://extensions` 加载这个已自动配对的目录。 -5. Safari(仅 Apple 签名构建):在 Gloss 设置中点“Safari 设置”,启用随 App 内置的 - Gloss Extension。Homebrew 的 ad-hoc 构建不会显示这个入口。 +## Quick start -Gloss 的登录状态和 Codex 配置保存在 `~/Library/Application Support/Gloss/Codex/`,不会修改系统 Codex CLI 的数据。 +1. Open Gloss. The main panel starts with browser translation status and extension actions. +2. Use the top-right settings button or `⌘,` to select a translation provider and the `System / Light / Dark` appearance. +3. For GPT translation, select **Sign in with ChatGPT**. No separate API key is + required by Gloss. +4. For local translation, install `llama.cpp` with + `brew install llama.cpp`, then choose **Local Model**. The first run downloads + an approximately 1.1 GB quantized model. +5. For Chrome, choose **Show Extension** from the main panel or Gloss settings and load the shown + directory from `chrome://extensions` in Developer mode. +6. Install the PDF component on first use, then select or drop one or more files and choose an output + directory. -剪贴板或图片翻译场景的处理代码仍保留,但当前构建不会注册系统“服务”;重新发布这些场景时 -需要同步恢复对应的 `NSServices` 构建配置。 +Safari support is available only in an Apple-signed build that includes the +Safari App Extension and matching App Group entitlement. It is intentionally +absent from the Homebrew build. -## 开发 - -```bash -swift test -swift run Gloss -``` +## Command line -直接验证翻译后端: +The Homebrew cask links `gloss-cli` into its `bin` directory: ```bash -# 查询 App 与 CLI 共用的能力/场景映射 -swift run gloss-cli capabilities --json +# Inspect the capabilities included in this build +gloss-cli capabilities --json -# 浏览器翻译场景(默认 content kind 为 webpage) -swift run gloss-cli browser --target 'Chinese (Simplified)' 'Translate this webpage.' +# Translate browser content extracted by another tool +gloss-cli browser --target 'Chinese (Simplified)' 'Translate this webpage.' -# PDF 批量翻译场景 -swift run gloss-cli pdf paper-a.pdf paper-b.pdf \ +# Translate one or more PDFs +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.' +# Translate plain text locally +gloss-cli text --provider llama \ + --target 'Chinese (Simplified)' \ + 'Translate locally.' ``` -`browser` 可从参数或 stdin 读取已经提取的网页正文,并使用与 Safari/Chrome 扩展相同的 -网页翻译语义;它不会自动操控浏览器 UI。`pdf` 接受一个或多个 PDF,顺序处理并复用同一 -BabelDOC 会话;进度写入 stderr,最终产物路径以 JSON 写入 stdout,适合脚本调用。默认源语言 -为 `en`、目标语言为 `Chinese (Simplified)`,输出方式为仅译文 PDF。 +`browser` accepts text from arguments or stdin; it does not automate browser +UI. `pdf` reports progress on stderr and writes the final artifact paths as +JSON on stdout, which makes it suitable for scripts. -开发时可以覆盖原生 app-server 和独立数据目录: - -```bash -GLOSS_CODEX_APP_SERVER_BIN=/path/to/codex-app-server \ -GLOSS_CODEX_HOME="$HOME/Library/Application Support/Gloss/Codex" \ -GLOSS_CODEX_MODEL=gpt-5.3-codex-spark \ -GLOSS_CODEX_REASONING_EFFORT=low \ -GLOSS_CODEX_MAX_CONCURRENCY=3 \ -GLOSS_CODEX_BACKGROUND_CONCURRENCY=2 \ -swift run gloss-cli 'Hello from Gloss.' -``` +## Privacy and security -`GLOSS_CODEX_BIN` 可覆盖外部 Codex CLI 路径。默认构建优先使用 App 内置 runtime;CLI 构建只使用用户安装的 Codex CLI。 - -未设置时使用 `gpt-5.3-codex-spark`;并发数默认 3,可配置范围为 1–8。Gloss 默认最多 -同时运行 2 个后台 PDF turn,保留一个 thread 给交互翻译或 Spark 长尾竞速。后台 PDF -turn 使用 Spark 支持的最低档 `low` reasoning。BabelDOC 默认用 qps 8 提前抓取段落,但模型侧仍严格限制为 -2 路;相邻请求会合并为最多 12 项、1,800 字符的有界批次。模型在 3 秒仍未开始 -响应时会记录慢请求;只有中心和 BabelDOC 上游的真实队列都排空,才会在空闲 thread 上 -发起 tail hedge,否则跳过竞速。没有统一队列状态的独立 Codex client 仍使用 8 秒阈值。每个 thread 默认完成 -10 次成功翻译后无中断轮换。可分别使用 `GLOSS_CODEX_DOCUMENT_REASONING_EFFORT`、 -`GLOSS_CODEX_MODEL_WAIT_HEDGE_SECONDS` 和 `GLOSS_CODEX_THREAD_ROTATION_TURNS` 覆盖这些值, -后两项设为 `off` 可关闭对应机制。 -Spark 当前拒绝 `minimal` reasoning,PDF 翻译可用的最低档是 `low`;不要将 -`GLOSS_CODEX_DOCUMENT_REASONING_EFFORT` 配置为 `minimal`。 - -批处理实验参数可通过 `GLOSS_BABELDOC_BATCH_ITEMS`、`GLOSS_BABELDOC_BATCH_CHARACTERS`、 -`GLOSS_BABELDOC_MODEL_CONCURRENCY`、`GLOSS_BABELDOC_FILL_DELAY_MS` 和 -`GLOSS_BABELDOC_REFILL_DELAY_MS` 覆盖。默认值分别为 `12`、`1800`、`2`、`25` 和 `0`; -提高真实模型并发会占用额外 Spark 额度,通常不如增加上游预取和合批稳定。 -`GLOSS_BABELDOC_SKIP_CLEAN=1`、`GLOSS_BABELDOC_DISABLE_SAME_TEXT_FALLBACK=1` -和 `GLOSS_BABELDOC_IGNORE_CACHE=1` 分别用于验证快速 PDF 保存、关闭同文重译和无缓存基准; -它们默认关闭,确认输出兼容性和翻译完整性后再考虑提升为默认行为。 - -打开 PDF 翻译模块会立即启动仅监听本机回环地址的 DocLayout 服务,并在窗口存续期间保持模型 -常驻;关闭模块后服务随即停止,避免 App 空闲时长期占用约 500–600 MB 内存。窗口支持按钮选择、 -Finder 多文件打开和直接拖拽多个 PDF,任务去重后进入批量队列。文档按队列顺序逐个处理,复用 -同一版面服务,避免并行启动多个 BabelDOC 进程造成内存峰值。进度展示会将服务启动、版面解析、 -模型等待、排版与保存拆成独立状态。 - -调度:网页、选词、字幕、OCR 和 BabelDOC 的真实 cache miss 统一进入 -`TranslationDispatchCenter`。上游 qps 只控制预取和入队;派发中心按优先级和 FIFO 派发, -总并发最多 3、后台最多 2,并统一处理取消和状态快照。BabelDOC 尚未派发的条目也汇总进 -同一状态;Codex 只有在中心和上游真实队列都排空时才允许 tail hedge。第三条 thread 因此 -默认保留给交互,provider 内部调度暂时作为执行器安全网保留。 - -本地 provider 默认查找 App 内的 `llama-server` helper、`GLOSS_LLAMA_SERVER_BIN`、`PATH`,以及 Homebrew 常用路径。模型可通过 `GLOSS_LLAMA_MODEL` 覆盖为 Hugging Face GGUF repo 或本地 `.gguf` 文件: +- Local-model translation keeps source text and translations on the device. +- OCR is performed locally with Apple Vision before recognized text is sent to + the selected translation provider. +- The browser bridge listens only on localhost and requires a random + per-device token. +- Runtime logs do not record source text or translations and are stored with + `0600` permissions in `~/Library/Logs/Gloss/`. +- BabelDOC packages and app-update manifests are signature-verified before + installation; release artifacts are also checksum-pinned. -```bash -GLOSS_LLAMA_SERVER_BIN=/opt/homebrew/bin/llama-server \ -GLOSS_LLAMA_MODEL=tencent/Hy-MT2-1.8B-GGUF:Q4_K_M \ -swift run gloss-cli --provider llama 'Hello from local Gloss.' -``` +## Development -## 打包 +You need macOS 14+, Xcode Command Line Tools, and Swift 6. ```bash -./Scripts/build_app.sh -open dist/Gloss.app -``` - -构建脚本会按 `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 的轻量版本: - -```bash -./Scripts/build_app_with_codex_cli.sh +swift test --parallel +swift run Gloss +swift run gloss-cli capabilities --json ``` -该脚本会先确认当前环境中的 `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 证书,以及允许 -`group.com.samsoncj.gloss` App Group 的宿主和扩展 provisioning profile: +Build a distributable app with: ```bash -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 +open dist/Gloss.app ``` -当前公开 Homebrew 发行也明确使用 ad-hoc 签名,不要求 Developer ID 或 Apple 公证。 +The packaging script expects the companion browser-extension source in a +sibling checkout and deliberately does not bundle Codex. Install Codex first, +or set `GLOSS_CODEX_BIN`; `./Scripts/build_app_without_bundled_codex.sh` is the +explicit equivalent of the default lightweight build. If the extension is not +a sibling checkout, set `GLOSS_BROWSER_EXTENSION_SOURCE` to its directory. -### BabelDOC runtime 更新 +## Repository layout -Gloss 可以管理来自 `SunChJ/BabelDOC` GitHub Releases 的固定版本 runtime。更新 manifest 使用 -内置 Ed25519 public key 验证 detached signature,runtime archive 再做 SHA-256 校验;签名或 -校验失败不会替换当前版本。安装器当前开放 stable 通道,并支持版本 pin 和一键 rollback; -beta、nightly 会在对应的已签名 release alias 上线后再开放。安装过程通过 staging directory 与 -atomic state file 防止半安装状态。完整 manifest schema、安全边界和发布 secret 见 -[Gloss 与 BabelDOC 发行链路](docs/runtime-distribution.md)。 +| Path | Responsibility | +| --- | --- | +| `Sources/GlossCore/` | Providers, translation models, caching, dispatch, BabelDOC, updates, and the local bridge | +| `Sources/GlossOCR/` | Apple Vision OCR and reading-order recovery | +| `Sources/Gloss/` | macOS app, settings, panels, PDF workflow, and browser integration | +| `Sources/GlossCLI/` | Browser, PDF, and text commands | +| `Sources/GlossUpdateHelper/` | Verified Homebrew update handoff | -### GitHub Release 与 Homebrew +For distribution internals and trust boundaries, see +[`docs/runtime-distribution.md`](docs/runtime-distribution.md). Release-specific +changes are recorded in [`docs/release-notes/`](docs/release-notes/). -推送与 `Resources/Info.plist` 一致的 `v*` tag 会运行 Release workflow,产出 -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 不会指向私有主仓。 +## License -首次安装以及后续升级为: +Gloss is **source-available** under the +[PolyForm Noncommercial License 1.0.0](LICENSE). -```bash -brew tap sunchj/tap -brew install --cask sunchj/tap/gloss -brew update -brew upgrade --cask sunchj/tap/gloss -``` +You may use, study, modify, and distribute Gloss only for purposes permitted by +that license. **Commercial use is not permitted without a separate written +license from SamsonLab.** This is not an OSI-approved open-source license. -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/ 浏览器/PDF 场景命令与向后兼容的文本入口 -``` +Third-party components and runtime dependencies remain under their respective +licenses. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..dd37b4f --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,146 @@ +
+ Gloss 图标 +

Gloss

+

面向浏览器与保留版式 PDF 的 macOS 原生翻译工具。

+

+ English · + 简体中文 +

+

+ macOS 14+ + Swift 6 + PolyForm Noncommercial 1.0.0 +

+
+ +Gloss 是一款围绕完整工作流构建的 macOS 原生翻译应用,而不是互不关联的功能集合。 +当前版本聚焦两个场景:浏览器内容翻译,以及保留原始版式的 PDF 翻译。 + +App 与 `gloss-cli` 共用同一套能力注册表、翻译 Provider、缓存和调度层。你既可以通过 +本机已安装并登录 ChatGPT 的 Codex CLI 翻译,也可以通过 `llama.cpp` 将翻译留在本机完成。 + +## 主要能力 + +| 工作流 | Gloss 提供的能力 | +| --- | --- | +| 浏览器翻译 | 仅监听本机的鉴权桥接和内置 Chrome 扩展;Apple 签名构建还可以包含 Safari 扩展。 | +| PDF 翻译 | 约 230 MB 的签名 BabelDOC runtime 按需安装,保留文档版式,支持批量队列、更新回滚和安全卸载。 | +| 翻译引擎 | 使用本机已安装的 Codex CLI 登录 ChatGPT,或使用 `llama.cpp` 与 Hy-MT2 在本机翻译。 | +| 自动化 | 通过 `gloss-cli` 查询能力、翻译浏览器文本与 PDF,并执行文本翻译和诊断。 | + +Gloss 使用 Swift 开发,通过 Apple Vision 在本机执行 OCR;浏览器桥接只监听 +`127.0.0.1`,并使用每台设备独立的随机令牌鉴权。 + +## 安装 + +Gloss 要求 **macOS 14 或更高版本**。 + +```bash +brew install --cask sunchj/tap/gloss +``` + +可以在 App 内升级,也可以运行: + +```bash +brew update +brew upgrade --cask sunchj/tap/gloss +``` + +Homebrew 版本使用 ad-hoc 签名,并未经过 Apple 公证。Cask 会在本机重新签名、移除 +quarantine 属性并验证安装结果。公开二进制和签名更新元数据发布在 +[`SunChJ/gloss-releases`](https://github.com/SunChJ/gloss-releases/releases/latest)。 + +## 快速开始 + +1. 打开 Gloss;主面板默认显示浏览器翻译状态与扩展入口。 +2. 点击右上角设置按钮或按 `⌘,`,选择翻译引擎和 `跟随系统 / 浅色 / 深色` 外观。 +3. 使用 GPT 翻译时选择 **登录 ChatGPT**;Gloss 不要求单独提供 API Key。 +4. 使用本地翻译时,先运行 `brew install llama.cpp`,再选择 **本地模型**。首次运行会 + 下载约 1.1 GB 的量化模型。 +5. 使用 Chrome 时,在 Gloss 主面板或设置中选择 **显示扩展**,然后在 + `chrome://extensions` 的开发者模式下加载该目录。 +6. 首次使用 PDF 翻译时按需安装组件,再选择或拖入文件并指定输出目录。 + +Safari 仅在包含 Safari App Extension 和匹配 App Group entitlement 的 Apple 签名构建中 +可用。Homebrew 版本会主动隐藏这一入口。 + +## 命令行 + +Homebrew Cask 会将 `gloss-cli` 链接到其 `bin` 目录: + +```bash +# 查看当前构建包含的能力 +gloss-cli capabilities --json + +# 翻译其他工具已经提取的网页正文 +gloss-cli browser --target 'Chinese (Simplified)' 'Translate this webpage.' + +# 翻译一个或多个 PDF +gloss-cli pdf paper-a.pdf paper-b.pdf \ + --output ./translated \ + --target 'Chinese (Simplified)' \ + --mode mono + +# 使用本地引擎翻译文本 +gloss-cli text --provider llama \ + --target 'Chinese (Simplified)' \ + 'Translate locally.' +``` + +`browser` 可以从参数或 stdin 读取正文,但不会操控浏览器 UI。`pdf` 将进度写入 stderr, +并以 JSON 形式将最终产物路径写入 stdout,适合用于自动化脚本。 + +## 隐私与安全 + +- 本地模型模式下,原文和译文都留在设备上。 +- OCR 通过 Apple Vision 在本机完成;只有识别后的文字会交给所选翻译引擎。 +- 浏览器桥接只监听 localhost,并要求每台设备独立的随机令牌。 +- 运行日志不记录原文或译文,并以 `0600` 权限保存在 `~/Library/Logs/Gloss/`。 +- BabelDOC 包和 App 更新 manifest 在安装前均会验证签名;发布产物还会固定校验和。 + +## 开发 + +开发环境需要 macOS 14+、Xcode Command Line Tools 和 Swift 6。 + +```bash +swift test --parallel +swift run Gloss +swift run gloss-cli capabilities --json +``` + +构建可分发的 App: + +```bash +./Scripts/build_app.sh +open dist/Gloss.app +``` + +打包脚本要求在相邻目录中提供配套的浏览器扩展源码,并且默认不再内置 Codex。 +请先安装 Codex CLI,或设置 `GLOSS_CODEX_BIN`; +`./Scripts/build_app_without_bundled_codex.sh` 是默认轻量构建的显式入口。扩展源码不在 +相邻目录时,可通过 `GLOSS_BROWSER_EXTENSION_SOURCE` 指定其位置。 + +## 代码结构 + +| 路径 | 职责 | +| --- | --- | +| `Sources/GlossCore/` | Provider、翻译模型、缓存、调度、BabelDOC、更新与本机桥接 | +| `Sources/GlossOCR/` | Apple Vision OCR 与阅读顺序恢复 | +| `Sources/Gloss/` | macOS App、设置、结果面板、PDF 工作流与浏览器集成 | +| `Sources/GlossCLI/` | 浏览器、PDF 与文本命令 | +| `Sources/GlossUpdateHelper/` | 经过验证的 Homebrew 更新交接 | + +发行机制和信任边界参见 +[`docs/runtime-distribution.md`](docs/runtime-distribution.md),各版本的具体变化记录在 +[`docs/release-notes/`](docs/release-notes/) 中。 + +## 许可证 + +Gloss 以 +[PolyForm Noncommercial License 1.0.0](LICENSE) +作为**源码可见(source-available)**软件发布。 + +你只能在该许可证允许的非商业目的下使用、研究、修改和分发 Gloss。 +**未经 SamsonLab 另行书面授权,不得用于商业用途。** 该许可证并非 OSI 认可的开源许可证。 + +第三方组件与 runtime 依赖继续适用其各自的许可证。 diff --git a/Resources/Info.plist b/Resources/Info.plist index e4f580d..6616520 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.8.4 + 1.0.0 CFBundleVersion - 12 + 13 GlossSafariExtensionAvailable CFBundleDocumentTypes @@ -48,7 +48,7 @@ NSHighResolutionCapable NSHumanReadableCopyright - © 2026 Gloss + © 2026 SamsonLab NSPrincipalClass NSApplication diff --git a/Resources/SamsonLabAvatar-Light-Upload.jpg b/Resources/SamsonLabAvatar-Light-Upload.jpg new file mode 100644 index 0000000..5a760b6 Binary files /dev/null and b/Resources/SamsonLabAvatar-Light-Upload.jpg differ diff --git a/Resources/SamsonLabAvatar-Light.png b/Resources/SamsonLabAvatar-Light.png new file mode 100644 index 0000000..c3e321f Binary files /dev/null and b/Resources/SamsonLabAvatar-Light.png differ diff --git a/Resources/SamsonLabAvatar.png b/Resources/SamsonLabAvatar.png new file mode 100644 index 0000000..0a3d04a Binary files /dev/null and b/Resources/SamsonLabAvatar.png differ diff --git a/Scripts/build_app.sh b/Scripts/build_app.sh index 0feed6a..c78ed5b 100755 --- a/Scripts/build_app.sh +++ b/Scripts/build_app.sh @@ -8,14 +8,16 @@ MACOS_DIR="$CONTENTS_DIR/MacOS" HELPERS_DIR="$CONTENTS_DIR/Helpers" RESOURCES_DIR="$CONTENTS_DIR/Resources" PLUGINS_DIR="$CONTENTS_DIR/PlugIns" -PLUGIN_DIR="$ROOT_DIR/../personal-immersive-translator" +PLUGIN_DIR="${GLOSS_BROWSER_EXTENSION_SOURCE:-$ROOT_DIR/../personal-immersive-translator}" BROWSER_EXTENSION_DIR="$PLUGIN_DIR/.output/chrome-mv3" SAFARI_PROJECT="$PLUGIN_DIR/safari/Gloss/Gloss.xcodeproj" SAFARI_BUILD_DIR="$PLUGIN_DIR/safari/build" SAFARI_EXTENSION="$SAFARI_BUILD_DIR/Build/Products/Release/Gloss Extension.appex" SAFARI_ENTITLEMENTS="$PLUGIN_DIR/safari/Gloss/Gloss Extension/Gloss Extension.entitlements" APP_ENTITLEMENTS="$ROOT_DIR/Resources/Gloss.entitlements" -CODEX_RUNTIME_MODE="${GLOSS_CODEX_RUNTIME_MODE:-bundled}" +# Release builds are intentionally lightweight: Gloss uses an already installed +# Codex CLI instead of embedding the roughly 206 MB app-server runtime. +CODEX_RUNTIME_MODE="${GLOSS_CODEX_RUNTIME_MODE:-cli}" CODEX_RUNTIME="" CODEX_LICENSE="" case "$CODEX_RUNTIME_MODE" in @@ -25,15 +27,16 @@ case "$CODEX_RUNTIME_MODE" in ;; cli) CODEX_CLI="${GLOSS_CODEX_BIN:-$(command -v codex || true)}" - if [[ -z "$CODEX_CLI" || ! -x "$CODEX_CLI" ]]; then - echo "Codex CLI not found. Install it or set GLOSS_CODEX_BIN." >&2 - exit 1 - fi - if ! "$CODEX_CLI" app-server --help >/dev/null 2>&1; then + if [[ -n "$CODEX_CLI" && -x "$CODEX_CLI" ]] \ + && ! "$CODEX_CLI" app-server --help >/dev/null 2>&1; then echo "Installed Codex CLI does not provide 'codex app-server'." >&2 exit 1 fi - echo "Using external Codex CLI at runtime: $CODEX_CLI" + if [[ -n "$CODEX_CLI" && -x "$CODEX_CLI" ]]; then + echo "Building without bundled Codex; verified runtime CLI: $CODEX_CLI" + else + echo "Building without bundled Codex; users must install Codex CLI at runtime." + fi ;; *) echo "Unsupported GLOSS_CODEX_RUNTIME_MODE: $CODEX_RUNTIME_MODE" >&2 @@ -44,6 +47,12 @@ SIGN_IDENTITY="${GLOSS_SIGN_IDENTITY:--}" SAFARI_HOST_PROFILE="${GLOSS_SAFARI_HOST_PROVISIONING_PROFILE:-}" SAFARI_EXTENSION_PROFILE="${GLOSS_SAFARI_EXTENSION_PROVISIONING_PROFILE:-}" +if [[ ! -f "$PLUGIN_DIR/package.json" ]]; then + echo "Gloss browser-extension source not found: $PLUGIN_DIR" >&2 + echo "Set GLOSS_BROWSER_EXTENSION_SOURCE to its checkout." >&2 + exit 66 +fi + 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 diff --git a/Scripts/build_app_with_codex_cli.sh b/Scripts/build_app_with_codex_cli.sh index 5e7d762..54d7a81 100755 --- a/Scripts/build_app_with_codex_cli.sh +++ b/Scripts/build_app_with_codex_cli.sh @@ -2,5 +2,4 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -export GLOSS_CODEX_RUNTIME_MODE=cli -exec "$SCRIPT_DIR/build_app.sh" "$@" +exec "$SCRIPT_DIR/build_app_without_bundled_codex.sh" "$@" diff --git a/Scripts/build_app_without_bundled_codex.sh b/Scripts/build_app_without_bundled_codex.sh new file mode 100755 index 0000000..5e7d762 --- /dev/null +++ b/Scripts/build_app_without_bundled_codex.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +export GLOSS_CODEX_RUNTIME_MODE=cli +exec "$SCRIPT_DIR/build_app.sh" "$@" diff --git a/Scripts/generate_homebrew_cask.sh b/Scripts/generate_homebrew_cask.sh index 45eb6ae..dc2ec3a 100755 --- a/Scripts/generate_homebrew_cask.sh +++ b/Scripts/generate_homebrew_cask.sh @@ -97,6 +97,7 @@ cask "gloss" do "#{app_path}/Contents/Helpers/gloss-update-helper", app_path, ] + code_paths.select! { |code_path| File.exist?(code_path) } code_paths.each do |code_path| system_command "/usr/bin/codesign", args: [ @@ -149,6 +150,8 @@ cask "gloss" do caveats <<~EOS Gloss uses an ad-hoc code signature and is not Apple-notarized. This custom tap re-signs the installed app and removes its quarantine attribute. + ChatGPT translation requires a separately installed Codex CLI with + the app-server subcommand; Gloss does not bundle Codex. EOS end RUBY diff --git a/Sources/Gloss/GlossAppDelegate.swift b/Sources/Gloss/GlossAppDelegate.swift index 71afa97..7e30fd1 100644 --- a/Sources/Gloss/GlossAppDelegate.swift +++ b/Sources/Gloss/GlossAppDelegate.swift @@ -51,6 +51,7 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private let servicesProvider = GlossServicesProvider() private var glossBarController: GlossBarController? private var resultPanelController: ResultPanelController? + private var mainWindowController: MainWindowController? private var settingsWindowController: SettingsWindowController? private var historyWindowController: HistoryWindowController? private var glossaryWindowController: GlossaryWindowController? @@ -295,6 +296,42 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return controller } + private var mainWindow: MainWindowController { + if let mainWindowController { return mainWindowController } + let controller = MainWindowController(capabilityRegistry: capabilityRegistry) + controller.onOpenSettings = { [weak self] in + self?.showSettings() + } + controller.onOpenPDFTranslation = { [weak self] in + self?.openPDFTranslation() + } + controller.onRevealBrowserExtension = { [weak self] in + self?.revealBrowserExtension() + } + controller.onCopyBrowserToken = { [weak self] in + self?.copyBrowserToken() + } + if capabilityRegistry.supports(.safariExtension) { + controller.onOpenSafariExtensionSettings = { [weak self] in + self?.openSafariExtensionSettings() + } + } + controller.onBridgeAction = { [weak self] in + self?.performBridgeDashboardAction() + } + controller.onPDFRuntimeAction = { [weak self] action in + self?.performPDFRuntimeAction(action) + } + controller.showBridgeState(bridgeDashboardState) + controller.showBrowserExtensionStatus( + browserExtensionStatus.message, + succeeded: browserExtensionStatus.succeeded + ) + controller.showPDFRuntimeState(pdfRuntimeController.dashboardState) + mainWindowController = controller + return controller + } + private var settingsWindow: SettingsWindowController { if let settingsWindowController { return settingsWindowController } let controller = SettingsWindowController(capabilityRegistry: capabilityRegistry) @@ -533,6 +570,8 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { func applicationDidFinishLaunching(_ notification: Notification) { try? runtimeLog.prepare() runtimeLog.write("app", "started version=\(applicationVersion)") + GlossAppearanceController.shared.activate() + configureApplicationMenu() if scenarioActivation.preparesPDFRuntime { startObservingPDFRuntime() pdfRuntimeController.prepareAtLaunch() @@ -649,10 +688,14 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { DispatchQueue.main.async { [weak self] in self?.settingsWindow.show() } - } else if UserDefaults.standard.string(forKey: welcomeVersionKey) != "0.1" { - UserDefaults.standard.set("0.1", forKey: welcomeVersionKey) + } else if arguments.contains("--show-dashboard") { DispatchQueue.main.async { [weak self] in - self?.settingsWindow.show() + self?.mainWindow.show() + } + } else if UserDefaults.standard.string(forKey: welcomeVersionKey) != "1.0" { + UserDefaults.standard.set("1.0", forKey: welcomeVersionKey) + DispatchQueue.main.async { [weak self] in + self?.mainWindow.show() } } } @@ -910,8 +953,8 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { if capabilityRegistry.isEnabled(.browserTranslation) { let browser = NSMenuItem( - title: "浏览器翻译与扩展…", - action: #selector(showSettings), + title: "打开 Gloss…", + action: #selector(showDashboard), keyEquivalent: "" ) browser.target = self @@ -1206,6 +1249,40 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { return menu } + private func configureApplicationMenu() { + let mainMenu = NSMenu() + let appMenuItem = NSMenuItem() + mainMenu.addItem(appMenuItem) + + let appMenu = NSMenu(title: "Gloss") + appMenuItem.submenu = appMenu + + let dashboard = NSMenuItem( + title: "打开 Gloss", + action: #selector(showDashboard), + keyEquivalent: "" + ) + dashboard.target = self + appMenu.addItem(dashboard) + + let settings = NSMenuItem( + title: "设置…", + action: #selector(showSettings), + keyEquivalent: "," + ) + settings.target = self + appMenu.addItem(settings) + appMenu.addItem(.separator()) + + let quit = NSMenuItem( + title: "退出 Gloss", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ) + appMenu.addItem(quit) + NSApp.mainMenu = mainMenu + } + func menuWillOpen(_ menu: NSMenu) { updateSelectionMenuItems(in: menu) if let item = menu.item(withTag: MenuTag.launchAtLogin) { @@ -1862,6 +1939,10 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { settingsWindow.show() } + @objc private func showDashboard() { + mainWindow.show() + } + @objc private func performAppUpdateAction() { guard appUpdateActionTask == nil, let controller = appUpdateController @@ -2172,11 +2253,24 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { for await state in controller.stateChanges() { guard let self, !Task.isCancelled else { return } settingsWindowController?.showPDFRuntimeState(state) + mainWindowController?.showPDFRuntimeState(state) } } } - private func performPDFRuntimeAction(_ action: PDFRuntimeDashboardAction) { + private func performPDFRuntimeAction( + _ action: PDFRuntimeDashboardAction, + uninstallConfirmed: Bool = false + ) { + if action == .uninstall, !uninstallConfirmed { + Task { @MainActor [weak self] in + guard let self else { return } + let bytes = await pdfRuntimeController.estimatedReclaimableBytes() + guard confirmPDFRuntimeUninstall(reclaimableBytes: bytes) else { return } + performPDFRuntimeAction(.uninstall, uninstallConfirmed: true) + } + return + } let previousTask = pdfRuntimeActionTask previousTask?.cancel() let actionID = UUID() @@ -2207,6 +2301,24 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + private func confirmPDFRuntimeUninstall(reclaimableBytes: Int64?) -> Bool { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "卸载 PDF 翻译组件?" + let sizeText = + reclaimableBytes.map { + ByteCountFormatter.string(fromByteCount: $0, countStyle: .file) + } ?? "未知" + alert.informativeText = """ + 预计释放空间:\(sizeText) + + 将删除 PDF 运行时、历史版本与相关缓存。源 PDF、翻译结果、输出目录以及 Gloss 偏好设置不会被删除。卸载后仍可使用浏览器翻译,也可以随时重新安装 PDF 组件。 + """ + alert.addButton(withTitle: "卸载 PDF 组件") + alert.addButton(withTitle: "取消") + return alert.runModal() == .alertFirstButtonReturn + } + private func performBridgeDashboardAction() { guard let occupant = bridgeDashboardState.occupant, !occupant.canAutomaticallyTerminate @@ -2278,6 +2390,7 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { "dashboard state=\(String(describing: state)) message=\(presentation.headline) detail=\(presentation.detail)" ) settingsWindowController?.showBridgeState(state) + mainWindowController?.showBridgeState(state) } private func updateBrowserExtensionStatus(_ message: String, succeeded: Bool) { @@ -2286,6 +2399,10 @@ final class GlossAppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { message, succeeded: succeeded ) + mainWindowController?.showBrowserExtensionStatus( + message, + succeeded: succeeded + ) } private func copyBrowserToken() { diff --git a/Sources/Gloss/GlossAppearanceController.swift b/Sources/Gloss/GlossAppearanceController.swift new file mode 100644 index 0000000..9f3bd0e --- /dev/null +++ b/Sources/Gloss/GlossAppearanceController.swift @@ -0,0 +1,79 @@ +import AppKit + +enum GlossAppearancePreference: String, CaseIterable, Sendable { + case system + case light + case dark + + var displayName: String { + switch self { + case .system: "跟随系统" + case .light: "浅色" + case .dark: "深色" + } + } + + var symbolName: String { + switch self { + case .system: "display" + case .light: "sun.max" + case .dark: "moon.fill" + } + } +} + +@MainActor +final class GlossAppearanceController { + static let shared = GlossAppearanceController() + static let didChangeNotification = Notification.Name( + "GlossAppearancePreferenceDidChange" + ) + + private let defaultsKey = "appearancePreference" + + private(set) var preference: GlossAppearancePreference { + didSet { + UserDefaults.standard.set(preference.rawValue, forKey: defaultsKey) + apply() + NotificationCenter.default.post(name: Self.didChangeNotification, object: self) + } + } + + private init() { + let rawValue = UserDefaults.standard.string(forKey: defaultsKey) + preference = GlossAppearancePreference(rawValue: rawValue ?? "") ?? .system + } + + func activate() { + apply() + } + + func select(_ preference: GlossAppearancePreference) { + guard self.preference != preference else { return } + self.preference = preference + } + + @discardableResult + func cycle() -> GlossAppearancePreference { + let next: GlossAppearancePreference = + switch preference { + case .system: .dark + case .dark: .light + case .light: .system + } + select(next) + return next + } + + private func apply() { + NSApp.appearance = + switch preference { + case .system: + nil + case .light: + NSAppearance(named: .aqua) + case .dark: + NSAppearance(named: .darkAqua) + } + } +} diff --git a/Sources/Gloss/MainWindowController.swift b/Sources/Gloss/MainWindowController.swift new file mode 100644 index 0000000..825923c --- /dev/null +++ b/Sources/Gloss/MainWindowController.swift @@ -0,0 +1,440 @@ +import AppKit +import GlossCore + +@MainActor +final class MainWindowController: NSObject, NSWindowDelegate { + var onOpenSettings: (() -> Void)? + var onOpenPDFTranslation: (() -> Void)? + var onRevealBrowserExtension: (() -> Void)? + var onCopyBrowserToken: (() -> Void)? + var onOpenSafariExtensionSettings: (() -> Void)? + var onBridgeAction: (() -> Void)? + var onPDFRuntimeAction: ((PDFRuntimeDashboardAction) -> Void)? + + private let capabilityRegistry: GlossCapabilityRegistry + private let window: NSWindow + private let appearanceController = GlossAppearanceController.shared + private let themeButton = NSButton() + private let browserStatus = NSTextField(labelWithString: "正在启动浏览器翻译…") + private let browserDetail = NSTextField(labelWithString: "正在准备本地翻译连接") + private let browserActionButton = NSButton(title: "正在检查…", target: nil, action: nil) + private let pdfStatus = NSTextField(labelWithString: "正在检查 PDF 组件…") + private let pdfDetail = NSTextField(labelWithString: "PDF 翻译作为附加能力按需安装") + private let pdfActionButton = NSButton(title: "正在检查…", target: nil, action: nil) + private let pdfProgress = NSProgressIndicator() + private var pdfRuntimeState = PDFRuntimeDashboardState.checking + private var appearanceObserver: NSObjectProtocol? + + init(capabilityRegistry: GlossCapabilityRegistry = .current) { + self.capabilityRegistry = capabilityRegistry + window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 760, height: 560), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: true + ) + super.init() + configureWindow() + appearanceObserver = NotificationCenter.default.addObserver( + forName: GlossAppearanceController.didChangeNotification, + object: appearanceController, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.updateThemeButton() + } + } + } + + func show() { + if !window.isVisible, !window.setFrameUsingName("GlossMainWindow") { + window.center() + } + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + } + + func showBridgeState(_ state: BridgeDashboardState) { + let presentation = state.presentation + browserStatus.stringValue = presentation.headline + browserStatus.textColor = color(for: presentation.tone) + browserActionButton.title = presentation.actionTitle + browserActionButton.isEnabled = presentation.actionEnabled + } + + func showBrowserExtensionStatus(_ message: String, succeeded: Bool) { + browserDetail.stringValue = message + browserDetail.textColor = succeeded ? .secondaryLabelColor : .systemRed + } + + func showPDFRuntimeState(_ state: PDFRuntimeDashboardState) { + pdfRuntimeState = state + let presentation = state.presentation + pdfStatus.stringValue = presentation.headline + pdfStatus.textColor = color(for: presentation.tone) + pdfDetail.stringValue = presentation.detail + pdfDetail.toolTip = presentation.detail + pdfActionButton.title = presentation.actionTitle + pdfActionButton.isEnabled = presentation.actionEnabled + pdfActionButton.contentTintColor = presentation.actionIsDestructive ? .systemRed : nil + pdfProgress.isHidden = !presentation.showsProgress + if presentation.showsProgress { + pdfProgress.startAnimation(nil) + } else { + pdfProgress.stopAnimation(nil) + } + } + + private func configureWindow() { + window.title = "Gloss" + window.subtitle = "浏览器翻译" + window.isReleasedWhenClosed = false + window.delegate = self + window.minSize = NSSize(width: 680, height: 500) + window.setFrameAutosaveName("GlossMainWindow") + window.titlebarAppearsTransparent = true + + let root = NSView() + root.translatesAutoresizingMaskIntoConstraints = false + window.contentView = root + + let brandIcon = NSImageView() + brandIcon.image = GlossBrand.markImage(pointSize: 28) + brandIcon.contentTintColor = .controlAccentColor + brandIcon.translatesAutoresizingMaskIntoConstraints = false + + let title = NSTextField(labelWithString: "Gloss") + title.font = .systemFont(ofSize: 28, weight: .bold) + let subtitle = NSTextField( + labelWithString: "浏览器翻译是默认能力,PDF 翻译按需启用。" + ) + subtitle.font = .systemFont(ofSize: 13) + subtitle.textColor = .secondaryLabelColor + let heading = NSStackView(views: [title, subtitle]) + heading.orientation = .vertical + heading.alignment = .leading + heading.spacing = 3 + + let headerLeft = NSStackView(views: [brandIcon, heading]) + headerLeft.orientation = .horizontal + headerLeft.alignment = .centerY + headerLeft.spacing = 13 + + configureIconButton( + themeButton, + symbol: appearanceController.preference.symbolName, + toolTip: "外观:\(appearanceController.preference.displayName)", + action: #selector(cycleTheme) + ) + let settingsButton = NSButton() + configureIconButton( + settingsButton, + symbol: "gearshape", + toolTip: "Gloss 设置(⌘,)", + action: #selector(openSettings) + ) + let headerActions = NSStackView(views: [themeButton, settingsButton]) + headerActions.orientation = .horizontal + headerActions.alignment = .centerY + headerActions.spacing = 8 + + let header = NSStackView(views: [headerLeft, NSView(), headerActions]) + header.orientation = .horizontal + header.alignment = .centerY + header.distribution = .fill + header.translatesAutoresizingMaskIntoConstraints = false + + let browserCard = makeBrowserCard() + let pdfCard = makePDFCard() + let content = NSStackView(views: [browserCard, pdfCard]) + content.orientation = .vertical + content.alignment = .leading + content.spacing = 16 + content.translatesAutoresizingMaskIntoConstraints = false + browserCard.widthAnchor.constraint(equalTo: content.widthAnchor).isActive = true + pdfCard.widthAnchor.constraint(equalTo: content.widthAnchor).isActive = true + + root.addSubview(header) + root.addSubview(content) + NSLayoutConstraint.activate([ + header.topAnchor.constraint(equalTo: root.topAnchor, constant: 30), + header.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 34), + header.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -34), + brandIcon.widthAnchor.constraint(equalToConstant: 42), + brandIcon.heightAnchor.constraint(equalToConstant: 42), + themeButton.widthAnchor.constraint(equalToConstant: 32), + themeButton.heightAnchor.constraint(equalToConstant: 32), + settingsButton.widthAnchor.constraint(equalToConstant: 32), + settingsButton.heightAnchor.constraint(equalToConstant: 32), + content.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 26), + content.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 34), + content.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -34), + content.bottomAnchor.constraint(lessThanOrEqualTo: root.bottomAnchor, constant: -28), + ]) + } + + private func makeBrowserCard() -> NSView { + let card = makeCard(material: .contentBackground, cornerRadius: 16) + let icon = makeSymbolView("globe", description: "浏览器翻译", size: 28) + icon.contentTintColor = .controlAccentColor + let title = NSTextField(labelWithString: "浏览器翻译") + title.font = .systemFont(ofSize: 20, weight: .semibold) + let badge = makeBadge("默认能力", color: .controlAccentColor) + let titleRow = NSStackView(views: [title, badge]) + titleRow.orientation = .horizontal + titleRow.alignment = .centerY + titleRow.spacing = 8 + + let description = NSTextField( + wrappingLabelWithString: "在 Chrome 或 Safari 中直接翻译网页。Gloss 启动后会自动维护本地连接,无需额外下载 PDF 组件。" + ) + description.font = .systemFont(ofSize: 13) + description.textColor = .secondaryLabelColor + description.maximumNumberOfLines = 2 + + browserStatus.font = .systemFont(ofSize: 12.5, weight: .medium) + browserDetail.font = .systemFont(ofSize: 11.5) + browserDetail.textColor = .secondaryLabelColor + browserDetail.lineBreakMode = .byTruncatingTail + + browserActionButton.target = self + browserActionButton.action = #selector(performBridgeAction) + let revealButton = NSButton( + title: "显示 Chrome 扩展", + target: self, + action: #selector(revealBrowserExtension) + ) + let tokenButton = NSButton( + title: "复制配对令牌", + target: self, + action: #selector(copyBrowserToken) + ) + var actions: [NSView] = [revealButton, tokenButton] + if capabilityRegistry.supports(.safariExtension) { + actions.append( + NSButton( + title: "Safari 设置", + target: self, + action: #selector(openSafariExtensionSettings) + ) + ) + } + actions.append(NSView()) + actions.append(browserActionButton) + let actionRow = NSStackView(views: actions) + actionRow.orientation = .horizontal + actionRow.alignment = .centerY + actionRow.spacing = 8 + actionRow.distribution = .fill + + let copy = NSStackView(views: [titleRow, description, browserStatus, browserDetail, actionRow]) + copy.orientation = .vertical + copy.alignment = .leading + copy.spacing = 8 + copy.translatesAutoresizingMaskIntoConstraints = false + + card.addSubview(icon) + card.addSubview(copy) + NSLayoutConstraint.activate([ + card.heightAnchor.constraint(equalToConstant: 224), + icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 24), + icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 25), + icon.widthAnchor.constraint(equalToConstant: 32), + icon.heightAnchor.constraint(equalToConstant: 32), + copy.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 16), + copy.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -24), + copy.topAnchor.constraint(equalTo: card.topAnchor, constant: 24), + copy.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -20), + titleRow.widthAnchor.constraint(equalTo: copy.widthAnchor), + description.widthAnchor.constraint(equalTo: copy.widthAnchor), + browserStatus.widthAnchor.constraint(equalTo: copy.widthAnchor), + browserDetail.widthAnchor.constraint(equalTo: copy.widthAnchor), + actionRow.widthAnchor.constraint(equalTo: copy.widthAnchor), + ]) + return card + } + + private func makePDFCard() -> NSView { + let card = makeCard(material: .sidebar, cornerRadius: 14) + let icon = makeSymbolView("doc.richtext", description: "PDF 翻译", size: 24) + let title = NSTextField(labelWithString: "PDF 翻译") + title.font = .systemFont(ofSize: 16, weight: .semibold) + let badge = makeBadge("附加组件", color: .secondaryLabelColor) + let titleRow = NSStackView(views: [title, badge]) + titleRow.orientation = .horizontal + titleRow.alignment = .centerY + titleRow.spacing = 8 + + pdfStatus.font = .systemFont(ofSize: 12.5, weight: .medium) + pdfDetail.font = .systemFont(ofSize: 11.5) + pdfDetail.textColor = .secondaryLabelColor + pdfDetail.lineBreakMode = .byTruncatingMiddle + + pdfProgress.style = .spinning + pdfProgress.controlSize = .small + pdfProgress.isHidden = true + let statusRow = NSStackView(views: [pdfProgress, pdfStatus]) + statusRow.orientation = .horizontal + statusRow.alignment = .centerY + statusRow.spacing = 6 + + let openButton = NSButton( + title: "打开 PDF 翻译…", + target: self, + action: #selector(openPDFTranslation) + ) + pdfActionButton.target = self + pdfActionButton.action = #selector(performPDFRuntimeAction) + let actions = NSStackView(views: [NSView(), pdfActionButton, openButton]) + actions.orientation = .horizontal + actions.alignment = .centerY + actions.spacing = 8 + actions.distribution = .fill + + let copy = NSStackView(views: [titleRow, statusRow, pdfDetail, actions]) + copy.orientation = .vertical + copy.alignment = .leading + copy.spacing = 6 + copy.translatesAutoresizingMaskIntoConstraints = false + + card.addSubview(icon) + card.addSubview(copy) + NSLayoutConstraint.activate([ + card.heightAnchor.constraint(equalToConstant: 154), + icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 22), + icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 22), + icon.widthAnchor.constraint(equalToConstant: 28), + icon.heightAnchor.constraint(equalToConstant: 28), + copy.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 15), + copy.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -22), + copy.topAnchor.constraint(equalTo: card.topAnchor, constant: 19), + copy.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -16), + titleRow.widthAnchor.constraint(equalTo: copy.widthAnchor), + statusRow.widthAnchor.constraint(equalTo: copy.widthAnchor), + pdfDetail.widthAnchor.constraint(equalTo: copy.widthAnchor), + actions.widthAnchor.constraint(equalTo: copy.widthAnchor), + ]) + showPDFRuntimeState(pdfRuntimeState) + return card + } + + private func makeCard( + material: NSVisualEffectView.Material, + cornerRadius: CGFloat + ) -> NSVisualEffectView { + let card = NSVisualEffectView() + card.material = material + card.blendingMode = .withinWindow + card.state = .active + card.wantsLayer = true + card.layer?.cornerRadius = cornerRadius + card.layer?.borderWidth = 0.5 + card.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.45).cgColor + card.translatesAutoresizingMaskIntoConstraints = false + return card + } + + private func makeSymbolView( + _ symbol: String, + description: String, + size: CGFloat + ) -> NSImageView { + let imageView = NSImageView() + imageView.image = NSImage( + systemSymbolName: symbol, + accessibilityDescription: description + ) + imageView.symbolConfiguration = NSImage.SymbolConfiguration( + pointSize: size, + weight: .medium + ) + imageView.contentTintColor = .secondaryLabelColor + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + } + + private func makeBadge(_ text: String, color: NSColor) -> NSTextField { + let label = NSTextField(labelWithString: " \(text) ") + label.font = .systemFont(ofSize: 10.5, weight: .semibold) + label.textColor = color + label.alignment = .center + label.wantsLayer = true + label.layer?.cornerRadius = 6 + label.layer?.backgroundColor = color.withAlphaComponent(0.10).cgColor + return label + } + + private func configureIconButton( + _ button: NSButton, + symbol: String, + toolTip: String, + action: Selector + ) { + button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: toolTip) + button.imagePosition = .imageOnly + button.bezelStyle = .accessoryBarAction + button.toolTip = toolTip + button.target = self + button.action = action + } + + private func updateThemeButton() { + let preference = appearanceController.preference + themeButton.image = NSImage( + systemSymbolName: preference.symbolName, + accessibilityDescription: preference.displayName + ) + themeButton.toolTip = "外观:\(preference.displayName)" + } + + private func color(for tone: PDFRuntimeDashboardPresentation.Tone) -> NSColor { + switch tone { + case .neutral: .secondaryLabelColor + case .positive: .systemGreen + case .warning: .systemOrange + case .negative: .systemRed + } + } + + private func color(for tone: BridgeDashboardPresentation.Tone) -> NSColor { + switch tone { + case .neutral: .secondaryLabelColor + case .positive: .systemGreen + case .warning: .systemOrange + case .negative: .systemRed + } + } + + @objc private func cycleTheme() { + appearanceController.cycle() + } + + @objc private func openSettings() { + onOpenSettings?() + } + + @objc private func openPDFTranslation() { + onOpenPDFTranslation?() + } + + @objc private func revealBrowserExtension() { + onRevealBrowserExtension?() + } + + @objc private func copyBrowserToken() { + onCopyBrowserToken?() + } + + @objc private func openSafariExtensionSettings() { + onOpenSafariExtensionSettings?() + } + + @objc private func performBridgeAction() { + onBridgeAction?() + } + + @objc private func performPDFRuntimeAction() { + guard let action = pdfRuntimeState.action else { return } + onPDFRuntimeAction?(action) + } +} diff --git a/Sources/Gloss/PDFRuntimeController.swift b/Sources/Gloss/PDFRuntimeController.swift index 0b43c8b..16f14bf 100644 --- a/Sources/Gloss/PDFRuntimeController.swift +++ b/Sources/Gloss/PDFRuntimeController.swift @@ -112,6 +112,11 @@ final class PDFRuntimeController { Self.compatibleManagedRuntimeLaunch(for: runtimeSnapshot) } + func estimatedReclaimableBytes() async -> Int64? { + guard let runtimeManager else { return nil } + return await runtimeManager.reclaimableBytes() + } + func prepareAtLaunch() { guard !launchPreparationCompleted, launchPreparationTask == nil @@ -309,6 +314,8 @@ final class PDFRuntimeController { _ = try await reconnect() case .rollback: _ = try await rollbackAndRestart() + case .uninstall: + try await uninstallAndStop() case .cancel: break } @@ -514,6 +521,17 @@ final class PDFRuntimeController { return try await start(launch) } + private func uninstallAndStop() async throws { + guard let runtimeManager else { + throw ControllerError.runtimeManagerUnavailable + } + try await stopServiceForRuntimeReplacement() + try Task.checkCancellation() + runtimeSnapshot = try await runtimeManager.uninstall() + resetLaunchPreparation() + refreshDashboard() + } + private func reconnect() async throws -> PreparedRuntime { guard let launch = currentRuntimeLaunch else { throw Self.runtimeUpdateRequiredError(for: runtimeSnapshot) @@ -685,6 +703,8 @@ final class PDFRuntimeController { version: runtime.availableVersion ?? runtime.currentVersion, progress: nil ) + case .removing: + return .uninstalling case .failed where runtime.currentVersion == nil: return .failed( message: runtime.lastError ?? "BabelDOC 运行时操作失败。", diff --git a/Sources/Gloss/PDFRuntimeDashboardState.swift b/Sources/Gloss/PDFRuntimeDashboardState.swift index e3ead03..e664f0e 100644 --- a/Sources/Gloss/PDFRuntimeDashboardState.swift +++ b/Sources/Gloss/PDFRuntimeDashboardState.swift @@ -15,6 +15,7 @@ enum PDFRuntimeDashboardAction: Equatable { case reconnect case retry case rollback + case uninstall } enum PDFRuntimeDashboardState: Equatable { @@ -27,6 +28,7 @@ enum PDFRuntimeDashboardState: Equatable { case updateAvailable(PDFRuntimeReadyInfo, availableVersion: String) case reconnecting(previousProcessIdentifier: Int32?) case stopping(previousProcessIdentifier: Int32?) + case uninstalling case failed(message: String, installedVersion: String?, canRollback: Bool) case stopped(installedVersion: String?, canRollback: Bool) @@ -39,9 +41,32 @@ enum PDFRuntimeDashboardState: Equatable { } } + var hasInstalledRuntime: Bool { + switch self { + case .ready, .translating, .updateAvailable, .starting, .reconnecting, + .stopping, .uninstalling: + true + case .failed(_, let installedVersion, _), .stopped(let installedVersion, _): + installedVersion != nil + case .checking, .notInstalled, .installing: + false + } + } + + var canRequestUninstall: Bool { + switch self { + case .ready, .updateAvailable: + true + case .failed(_, let installedVersion, _), .stopped(let installedVersion, _): + installedVersion != nil + default: + false + } + } + var action: PDFRuntimeDashboardAction? { switch self { - case .checking, .installing, .starting, .reconnecting, .stopping: + case .checking, .installing, .starting, .reconnecting, .stopping, .uninstalling: nil case .notInstalled: .install @@ -175,6 +200,17 @@ enum PDFRuntimeDashboardState: Equatable { actionIsDestructive: false, showsProgress: true ) + case .uninstalling: + return PDFRuntimeDashboardPresentation( + headline: "正在卸载 PDF 组件…", + detail: "正在安全停止服务并清理运行时、历史版本与缓存", + path: nil, + tone: .warning, + actionTitle: "正在卸载…", + actionEnabled: false, + actionIsDestructive: true, + showsProgress: true + ) case .failed(let message, let installedVersion, let canRollback): let versionText = installedVersion.map { " · 已安装 \($0)" } ?? "" let actionTitle = diff --git a/Sources/Gloss/PDFTranslationWindowController.swift b/Sources/Gloss/PDFTranslationWindowController.swift index c34f716..70bf0ae 100644 --- a/Sources/Gloss/PDFTranslationWindowController.swift +++ b/Sources/Gloss/PDFTranslationWindowController.swift @@ -1387,7 +1387,7 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { case .ready, .updateAvailable: true case .checking, .notInstalled, .installing, .starting, .translating, - .reconnecting, .stopping, .failed, .stopped: + .reconnecting, .stopping, .uninstalling, .failed, .stopped: false } } @@ -1430,7 +1430,7 @@ final class PDFTranslationWindowController: NSObject, NSWindowDelegate { layoutServiceBaseURL = nil layoutCacheDirectoryURL = nil serviceState = .failed(message) - case .stopping: + case .stopping, .uninstalling: layoutServiceBaseURL = nil layoutCacheDirectoryURL = nil serviceState = .stopped diff --git a/Sources/Gloss/SettingsWindowController.swift b/Sources/Gloss/SettingsWindowController.swift index 3df6ec3..78167a4 100644 --- a/Sources/Gloss/SettingsWindowController.swift +++ b/Sources/Gloss/SettingsWindowController.swift @@ -3,6 +3,48 @@ import GlossCore @MainActor final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDelegate { + private enum Section: Int, CaseIterable { + case general + case engine + case browser + case pdf + case shortcuts + case updates + + var title: String { + switch self { + case .general: "通用" + case .engine: "翻译引擎" + case .browser: "浏览器" + case .pdf: "PDF 组件" + case .shortcuts: "快捷键与服务" + case .updates: "更新与诊断" + } + } + + var symbol: String { + switch self { + case .general: "gearshape" + case .engine: "bolt.horizontal.circle" + case .browser: "globe" + case .pdf: "doc.richtext" + case .shortcuts: "command" + case .updates: "arrow.triangle.2.circlepath.circle" + } + } + + var subtitle: String { + switch self { + case .general: "管理 Gloss 的外观与启动方式。" + case .engine: "选择网页与 PDF 翻译使用的模型和账号。" + case .browser: "管理浏览器扩展、配对令牌与本地连接。" + case .pdf: "按需安装、更新或移除 PDF 翻译组件。" + case .shortcuts: "配置系统选区、服务与全局快捷键。" + case .updates: "检查 Gloss 更新并查看运行日志。" + } + } + } + var onRequestAccessibility: (() -> Void)? var onVerifyProvider: (() -> Void)? var onProviderConfigurationChange: ((TranslationProviderConfiguration) -> Void)? @@ -23,6 +65,12 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel private let capabilityRegistry: GlossCapabilityRegistry private let window: NSWindow + private let appearanceController = GlossAppearanceController.shared + private let pageContainer = NSView() + private var sectionButtons: [Section: NSButton] = [:] + private var sectionPages: [Section: NSView] = [:] + private var appearanceButtons: [GlossAppearancePreference: NSButton] = [:] + private var selectedSection = Section.general private let accessibilityStatus = NSTextField(labelWithString: "") private let providerStatus = NSTextField(labelWithString: "正在启动翻译引擎") private let bridgeStatus = NSTextField(labelWithString: "正在检查本地连接…") @@ -61,6 +109,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel action: nil ) private let pdfRuntimeProgressIndicator = NSProgressIndicator() + private let pdfRuntimeUninstallButton = NSButton( + title: "卸载…", + target: nil, + action: nil + ) private var pdfRuntimeState = PDFRuntimeDashboardState.checking private let appUpdateActionButton = NSButton( title: "检查更新", @@ -75,15 +128,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel 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: windowHeight), - styleMask: [.titled, .closable, .miniaturizable], + contentRect: NSRect(x: 0, y: 0, width: 940, height: 680), + styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: true ) @@ -95,7 +142,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel if capabilityRegistry.isEnabled(.selectionTranslation) { updateAccessibilityStatus() } - window.center() + if !window.isVisible, !window.setFrameUsingName("GlossSettingsWindow") { + window.center() + } + selectSection(selectedSection) NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) } @@ -168,71 +218,119 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel } private func configureWindow() { - window.title = "Gloss" + window.title = "Gloss 设置" window.isReleasedWhenClosed = false window.delegate = self - window.minSize = NSSize( - width: 520, - height: - capabilityRegistry.isEnabled(.selectionTranslation) - ? 810 - : capabilityRegistry.supports(.appUpdates) ? 690 : 610 - ) - - let root = NSView() - let scrollView = NSScrollView() - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - scrollView.drawsBackground = false - scrollView.borderType = .noBorder - scrollView.translatesAutoresizingMaskIntoConstraints = false - root.addSubview(scrollView) - window.contentView = root - - let content = NSView() - content.translatesAutoresizingMaskIntoConstraints = false - scrollView.documentView = content + window.minSize = NSSize(width: 780, height: 560) + window.setFrameAutosaveName("GlossSettingsWindow") + window.titlebarAppearsTransparent = true + + let splitView = NSSplitView() + splitView.isVertical = true + splitView.dividerStyle = .thin + splitView.translatesAutoresizingMaskIntoConstraints = false + + let sidebar = NSVisualEffectView() + sidebar.material = .sidebar + sidebar.blendingMode = .behindWindow + sidebar.state = .active + sidebar.translatesAutoresizingMaskIntoConstraints = false + + let brandIcon = NSImageView() + brandIcon.image = GlossBrand.markImage(pointSize: 24) + brandIcon.contentTintColor = .controlAccentColor + brandIcon.translatesAutoresizingMaskIntoConstraints = false + let brandTitle = NSTextField(labelWithString: "Gloss") + brandTitle.font = .systemFont(ofSize: 17, weight: .semibold) + let brand = NSStackView(views: [brandIcon, brandTitle]) + brand.orientation = .horizontal + brand.alignment = .centerY + brand.spacing = 10 + brand.translatesAutoresizingMaskIntoConstraints = false + + var navigationViews: [NSView] = [] + for section in Section.allCases { + if section == .pdf, !capabilityRegistry.isEnabled(.pdfTranslation) { + continue + } + if section == .updates, !capabilityRegistry.supports(.appUpdates) { + continue + } + let button = NSButton( + title: section.title, + image: NSImage( + systemSymbolName: section.symbol, + accessibilityDescription: section.title + ) ?? NSImage(), + target: self, + action: #selector(selectSectionButton(_:)) + ) + button.tag = section.rawValue + button.alternateTitle = section.title + button.alternateImage = button.image + button.imagePosition = .imageLeading + button.alignment = .left + button.bezelStyle = .rounded + button.isBordered = false + button.wantsLayer = true + button.layer?.cornerRadius = 8 + button.setButtonType(.toggle) + button.controlSize = .large + button.font = .systemFont(ofSize: 13, weight: .medium) + button.translatesAutoresizingMaskIntoConstraints = false + button.heightAnchor.constraint(equalToConstant: 38).isActive = true + sectionButtons[section] = button + navigationViews.append(button) + } + let navigation = NSStackView(views: navigationViews) + navigation.orientation = .vertical + navigation.alignment = .leading + navigation.spacing = 5 + navigation.translatesAutoresizingMaskIntoConstraints = false + for view in navigationViews { + view.widthAnchor.constraint(equalTo: navigation.widthAnchor).isActive = true + } + let version = + Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String ?? "dev" + let versionLabel = NSTextField(labelWithString: "Gloss \(version)") + versionLabel.font = .systemFont(ofSize: 10.5, weight: .medium) + versionLabel.textColor = .tertiaryLabelColor + versionLabel.translatesAutoresizingMaskIntoConstraints = false + + sidebar.addSubview(brand) + sidebar.addSubview(navigation) + sidebar.addSubview(versionLabel) NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: root.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: root.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: root.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: root.bottomAnchor), - content.leadingAnchor.constraint( - equalTo: scrollView.contentView.leadingAnchor - ), - content.trailingAnchor.constraint( - equalTo: scrollView.contentView.trailingAnchor - ), - content.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), - content.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor), + brand.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 28), + brand.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 22), + brandIcon.widthAnchor.constraint(equalToConstant: 30), + brandIcon.heightAnchor.constraint(equalToConstant: 30), + navigation.topAnchor.constraint(equalTo: brand.bottomAnchor, constant: 28), + navigation.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 14), + navigation.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -14), + versionLabel.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 22), + versionLabel.bottomAnchor.constraint(equalTo: sidebar.bottomAnchor, constant: -18), ]) - let icon = NSImageView() - icon.image = GlossBrand.markImage(pointSize: 36) - icon.contentTintColor = .controlAccentColor - icon.translatesAutoresizingMaskIntoConstraints = false - - let title = NSTextField(labelWithString: "Gloss") - title.font = .systemFont(ofSize: 28, weight: .bold) - let subtitle = NSTextField( - labelWithString: productSubtitle - ) - subtitle.font = .systemFont(ofSize: 15) - subtitle.textColor = .secondaryLabelColor - let heading = NSStackView(views: [title, subtitle]) - heading.orientation = .vertical - heading.alignment = .leading - heading.spacing = 3 - heading.translatesAutoresizingMaskIntoConstraints = false + pageContainer.translatesAutoresizingMaskIntoConstraints = false + splitView.addArrangedSubview(sidebar) + splitView.addArrangedSubview(pageContainer) + sidebar.widthAnchor.constraint(equalToConstant: 220).isActive = true - let header = NSStackView(views: [icon, heading]) - header.orientation = .horizontal - header.alignment = .centerY - header.spacing = 14 - header.translatesAutoresizingMaskIntoConstraints = false + let root = NSView() + root.addSubview(splitView) + window.contentView = root + NSLayoutConstraint.activate([ + splitView.leadingAnchor.constraint(equalTo: root.leadingAnchor), + splitView.trailingAnchor.constraint(equalTo: root.trailingAnchor), + splitView.topAnchor.constraint(equalTo: root.topAnchor), + splitView.bottomAnchor.constraint(equalTo: root.bottomAnchor), + ]) - var cards: [NSView] = [] + var shortcutCards: [NSView] = [] if capabilityRegistry.isEnabled(.selectionTranslation) { let accessibilityCard = makeStatusCard( symbol: "selection.pin.in.out", @@ -242,7 +340,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel ) accessibilityButton.target = self accessibilityButton.action = #selector(requestAccessibility) - cards.append(accessibilityCard) + shortcutCards.append(accessibilityCard) let shortcutCard = makeStatusCard( symbol: "command", @@ -253,7 +351,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel shortcutButton.onChange = { [weak self] shortcut in self?.onSetGlobalShortcut?(shortcut) } - cards.append(shortcutCard) + shortcutCards.append(shortcutCard) } if capabilityRegistry.isEnabled(.clipboardTranslation) @@ -270,21 +368,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel ) servicesButton.target = self servicesButton.action = #selector(openServicesSettings) - cards.append(servicesCard) + shortcutCards.append(servicesCard) } - 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()) - } + var generalCards: [NSView] = [makeAppearanceCard()] if capabilityRegistry.supports(.launchAtLogin) { let launchAtLoginCard = makeStatusCard( symbol: "power", @@ -294,99 +381,214 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel ) launchAtLoginSwitch.target = self launchAtLoginSwitch.action = #selector(setLaunchAtLogin(_:)) - cards.append(launchAtLoginCard) + generalCards.append(launchAtLoginCard) } - 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 + installPage(.general, cards: generalCards) + installPage( + .engine, + cards: capabilityRegistry.supports(.providerConfiguration) + ? [makeProviderCard()] + : [] + ) + installPage( + .browser, + cards: capabilityRegistry.supports(.translationLoopbackBridge) + ? [makeBridgeCard()] + : [] + ) + if capabilityRegistry.isEnabled(.pdfTranslation) { + installPage(.pdf, cards: [makePDFRuntimeCard()]) + } + installPage(.shortcuts, cards: shortcutCards) + if capabilityRegistry.supports(.appUpdates) { + installPage(.updates, cards: [makeAppUpdateCard(), makeDiagnosticsCard()]) } + selectSection(.general) + } - var quickActionButtons: [NSButton] = [] - if capabilityRegistry.isEnabled(.clipboardTranslation) { - let clipboardButton = NSButton( - title: "翻译文本", - target: self, - action: #selector(translateClipboard) + private func installPage(_ section: Section, cards: [NSView]) { + let page = NSView() + page.translatesAutoresizingMaskIntoConstraints = false + + let title = NSTextField(labelWithString: section.title) + title.font = .systemFont(ofSize: 26, weight: .bold) + let subtitle = NSTextField(wrappingLabelWithString: section.subtitle) + subtitle.font = .systemFont(ofSize: 13) + subtitle.textColor = .secondaryLabelColor + subtitle.maximumNumberOfLines = 2 + let heading = NSStackView(views: [title, subtitle]) + heading.orientation = .vertical + heading.alignment = .leading + heading.spacing = 5 + heading.translatesAutoresizingMaskIntoConstraints = false + + let contentViews: [NSView] + if cards.isEmpty { + let empty = NSTextField( + wrappingLabelWithString: "当前版本没有可配置的项目。" ) - clipboardButton.bezelStyle = .rounded - clipboardButton.controlSize = .large - clipboardButton.keyEquivalent = "\r" - quickActionButtons.append(clipboardButton) + empty.font = .systemFont(ofSize: 13) + empty.textColor = .secondaryLabelColor + contentViews = [empty] + } else { + contentViews = cards + } + let stack = NSStackView(views: contentViews) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + for card in cards { + card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } - 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: "截图翻译…", + page.addSubview(heading) + page.addSubview(stack) + NSLayoutConstraint.activate([ + heading.topAnchor.constraint(equalTo: page.topAnchor, constant: 42), + heading.leadingAnchor.constraint(equalTo: page.leadingAnchor, constant: 38), + heading.trailingAnchor.constraint(equalTo: page.trailingAnchor, constant: -38), + stack.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: 28), + stack.leadingAnchor.constraint(equalTo: page.leadingAnchor, constant: 38), + stack.trailingAnchor.constraint(equalTo: page.trailingAnchor, constant: -38), + stack.bottomAnchor.constraint(lessThanOrEqualTo: page.bottomAnchor, constant: -30), + ]) + + pageContainer.addSubview(page) + NSLayoutConstraint.activate([ + page.leadingAnchor.constraint(equalTo: pageContainer.leadingAnchor), + page.trailingAnchor.constraint(equalTo: pageContainer.trailingAnchor), + page.topAnchor.constraint(equalTo: pageContainer.topAnchor), + page.bottomAnchor.constraint(equalTo: pageContainer.bottomAnchor), + ]) + page.isHidden = true + sectionPages[section] = page + } + + private func makeAppearanceCard() -> NSView { + let card = NSVisualEffectView() + card.material = .contentBackground + card.blendingMode = .withinWindow + card.state = .active + card.wantsLayer = true + card.layer?.cornerRadius = 12 + card.translatesAutoresizingMaskIntoConstraints = false + + let title = NSTextField(labelWithString: "外观") + title.font = .systemFont(ofSize: 14, weight: .semibold) + title.translatesAutoresizingMaskIntoConstraints = false + + let preferences: [GlossAppearancePreference] = [.system, .light, .dark] + let buttons = preferences.map { preference in + let button = NSButton( + title: preference.displayName, + image: NSImage( + systemSymbolName: preference.symbolName, + accessibilityDescription: preference.displayName + ) ?? NSImage(), target: self, - action: #selector(translateScreenshot) + action: #selector(selectAppearance(_:)) ) - screenshotButton.bezelStyle = .rounded - screenshotButton.controlSize = .large - quickActionButtons.append(screenshotButton) + button.tag = preferences.firstIndex(of: preference) ?? 0 + button.imagePosition = .imageAbove + button.imageScaling = .scaleProportionallyDown + button.isBordered = false + button.wantsLayer = true + button.layer?.cornerRadius = 9 + button.setButtonType(.toggle) + button.font = .systemFont(ofSize: 12.5, weight: .semibold) + button.translatesAutoresizingMaskIntoConstraints = false + button.heightAnchor.constraint(equalToConstant: 110).isActive = true + appearanceButtons[preference] = button + return button } + let choices = NSStackView(views: buttons) + choices.orientation = .horizontal + choices.alignment = .centerY + choices.distribution = .fillEqually + choices.spacing = 12 + choices.translatesAutoresizingMaskIntoConstraints = false + + card.addSubview(title) + card.addSubview(choices) + NSLayoutConstraint.activate([ + card.heightAnchor.constraint(equalToConstant: 176), + title.topAnchor.constraint(equalTo: card.topAnchor, constant: 16), + title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18), + choices.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 12), + choices.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18), + choices.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18), + choices.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -15), + ]) + updateAppearanceButtons() + return card + } - 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), - cardsStack.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 26), - cardsStack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 28), - cardsStack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -28), - ] + private func makeDiagnosticsCard() -> NSView { + let status = NSTextField( + labelWithString: "打开诊断目录,查看桥接、翻译引擎与 PDF 组件日志" + ) + let button = NSButton(title: "查看日志", target: self, action: #selector(revealLogs)) + return makeStatusCard( + symbol: "doc.text.magnifyingglass", + title: "运行诊断", + status: status, + accessory: button + ) + } - 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), - ]) + @objc private func selectSectionButton(_ sender: NSButton) { + guard let section = Section(rawValue: sender.tag) else { return } + selectSection(section) + } + + private func selectSection(_ section: Section) { + guard sectionPages[section] != nil else { return } + selectedSection = section + for (candidate, page) in sectionPages { + page.isHidden = candidate != section + } + for (candidate, button) in sectionButtons { + let isSelected = candidate == section + button.state = isSelected ? .on : .off + button.isBordered = false + button.layer?.backgroundColor = + isSelected + ? NSColor.controlAccentColor.cgColor + : NSColor.clear.cgColor + button.contentTintColor = isSelected ? .white : .labelColor + } + if section == .general { + updateAppearanceButtons() + } + } + + @objc private func selectAppearance(_ sender: NSButton) { + let preferences: [GlossAppearancePreference] = [.system, .light, .dark] + guard preferences.indices.contains(sender.tag) else { return } + appearanceController.select(preferences[sender.tag]) + updateAppearanceButtons() + selectSection(selectedSection) + } + + private func updateAppearanceButtons() { + let selected = appearanceController.preference + for (preference, button) in appearanceButtons { + let isSelected = preference == selected + button.state = isSelected ? .on : .off + button.layer?.borderWidth = isSelected ? 2 : 1 + button.layer?.borderColor = + isSelected + ? NSColor.controlAccentColor.cgColor + : NSColor.separatorColor.cgColor + button.layer?.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.55).cgColor + button.contentTintColor = isSelected ? .controlAccentColor : .labelColor + button.toolTip = + isSelected + ? "当前外观:\(preference.displayName)" + : "切换到\(preference.displayName)" } - NSLayoutConstraint.activate(layoutConstraints) } private func makePDFRuntimeCard() -> NSView { @@ -433,9 +635,21 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel .required, for: .horizontal ) + pdfRuntimeUninstallButton.target = self + pdfRuntimeUninstallButton.action = #selector(uninstallPDFRuntime) + pdfRuntimeUninstallButton.contentTintColor = .systemRed + pdfRuntimeUninstallButton.setContentHuggingPriority( + .required, + for: .horizontal + ) let topRow = NSStackView( - views: [titleLabel, statusStack, pdfRuntimeActionButton] + views: [ + titleLabel, + statusStack, + pdfRuntimeActionButton, + pdfRuntimeUninstallButton, + ] ) topRow.orientation = .horizontal topRow.alignment = .centerY @@ -964,6 +1178,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel onPDFRuntimeAction?(action) } + @objc private func uninstallPDFRuntime() { + onPDFRuntimeAction?(.uninstall) + } + @objc private func performAppUpdateAction() { onAppUpdateAction?() } @@ -1019,6 +1237,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel pdfRuntimeActionButton.isEnabled = presentation.actionEnabled pdfRuntimeActionButton.contentTintColor = presentation.actionIsDestructive ? .systemRed : nil + pdfRuntimeUninstallButton.isHidden = !state.hasInstalledRuntime + pdfRuntimeUninstallButton.isEnabled = state.canRequestUninstall pdfRuntimeProgressIndicator.isHidden = !presentation.showsProgress if presentation.showsProgress { pdfRuntimeProgressIndicator.startAnimation(nil) diff --git a/Sources/GlossCore/BabelDOCRuntimeDistribution.swift b/Sources/GlossCore/BabelDOCRuntimeDistribution.swift index 4f27339..ad6e963 100644 --- a/Sources/GlossCore/BabelDOCRuntimeDistribution.swift +++ b/Sources/GlossCore/BabelDOCRuntimeDistribution.swift @@ -351,6 +351,7 @@ public enum BabelDOCRuntimeOperation: String, Codable, Sendable { case extracting case installing case rollingBack + case removing case ready case failed } @@ -518,6 +519,10 @@ public actor BabelDOCRuntimeManager { makeSnapshot().updateAvailable } + public func reclaimableBytes() -> Int64 { + Self.directorySize(at: rootDirectory, fileManager: fileManager) + } + public func snapshots() -> AsyncStream { let identifier = UUID() return AsyncStream { continuation in @@ -800,6 +805,74 @@ public actor BabelDOCRuntimeManager { return publishSnapshot() } + @discardableResult + public func uninstall( + progress: ProgressHandler? = nil + ) throws -> BabelDOCRuntimeSnapshot { + let stateBeforeRemoval = persistedState + let manifestBeforeRemoval = availableManifest + let quarantineDirectory = rootDirectory.appendingPathComponent( + ".removing-\(UUID().uuidString)", + isDirectory: true + ) + var movedItems: [(source: URL, destination: URL)] = [] + var createdReplacementVersionsDirectory = false + + do { + emit(.init(operation: .removing, version: persistedState.current?.version), progress: progress) + try Self.prepareDirectory(quarantineDirectory, fileManager: fileManager) + let contents = try fileManager.contentsOfDirectory( + at: rootDirectory, + includingPropertiesForKeys: nil, + options: [] + ) + let preservedNames = Set([ + stateURL.lastPathComponent, + quarantineDirectory.lastPathComponent, + ]) + for source in contents { + guard !preservedNames.contains(source.lastPathComponent) else { continue } + let destination = quarantineDirectory.appendingPathComponent( + source.lastPathComponent, + isDirectory: source.hasDirectoryPath + ) + try fileManager.moveItem(at: source, to: destination) + movedItems.append((source, destination)) + } + try Self.prepareDirectory(versionsDirectory, fileManager: fileManager) + createdReplacementVersionsDirectory = true + + persistedState.current = nil + persistedState.previous = nil + availableManifest = nil + lastError = nil + operation = .idle + try persist() + + // State is already committed as uninstalled. Cleanup is best-effort: + // a quarantined remainder is inert and will be included in the next + // reclaimable-size calculation instead of risking a half-restored + // executable after a partial filesystem deletion. + try? fileManager.removeItem(at: quarantineDirectory) + emit(.init(operation: .idle), progress: progress) + return publishSnapshot() + } catch { + if createdReplacementVersionsDirectory { + try? fileManager.removeItem(at: versionsDirectory) + } + for item in movedItems.reversed() + where fileManager.fileExists(atPath: item.destination.path) { + try? fileManager.moveItem(at: item.destination, to: item.source) + } + try? fileManager.removeItem(at: quarantineDirectory) + persistedState = stateBeforeRemoval + availableManifest = manifestBeforeRemoval + try? persist() + record(error, progress: progress) + throw error + } + } + private func validate(_ manifest: BabelDOCRuntimeManifest) throws { guard manifest.schemaVersion == 1 else { throw BabelDOCRuntimeDistributionError.invalidManifest( @@ -1111,6 +1184,31 @@ public actor BabelDOCRuntimeManager { } } + private static func directorySize( + at root: URL, + fileManager: FileManager + ) -> Int64 { + guard + let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey], + options: [], + errorHandler: { _, _ in true } + ) + else { return 0 } + + var total: Int64 = 0 + while let url = enumerator.nextObject() as? URL { + guard + let values = try? url.resourceValues( + forKeys: [.isRegularFileKey, .fileSizeKey] + ), values.isRegularFile == true + else { continue } + total += Int64(values.fileSize ?? 0) + } + return total + } + private static func prepareDirectory( _ url: URL, fileManager: FileManager diff --git a/Sources/GlossCore/CodexAppServerClient.swift b/Sources/GlossCore/CodexAppServerClient.swift index bb4cc58..6bda4b2 100644 --- a/Sources/GlossCore/CodexAppServerClient.swift +++ b/Sources/GlossCore/CodexAppServerClient.swift @@ -845,7 +845,7 @@ public actor CodexAppServerClient: TranslationBackend { let startedAt = DispatchTime.now().uptimeNanoseconds guard let runtime = Self.resolveRuntime(environment: environment) else { throw TranslationError.backendUnavailable( - "Gloss 内置翻译引擎不可用。请重新安装 Gloss,或为开发环境配置 Codex CLI。" + "未找到 Codex CLI。请先安装 Codex,或在 GLOSS_CODEX_BIN 中指定其位置。" ) } runtimeLog.write( diff --git a/Tests/GlossAppTests/PDFRuntimeDashboardStateTests.swift b/Tests/GlossAppTests/PDFRuntimeDashboardStateTests.swift index be384d4..7de3000 100644 --- a/Tests/GlossAppTests/PDFRuntimeDashboardStateTests.swift +++ b/Tests/GlossAppTests/PDFRuntimeDashboardStateTests.swift @@ -84,6 +84,29 @@ final class PDFRuntimeDashboardStateTests: XCTestCase { XCTAssertTrue(state.presentation.showsProgress) } + func testInstalledRuntimeCanBeUninstalledOnlyFromStableStates() { + let info = PDFRuntimeReadyInfo( + endpoint: "http://127.0.0.1:49160", + processIdentifier: 42, + version: "1.0.0", + executablePath: "/runtime/gloss-babeldoc" + ) + XCTAssertTrue(PDFRuntimeDashboardState.ready(info).hasInstalledRuntime) + XCTAssertTrue(PDFRuntimeDashboardState.ready(info).canRequestUninstall) + XCTAssertFalse( + PDFRuntimeDashboardState.translating( + info, + fileName: "paper.pdf", + progress: 20 + ).canRequestUninstall + ) + XCTAssertEqual( + PDFRuntimeDashboardState.uninstalling.presentation.actionTitle, + "正在卸载…" + ) + XCTAssertFalse(PDFRuntimeDashboardState.notInstalled.hasInstalledRuntime) + } + func testControllerMapsMissingRuntimeToAutomaticInstall() { let state = PDFRuntimeController.dashboardState( runtime: nil, diff --git a/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift b/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift index 04f9315..8e0dde3 100644 --- a/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift +++ b/Tests/GlossCoreTests/BabelDOCRuntimeDistributionTests.swift @@ -544,6 +544,66 @@ struct BabelDOCRuntimeDistributionTests { #expect(try Data(contentsOf: executable) == firstPayload) } + @Test("uninstall removes current and rollback runtimes and allows reinstall") + func uninstallAndReinstall() async throws { + let root = try temporaryDirectory() + let firstPayload = Data("first-runtime".utf8) + let secondPayload = Data("second-runtime".utf8) + let firstURL = try #require(URL(string: "https://example.com/first-runtime")) + let secondURL = try #require(URL(string: "https://example.com/second-runtime")) + let manager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport( + payloads: [ + firstURL: firstPayload, + secondURL: secondPayload, + ] + ), + manifestSigningPublicKey: signingPublicKey + ) + + _ = try await manager.install( + manifest( + version: "1.0.0", + assets: [asset(url: firstURL, payload: firstPayload)] + ) + ) + _ = try await manager.install( + manifest( + version: "1.1.0", + assets: [asset(url: secondURL, payload: secondPayload)] + ) + ) + #expect(await manager.reclaimableBytes() >= Int64(firstPayload.count + secondPayload.count)) + + let removed = try await manager.uninstall() + #expect(removed.currentVersion == nil) + #expect(removed.previousVersion == nil) + #expect(removed.currentExecutableURL == nil) + #expect(removed.operation == .idle) + let versions = root.appendingPathComponent("versions", isDirectory: true) + #expect( + try FileManager.default.contentsOfDirectory(atPath: versions.path).isEmpty + ) + + let restoredManager = try BabelDOCRuntimeManager( + rootDirectory: root, + platform: platform, + transport: transport(payloads: [firstURL: firstPayload]), + manifestSigningPublicKey: signingPublicKey + ) + #expect(await restoredManager.snapshot().currentVersion == nil) + let reinstalled = try await restoredManager.install( + manifest( + version: "1.0.0", + assets: [asset(url: firstURL, payload: firstPayload)] + ) + ) + #expect(reinstalled.currentVersion == "1.0.0") + #expect(reinstalled.currentExecutableURL != nil) + } + @Test("update fetches signed metadata once and installs it") func updateUsesInjectedURLAndTransport() async throws { let root = try temporaryDirectory() diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..14f7388 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,62 @@ +**Comparison Target** + +- Source visual truth: `docs/design-qa/gloss-settings-reference-dark.png` +- Implementation: `docs/design-qa/gloss-settings-dark.jpeg` +- Full-view comparison: `docs/design-qa/settings-comparison-dark.png` +- Additional product view: `docs/design-qa/gloss-main-dark.jpeg` +- Viewport: native macOS settings window, 940 × 712 points, dark appearance +- Pixels and density: source 1680 × 1360 px; implementation 940 × 712 px; the combined comparison fits each capture into an equal 930 × 760 point region while preserving aspect ratio. The source includes a larger reference window and more settings content, so density and feature-count differences were treated as intentional rather than pixel-exact drift. +- State: General / 通用 selected, Dark / 深色 selected, launch at login off + +**Findings** + +- No actionable P0, P1, or P2 mismatches remain. +- The implementation intentionally uses Gloss-specific navigation and a quieter first-version content density. It preserves the reference's native split-view hierarchy, blue selected sidebar row, three-card appearance selector, restrained dark surfaces, and bottom-aligned version label. + +**Required Fidelity Surfaces** + +- Fonts and typography: native San Francisco system typography, weights, line heights, and label hierarchy are consistent with the macOS reference. Chinese labels remain legible without truncation. +- Spacing and layout rhythm: the 220-point sidebar, page insets, card gaps, radii, and vertical rhythm preserve the reference's structure while leaving room for future settings rows. +- Colors and visual tokens: semantic AppKit colors respond to System/Light/Dark; the selected navigation row and appearance card use the current system accent color with sufficient contrast. +- Image quality and asset fidelity: Gloss uses its existing crisp brand mark and native SF Symbols. The reference's miniature window illustrations were not copied because they describe another product; native symbols are an intentional Gloss-specific substitution, not placeholders. +- Copy and content: labels describe Gloss capabilities directly. Browser translation is identified as the default capability and PDF translation as an optional component. + +**Full-view Evidence** + +- The side-by-side image shows the same major-region composition: fixed navigation sidebar, focused settings canvas, appearance section first, and selected state communicated with system blue. +- The implementation has no clipped controls, accidental overflow, illegible secondary text, or inconsistent alignment at the tested window size. + +**Focused Region Evidence** + +- A separate crop was not needed: the 3800 × 1640 combined image keeps the sidebar selection, appearance cards, selected border, labels, and launch-at-login row large enough for direct inspection. + +**Comparison History** + +- Earlier pass — P2: the selected sidebar row did not have a persistent blue fill, and the chosen appearance card relied only on control state. Fix: added semantic accent fill to the active sidebar item and a 2-point accent border plus accent icon/title tint to the selected appearance card. +- Post-fix evidence: `docs/design-qa/gloss-settings-dark.jpeg` and `docs/design-qa/settings-comparison-dark.png` show both selected states clearly in the same dark appearance used by the reference. + +**Primary Interactions Tested** + +- Open Settings from the main-window gear button. +- Open Settings with Command–Comma. +- Switch among System, Light, and Dark appearance choices and observe the app update live. +- Navigate the settings sidebar, including PDF Component. +- Verify the PDF component install/start and uninstall entry states without confirming a destructive uninstall. + +**Open Questions** + +- None blocking the first release. + +**Implementation Checklist** + +- [x] Native split-view settings window +- [x] Independent theme control and System/Light/Dark settings +- [x] Gloss-specific functional grouping +- [x] Keyboard and button settings entry points +- [x] Stable selected and disabled states + +**Follow-up Polish** + +- P3: richer miniature previews could be added to the three appearance cards in a later release if stronger visual demonstration becomes more valuable than the current compact native treatment. + +final result: passed diff --git a/docs/design-qa/gloss-main-dark.jpeg b/docs/design-qa/gloss-main-dark.jpeg new file mode 100644 index 0000000..3750c37 Binary files /dev/null and b/docs/design-qa/gloss-main-dark.jpeg differ diff --git a/docs/design-qa/gloss-settings-dark.jpeg b/docs/design-qa/gloss-settings-dark.jpeg new file mode 100644 index 0000000..eb9f393 Binary files /dev/null and b/docs/design-qa/gloss-settings-dark.jpeg differ diff --git a/docs/design-qa/gloss-settings-reference-dark.png b/docs/design-qa/gloss-settings-reference-dark.png new file mode 100644 index 0000000..efbe913 Binary files /dev/null and b/docs/design-qa/gloss-settings-reference-dark.png differ diff --git a/docs/design-qa/settings-comparison-dark.png b/docs/design-qa/settings-comparison-dark.png new file mode 100644 index 0000000..5aec865 Binary files /dev/null and b/docs/design-qa/settings-comparison-dark.png differ diff --git a/docs/release-notes/v1.0.0.md b/docs/release-notes/v1.0.0.md new file mode 100644 index 0000000..391c259 --- /dev/null +++ b/docs/release-notes/v1.0.0.md @@ -0,0 +1,57 @@ +# Gloss 1.0.0 + +Gloss 1.0.0 establishes the first stable product structure: browser translation +is the default capability, while layout-preserving PDF translation is an +independently managed add-on. + +## New main panel + +- Replaces the configuration-heavy first screen with a focused native macOS + dashboard. +- Makes browser translation the primary card with connection, extension, and + pairing actions available immediately. +- Separates PDF translation into a visually secondary add-on card with clear + install, start, update, and open states. +- Adds dedicated top-right appearance and settings buttons. + +## Settings and appearance + +- Moves detailed configuration into a standalone split-view settings window. +- Organizes settings into General, Translation Engine, Browser, PDF Component, + Shortcuts and Services, and Updates and Diagnostics sections. +- Supports System, Light, and Dark appearances, persists the preference, and + follows live macOS appearance changes in System mode. +- Makes Settings available from the main panel, status menu, app menu, and the + standard `Command-,` shortcut. + +## On-demand PDF component + +- Keeps the approximately 230 MB signed BabelDOC runtime outside the initial + app package and installs it only when PDF translation is needed. +- Preserves signature verification, SHA-256 validation, atomic activation, + background update discovery, and automatic rollback after a failed update. +- Adds reclaimable-space estimation and a second confirmation before uninstall. +- Safely stops the resident service before removing current and rollback + runtimes, then returns the UI to the reinstallable Not Installed state. +- Never removes source PDFs, translated outputs, output-directory choices, or + Gloss preferences. + +## Lightweight application package + +- Release builds no longer embed the roughly 206 MB Codex app-server binary. +- Gloss resolves an existing Codex CLI from `GLOSS_CODEX_BIN`, `PATH`, or the + standard Homebrew locations and launches its `app-server` subcommand. +- PDF and Codex payloads are therefore both outside the initial Gloss app bundle. + +## Validation + +- The complete Swift package builds successfully. +- The complete automated test suite passes, including new uninstall and + reinstall coverage. +- The native main panel, split settings navigation, appearance switching, PDF + component state, and uninstall entry were exercised in a signed preview app. + +## Compatibility + +Gloss 1.0.0 requires macOS 14 or newer. Existing 0.8.x installations can update +without reinstalling browser or PDF preferences. diff --git a/docs/runtime-distribution.md b/docs/runtime-distribution.md index 962d260..07882d6 100644 --- a/docs/runtime-distribution.md +++ b/docs/runtime-distribution.md @@ -67,6 +67,9 @@ active runtime。 - `pin(version:)`:固定 runtime 版本;其他版本的 manifest 会被拒绝。 - `setChannel(_:)`:切换到已发布通道并清除旧 pin;当前仅允许 stable。 - `rollback()`:原子交换 current/previous,保留一次快速回滚能力。 +- `reclaimableBytes()`:在确认卸载前计算运行时、历史版本与残留缓存占用。 +- `uninstall()`:先停止服务,再将组件内容隔离并原子提交未安装状态;保留通道与 pin 偏好, + 不触碰源 PDF、翻译结果或输出目录,之后仍可重新安装。 默认数据目录是: