From 829fea3c141c2d707ad89054531530c9af7b40f7 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Thu, 7 May 2026 20:58:08 +0800 Subject: [PATCH 1/4] Expose translation save failures before users trust stale settings Translation settings were optimistically updated without awaiting or surfacing write failures, so users could see enabled language and shortcut states that were never persisted. This keeps the fix inside the Translation page: all three save paths now show saving/saved/failed feedback, catch failed writes, and refresh preferences after failed preference persistence to roll back local state. Constraint: Follow the new beta-first PR workflow from #327. Rejected: Refactor HotkeySettingsContext queue behavior | issue explicitly excludes queue redesign. Confidence: high Scope-risk: narrow Directive: Keep Translation page save feedback local unless SelectionAsk receives the same treatment in its own issue. Tested: npm run build Tested: git diff --check Related: #314 --- openless-all/app/src/i18n/en.ts | 6 ++ openless-all/app/src/i18n/ja.ts | 6 ++ openless-all/app/src/i18n/ko.ts | 6 ++ openless-all/app/src/i18n/zh-CN.ts | 6 ++ openless-all/app/src/i18n/zh-TW.ts | 6 ++ openless-all/app/src/pages/Translation.tsx | 99 ++++++++++++++++++++-- 6 files changed, 120 insertions(+), 9 deletions(-) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 52c156ad..30843cd9 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).', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index b11bf9f2..27790919 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: '別のアプリの入力欄でカーソルにフォーカス(メモ、メール、チャットウィンドウなど)。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 4d042413..a517a03f 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: '다른 앱의 입력 상자에서 커서에 포커스합니다(메모, 메일, 채팅 창 모두 가능).', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ea1653ea..db99cebc 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 的输入框里聚焦光标(备忘录、邮件、聊天窗口都行)。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 9a9f2a90..e457f8cb 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 的輸入框裏聚焦光標(備忘錄、郵件、聊天窗口都行)。', diff --git a/openless-all/app/src/pages/Translation.tsx b/openless-all/app/src/pages/Translation.tsx index c8452d91..bea8deb4 100644 --- a/openless-all/app/src/pages/Translation.tsx +++ b/openless-all/app/src/pages/Translation.tsx @@ -4,6 +4,7 @@ // - 选一个翻译目标语言(单选;选"不启用"则 Shift 不触发翻译) // - 看完整使用说明(怎么触发、按钮位置、胶囊显示) +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Card, PageHeader } from './_atoms'; import { SUPPORTED_LANGUAGES } from '../lib/types'; @@ -11,10 +12,53 @@ import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { formatComboLabel } from '../lib/hotkey'; import { ShortcutRecorder } from '../components/ShortcutRecorder'; import { setTranslationHotkey } from '../lib/ipc'; +import type { UserPreferences, ShortcutBinding } from '../lib/types'; + +type SaveState = 'idle' | 'saving' | 'saved' | 'failed'; export function Translation() { const { t } = useTranslation(); - const { prefs, updatePrefs: savePrefs } = useHotkeySettings(); + const { prefs, refresh, updatePrefs: savePrefs } = useHotkeySettings(); + const [saveState, setSaveState] = useState('idle'); + const [saveMessage, setSaveMessage] = useState(''); + const statusTimer = useRef(null); + + useEffect(() => () => { + if (statusTimer.current !== null) window.clearTimeout(statusTimer.current); + }, []); + + const showSaveStatus = (state: SaveState, message: string, temporary = false) => { + if (statusTimer.current !== null) { + window.clearTimeout(statusTimer.current); + statusTimer.current = null; + } + setSaveState(state); + setSaveMessage(message); + if (temporary) { + statusTimer.current = window.setTimeout(() => { + setSaveState('idle'); + setSaveMessage(''); + statusTimer.current = null; + }, 1600); + } + }; + + const persistPrefs = async ( + resolveNext: (current: UserPreferences) => UserPreferences, + failureMessage: string, + ) => { + showSaveStatus('saving', t('common.saving')); + try { + await savePrefs(resolveNext); + showSaveStatus('saved', t('common.saved'), true); + } catch (error) { + console.error('[translation] failed to save preferences', error); + showSaveStatus('failed', failureMessage); + await refresh().catch(refreshError => { + console.warn('[translation] failed to refresh preferences after save error', refreshError); + }); + } + }; if (!prefs) { return ( @@ -31,16 +75,38 @@ export function Translation() { ); } - const onWorkingLanguagesChange = (workingLanguages: string[]) => - savePrefs({ ...prefs, workingLanguages }); + const onWorkingLanguagesChange = (workingLanguages: string[]) => { + void persistPrefs( + current => ({ ...current, workingLanguages }), + t('translation.save.workingFailed'), + ); + }; const toggleWorkingLanguage = (lang: string) => { const next = prefs.workingLanguages.includes(lang) ? prefs.workingLanguages.filter(l => l !== lang) : [...prefs.workingLanguages, lang]; onWorkingLanguagesChange(next); }; - const onTargetChange = (translationTargetLanguage: string) => - savePrefs({ ...prefs, translationTargetLanguage }); + const onTargetChange = (translationTargetLanguage: string) => { + void persistPrefs( + current => ({ ...current, translationTargetLanguage }), + t('translation.save.targetFailed'), + ); + }; + const onTranslationHotkeySave = async (binding: ShortcutBinding) => { + showSaveStatus('saving', t('common.saving')); + try { + await setTranslationHotkey(binding); + } catch (error) { + console.error('[translation] failed to register translation hotkey', error); + showSaveStatus('failed', t('translation.save.hotkeyRegisterFailed')); + return; + } + await persistPrefs( + current => ({ ...current, translationHotkey: binding }), + t('translation.save.hotkeySaveFailed'), + ); + }; const triggerLabel = formatComboLabel(prefs.dictationHotkey); const translationHotkeyLabel = formatComboLabel(prefs.translationHotkey); @@ -55,6 +121,24 @@ export function Translation() { />
+ {saveState !== 'idle' && ( +
+ {saveMessage} +
+ )} {/* 1. 工作语言 */} @@ -142,10 +226,7 @@ export function Translation() {
{ - await setTranslationHotkey(binding); - await savePrefs({ ...prefs, translationHotkey: binding }); - }} + onSave={onTranslationHotkeySave} /> From 2fe5f2e0d42066eb1e28ace0adf30b570c29b868 Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Thu, 7 May 2026 21:55:31 +0800 Subject: [PATCH 2/4] docs: introduce Beta/Stable two-channel branching workflow (#327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把分支模型从「dev/main」改造为「Beta/正式版」双渠道,渠道语义直接由分支名承载: - `beta` — Beta 渠道(开发版),默认分支、集成缓冲区,所有 PR 一律打到这里。 Beta 包不推送给普通用户,只面向主动加入 Beta 渠道的用户。 - `main` — 正式版渠道(Stable),始终可发布。仅由维护者在双端冒烟测试通过后从 beta 合入;普通用户默认拿到的就是这条线。 文档与 CI 改动: - README.md / README.zh.md:把「Contributing workflow / 贡献流程」章节 改写为 Beta/正式版双渠道,含分支流向 ASCII 图、6 条核心规则, 以及「Beta 不溢出正式版」的隔离要求。 - CLAUDE.md:把 `### Branch & contribution workflow` 改名为 `### Branch & release-channel workflow`,明确「分支名=渠道名」。 - ci.yml:触发分支从 [main, dev] 改为 [main, beta]。 仓库侧已同步: - 远端 dev 分支已重命名为 beta(git push origin dev:beta + 删除旧 dev) - GitHub 仓库默认分支已切到 beta - release-tauri.yml 用 v*-tauri tag 触发,跟分支无关,发版机制不受影响 Beta 包的 opt-in 分发机制(per-channel updater endpoint + 设置页开关) 将在独立 PR 中接入。在它落地之前,所有 v*-tauri tag 都按正式版对待, 不要从 beta 分支直接打 tag。 Co-authored-by: baiqing --- .github/workflows/ci.yml | 4 ++-- CLAUDE.md | 22 ++++++++++++++++++++++ README.md | 31 +++++++++++++++++++++++++++++++ README.zh.md | 31 +++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) 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/CLAUDE.md b/CLAUDE.md index d8a70984..5864b121 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -105,6 +105,28 @@ 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 (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. + ## 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..84c334fe 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,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 (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. + ## 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: diff --git a/README.zh.md b/README.zh.md index 694d4d0f..3200da61 100644 --- a/README.zh.md +++ b/README.zh.md @@ -211,6 +211,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,尚未接入):Beta 包面向主动加入 Beta 渠道的用户;当前 App 内 updater 把所有 release 都当作正式版处理,后续会在设置页加入"加入 Beta 渠道"开关,并把 updater endpoint 按渠道拆开。 + ## 凭据 凭据保存在系统凭据库(service = `com.openless.app`):macOS Keychain、Windows Credential Manager 或 Linux keyring。旧版明文 JSON 只作为迁移来源读取,成功写入系统凭据库后会被删除: From ec9584ea0c2386d5ecebeda24e08d9e98cf420c5 Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Thu, 7 May 2026 22:00:08 +0800 Subject: [PATCH 3/4] feat(release): split updater manifest by channel from tag suffix (#330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 release pipeline 根据 tag 名后缀区分 Beta / 正式版渠道,是 opt-in beta 机制的服务端基础。客户端读取的 endpoint 在 PR-B-2 接入。 约定: - `v-tauri` = 正式版渠道(stable) - `v-beta-tauri` = Beta 渠道(beta) release-tauri.yml 改动: - 在 jobs.build 顶层加 OPENLESS_RELEASE_CHANNEL job-env,由 endsWith(github.ref_name, '-beta-tauri') 决定;workflow_dispatch 与 非 tag 触发回退为 stable,不改变现有 dispatch 行为。 - Write updater manifest step 把 OPENLESS_RELEASE_CHANNEL 透传给脚本。 - Upload-artifact 的 manifest path 改为 `latest-{tgt}-{arch}*.json` glob, 让 stable 和 beta 两种文件名(或 mirror 变体)都能被收集,避免 if-no-files-found 在 beta release 时挂掉。 - Create release 的 prerelease 改为 `${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }}`:beta tag 自动标 GitHub prerelease(UI 折叠 + 默认不算 latest),stable 保持不变。 write-updater-manifest.mjs 改动: - 读取 OPENLESS_RELEASE_CHANNEL(默认 stable);非 stable / beta 时 fail-fast,避免拼错 env 静默走错路径。 - beta 渠道时 manifest 文件名加 `-beta` 后缀: latest-{tgt}-{arch}-beta.json latest-{tgt}-{arch}-beta-mirror.json 正式版用户的 endpoint 永远指向不带后缀的旧文件名,不会被 beta release 覆盖。 向后兼容: - 现有 v1.2.23-tauri 等 stable release 行为完全不变(文件名、prerelease=false 沿用旧逻辑)。 - workflow_dispatch 不打 tag 时也照常构建 + 不上传 release。 发版后 checklist(PR-B-2 文档段会写进 CLAUDE.md): - 推 v-beta-tauri tag → GitHub Release 应标 prerelease 且只含 latest-*-beta.json - 推 v-tauri tag → 正式 release 且只含 latest-*.json(不带后缀) Co-authored-by: baiqing --- .github/workflows/release-tauri.yml | 24 +++++++++++++------ .../app/scripts/write-updater-manifest.mjs | 11 +++++++-- 2 files changed, 26 insertions(+), 9 deletions(-) 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/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 = { From fd79a051bd704ed591e3f4e7172a1499f717a412 Mon Sep 17 00:00:00 2001 From: TRIP <1933142963@qq.com> Date: Thu, 7 May 2026 22:00:33 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(updater):=20manual-download=20Beta=20o?= =?UTF-8?q?pt-in=20in=20Settings=20=E2=86=92=20About=20(#332)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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-tauri) 自动推送,Beta (v-beta-tauri) 手动下载 Maintainer release checklist: - 完全重写为双渠道流程:通用准备(5 处版本号 + 冒烟 + secrets)+ Beta 流程(在 beta 分支打 v-beta-tauri tag)+ 正式版流程 (beta→main 后在 main 打 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 --- CLAUDE.md | 22 +++- README.md | 42 +++++-- README.zh.md | 42 +++++-- 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, 388 insertions(+), 21 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..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 (