From eaf2cfb3d6c3ba1f79ad047cd32c2af40a023a1a Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 7 May 2026 21:36:57 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(updater):=20manual-download=20Beta=20o?= =?UTF-8?q?pt-in=20in=20Settings=20=E2=86=92=20About?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入 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。 --- CLAUDE.md | 22 +++- README.md | 2 +- README.zh.md | 2 +- openless-all/app/src-tauri/src/commands.rs | 96 ++++++++++++++- openless-all/app/src-tauri/src/lib.rs | 3 + openless-all/app/src-tauri/src/types.rs | 24 ++++ .../app/src/components/SettingsModal.tsx | 110 +++++++++++++++++- openless-all/app/src/i18n/en.ts | 9 ++ openless-all/app/src/i18n/ja.ts | 9 ++ openless-all/app/src/i18n/ko.ts | 9 ++ openless-all/app/src/i18n/zh-CN.ts | 9 ++ openless-all/app/src/i18n/zh-TW.ts | 9 ++ openless-all/app/src/lib/ipc.ts | 23 ++++ openless-all/app/src/pages/Settings.tsx | 2 +- 14 files changed, 322 insertions(+), 7 deletions(-) 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..3c32f9ef 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,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 diff --git a/README.zh.md b/README.zh.md index 3200da61..2e3a0ab4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -240,7 +240,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。 ## 凭据 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 (