feat(updater): manual-download Beta opt-in in Settings → About (#332)

* feat(updater): manual-download Beta opt-in in Settings → About

接入 PR-B-2:客户端「加入 Beta 渠道」开关 + 文档完善。

为什么不走 auto-update 切 endpoint:
  tauri-plugin-updater 2.10 的 Builder 不暴露 endpoints() 运行时 API,
  endpoints 只能从 tauri.conf.json 编译期读,不能跑时切。继续用 plugin
  自带 check 路径意味着 Beta 包会被推到所有用户,违背「Beta 不溢出正式
  版」原则。fork plugin 或自实现 update flow ~500 行高风险,本轮采用
  最稳路径:plugin 永远只读正式版 manifest(旧文件名),Beta 改成手动
  下载——Settings 里展示最新 prerelease 的下载入口,用户点了跳浏览器。

Rust 端:
- types.rs:UpdateChannel enum (Stable | Beta) + UserPreferences::update_channel
  字段;UserPreferencesWire / Default / Deserialize 全套兼容(旧 prefs 反序列化
  默认 Stable,迁移无痛)。
- commands.rs:三个新命令——
  - get_update_channel:读 prefs
  - set_update_channel:写 prefs,复用 persist_settings + emit prefs:changed
  - fetch_latest_beta_release:reqwest 调 GitHub Releases API,过滤
    prerelease=true 且 tag 以 -beta-tauri 结尾的最新一条
- lib.rs:三个命令注册到 invoke_handler

前端:
- ipc.ts:UpdateChannel + LatestBetaRelease 类型 + 三个 invoke 包装
- SettingsModal.tsx:在 AboutMini 末尾加 BetaChannelControl 组件——
  Toggle 切换渠道;切到 beta 时拉一次最新 prerelease 信息,展示
  「最新 Beta:v1.x.x-beta-tauri」+「前往下载」按钮(openExternal 跳
  GitHub release 页面)+ 重新查询按钮。切回 stable 立即清空状态。
- Settings.tsx:把 Toggle 组件 export,让 SettingsModal 复用同一开关样式。
- i18n 五个 locale(zh-CN / zh-TW / en / ja / ko)的 settings.about 都补
  齐 betaChannel* 9 个 key(zh-CN 是 source of truth)。

文档:
- README.md / README.zh.md:把 PR-A 的「opt-in,尚未接入」占位换成
  manual-download 路径的说明 + tag 约定。
- CLAUDE.md:把「Channel distribution (in progress)」整段重写为已接入
  状态,含 tag 约定 / wiring 位置 / 新加的 release verification checklist
  (5 条,发版后一定要走一遍,含 stable/beta 双向 cross-check 与 endpoint
  采样验证)。

cargo check + npm run build 本地都过。本 PR base = chore/branching-workflow
(PR-A),等 PR-A merge 后会自动指向 beta;与 PR-B-1(CI 端)解耦,可独立
review / merge。

* docs(readme): refresh Status / Architecture / Maintainer release checklist

把 README 的产品/发版描述更新到当前真实状态,配合 PR-A / PR-B 系列工作流改造:

Status (v1.2) 章节:
- 把云端 ASR / 本地 ASR / 润色 provider 三类拆开列;本地 ASR 提及内置
  Qwen3-ASR + Windows Foundry Local Whisper 两条线
- 加翻译热键、QA 浮窗、Beta 渠道 opt-in、Homebrew Cask 分发渠道四个要点
- 把「中英双语」改成多语言 UI(实际已支持 zh-CN / zh-TW / en / ja / ko)

Architecture 顶句:
- 由「Auto-updates ride on Tauri updater plugin」改为双渠道描述:Stable
  (v<v>-tauri) 自动推送,Beta (v<v>-beta-tauri) 手动下载

Maintainer release checklist:
- 完全重写为双渠道流程:通用准备(5 处版本号 + 冒烟 + secrets)+
  Beta 流程(在 beta 分支打 v<v>-beta-tauri tag)+ 正式版流程
  (beta→main 后在 main 打 v<v>-tauri tag)
- 把 CI 的 Verify version sync 5-file 校验显式提一次,避免维护者
  漏改 Cargo.lock / package-lock 撞 CI 红灯
- 末尾引用 CLAUDE.md 的 5 步 release verification checklist

Roadmap 章节保持原样(多个项目实际状态我无法在不引入回归的前提下
确认,避免误判)。

* docs: drop reference to non-existent bump-version.sh; describe manual bump

---------

Co-authored-by: baiqing <lbx12309@icloud.com>
This commit is contained in:
TRIPandbaiqing authored and GitHub committed 2026-05-07 22:00:33 +08:00
1 parent ec9584ea0c
commit fd79a051bd
14 files changed
+388 -21

No files matched your search

+21 -1
View File
@@ -125,7 +125,27 @@ For maintainers:
- Tag `v<version>-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 (in progress): per-channel updater endpoints + a Settings toggle for "join Beta channel" are tracked as a separate change. Until that lands, every release reaches every user; treat all `v*-tauri` tags as Stable-grade for now and avoid tagging anything from `beta` directly.
Channel distribution (manual-download opt-in):
- **Tag convention.** `v<v>-tauri` → Stable release (GitHub `prerelease=false`, manifest `latest-{tgt}-{arch}.json`). `v<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
+34 -8
View File
@@ -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.
@@ -237,7 +242,7 @@ Rules of thumb:
- **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<version>-tauri` tag — see the maintainer release checklist below.
Beta release distribution (opt-in, not yet wired): Beta builds are intended for users who consciously join the Beta channel; the in-app updater currently treats every release as Stable, and a follow-up change will introduce per-channel updater endpoints + a Settings toggle.
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<version>-beta-tauri` produces the Beta release (marked GitHub pre-release; manifest written as `latest-{tgt}-{arch}-beta.json`); `v<version>-tauri` produces the Stable release. The two manifest files never overlap, so Stable users' updater feed cannot pick up Beta releases.
## Credentials
@@ -287,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<v>-tauri` tag, auto-updated to all users) and **Beta** (`v<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`:
@@ -323,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<version>-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<v>-beta-tauri`
1. Land changes onto the `beta` branch via PR review.
2. Push tag **on `beta`**: `git tag v<v>-beta-tauri && git push origin v<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<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<v>-tauri && git push origin v<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
+34 -8
View File
@@ -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`)。
@@ -240,7 +245,7 @@ OpenLess 采用 **Beta / 正式版** 双渠道分支模型。
- **Beta 不能溢出到正式版。** `main` 只接收来自 `beta` 的合并,由维护者在双端冒烟测试通过后执行;任何人不要直接 push `main`。
- **正式版 Release 从 `main` 切出**,通过推送 `v<版本>-tauri` tag 触发,详见下方"维护者:发布检查"。
Beta 包的分发(opt-in,尚未接入):Beta 包面向主动加入 Beta 渠道的用户;当前 App 内 updater 把所有 release 都当作正式版处理,后续会在设置页加入"加入 Beta 渠道"开关,并把 updater endpoint 按渠道拆开。
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。
## 凭据
@@ -290,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<v>-tauri` tag,自动推送给所有用户)和 **Beta**(`v<v>-beta-tauri` tag,GitHub 标 pre-release,由 opt-in 用户手动下载)。CI 在每次 release tag 都签名 updater artifact + manifest。
**Tauri 后端(Rust)** — 各模块只依赖 `types.rs`:
@@ -326,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<version>-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<v>-beta-tauri`
1. 通过 PR review 把改动落到 `beta` 分支。
2. **在 `beta` 上**打 tag:`git tag v<v>-beta-tauri && git push origin v<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<v>-tauri`
1. Beta 经过足够时间 soak(或直接做最终的双端冒烟)后把 `beta` 合到 `main`。
2. **在 `main` 上**打 tag:`git tag v<v>-tauri && git push origin v<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 抽查。
## 致谢
+95 -1
View File
@@ -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<Coordinator>>;
@@ -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<Option<LatestBetaRelease>, 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<serde_json::Value> = 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()
+3
View File
@@ -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,
+24
View File
@@ -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(),
}
}
}
@@ -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() {
<Row label={t('modal.about.privacy')} desc={t('modal.about.privacyDesc')}>
<span style={{ fontSize: 11, padding: '3px 8px', borderRadius: 999, background: 'var(--ol-blue-soft)', color: 'var(--ol-blue)', fontWeight: 500 }}>{t('modal.about.localFirst')}</span>
</Row>
<BetaChannelControl />
</div>
);
}
// Beta 渠道开关:物理隔离的 opt-in,不接 auto-update。
// - 关闭状态 = 正式版渠道,默认行为,用户从「检查更新」拿正式 release
// - 打开 = 用户主动加入 Beta;写 prefs(无重启需要)+ 拉一次最新 prerelease 信息
// - 点"打开 GitHub"跳浏览器到具体的 Beta release 页面,用户手动下载安装
// 不在 Beta 渠道时不发起 GitHub API 请求,避免空切换浪费配额。
function BetaChannelControl() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannel>('stable');
const [latest, setLatest] = useState<LatestBetaRelease | null>(null);
const [status, setStatus] = useState<'idle' | 'fetching' | 'empty' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState<string>('');
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 (
<>
<Row label={t('settings.about.betaChannelLabel')} desc={t('settings.about.betaChannelDesc')}>
<Toggle on={channel === 'beta'} onToggle={onToggle} />
</Row>
{channel === 'beta' && (
<div style={{ fontSize: 11, color: 'var(--ol-ink-3)', marginTop: -4, marginBottom: 12, lineHeight: 1.6 }}>
{status === 'fetching' && <span>{t('settings.about.betaChannelFetching')}</span>}
{status === 'empty' && <span>{t('settings.about.betaChannelNoBeta')}</span>}
{status === 'error' && (
<span style={{ color: 'var(--ol-err)' }} title={errorMessage}>
{t('settings.about.betaChannelFetchError')}
</span>
)}
{status === 'idle' && latest && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>
{t('settings.about.betaChannelLatestPrefix')} <code style={{ fontFamily: 'var(--ol-font-mono)' }}>{latest.tagName}</code>
</span>
<button style={btnGhost} onClick={() => openExternal(latest.htmlUrl)}>
{t('settings.about.betaChannelDownloadBtn')}
</button>
<button style={btnGhost} onClick={fetchBeta} title={t('settings.about.betaChannelRefresh')}>
<Icon name="refresh" size={12} />
</button>
</div>
)}
{status === 'idle' && !latest && (
<button style={btnGhost} onClick={fetchBeta}>
{t('settings.about.betaChannelFetchBtn')}
</button>
)}
</div>
)}
</>
);
}
const btnGhost: CSSProperties = {
padding: '5px 10px', fontSize: 12, borderRadius: 6,
border: '0.5px solid var(--ol-line-strong)',
+9
View File
@@ -457,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',
+9
View File
@@ -459,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: '新しいバージョンがあります',
+9
View File
@@ -459,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: '새 버전 발견',
+9
View File
@@ -455,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: '发现新版本',
+9
View File
@@ -457,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: '發現新版本',
+23
View File
@@ -152,6 +152,29 @@ export function setSettings(prefs: UserPreferences): Promise<void> {
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<UpdateChannel> {
return invokeOrMock('get_update_channel', undefined, () => 'stable' as UpdateChannel);
}
export function setUpdateChannel(channel: UpdateChannel): Promise<void> {
return invokeOrMock('set_update_channel', { channel }, () => undefined);
}
export function fetchLatestBetaRelease(): Promise<LatestBetaRelease | null> {
return invokeOrMock('fetch_latest_beta_release', undefined, () => null);
}
export function getHotkeyStatus(): Promise<HotkeyStatus> {
return invokeOrMock('get_hotkey_status', undefined, () => mockHotkeyStatus);
}
+1 -1
View File
@@ -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 (
<button
onClick={() => onToggle?.(!on)}