diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 345036d4..8de2b1f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,9 +8,9 @@ name: CI on: push: - branches: [main, dev] + branches: [main, beta] pull_request: - branches: [main, dev] + branches: [main, beta] jobs: cross-platform: diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index c5f57892..00138246 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -24,6 +24,14 @@ jobs: build: permissions: contents: write + # 渠道由 tag 后缀决定: + # v-beta-tauri → beta 渠道(GitHub Release 标 prerelease, + # manifest 文件名带 -beta 后缀,正式版用户的 endpoint 拿不到) + # v-tauri → stable 渠道(正式版,文件名沿用旧约定,向后兼容) + # workflow_dispatch / 非 tag 触发时 github.ref_name 不是 tag 字符串, + # endsWith 返回 false,回退为 stable,不改变现有 dispatch 行为。 + env: + OPENLESS_RELEASE_CHANNEL: ${{ endsWith(github.ref_name, '-beta-tauri') && 'beta' || 'stable' }} strategy: fail-fast: false matrix: @@ -370,6 +378,8 @@ jobs: OPENLESS_UPDATE_ARCH: ${{ matrix.updater-arch }} OPENLESS_UPDATE_REPO: appergb/openless OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com + # beta 渠道时输出 latest-{tgt}-{arch}-beta.json,stable 沿用旧文件名。 + OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }} run: node scripts/write-updater-manifest.mjs # ── 收集产物 ── @@ -413,8 +423,7 @@ jobs: path: | openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz.sig - openless-all/app/src-tauri/target/release/bundle/latest-darwin-${{ matrix.updater-arch }}.json - openless-all/app/src-tauri/target/release/bundle/latest-darwin-${{ matrix.updater-arch }}-mirror.json + openless-all/app/src-tauri/target/release/bundle/latest-darwin-${{ matrix.updater-arch }}*.json if-no-files-found: error - name: Upload Windows artifacts @@ -435,8 +444,7 @@ jobs: path: | openless-all/app/src-tauri/target/release/bundle/nsis/*.exe.sig openless-all/app/src-tauri/target/release/bundle/msi/*.msi.sig - openless-all/app/src-tauri/target/release/bundle/latest-windows-x86_64.json - openless-all/app/src-tauri/target/release/bundle/latest-windows-x86_64-mirror.json + openless-all/app/src-tauri/target/release/bundle/latest-windows-x86_64*.json if-no-files-found: error - name: Upload Linux artifacts @@ -457,8 +465,7 @@ jobs: name: openless-linux-x64-updater path: | openless-all/app/src-tauri/target/release/bundle/appimage/*.AppImage.sig - openless-all/app/src-tauri/target/release/bundle/latest-linux-x86_64.json - openless-all/app/src-tauri/target/release/bundle/latest-linux-x86_64-mirror.json + openless-all/app/src-tauri/target/release/bundle/latest-linux-x86_64*.json if-no-files-found: error # ── tag 推送时,同步上传到 GitHub Release ── @@ -469,7 +476,10 @@ jobs: tag_name: ${{ github.ref_name }} name: 'OpenLess ${{ github.ref_name }}' draft: false - prerelease: false + # beta 渠道的 release 必须标 prerelease=true:GitHub UI 会折叠它, + # 普通用户看不到;同时只上传 latest-*-beta.json,正式版用户的 + # endpoint(latest-*.json)永远不会被覆盖,保证 Beta 不溢出正式版。 + prerelease: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }} # Matrix jobs all upload assets to the same release. Generate notes once # so macOS, Windows, and Linux jobs do not duplicate the release body. generate_release_notes: ${{ matrix.updater-target == 'darwin' && matrix.updater-arch == 'aarch64' }} diff --git a/CLAUDE.md b/CLAUDE.md index d8a70984..3fd6da4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,48 @@ Push a `v*-tauri` tag → `.github/workflows/release-tauri.yml` builds macOS arm When bumping versions, update **both** `version` fields: `openless-all/app/package.json` and `openless-all/app/src-tauri/tauri.conf.json` (and `Cargo.toml`). +### Branch & release-channel workflow + +Two-channel branching. **Branch name = release channel.** + +- **`beta`** — **Beta channel** (开发版). Default branch, integration buffer. **All PRs target `beta`** (never `main`). Beta builds may exist but are not pushed to general users — only opt-in users on the Beta channel see them. +- **`main`** — **Stable channel** (正式版). Always-releasable. Updated only by `beta → main` merges performed by maintainers after a two-platform smoke build. Release tags `v-tauri` are pushed on `main` and trigger `release-tauri.yml` (tag-driven; unaffected by branch renames). + +Per-PR contract: + +- Run the change locally on your target platform before opening the PR (build green + manual verification of the affected feature). +- `pr-agent.yml` runs one AI review pass per PR — treat it as advisory, do not iterate on it. +- Keep AI rework rounds tight (1–2). If a fix resists, escalate to a human or restart with fresh context. +- `ci.yml` runs on push/PR for both `main` and `beta`; no extra wiring needed when adding new branches off `beta`. + +For maintainers: + +- Merge `beta → main` only after the two-platform (macOS + Windows) smoke build passes. **Beta work must not leak to Stable** — that gate exists for a reason. +- Tag `v-tauri` **on `main`**, not on `beta`. The release workflow keys off the tag, but tagging on `main` keeps the release commit linear with the always-releasable line. +- Avoid direct pushes to `main` outside the `beta → main` merge — it bypasses the smoke-test gate. + +Channel distribution (manual-download opt-in): + +- **Tag convention.** `v-tauri` → Stable release (GitHub `prerelease=false`, manifest `latest-{tgt}-{arch}.json`). `v-beta-tauri` → Beta release (GitHub `prerelease=true`, manifest `latest-{tgt}-{arch}-beta.json`). The two manifest filenames never overlap, so the in-app updater endpoint (which is fixed at compile time to the no-suffix file) cannot pick up Beta releases. This is the **physical isolation** that guarantees Beta does not leak to Stable users. +- **Why not auto-update for Beta.** `tauri-plugin-updater` 2.10's `Builder` does not expose `endpoints()` — endpoints are only readable from `tauri.conf.json` at build time and cannot be swapped at runtime. Rather than fork the plugin or write a custom updater (~500 lines, high risk), Beta opt-in is implemented as a manual-download flow: Settings → About has a "Join Beta channel" toggle that, when on, calls `fetch_latest_beta_release` (GitHub Releases API), shows the latest pre-release tag, and routes the user to the GitHub release page to download manually. No installer signing/install path needs to be re-implemented. +- **Where the wiring lives.** Pref field: `UserPreferences::update_channel` (`types.rs`). IPC: `get_update_channel` / `set_update_channel` / `fetch_latest_beta_release` (`commands.rs`). UI: `BetaChannelControl` inside `AboutMini` (`SettingsModal.tsx`). i18n: `settings.about.betaChannel*` keys. + +### Release verification checklist (run after every tag push) + +Run after pushing **either** a `v*-tauri` or `v*-beta-tauri` tag, **before** announcing the release: + +1. **GitHub Release page** matches expectation: + - Stable tag: not marked `Pre-release`, in the `releases/latest` redirect. + - Beta tag: marked `Pre-release`, **not** the target of `releases/latest`. +2. **Release assets** are channel-correct: + - Stable tag includes `latest-{darwin,windows,linux}-{aarch64,x86_64}.json` + their `-mirror.json` siblings, **without** `-beta` suffix. + - Beta tag includes `latest-{tgt}-{arch}-beta.json` + `-beta-mirror.json`, **without** the no-suffix variant. +3. **Stable user flow.** Install a Stable build, click `Settings → About → Check for updates`. After a Stable release: should offer the new version. After a Beta release only: should report "up to date" (Beta must not appear). +4. **Beta user flow.** In the same Stable build, toggle on `Join Beta channel`. The latest Beta tag should appear (or "no Beta released yet"). Clicking the download button should open the corresponding GitHub release page. +5. **Updater endpoint sanity.** `curl -fsSL https://github.com/appergb/openless/releases/latest/download/latest-darwin-aarch64.json` returns the Stable manifest (version field matches the latest Stable tag). It should never return a Beta version, regardless of which tag was pushed most recently. + +If any step fails, do not announce the release; investigate `release-tauri.yml` channel detection (`endsWith(github.ref_name, '-beta-tauri')`) and the `OPENLESS_RELEASE_CHANNEL` env propagation in the run logs. + ## Repo conventions - **Comments, log messages, user-facing strings, and most docs are in Simplified Chinese.** UI strings additionally route through `react-i18next` (`src/i18n/{zh-CN,en}.ts`) so we ship English alongside; `zh-CN.ts` is source of truth. diff --git a/README.md b/README.md index 442fccbd..b00d2fb3 100644 --- a/README.md +++ b/README.md @@ -140,11 +140,16 @@ OpenLess does one thing: **turn speech into usable written text (especially AI p - Tauri 2 + Rust backend + React/TS frontend. macOS 12+, Windows 10+. - **Toggle and push-to-talk** recording modes. `Esc` cancels at any phase, including polish/insert. -- Volcengine streaming ASR + OpenAI Whisper-compatible batch ASR; Ark / DeepSeek / OpenAI-compatible chat-completions for polish. -- 4 output modes: raw, light polish, structured (**AI prompt mode**), formal. +- **Cloud ASR**: Volcengine streaming ASR, OpenAI Whisper-compatible batch ASR, Apple Speech (macOS). +- **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `antirez/qwen-asr`; Windows Foundry Local Whisper variants. +- **Polish providers**: Ark / DeepSeek / OpenAI / Doubao / Anthropic-compatible chat-completions, plus any OpenAI-compatible endpoint you bring. +- 4 output modes: raw, light polish, structured (**AI prompt mode**), formal. Plus a **translation hotkey** that converts speech directly into the configured target language ([#43](../../issues/43)). +- **Selection-ask QA panel** — separate hotkey opens a floating panel that runs voice Q&A against the highlighted text in any app ([#118](../../issues/118)). - Main window: Overview / History / Vocab / Style / Settings. Persistent tray icon. Mini status capsule floating on screen. -- **Bilingual UI** — Settings → Language switches between 简体中文 and English (auto-detects on first launch). +- **Multilingual UI** — Settings → Language switches between 简体中文 / 繁體中文 / English / 日本語 / 한국어 (auto-detects on first launch). - **In-app auto-update** — Settings → About → Check button; signed updater artifacts via Tauri updater plugin. +- **Beta channel (opt-in)** — Settings → About → Join Beta channel exposes the latest pre-release build for manual download; Beta releases never reach Stable users automatically (see [Contributing workflow](#contributing-workflow)). +- **Distribution channels** — direct DMG/EXE from [Releases](../../releases), Homebrew Cask (`brew install --cask openless`), Windows installer. - **Single-instance lock** — prevents two OpenLess processes from racing the same hotkey edge. - Dictionary entries injected as Volcengine ASR `context.hotwords` and as semantic hints during polish; hits accumulate per session. - Platform-native global hotkey: CGEventTap on macOS, low-level keyboard hook (`WH_KEYBOARD_LL`) on Windows. @@ -208,6 +213,37 @@ Logs: `~/Library/Logs/OpenLess/openless.log` (macOS) / `%LOCALAPPDATA%\OpenLess\ **Windows build** — see [`openless-all/README.md`](openless-all/README.md) for MSVC vs GNU/MinGW routes. +## Contributing workflow + +OpenLess uses a two-channel branching model. + +- **`beta`** — the **Beta channel**. Default branch and integration buffer; all in-progress development lands here. Beta builds may exist but are **not pushed to regular users** — they only reach people who explicitly opt into the Beta channel. +- **`main`** — the **Stable channel (正式版)**. Always-releasable. The build everyone gets by default. + +```text +your fork / topic branch + │ (test locally on your target platform first) + ▼ + PR → beta ← AI review (one pass, advisory only) + │ ← maintainer lightweight glance (scope, cross-module impact) + ▼ + merged into beta + │ (periodically, after a two-platform smoke build) + ▼ + merged into main → tag `v-tauri` → release CI → Stable users +``` + +Rules of thumb: + +- **Open PRs against `beta`, never against `main`.** GitHub already defaults the base branch to `beta` for new PRs. +- **Verify the change on your target platform before opening the PR** — build green is necessary, manual verification is required. +- **AI review runs once per PR and is advisory.** Don't loop on it. Apply your judgment. +- **Keep AI rework rounds tight (1–2).** If a fix resists, ask a human or restart with fresh context — multi-round AI back-and-forth tends to do more harm than good here. +- **Beta work must not leak to Stable.** `main` only receives merges from `beta`, performed by maintainers after a successful two-platform smoke build. No direct pushes to `main`. +- **Stable releases are cut from `main`** by pushing a `v-tauri` tag — see the maintainer release checklist below. + +Beta release distribution (manual-download opt-in): the in-app updater always reads the Stable manifest, so regular users never get Beta builds via auto-update. Users who want to try Beta open **Settings → About**, flip "Join Beta channel", and download the latest Beta installer manually from the link the app fetches from GitHub. Tag convention: `v-beta-tauri` produces the Beta release (marked GitHub pre-release; manifest written as `latest-{tgt}-{arch}-beta.json`); `v-tauri` produces the Stable release. The two manifest files never overlap, so Stable users' updater feed cannot pick up Beta releases. + ## Credentials Credentials live in the OS credential vault (service = `com.openless.app`): macOS Keychain, Windows Credential Manager, or Linux keyring. A legacy plaintext JSON file is read only as a migration source and removed after a successful vault write: @@ -256,7 +292,7 @@ The main window is organized as Home / History / Dictionary / Settings. The Dict ## Architecture -The active implementation is Tauri 2 (`openless-all/app/`). Auto-updates ride on the Tauri updater plugin; signed updater artifacts are produced by CI on every `v*-tauri` tag. +The active implementation is Tauri 2 (`openless-all/app/`). Releases are split into two channels: **Stable** (`v-tauri` tag, auto-updated to all users) and **Beta** (`v-beta-tauri` tag, GitHub pre-release, manually downloaded by opt-in users). Signed updater artifacts are produced by CI on every release tag. **Tauri backend (Rust)** — each module depends only on `types.rs`: @@ -292,10 +328,31 @@ Planned but not yet shipped: ## Maintainer release checklist -- Bump version in `openless-all/app/package.json`, `src-tauri/tauri.conf.json`, and `src-tauri/Cargo.toml`. +OpenLess ships two release channels. Branch name = channel name (see [Contributing workflow](#contributing-workflow)). + +### Common prep (both channels) + +- Bump version in **all five** files: `package.json`, `package-lock.json` (root + nested entry under `packages.""`), `src-tauri/tauri.conf.json`, `src-tauri/Cargo.toml`, `Cargo.lock` (look for the `name = "openless"` block). CI's `Verify version sync` step will fail the build otherwise. - Run `INSTALL=0 ./scripts/build-mac.sh` and confirm the `.app` launches. -- Verify on a clean macOS box: permission flow, hotkey, recording, ASR, polish, insertion, clipboard fallback. -- Push a `v-tauri` tag — CI builds + signs the updater artifacts and the macOS `.dmg` + Windows `.msi`. The updater needs `TAURI_SIGNING_PRIVATE_KEY` repo secret (matching the pubkey in `tauri.conf.json`). +- Smoke-test on a clean machine: permission flow, hotkey, recording, ASR, polish, insertion, clipboard fallback. +- Confirm `TAURI_SIGNING_PRIVATE_KEY` and (for macOS) the Apple signing/notarization secrets are set on the repo. + +### Beta channel — `v-beta-tauri` + +1. Land changes onto the `beta` branch via PR review. +2. Push tag **on `beta`**: `git tag v-beta-tauri && git push origin v-beta-tauri`. +3. CI tags the GitHub Release as `Pre-release` and uploads only `latest-{tgt}-{arch}-beta.json` updater manifests. Stable users' `releases/latest` redirect is unaffected. +4. Announce in the appropriate channel (issue thread, QQ group) that opt-in Beta users can grab it from Settings → About → Join Beta channel. + +### Stable channel — `v-tauri` + +1. Merge `beta → main` after the Beta release has soaked enough (or run a final two-platform smoke build directly). +2. Push tag **on `main`**: `git tag v-tauri && git push origin v-tauri`. +3. CI publishes a normal GitHub Release and uploads `latest-{tgt}-{arch}.json` (no `-beta` suffix). All Stable users get the update through the in-app updater. + +### Post-release verification (always run) + +Run the 5-step checklist in [`CLAUDE.md` → Branch & release-channel workflow → Channel distribution](CLAUDE.md): page status (pre-release flag), asset filename channel-correctness, Stable user flow, Beta opt-in flow, raw endpoint sanity. ## Acknowledgements diff --git a/README.zh.md b/README.zh.md index 694d4d0f..c72029b7 100644 --- a/README.zh.md +++ b/README.zh.md @@ -140,11 +140,16 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI - Tauri 2 + Rust 后端 + React/TS 前端;macOS 12+,Windows 10+。 - **切换式 + 按住说话** 双模式录音;任意阶段按 `Esc` 都能取消(包括润色 / 插入中)。 -- 接入火山引擎流式 ASR + OpenAI Whisper 兼容批式 ASR;Ark / DeepSeek / OpenAI 兼容 Chat Completions 进行润色。 -- 4 种输出模式:原文、轻度润色、清晰结构(**AI prompt 模式**)、正式表达。 +- **云端 ASR**:火山引擎流式 ASR、OpenAI Whisper 兼容批式 ASR、Apple Speech(macOS)。 +- **本地 ASR**:内置 Qwen3-ASR(0.6B / 1.7B),通过 vendored `antirez/qwen-asr` 链接;Windows 端支持 Foundry Local Whisper。 +- **润色 Provider**:Ark / DeepSeek / OpenAI / Doubao / Anthropic 兼容的 Chat Completions,以及任意 OpenAI 兼容的自定义 endpoint。 +- 4 种输出模式:原文、轻度润色、清晰结构(**AI prompt 模式**)、正式表达。另含**翻译热键**——按下后说一段话直接转成目标语言插入([#43](../../issues/43))。 +- **划词语音问答(QA)面板** — 独立热键打开浮窗,对当前选中文本发起语音 Q&A([#118](../../issues/118))。 - 主窗口按「概览 / 历史 / 词典 / 风格 / 设置」组织;托盘图标常驻;浮动状态胶囊。 -- **中英双语 UI** — 设置 → 语言 切换简体中文 / English(首启按系统语言自动)。 +- **多语言 UI** — 设置 → 语言 切换简体中文 / 繁體中文 / English / 日本語 / 한국어(首启按系统语言自动)。 - **应用内自动更新** — 设置 → 关于 → 检查按钮;CI 用 Tauri updater 签名 manifest,客户端校验后下载安装。 +- **Beta 渠道(opt-in)** — 设置 → 关于 → 加入 Beta 渠道,会显示最新 prerelease 的下载入口供手动安装;Beta 包永远不会被自动推送给正式版用户(详见 [贡献流程](#贡献流程))。 +- **分发渠道** — [Releases](../../releases) 直接下载 DMG/EXE,Homebrew Cask(`brew install --cask openless`),Windows 安装程序。 - **单实例锁** — 防止两份 OpenLess 进程并存争抢同一热键边沿。 - 词典条目作为 Volcengine ASR `context.hotwords` 注入 + 润色语义提示,每次会话累计命中数。 - 平台原生全局快捷键:macOS 使用 CGEventTap,Windows 使用低层键盘钩子(`WH_KEYBOARD_LL`)。 @@ -211,6 +216,37 @@ npm run build **Windows 构建** — MSVC 和 GNU/MinGW 两种路线详见 [`openless-all/README.md`](openless-all/README.md)。 +## 贡献流程 + +OpenLess 采用 **Beta / 正式版** 双渠道分支模型。 + +- **`beta`** —— **Beta 渠道(开发版)**。默认分支,也是日常集成缓冲区;所有进行中的开发都先落到这里。Beta 渠道可以直接出包,但**不会推送给普通用户**——只有主动切换到 Beta 渠道的用户才会拿到 Beta 包。 +- **`main`** —— **正式版渠道(Stable)**。始终保持可发布状态,普通用户默认拿到的就是这条线上的版本。 + +```text +你的 fork / topic 分支 + │ (先在目标平台本地自测通过) + ▼ + PR → beta ← AI Review(一次性,仅供参考) + │ ← 维护者轻量过一眼(范围、跨模块影响) + ▼ + 合入 beta + │ (定期或里程碑节点,跑双端冒烟测试) + ▼ + 合入 main → 打 tag `v<版本>-tauri` → Release CI → 推给正式版用户 +``` + +核心规则: + +- **PR 一律打到 `beta`,不要直接打到 `main`。** GitHub 上新建 PR 的 base 已默认是 `beta`。 +- **开 PR 前先在目标平台跑通功能** —— build 绿是底线,必须做人工验证。 +- **AI Review 每个 PR 只跑一轮,结果仅供参考。** 不要围绕它反复改,最终判断权在贡献者和维护者手里。 +- **AI 改 Review 意见控制在 1–2 轮。** 卡住了直接换人工或重开对话上下文,避免多轮 AI 越改越乱。 +- **Beta 不能溢出到正式版。** `main` 只接收来自 `beta` 的合并,由维护者在双端冒烟测试通过后执行;任何人不要直接 push `main`。 +- **正式版 Release 从 `main` 切出**,通过推送 `v<版本>-tauri` tag 触发,详见下方"维护者:发布检查"。 + +Beta 包的分发(手动下载式 opt-in):App 内自动更新永远只读正式版 manifest,普通用户拿不到 Beta 包。想试 Beta 的用户去 **设置 → 关于**,打开「加入 Beta 渠道」开关,App 会从 GitHub 拉到最新 Beta release 信息并展示下载入口,由用户手动下载安装。Tag 约定:`v<版本>-beta-tauri` 出 Beta release(GitHub 标 pre-release,manifest 写到 `latest-{tgt}-{arch}-beta.json`);`v<版本>-tauri` 出正式版。两组 manifest 文件名物理隔离,正式版用户的 endpoint 永远拿不到 Beta release。 + ## 凭据 凭据保存在系统凭据库(service = `com.openless.app`):macOS Keychain、Windows Credential Manager 或 Linux keyring。旧版明文 JSON 只作为迁移来源读取,成功写入系统凭据库后会被删除: @@ -259,7 +295,7 @@ OpenLess 的润色模型只做文本整理,不做问答、不做任务执行 ## 架构概览 -当前活跃实现是 Tauri 2(`openless-all/app/`)。自动更新走 Tauri updater 插件;CI 在每次 `v*-tauri` tag 自动签名 updater artifact + manifest。 +当前活跃实现是 Tauri 2(`openless-all/app/`)。Release 分两条渠道:**正式版**(`v-tauri` tag,自动推送给所有用户)和 **Beta**(`v-beta-tauri` tag,GitHub 标 pre-release,由 opt-in 用户手动下载)。CI 在每次 release tag 都签名 updater artifact + manifest。 **Tauri 后端(Rust)** — 各模块只依赖 `types.rs`: @@ -295,10 +331,31 @@ commands.rs Tauri IPC 接口 ## 维护者:发布检查 -- 同步更新 `openless-all/app/package.json`、`src-tauri/tauri.conf.json`、`src-tauri/Cargo.toml` 中的版本号。 +OpenLess 走两条 release 渠道,分支名 = 渠道名(详见 [贡献流程](#贡献流程))。 + +### 通用准备(两条渠道都要做) + +- 同步更新**全部 5 处**版本号:`package.json`、`package-lock.json`(root + `packages.""` 嵌套项)、`src-tauri/tauri.conf.json`、`src-tauri/Cargo.toml`、`Cargo.lock`(找 `name = "openless"` 的那段)。CI 的 `Verify version sync` 步骤会拦截不同步的版本号。 - 运行 `INSTALL=0 ./scripts/build-mac.sh`,确认 `.app` 可启动。 -- 在干净 macOS 机器上验证:权限引导、快捷键、录音、ASR、润色、插入、剪贴板兜底。 -- 推送 `v-tauri` tag → CI 构建并签名 updater artifact + macOS `.dmg` + Windows `.msi`。需要 repo secret `TAURI_SIGNING_PRIVATE_KEY`(对应 `tauri.conf.json` 中的 pubkey)才能签名 updater 包。 +- 在干净机器上跑冒烟:权限引导、快捷键、录音、ASR、润色、插入、剪贴板兜底。 +- 确认 repo 已配置 `TAURI_SIGNING_PRIVATE_KEY`,macOS 还需 Apple 签名/公证 secrets。 + +### Beta 渠道 — `v-beta-tauri` + +1. 通过 PR review 把改动落到 `beta` 分支。 +2. **在 `beta` 上**打 tag:`git tag v-beta-tauri && git push origin v-beta-tauri`。 +3. CI 把 GitHub Release 标为 `Pre-release`,只上传 `latest-{tgt}-{arch}-beta.json` updater manifest;正式版用户的 `releases/latest` 重定向不受影响。 +4. 在合适的频道(issue 帖子、QQ 群)通知 opt-in Beta 用户:可以从 设置 → 关于 → 加入 Beta 渠道 拿到最新版本下载入口。 + +### 正式版渠道 — `v-tauri` + +1. Beta 经过足够时间 soak(或直接做最终的双端冒烟)后把 `beta` 合到 `main`。 +2. **在 `main` 上**打 tag:`git tag v-tauri && git push origin v-tauri`。 +3. CI 发布常规 GitHub Release 并上传 `latest-{tgt}-{arch}.json`(不带 `-beta` 后缀)。所有正式版用户通过应用内 updater 收到此版本。 + +### 发版后验证(每次必跑) + +走 [`CLAUDE.md` → Branch & release-channel workflow → Channel distribution](CLAUDE.md) 里的 5 步 checklist:页面状态(pre-release 标记)、资产文件名按渠道正确、正式版用户流、Beta opt-in 流、原始 endpoint 抽查。 ## 致谢 diff --git a/openless-all/app/scripts/write-updater-manifest.mjs b/openless-all/app/scripts/write-updater-manifest.mjs index eb0e7762..171cd35a 100755 --- a/openless-all/app/scripts/write-updater-manifest.mjs +++ b/openless-all/app/scripts/write-updater-manifest.mjs @@ -8,6 +8,13 @@ const target = process.env.OPENLESS_UPDATE_TARGET; const arch = process.env.OPENLESS_UPDATE_ARCH; const repo = process.env.OPENLESS_UPDATE_REPO || 'appergb/openless'; const mirrorBaseUrl = process.env.OPENLESS_UPDATE_MIRROR_BASE_URL || 'https://fastgit.cc/https://github.com'; +// 渠道决定 manifest 文件名后缀:stable → 旧文件名(向后兼容);beta → 加 -beta 后缀, +// 让 stable 用户的 endpoint 永远拿不到 beta 包。空 / 未设置 = stable。 +const rawChannel = (process.env.OPENLESS_RELEASE_CHANNEL || 'stable').toLowerCase(); +if (rawChannel !== 'stable' && rawChannel !== 'beta') { + throw new Error(`Invalid OPENLESS_RELEASE_CHANNEL: "${rawChannel}" (expected "stable" or "beta")`); +} +const channelSuffix = rawChannel === 'beta' ? '-beta' : ''; if (!target || !arch) { throw new Error('OPENLESS_UPDATE_TARGET and OPENLESS_UPDATE_ARCH are required'); @@ -55,8 +62,8 @@ if (!existsSync(signaturePath)) { } const assetName = basename(artifact); -const manifestName = `latest-${target}-${arch}.json`; -const mirrorManifestName = `latest-${target}-${arch}-mirror.json`; +const manifestName = `latest-${target}-${arch}${channelSuffix}.json`; +const mirrorManifestName = `latest-${target}-${arch}${channelSuffix}-mirror.json`; const githubAssetUrl = `https://github.com/${repo}/releases/latest/download/${assetName}`; const mirrorAssetUrl = `${mirrorBaseUrl.replace(/\/$/, '')}/${repo}/releases/latest/download/${assetName}`; const manifest = { diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index a7dd8ece..136c0340 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -21,7 +21,7 @@ use crate::recorder::{AudioConsumer, Recorder}; use crate::types::{ ChineseScriptPreference, ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding, - UserPreferences, VocabPresetStore, WindowsImeStatus, + UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus, }; type CoordinatorState<'a> = State<'a, Arc>; @@ -158,6 +158,100 @@ pub fn set_settings( Ok(()) } +// ─────────────────────────── release channel (Beta opt-in) ─────────────────────────── +// +// 渠道偏好的写入路径跟 set_settings 复用 persist_settings:保持热键兜底归一化 +// 跟其他 prefs 写入一致,且写完后 emit "prefs:changed",让前端跨 webview 同步。 +// +// 注意:plugin-updater 2.10 的 Builder 不暴露 endpoints() 运行时 API,因此切到 Beta +// 渠道**不会**改变 in-app「检查更新」的行为——它仍然只看正式版 manifest。Beta 用户 +// 通过 `fetch_latest_beta_release` 获取最新 prerelease,由前端跳浏览器手动下载, +// 物理隔离 Beta 包不会通过 auto-update 推到正式版用户。详见 PR-B-2 description 与 +// CLAUDE.md `Branch & release-channel workflow` 段落。 + +#[tauri::command] +pub fn get_update_channel(coord: CoordinatorState<'_>) -> UpdateChannel { + coord.prefs().get().update_channel +} + +#[tauri::command] +pub fn set_update_channel( + coord: CoordinatorState<'_>, + app: AppHandle, + channel: UpdateChannel, +) -> Result<(), String> { + let mut prefs = coord.prefs().get(); + if prefs.update_channel == channel { + return Ok(()); + } + prefs.update_channel = channel; + persist_settings(&*coord, prefs.clone())?; + let _ = app.emit("prefs:changed", &prefs); + Ok(()) +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LatestBetaRelease { + pub tag_name: String, + pub html_url: String, + pub published_at: String, +} + +/// 调 GitHub Releases API 拿最近 20 条 release,找出第一条 `prerelease=true` 且 +/// tag 以 `-beta-tauri` 结尾的。返回 `Ok(None)` 表示当前没有发布过 Beta 版。 +/// 网络/解析错误以 `Err(String)` 上报,让前端展示具体原因。 +#[tauri::command] +pub async fn fetch_latest_beta_release() -> Result, String> { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .user_agent(concat!("OpenLess/", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(|e| format!("build http client: {e}"))?; + let resp = client + .get("https://api.github.com/repos/appergb/openless/releases?per_page=20") + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| format!("fetch releases: {e}"))?; + if !resp.status().is_success() { + return Err(format!("GitHub API status {}", resp.status())); + } + let releases: Vec = resp + .json() + .await + .map_err(|e| format!("parse releases json: {e}"))?; + let latest = releases.into_iter().find(|r| { + let is_pre = r + .get("prerelease") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let tag_ok = r + .get("tag_name") + .and_then(|v| v.as_str()) + .map(|s| s.ends_with("-beta-tauri")) + .unwrap_or(false); + is_pre && tag_ok + }); + Ok(latest.map(|r| LatestBetaRelease { + tag_name: r + .get("tag_name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + html_url: r + .get("html_url") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + published_at: r + .get("published_at") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(), + })) +} + #[tauri::command] pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus { coord.hotkey_status() diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 3d79a17d..fe4e9708 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -222,6 +222,9 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::get_settings, commands::set_settings, + commands::get_update_channel, + commands::set_update_channel, + commands::fetch_latest_beta_release, commands::get_hotkey_status, commands::get_hotkey_capability, commands::set_shortcut_recording_active, diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 5c4b8ca3..80db02a8 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -45,6 +45,21 @@ pub enum OutputLanguagePreference { Ko, } +/// Auto-update 渠道。决定 Settings → 关于 里展示哪一类版本信息。 +/// `Stable` 沿用 `tauri-plugin-updater` 的默认 endpoints(即 `tauri.conf.json` +/// 里的 `latest-{{target}}-{{arch}}.json`),与发版 pipeline 对齐。 +/// `Beta` 不动 plugin endpoints —— 只解锁 Settings 里"手动下载最新 Beta"的入口 +/// (fetch GitHub `prerelease` + 跳浏览器),物理隔离 Beta 包不会通过 auto-update +/// 推到正式版用户。详见 README 的"Contributing workflow"和 CLAUDE.md 的 +/// `Branch & release-channel workflow` 段落。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum UpdateChannel { + #[default] + Stable, + Beta, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum InsertStatus { @@ -195,6 +210,10 @@ pub struct UserPreferences { /// Windows Foundry Local Whisper 模型在 runtime 中保持加载多久。 #[serde(default = "default_local_asr_keep_loaded_secs")] pub foundry_local_asr_keep_loaded_secs: u32, + /// Auto-update 渠道偏好。stable = 跟正式版(默认);beta = Settings 里多 + /// 一个手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。 + #[serde(default)] + pub update_channel: UpdateChannel, } fn default_local_asr_model() -> String { @@ -264,6 +283,8 @@ struct UserPreferencesWire { foundry_local_asr_language_hint: String, #[serde(default = "default_local_asr_keep_loaded_secs")] foundry_local_asr_keep_loaded_secs: u32, + #[serde(default)] + update_channel: UpdateChannel, } impl Default for UserPreferencesWire { @@ -298,6 +319,7 @@ impl Default for UserPreferencesWire { foundry_local_asr_model: prefs.foundry_local_asr_model, foundry_local_asr_language_hint: prefs.foundry_local_asr_language_hint, foundry_local_asr_keep_loaded_secs: prefs.foundry_local_asr_keep_loaded_secs, + update_channel: prefs.update_channel, } } } @@ -346,6 +368,7 @@ impl<'de> Deserialize<'de> for UserPreferences { foundry_local_asr_model: wire.foundry_local_asr_model, foundry_local_asr_language_hint: wire.foundry_local_asr_language_hint, foundry_local_asr_keep_loaded_secs: wire.foundry_local_asr_keep_loaded_secs, + update_channel: wire.update_channel, }) } } @@ -450,6 +473,7 @@ impl Default for UserPreferences { foundry_local_asr_model: default_foundry_local_asr_model(), foundry_local_asr_language_hint: String::new(), foundry_local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(), + update_channel: UpdateChannel::default(), } } } diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index bfaa23de..62e98e85 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -7,10 +7,18 @@ import { useEffect, useRef, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from './Icon'; -import { AboutUpdateControl, Settings as SettingsContent, type SettingsSectionId } from '../pages/Settings'; +import { AboutUpdateControl, Settings as SettingsContent, Toggle, type SettingsSectionId } from '../pages/Settings'; import { Row } from './ui/Row'; import { readFontScale, setFontScale, type FontScaleId } from '../lib/fontScale'; -import { exportErrorLog, openExternal } from '../lib/ipc'; +import { + exportErrorLog, + fetchLatestBetaRelease, + getUpdateChannel, + openExternal, + setUpdateChannel, + type LatestBetaRelease, + type UpdateChannel, +} from '../lib/ipc'; import { FOLLOW_SYSTEM, getLocalePreference, @@ -375,10 +383,108 @@ function AboutMini() { {t('modal.about.localFirst')} + ); } +// Beta 渠道开关:物理隔离的 opt-in,不接 auto-update。 +// - 关闭状态 = 正式版渠道,默认行为,用户从「检查更新」拿正式 release +// - 打开 = 用户主动加入 Beta;写 prefs(无重启需要)+ 拉一次最新 prerelease 信息 +// - 点"打开 GitHub"跳浏览器到具体的 Beta release 页面,用户手动下载安装 +// 不在 Beta 渠道时不发起 GitHub API 请求,避免空切换浪费配额。 +function BetaChannelControl() { + const { t } = useTranslation(); + const [channel, setChannel] = useState('stable'); + const [latest, setLatest] = useState(null); + const [status, setStatus] = useState<'idle' | 'fetching' | 'empty' | 'error'>('idle'); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + let cancelled = false; + void getUpdateChannel() + .then(c => { if (!cancelled) setChannel(c); }) + .catch(() => { /* fall back to stable already in initial state */ }); + return () => { cancelled = true; }; + }, []); + + const fetchBeta = async () => { + setStatus('fetching'); + setErrorMessage(''); + try { + const info = await fetchLatestBetaRelease(); + if (info == null) { + setLatest(null); + setStatus('empty'); + } else { + setLatest(info); + setStatus('idle'); + } + } catch (err) { + setStatus('error'); + setErrorMessage(err instanceof Error ? err.message : String(err)); + } + }; + + const onToggle = async (next: boolean) => { + const target: UpdateChannel = next ? 'beta' : 'stable'; + setChannel(target); + try { + await setUpdateChannel(target); + } catch (err) { + setStatus('error'); + setErrorMessage(err instanceof Error ? err.message : String(err)); + // 写入失败时回滚 UI,免得用户以为切成功了。 + setChannel(target === 'beta' ? 'stable' : 'beta'); + return; + } + if (target === 'beta') { + void fetchBeta(); + } else { + setLatest(null); + setStatus('idle'); + setErrorMessage(''); + } + }; + + return ( + <> + + + + {channel === 'beta' && ( +
+ {status === 'fetching' && {t('settings.about.betaChannelFetching')}} + {status === 'empty' && {t('settings.about.betaChannelNoBeta')}} + {status === 'error' && ( + + {t('settings.about.betaChannelFetchError')} + + )} + {status === 'idle' && latest && ( +
+ + {t('settings.about.betaChannelLatestPrefix')} {latest.tagName} + + + +
+ )} + {status === 'idle' && !latest && ( + + )} +
+ )} + + ); +} + const btnGhost: CSSProperties = { padding: '5px 10px', fontSize: 12, borderRadius: 6, border: '0.5px solid var(--ol-line-strong)', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 52c156ad..3261ef57 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -208,6 +208,12 @@ export const en: typeof zhCN = { desc: 'Pick a language and pressing Shift any time during recording will translate the transcript into it before insertion. Pick "Disabled" to make Shift a no-op (regular polish runs instead).', disabled: 'Disabled (Shift does nothing)', }, + save: { + workingFailed: 'Failed to save working languages. Please try again.', + targetFailed: 'Failed to save translation target. Please try again.', + hotkeyRegisterFailed: 'Failed to register the translation shortcut. The preference was not saved.', + hotkeySaveFailed: 'Failed to save the translation shortcut. Please try again.', + }, howto: { title: 'How to use', step1: 'Place the text cursor in another app (Notes, mail, chat — anything with a text field).', @@ -451,6 +457,15 @@ export const en: typeof zhCN = { privacy: 'Privacy', privacyDesc: 'All transcripts stay on this device. Cloud APIs are only called for real-time transcription/polish; no recordings are retained.', localFirst: 'Local-first', + betaChannelLabel: 'Join Beta channel', + betaChannelDesc: 'Stable channel is the default. Enabling this exposes a manual download link to the latest Beta below; Beta builds are NOT pushed to regular users via auto-update — you have to download and install them yourself. May be unstable, only recommended if you are willing to test pre-release builds and report issues.', + betaChannelFetching: 'Fetching the latest Beta…', + betaChannelFetchBtn: 'Look up latest Beta', + betaChannelLatestPrefix: 'Latest Beta:', + betaChannelDownloadBtn: 'Open download page', + betaChannelRefresh: 'Refresh', + betaChannelNoBeta: 'No Beta release has been published yet.', + betaChannelFetchError: 'Failed to fetch Beta release info. Please try again later.', updateDialog: { available: { title: 'Update available', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b11bf9f2..3c3d56c6 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -210,6 +210,12 @@ export const ja: typeof zhCN = { desc: 'いずれかの言語を選択すると、録音中の任意のタイミングで Shift を 1 回押すだけで、停止後に転写をその言語に翻訳してカーソル位置に入力します。「無効」を選ぶと Shift は何の効果もなく、通常の整文パイプラインに進みます。', disabled: '無効(Shift で翻訳を発動しない)', }, + save: { + workingFailed: '作業言語の保存に失敗しました。もう一度お試しください。', + targetFailed: '翻訳ターゲット言語の保存に失敗しました。もう一度お試しください。', + hotkeyRegisterFailed: '翻訳ショートカットの登録に失敗しました。設定は保存されていません。', + hotkeySaveFailed: '翻訳ショートカットの保存に失敗しました。もう一度お試しください。', + }, howto: { title: '使い方', step1: '別のアプリの入力欄でカーソルにフォーカス(メモ、メール、チャットウィンドウなど)。', @@ -453,6 +459,15 @@ export const ja: typeof zhCN = { privacy: 'プライバシー', privacyDesc: 'すべての認識結果はローカルにのみ保存されます。クラウド API はリアルタイム転写と整文にのみ使用され、録音は保持されません。', localFirst: 'ローカル優先', + betaChannelLabel: 'Beta チャンネルに参加', + betaChannelDesc: '既定は正式版です。オンにすると最新 Beta 版のダウンロードリンクが下に表示されます。Beta ビルドは自動更新で配布されず、手動でダウンロード・インストールする必要があります。不安定な場合があるため、検証とフィードバック協力に同意するユーザーのみ推奨。', + betaChannelFetching: '最新 Beta 版を取得中…', + betaChannelFetchBtn: '最新 Beta を確認', + betaChannelLatestPrefix: '最新 Beta:', + betaChannelDownloadBtn: 'ダウンロード ページを開く', + betaChannelRefresh: '再取得', + betaChannelNoBeta: 'まだ Beta リリースは公開されていません。', + betaChannelFetchError: 'Beta バージョン情報の取得に失敗しました。後で再試行してください。', updateDialog: { available: { title: '新しいバージョンがあります', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 4d042413..dbff73b1 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -210,6 +210,12 @@ export const ko: typeof zhCN = { desc: '하나의 언어를 선택하면 녹음 중 임의의 시점에 Shift 를 한 번 눌러 정지 후 전사를 해당 언어로 번역하여 커서 위치에 삽입합니다. "비활성화"를 선택하면 Shift 는 효과가 없으며 일반 정리 파이프라인을 따릅니다.', disabled: '비활성화 (Shift 로 번역 발동 안 함)', }, + save: { + workingFailed: '작업 언어 저장에 실패했습니다. 다시 시도하세요.', + targetFailed: '번역 대상 언어 저장에 실패했습니다. 다시 시도하세요.', + hotkeyRegisterFailed: '번역 단축키 등록에 실패했습니다. 설정은 저장되지 않았습니다.', + hotkeySaveFailed: '번역 단축키 저장에 실패했습니다. 다시 시도하세요.', + }, howto: { title: '사용 방법', step1: '다른 앱의 입력 상자에서 커서에 포커스합니다(메모, 메일, 채팅 창 모두 가능).', @@ -453,6 +459,15 @@ export const ko: typeof zhCN = { privacy: '프라이버시', privacyDesc: '모든 인식 결과는 로컬에만 저장됩니다. 클라우드 API 는 실시간 전사와 정리에만 사용되며 녹음을 보관하지 않습니다.', localFirst: '로컬 우선', + betaChannelLabel: 'Beta 채널 참여', + betaChannelDesc: '기본은 정식 버전입니다. 켜면 최신 Beta 버전 다운로드 링크가 아래에 표시됩니다. Beta 빌드는 자동 업데이트로 배포되지 않으며 직접 다운로드해 설치해야 합니다. 불안정할 수 있으므로 사전 평가와 피드백을 제공할 의향이 있는 사용자에게만 권장합니다.', + betaChannelFetching: '최신 Beta 버전을 가져오는 중…', + betaChannelFetchBtn: '최신 Beta 확인', + betaChannelLatestPrefix: '최신 Beta:', + betaChannelDownloadBtn: '다운로드 페이지 열기', + betaChannelRefresh: '새로 고침', + betaChannelNoBeta: '아직 게시된 Beta 릴리스가 없습니다.', + betaChannelFetchError: 'Beta 릴리스 정보를 가져오지 못했습니다. 잠시 후 다시 시도하세요.', updateDialog: { available: { title: '새 버전 발견', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ea1653ea..c667a714 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -206,6 +206,12 @@ export const zhCN = { desc: '选了某个语言后,录音过程中任意时刻按一下 Shift,停止后就会把转写翻译成该语言再插入到光标位置。选「不启用」则 Shift 没有任何效果,走普通润色管线。', disabled: '不启用(Shift 按下不触发翻译)', }, + save: { + workingFailed: '工作语言保存失败,请重试。', + targetFailed: '翻译目标语言保存失败,请重试。', + hotkeyRegisterFailed: '翻译快捷键注册失败,未继续保存。', + hotkeySaveFailed: '翻译快捷键保存失败,请重试。', + }, howto: { title: '使用方法', step1: '在另一个 app 的输入框里聚焦光标(备忘录、邮件、聊天窗口都行)。', @@ -449,6 +455,15 @@ export const zhCN = { privacy: '隐私', privacyDesc: '所有识别结果仅保存在本机。云端 API 仅用于实时转写与润色,不会保留你的录音。', localFirst: '本地优先', + betaChannelLabel: '加入 Beta 渠道', + betaChannelDesc: '默认拿到的是正式版。打开后可在下方看到最新 Beta 版的下载入口;Beta 包不会通过自动更新推到普通用户,需要手动下载安装。可能不稳定,仅推荐愿意尝鲜与反馈问题的用户开启。', + betaChannelFetching: '正在获取最新 Beta 版本…', + betaChannelFetchBtn: '查询最新 Beta', + betaChannelLatestPrefix: '最新 Beta:', + betaChannelDownloadBtn: '前往下载', + betaChannelRefresh: '重新查询', + betaChannelNoBeta: '暂无已发布的 Beta 版。', + betaChannelFetchError: '获取 Beta 版本信息失败,请稍后重试。', updateDialog: { available: { title: '发现新版本', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 9a9f2a90..d9ed08a8 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -208,6 +208,12 @@ export const zhTW: typeof zhCN = { desc: '選了某個語言後,錄音過程中任意時刻按一下 Shift,停止後就會把轉寫翻譯成該語言再插入到光標位置。選「不啓用」則 Shift 沒有任何效果,走普通潤色管線。', disabled: '不啓用(Shift 按下不觸發翻譯)', }, + save: { + workingFailed: '工作語言保存失敗,請重試。', + targetFailed: '翻譯目標語言保存失敗,請重試。', + hotkeyRegisterFailed: '翻譯快捷鍵註冊失敗,未繼續保存。', + hotkeySaveFailed: '翻譯快捷鍵保存失敗,請重試。', + }, howto: { title: '使用方法', step1: '在另一個 app 的輸入框裏聚焦光標(備忘錄、郵件、聊天窗口都行)。', @@ -451,6 +457,15 @@ export const zhTW: typeof zhCN = { privacy: '隱私', privacyDesc: '所有識別結果僅保存在本機。雲端 API 僅用於實時轉寫與潤色,不會保留你的錄音。', localFirst: '本地優先', + betaChannelLabel: '加入 Beta 渠道', + betaChannelDesc: '預設拿到的是正式版。打開後可在下方看到最新 Beta 版的下載入口;Beta 包不會通過自動更新推送給普通用戶,需要手動下載安裝。可能不穩定,僅推薦願意嘗鮮並回報問題的用戶開啟。', + betaChannelFetching: '正在獲取最新 Beta 版本…', + betaChannelFetchBtn: '查詢最新 Beta', + betaChannelLatestPrefix: '最新 Beta:', + betaChannelDownloadBtn: '前往下載', + betaChannelRefresh: '重新查詢', + betaChannelNoBeta: '尚未發佈過 Beta 版。', + betaChannelFetchError: '獲取 Beta 版本資訊失敗,請稍後重試。', updateDialog: { available: { title: '發現新版本', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 3807725b..4d383f63 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -152,6 +152,29 @@ export function setSettings(prefs: UserPreferences): Promise { return invokeOrMock('set_settings', { prefs }, () => undefined); } +// ── Release channel (Beta opt-in) ────────────────────────────────────── +// 渠道偏好与 fetch_latest_beta_release 实际效果只在 Tauri runtime 内有意义; +// 浏览器开发模式下走 mock,避免设置页因 invoke 抛错而白屏。 +export type UpdateChannel = 'stable' | 'beta'; + +export interface LatestBetaRelease { + tagName: string; + htmlUrl: string; + publishedAt: string; +} + +export function getUpdateChannel(): Promise { + return invokeOrMock('get_update_channel', undefined, () => 'stable' as UpdateChannel); +} + +export function setUpdateChannel(channel: UpdateChannel): Promise { + return invokeOrMock('set_update_channel', { channel }, () => undefined); +} + +export function fetchLatestBetaRelease(): Promise { + return invokeOrMock('fetch_latest_beta_release', undefined, () => null); +} + export function getHotkeyStatus(): Promise { return invokeOrMock('get_hotkey_status', undefined, () => mockHotkeyStatus); } diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 2146c641..4c14a3a7 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -1010,7 +1010,7 @@ function AutostartRow() { ); } -function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => void }) { +export function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => void }) { return (