diff --git a/CLAUDE.md b/CLAUDE.md index 5864b121..3fd6da4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,7 +125,27 @@ For maintainers: - 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 (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-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 diff --git a/README.md b/README.md index 84c334fe..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. @@ -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-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-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 @@ -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-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`: @@ -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-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 3200da61..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`)。 @@ -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-tauri` tag,自动推送给所有用户)和 **Beta**(`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-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/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 30843cd9..3261ef57 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -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', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 27790919..3c3d56c6 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -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: '新しいバージョンがあります', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index a517a03f..dbff73b1 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -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: '새 버전 발견', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index db99cebc..c667a714 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -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: '发现新版本', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index e457f8cb..d9ed08a8 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -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: '發現新版本', 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 (