feat(updater): manual-download Beta opt-in in Settings → About (#332)
* feat(updater): manual-download Beta opt-in in Settings → About
接入 PR-B-2:客户端「加入 Beta 渠道」开关 + 文档完善。
为什么不走 auto-update 切 endpoint:
tauri-plugin-updater 2.10 的 Builder 不暴露 endpoints() 运行时 API,
endpoints 只能从 tauri.conf.json 编译期读,不能跑时切。继续用 plugin
自带 check 路径意味着 Beta 包会被推到所有用户,违背「Beta 不溢出正式
版」原则。fork plugin 或自实现 update flow ~500 行高风险,本轮采用
最稳路径:plugin 永远只读正式版 manifest(旧文件名),Beta 改成手动
下载——Settings 里展示最新 prerelease 的下载入口,用户点了跳浏览器。
Rust 端:
- types.rs:UpdateChannel enum (Stable | Beta) + UserPreferences::update_channel
字段;UserPreferencesWire / Default / Deserialize 全套兼容(旧 prefs 反序列化
默认 Stable,迁移无痛)。
- commands.rs:三个新命令——
- get_update_channel:读 prefs
- set_update_channel:写 prefs,复用 persist_settings + emit prefs:changed
- fetch_latest_beta_release:reqwest 调 GitHub Releases API,过滤
prerelease=true 且 tag 以 -beta-tauri 结尾的最新一条
- lib.rs:三个命令注册到 invoke_handler
前端:
- ipc.ts:UpdateChannel + LatestBetaRelease 类型 + 三个 invoke 包装
- SettingsModal.tsx:在 AboutMini 末尾加 BetaChannelControl 组件——
Toggle 切换渠道;切到 beta 时拉一次最新 prerelease 信息,展示
「最新 Beta:v1.x.x-beta-tauri」+「前往下载」按钮(openExternal 跳
GitHub release 页面)+ 重新查询按钮。切回 stable 立即清空状态。
- Settings.tsx:把 Toggle 组件 export,让 SettingsModal 复用同一开关样式。
- i18n 五个 locale(zh-CN / zh-TW / en / ja / ko)的 settings.about 都补
齐 betaChannel* 9 个 key(zh-CN 是 source of truth)。
文档:
- README.md / README.zh.md:把 PR-A 的「opt-in,尚未接入」占位换成
manual-download 路径的说明 + tag 约定。
- CLAUDE.md:把「Channel distribution (in progress)」整段重写为已接入
状态,含 tag 约定 / wiring 位置 / 新加的 release verification checklist
(5 条,发版后一定要走一遍,含 stable/beta 双向 cross-check 与 endpoint
采样验证)。
cargo check + npm run build 本地都过。本 PR base = chore/branching-workflow
(PR-A),等 PR-A merge 后会自动指向 beta;与 PR-B-1(CI 端)解耦,可独立
review / merge。
* docs(readme): refresh Status / Architecture / Maintainer release checklist
把 README 的产品/发版描述更新到当前真实状态,配合 PR-A / PR-B 系列工作流改造:
Status (v1.2) 章节:
- 把云端 ASR / 本地 ASR / 润色 provider 三类拆开列;本地 ASR 提及内置
Qwen3-ASR + Windows Foundry Local Whisper 两条线
- 加翻译热键、QA 浮窗、Beta 渠道 opt-in、Homebrew Cask 分发渠道四个要点
- 把「中英双语」改成多语言 UI(实际已支持 zh-CN / zh-TW / en / ja / ko)
Architecture 顶句:
- 由「Auto-updates ride on Tauri updater plugin」改为双渠道描述:Stable
(v<v>-tauri) 自动推送,Beta (v<v>-beta-tauri) 手动下载
Maintainer release checklist:
- 完全重写为双渠道流程:通用准备(5 处版本号 + 冒烟 + secrets)+
Beta 流程(在 beta 分支打 v<v>-beta-tauri tag)+ 正式版流程
(beta→main 后在 main 打 v<v>-tauri tag)
- 把 CI 的 Verify version sync 5-file 校验显式提一次,避免维护者
漏改 Cargo.lock / package-lock 撞 CI 红灯
- 末尾引用 CLAUDE.md 的 5 步 release verification checklist
Roadmap 章节保持原样(多个项目实际状态我无法在不引入回归的前提下
确认,避免误判)。
* docs: drop reference to non-existent bump-version.sh; describe manual bump
---------
Co-authored-by: baiqing <lbx12309@icloud.com>
This commit is contained in:
1 parent
ec9584ea0c
commit
fd79a051bd
14 files changed
+388
-21
No files matched your search
@@ -125,7 +125,27 @@ For maintainers:
|
||||
- Tag `v<version>-tauri` **on `main`**, not on `beta`. The release workflow keys off the tag, but tagging on `main` keeps the release commit linear with the always-releasable line.
|
||||
- Avoid direct pushes to `main` outside the `beta → main` merge — it bypasses the smoke-test gate.
|
||||
|
||||
Channel distribution (in progress): per-channel updater endpoints + a Settings toggle for "join Beta channel" are tracked as a separate change. Until that lands, every release reaches every user; treat all `v*-tauri` tags as Stable-grade for now and avoid tagging anything from `beta` directly.
|
||||
Channel distribution (manual-download opt-in):
|
||||
|
||||
- **Tag convention.** `v<v>-tauri` → Stable release (GitHub `prerelease=false`, manifest `latest-{tgt}-{arch}.json`). `v<v>-beta-tauri` → Beta release (GitHub `prerelease=true`, manifest `latest-{tgt}-{arch}-beta.json`). The two manifest filenames never overlap, so the in-app updater endpoint (which is fixed at compile time to the no-suffix file) cannot pick up Beta releases. This is the **physical isolation** that guarantees Beta does not leak to Stable users.
|
||||
- **Why not auto-update for Beta.** `tauri-plugin-updater` 2.10's `Builder` does not expose `endpoints()` — endpoints are only readable from `tauri.conf.json` at build time and cannot be swapped at runtime. Rather than fork the plugin or write a custom updater (~500 lines, high risk), Beta opt-in is implemented as a manual-download flow: Settings → About has a "Join Beta channel" toggle that, when on, calls `fetch_latest_beta_release` (GitHub Releases API), shows the latest pre-release tag, and routes the user to the GitHub release page to download manually. No installer signing/install path needs to be re-implemented.
|
||||
- **Where the wiring lives.** Pref field: `UserPreferences::update_channel` (`types.rs`). IPC: `get_update_channel` / `set_update_channel` / `fetch_latest_beta_release` (`commands.rs`). UI: `BetaChannelControl` inside `AboutMini` (`SettingsModal.tsx`). i18n: `settings.about.betaChannel*` keys.
|
||||
|
||||
### Release verification checklist (run after every tag push)
|
||||
|
||||
Run after pushing **either** a `v*-tauri` or `v*-beta-tauri` tag, **before** announcing the release:
|
||||
|
||||
1. **GitHub Release page** matches expectation:
|
||||
- Stable tag: not marked `Pre-release`, in the `releases/latest` redirect.
|
||||
- Beta tag: marked `Pre-release`, **not** the target of `releases/latest`.
|
||||
2. **Release assets** are channel-correct:
|
||||
- Stable tag includes `latest-{darwin,windows,linux}-{aarch64,x86_64}.json` + their `-mirror.json` siblings, **without** `-beta` suffix.
|
||||
- Beta tag includes `latest-{tgt}-{arch}-beta.json` + `-beta-mirror.json`, **without** the no-suffix variant.
|
||||
3. **Stable user flow.** Install a Stable build, click `Settings → About → Check for updates`. After a Stable release: should offer the new version. After a Beta release only: should report "up to date" (Beta must not appear).
|
||||
4. **Beta user flow.** In the same Stable build, toggle on `Join Beta channel`. The latest Beta tag should appear (or "no Beta released yet"). Clicking the download button should open the corresponding GitHub release page.
|
||||
5. **Updater endpoint sanity.** `curl -fsSL https://github.com/appergb/openless/releases/latest/download/latest-darwin-aarch64.json` returns the Stable manifest (version field matches the latest Stable tag). It should never return a Beta version, regardless of which tag was pushed most recently.
|
||||
|
||||
If any step fails, do not announce the release; investigate `release-tauri.yml` channel detection (`endsWith(github.ref_name, '-beta-tauri')`) and the `OPENLESS_RELEASE_CHANNEL` env propagation in the run logs.
|
||||
|
||||
## Repo conventions
|
||||
|
||||
|
||||
@@ -140,11 +140,16 @@ OpenLess does one thing: **turn speech into usable written text (especially AI p
|
||||
|
||||
- Tauri 2 + Rust backend + React/TS frontend. macOS 12+, Windows 10+.
|
||||
- **Toggle and push-to-talk** recording modes. `Esc` cancels at any phase, including polish/insert.
|
||||
- Volcengine streaming ASR + OpenAI Whisper-compatible batch ASR; Ark / DeepSeek / OpenAI-compatible chat-completions for polish.
|
||||
- 4 output modes: raw, light polish, structured (**AI prompt mode**), formal.
|
||||
- **Cloud ASR**: Volcengine streaming ASR, OpenAI Whisper-compatible batch ASR, Apple Speech (macOS).
|
||||
- **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `antirez/qwen-asr`; Windows Foundry Local Whisper variants.
|
||||
- **Polish providers**: Ark / DeepSeek / OpenAI / Doubao / Anthropic-compatible chat-completions, plus any OpenAI-compatible endpoint you bring.
|
||||
- 4 output modes: raw, light polish, structured (**AI prompt mode**), formal. Plus a **translation hotkey** that converts speech directly into the configured target language ([#43](../../issues/43)).
|
||||
- **Selection-ask QA panel** — separate hotkey opens a floating panel that runs voice Q&A against the highlighted text in any app ([#118](../../issues/118)).
|
||||
- Main window: Overview / History / Vocab / Style / Settings. Persistent tray icon. Mini status capsule floating on screen.
|
||||
- **Bilingual UI** — Settings → Language switches between 简体中文 and English (auto-detects on first launch).
|
||||
- **Multilingual UI** — Settings → Language switches between 简体中文 / 繁體中文 / English / 日本語 / 한국어 (auto-detects on first launch).
|
||||
- **In-app auto-update** — Settings → About → Check button; signed updater artifacts via Tauri updater plugin.
|
||||
- **Beta channel (opt-in)** — Settings → About → Join Beta channel exposes the latest pre-release build for manual download; Beta releases never reach Stable users automatically (see [Contributing workflow](#contributing-workflow)).
|
||||
- **Distribution channels** — direct DMG/EXE from [Releases](../../releases), Homebrew Cask (`brew install --cask openless`), Windows installer.
|
||||
- **Single-instance lock** — prevents two OpenLess processes from racing the same hotkey edge.
|
||||
- Dictionary entries injected as Volcengine ASR `context.hotwords` and as semantic hints during polish; hits accumulate per session.
|
||||
- Platform-native global hotkey: CGEventTap on macOS, low-level keyboard hook (`WH_KEYBOARD_LL`) on Windows.
|
||||
@@ -237,7 +242,7 @@ Rules of thumb:
|
||||
- **Beta work must not leak to Stable.** `main` only receives merges from `beta`, performed by maintainers after a successful two-platform smoke build. No direct pushes to `main`.
|
||||
- **Stable releases are cut from `main`** by pushing a `v<version>-tauri` tag — see the maintainer release checklist below.
|
||||
|
||||
Beta release distribution (opt-in, not yet wired): Beta builds are intended for users who consciously join the Beta channel; the in-app updater currently treats every release as Stable, and a follow-up change will introduce per-channel updater endpoints + a Settings toggle.
|
||||
Beta release distribution (manual-download opt-in): the in-app updater always reads the Stable manifest, so regular users never get Beta builds via auto-update. Users who want to try Beta open **Settings → About**, flip "Join Beta channel", and download the latest Beta installer manually from the link the app fetches from GitHub. Tag convention: `v<version>-beta-tauri` produces the Beta release (marked GitHub pre-release; manifest written as `latest-{tgt}-{arch}-beta.json`); `v<version>-tauri` produces the Stable release. The two manifest files never overlap, so Stable users' updater feed cannot pick up Beta releases.
|
||||
|
||||
## Credentials
|
||||
|
||||
@@ -287,7 +292,7 @@ The main window is organized as Home / History / Dictionary / Settings. The Dict
|
||||
|
||||
## Architecture
|
||||
|
||||
The active implementation is Tauri 2 (`openless-all/app/`). Auto-updates ride on the Tauri updater plugin; signed updater artifacts are produced by CI on every `v*-tauri` tag.
|
||||
The active implementation is Tauri 2 (`openless-all/app/`). Releases are split into two channels: **Stable** (`v<v>-tauri` tag, auto-updated to all users) and **Beta** (`v<v>-beta-tauri` tag, GitHub pre-release, manually downloaded by opt-in users). Signed updater artifacts are produced by CI on every release tag.
|
||||
|
||||
**Tauri backend (Rust)** — each module depends only on `types.rs`:
|
||||
|
||||
@@ -323,10 +328,31 @@ Planned but not yet shipped:
|
||||
|
||||
## Maintainer release checklist
|
||||
|
||||
- Bump version in `openless-all/app/package.json`, `src-tauri/tauri.conf.json`, and `src-tauri/Cargo.toml`.
|
||||
OpenLess ships two release channels. Branch name = channel name (see [Contributing workflow](#contributing-workflow)).
|
||||
|
||||
### Common prep (both channels)
|
||||
|
||||
- Bump version in **all five** files: `package.json`, `package-lock.json` (root + nested entry under `packages.""`), `src-tauri/tauri.conf.json`, `src-tauri/Cargo.toml`, `Cargo.lock` (look for the `name = "openless"` block). CI's `Verify version sync` step will fail the build otherwise.
|
||||
- Run `INSTALL=0 ./scripts/build-mac.sh` and confirm the `.app` launches.
|
||||
- Verify on a clean macOS box: permission flow, hotkey, recording, ASR, polish, insertion, clipboard fallback.
|
||||
- Push a `v<version>-tauri` tag — CI builds + signs the updater artifacts and the macOS `.dmg` + Windows `.msi`. The updater needs `TAURI_SIGNING_PRIVATE_KEY` repo secret (matching the pubkey in `tauri.conf.json`).
|
||||
- Smoke-test on a clean machine: permission flow, hotkey, recording, ASR, polish, insertion, clipboard fallback.
|
||||
- Confirm `TAURI_SIGNING_PRIVATE_KEY` and (for macOS) the Apple signing/notarization secrets are set on the repo.
|
||||
|
||||
### Beta channel — `v<v>-beta-tauri`
|
||||
|
||||
1. Land changes onto the `beta` branch via PR review.
|
||||
2. Push tag **on `beta`**: `git tag v<v>-beta-tauri && git push origin v<v>-beta-tauri`.
|
||||
3. CI tags the GitHub Release as `Pre-release` and uploads only `latest-{tgt}-{arch}-beta.json` updater manifests. Stable users' `releases/latest` redirect is unaffected.
|
||||
4. Announce in the appropriate channel (issue thread, QQ group) that opt-in Beta users can grab it from Settings → About → Join Beta channel.
|
||||
|
||||
### Stable channel — `v<v>-tauri`
|
||||
|
||||
1. Merge `beta → main` after the Beta release has soaked enough (or run a final two-platform smoke build directly).
|
||||
2. Push tag **on `main`**: `git tag v<v>-tauri && git push origin v<v>-tauri`.
|
||||
3. CI publishes a normal GitHub Release and uploads `latest-{tgt}-{arch}.json` (no `-beta` suffix). All Stable users get the update through the in-app updater.
|
||||
|
||||
### Post-release verification (always run)
|
||||
|
||||
Run the 5-step checklist in [`CLAUDE.md` → Branch & release-channel workflow → Channel distribution](CLAUDE.md): page status (pre-release flag), asset filename channel-correctness, Stable user flow, Beta opt-in flow, raw endpoint sanity.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
|
||||
+34
-8
@@ -140,11 +140,16 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI
|
||||
|
||||
- Tauri 2 + Rust 后端 + React/TS 前端;macOS 12+,Windows 10+。
|
||||
- **切换式 + 按住说话** 双模式录音;任意阶段按 `Esc` 都能取消(包括润色 / 插入中)。
|
||||
- 接入火山引擎流式 ASR + OpenAI Whisper 兼容批式 ASR;Ark / DeepSeek / OpenAI 兼容 Chat Completions 进行润色。
|
||||
- 4 种输出模式:原文、轻度润色、清晰结构(**AI prompt 模式**)、正式表达。
|
||||
- **云端 ASR**:火山引擎流式 ASR、OpenAI Whisper 兼容批式 ASR、Apple Speech(macOS)。
|
||||
- **本地 ASR**:内置 Qwen3-ASR(0.6B / 1.7B),通过 vendored `antirez/qwen-asr` 链接;Windows 端支持 Foundry Local Whisper。
|
||||
- **润色 Provider**:Ark / DeepSeek / OpenAI / Doubao / Anthropic 兼容的 Chat Completions,以及任意 OpenAI 兼容的自定义 endpoint。
|
||||
- 4 种输出模式:原文、轻度润色、清晰结构(**AI prompt 模式**)、正式表达。另含**翻译热键**——按下后说一段话直接转成目标语言插入([#43](../../issues/43))。
|
||||
- **划词语音问答(QA)面板** — 独立热键打开浮窗,对当前选中文本发起语音 Q&A([#118](../../issues/118))。
|
||||
- 主窗口按「概览 / 历史 / 词典 / 风格 / 设置」组织;托盘图标常驻;浮动状态胶囊。
|
||||
- **中英双语 UI** — 设置 → 语言 切换简体中文 / English(首启按系统语言自动)。
|
||||
- **多语言 UI** — 设置 → 语言 切换简体中文 / 繁體中文 / English / 日本語 / 한국어(首启按系统语言自动)。
|
||||
- **应用内自动更新** — 设置 → 关于 → 检查按钮;CI 用 Tauri updater 签名 manifest,客户端校验后下载安装。
|
||||
- **Beta 渠道(opt-in)** — 设置 → 关于 → 加入 Beta 渠道,会显示最新 prerelease 的下载入口供手动安装;Beta 包永远不会被自动推送给正式版用户(详见 [贡献流程](#贡献流程))。
|
||||
- **分发渠道** — [Releases](../../releases) 直接下载 DMG/EXE,Homebrew Cask(`brew install --cask openless`),Windows 安装程序。
|
||||
- **单实例锁** — 防止两份 OpenLess 进程并存争抢同一热键边沿。
|
||||
- 词典条目作为 Volcengine ASR `context.hotwords` 注入 + 润色语义提示,每次会话累计命中数。
|
||||
- 平台原生全局快捷键:macOS 使用 CGEventTap,Windows 使用低层键盘钩子(`WH_KEYBOARD_LL`)。
|
||||
@@ -240,7 +245,7 @@ OpenLess 采用 **Beta / 正式版** 双渠道分支模型。
|
||||
- **Beta 不能溢出到正式版。** `main` 只接收来自 `beta` 的合并,由维护者在双端冒烟测试通过后执行;任何人不要直接 push `main`。
|
||||
- **正式版 Release 从 `main` 切出**,通过推送 `v<版本>-tauri` tag 触发,详见下方"维护者:发布检查"。
|
||||
|
||||
Beta 包的分发(opt-in,尚未接入):Beta 包面向主动加入 Beta 渠道的用户;当前 App 内 updater 把所有 release 都当作正式版处理,后续会在设置页加入"加入 Beta 渠道"开关,并把 updater endpoint 按渠道拆开。
|
||||
Beta 包的分发(手动下载式 opt-in):App 内自动更新永远只读正式版 manifest,普通用户拿不到 Beta 包。想试 Beta 的用户去 **设置 → 关于**,打开「加入 Beta 渠道」开关,App 会从 GitHub 拉到最新 Beta release 信息并展示下载入口,由用户手动下载安装。Tag 约定:`v<版本>-beta-tauri` 出 Beta release(GitHub 标 pre-release,manifest 写到 `latest-{tgt}-{arch}-beta.json`);`v<版本>-tauri` 出正式版。两组 manifest 文件名物理隔离,正式版用户的 endpoint 永远拿不到 Beta release。
|
||||
|
||||
## 凭据
|
||||
|
||||
@@ -290,7 +295,7 @@ OpenLess 的润色模型只做文本整理,不做问答、不做任务执行
|
||||
|
||||
## 架构概览
|
||||
|
||||
当前活跃实现是 Tauri 2(`openless-all/app/`)。自动更新走 Tauri updater 插件;CI 在每次 `v*-tauri` tag 自动签名 updater artifact + manifest。
|
||||
当前活跃实现是 Tauri 2(`openless-all/app/`)。Release 分两条渠道:**正式版**(`v<v>-tauri` tag,自动推送给所有用户)和 **Beta**(`v<v>-beta-tauri` tag,GitHub 标 pre-release,由 opt-in 用户手动下载)。CI 在每次 release tag 都签名 updater artifact + manifest。
|
||||
|
||||
**Tauri 后端(Rust)** — 各模块只依赖 `types.rs`:
|
||||
|
||||
@@ -326,10 +331,31 @@ commands.rs Tauri IPC 接口
|
||||
|
||||
## 维护者:发布检查
|
||||
|
||||
- 同步更新 `openless-all/app/package.json`、`src-tauri/tauri.conf.json`、`src-tauri/Cargo.toml` 中的版本号。
|
||||
OpenLess 走两条 release 渠道,分支名 = 渠道名(详见 [贡献流程](#贡献流程))。
|
||||
|
||||
### 通用准备(两条渠道都要做)
|
||||
|
||||
- 同步更新**全部 5 处**版本号:`package.json`、`package-lock.json`(root + `packages.""` 嵌套项)、`src-tauri/tauri.conf.json`、`src-tauri/Cargo.toml`、`Cargo.lock`(找 `name = "openless"` 的那段)。CI 的 `Verify version sync` 步骤会拦截不同步的版本号。
|
||||
- 运行 `INSTALL=0 ./scripts/build-mac.sh`,确认 `.app` 可启动。
|
||||
- 在干净 macOS 机器上验证:权限引导、快捷键、录音、ASR、润色、插入、剪贴板兜底。
|
||||
- 推送 `v<version>-tauri` tag → CI 构建并签名 updater artifact + macOS `.dmg` + Windows `.msi`。需要 repo secret `TAURI_SIGNING_PRIVATE_KEY`(对应 `tauri.conf.json` 中的 pubkey)才能签名 updater 包。
|
||||
- 在干净机器上跑冒烟:权限引导、快捷键、录音、ASR、润色、插入、剪贴板兜底。
|
||||
- 确认 repo 已配置 `TAURI_SIGNING_PRIVATE_KEY`,macOS 还需 Apple 签名/公证 secrets。
|
||||
|
||||
### Beta 渠道 — `v<v>-beta-tauri`
|
||||
|
||||
1. 通过 PR review 把改动落到 `beta` 分支。
|
||||
2. **在 `beta` 上**打 tag:`git tag v<v>-beta-tauri && git push origin v<v>-beta-tauri`。
|
||||
3. CI 把 GitHub Release 标为 `Pre-release`,只上传 `latest-{tgt}-{arch}-beta.json` updater manifest;正式版用户的 `releases/latest` 重定向不受影响。
|
||||
4. 在合适的频道(issue 帖子、QQ 群)通知 opt-in Beta 用户:可以从 设置 → 关于 → 加入 Beta 渠道 拿到最新版本下载入口。
|
||||
|
||||
### 正式版渠道 — `v<v>-tauri`
|
||||
|
||||
1. Beta 经过足够时间 soak(或直接做最终的双端冒烟)后把 `beta` 合到 `main`。
|
||||
2. **在 `main` 上**打 tag:`git tag v<v>-tauri && git push origin v<v>-tauri`。
|
||||
3. CI 发布常规 GitHub Release 并上传 `latest-{tgt}-{arch}.json`(不带 `-beta` 后缀)。所有正式版用户通过应用内 updater 收到此版本。
|
||||
|
||||
### 发版后验证(每次必跑)
|
||||
|
||||
走 [`CLAUDE.md` → Branch & release-channel workflow → Channel distribution](CLAUDE.md) 里的 5 步 checklist:页面状态(pre-release 标记)、资产文件名按渠道正确、正式版用户流、Beta opt-in 流、原始 endpoint 抽查。
|
||||
|
||||
## 致谢
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::recorder::{AudioConsumer, Recorder};
|
||||
use crate::types::{
|
||||
ChineseScriptPreference, ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry,
|
||||
HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding,
|
||||
UserPreferences, VocabPresetStore, WindowsImeStatus,
|
||||
UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus,
|
||||
};
|
||||
|
||||
type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
|
||||
@@ -158,6 +158,100 @@ pub fn set_settings(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─────────────────────────── release channel (Beta opt-in) ───────────────────────────
|
||||
//
|
||||
// 渠道偏好的写入路径跟 set_settings 复用 persist_settings:保持热键兜底归一化
|
||||
// 跟其他 prefs 写入一致,且写完后 emit "prefs:changed",让前端跨 webview 同步。
|
||||
//
|
||||
// 注意:plugin-updater 2.10 的 Builder 不暴露 endpoints() 运行时 API,因此切到 Beta
|
||||
// 渠道**不会**改变 in-app「检查更新」的行为——它仍然只看正式版 manifest。Beta 用户
|
||||
// 通过 `fetch_latest_beta_release` 获取最新 prerelease,由前端跳浏览器手动下载,
|
||||
// 物理隔离 Beta 包不会通过 auto-update 推到正式版用户。详见 PR-B-2 description 与
|
||||
// CLAUDE.md `Branch & release-channel workflow` 段落。
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_update_channel(coord: CoordinatorState<'_>) -> UpdateChannel {
|
||||
coord.prefs().get().update_channel
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_update_channel(
|
||||
coord: CoordinatorState<'_>,
|
||||
app: AppHandle,
|
||||
channel: UpdateChannel,
|
||||
) -> Result<(), String> {
|
||||
let mut prefs = coord.prefs().get();
|
||||
if prefs.update_channel == channel {
|
||||
return Ok(());
|
||||
}
|
||||
prefs.update_channel = channel;
|
||||
persist_settings(&*coord, prefs.clone())?;
|
||||
let _ = app.emit("prefs:changed", &prefs);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LatestBetaRelease {
|
||||
pub tag_name: String,
|
||||
pub html_url: String,
|
||||
pub published_at: String,
|
||||
}
|
||||
|
||||
/// 调 GitHub Releases API 拿最近 20 条 release,找出第一条 `prerelease=true` 且
|
||||
/// tag 以 `-beta-tauri` 结尾的。返回 `Ok(None)` 表示当前没有发布过 Beta 版。
|
||||
/// 网络/解析错误以 `Err(String)` 上报,让前端展示具体原因。
|
||||
#[tauri::command]
|
||||
pub async fn fetch_latest_beta_release() -> Result<Option<LatestBetaRelease>, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.user_agent(concat!("OpenLess/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.map_err(|e| format!("build http client: {e}"))?;
|
||||
let resp = client
|
||||
.get("https://api.github.com/repos/appergb/openless/releases?per_page=20")
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("fetch releases: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GitHub API status {}", resp.status()));
|
||||
}
|
||||
let releases: Vec<serde_json::Value> = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("parse releases json: {e}"))?;
|
||||
let latest = releases.into_iter().find(|r| {
|
||||
let is_pre = r
|
||||
.get("prerelease")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let tag_ok = r
|
||||
.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.ends_with("-beta-tauri"))
|
||||
.unwrap_or(false);
|
||||
is_pre && tag_ok
|
||||
});
|
||||
Ok(latest.map(|r| LatestBetaRelease {
|
||||
tag_name: r
|
||||
.get("tag_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
html_url: r
|
||||
.get("html_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
published_at: r
|
||||
.get("published_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus {
|
||||
coord.hotkey_status()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -45,6 +45,21 @@ pub enum OutputLanguagePreference {
|
||||
Ko,
|
||||
}
|
||||
|
||||
/// Auto-update 渠道。决定 Settings → 关于 里展示哪一类版本信息。
|
||||
/// `Stable` 沿用 `tauri-plugin-updater` 的默认 endpoints(即 `tauri.conf.json`
|
||||
/// 里的 `latest-{{target}}-{{arch}}.json`),与发版 pipeline 对齐。
|
||||
/// `Beta` 不动 plugin endpoints —— 只解锁 Settings 里"手动下载最新 Beta"的入口
|
||||
/// (fetch GitHub `prerelease` + 跳浏览器),物理隔离 Beta 包不会通过 auto-update
|
||||
/// 推到正式版用户。详见 README 的"Contributing workflow"和 CLAUDE.md 的
|
||||
/// `Branch & release-channel workflow` 段落。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UpdateChannel {
|
||||
#[default]
|
||||
Stable,
|
||||
Beta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum InsertStatus {
|
||||
@@ -195,6 +210,10 @@ pub struct UserPreferences {
|
||||
/// Windows Foundry Local Whisper 模型在 runtime 中保持加载多久。
|
||||
#[serde(default = "default_local_asr_keep_loaded_secs")]
|
||||
pub foundry_local_asr_keep_loaded_secs: u32,
|
||||
/// Auto-update 渠道偏好。stable = 跟正式版(默认);beta = Settings 里多
|
||||
/// 一个手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。
|
||||
#[serde(default)]
|
||||
pub update_channel: UpdateChannel,
|
||||
}
|
||||
|
||||
fn default_local_asr_model() -> String {
|
||||
@@ -264,6 +283,8 @@ struct UserPreferencesWire {
|
||||
foundry_local_asr_language_hint: String,
|
||||
#[serde(default = "default_local_asr_keep_loaded_secs")]
|
||||
foundry_local_asr_keep_loaded_secs: u32,
|
||||
#[serde(default)]
|
||||
update_channel: UpdateChannel,
|
||||
}
|
||||
|
||||
impl Default for UserPreferencesWire {
|
||||
@@ -298,6 +319,7 @@ impl Default for UserPreferencesWire {
|
||||
foundry_local_asr_model: prefs.foundry_local_asr_model,
|
||||
foundry_local_asr_language_hint: prefs.foundry_local_asr_language_hint,
|
||||
foundry_local_asr_keep_loaded_secs: prefs.foundry_local_asr_keep_loaded_secs,
|
||||
update_channel: prefs.update_channel,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,6 +368,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
||||
foundry_local_asr_model: wire.foundry_local_asr_model,
|
||||
foundry_local_asr_language_hint: wire.foundry_local_asr_language_hint,
|
||||
foundry_local_asr_keep_loaded_secs: wire.foundry_local_asr_keep_loaded_secs,
|
||||
update_channel: wire.update_channel,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -450,6 +473,7 @@ impl Default for UserPreferences {
|
||||
foundry_local_asr_model: default_foundry_local_asr_model(),
|
||||
foundry_local_asr_language_hint: String::new(),
|
||||
foundry_local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
|
||||
update_channel: UpdateChannel::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,18 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Icon } from './Icon';
|
||||
import { AboutUpdateControl, Settings as SettingsContent, type SettingsSectionId } from '../pages/Settings';
|
||||
import { AboutUpdateControl, Settings as SettingsContent, Toggle, type SettingsSectionId } from '../pages/Settings';
|
||||
import { Row } from './ui/Row';
|
||||
import { readFontScale, setFontScale, type FontScaleId } from '../lib/fontScale';
|
||||
import { exportErrorLog, openExternal } from '../lib/ipc';
|
||||
import {
|
||||
exportErrorLog,
|
||||
fetchLatestBetaRelease,
|
||||
getUpdateChannel,
|
||||
openExternal,
|
||||
setUpdateChannel,
|
||||
type LatestBetaRelease,
|
||||
type UpdateChannel,
|
||||
} from '../lib/ipc';
|
||||
import {
|
||||
FOLLOW_SYSTEM,
|
||||
getLocalePreference,
|
||||
@@ -375,10 +383,108 @@ function AboutMini() {
|
||||
<Row label={t('modal.about.privacy')} desc={t('modal.about.privacyDesc')}>
|
||||
<span style={{ fontSize: 11, padding: '3px 8px', borderRadius: 999, background: 'var(--ol-blue-soft)', color: 'var(--ol-blue)', fontWeight: 500 }}>{t('modal.about.localFirst')}</span>
|
||||
</Row>
|
||||
<BetaChannelControl />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Beta 渠道开关:物理隔离的 opt-in,不接 auto-update。
|
||||
// - 关闭状态 = 正式版渠道,默认行为,用户从「检查更新」拿正式 release
|
||||
// - 打开 = 用户主动加入 Beta;写 prefs(无重启需要)+ 拉一次最新 prerelease 信息
|
||||
// - 点"打开 GitHub"跳浏览器到具体的 Beta release 页面,用户手动下载安装
|
||||
// 不在 Beta 渠道时不发起 GitHub API 请求,避免空切换浪费配额。
|
||||
function BetaChannelControl() {
|
||||
const { t } = useTranslation();
|
||||
const [channel, setChannel] = useState<UpdateChannel>('stable');
|
||||
const [latest, setLatest] = useState<LatestBetaRelease | null>(null);
|
||||
const [status, setStatus] = useState<'idle' | 'fetching' | 'empty' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getUpdateChannel()
|
||||
.then(c => { if (!cancelled) setChannel(c); })
|
||||
.catch(() => { /* fall back to stable already in initial state */ });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const fetchBeta = async () => {
|
||||
setStatus('fetching');
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const info = await fetchLatestBetaRelease();
|
||||
if (info == null) {
|
||||
setLatest(null);
|
||||
setStatus('empty');
|
||||
} else {
|
||||
setLatest(info);
|
||||
setStatus('idle');
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const onToggle = async (next: boolean) => {
|
||||
const target: UpdateChannel = next ? 'beta' : 'stable';
|
||||
setChannel(target);
|
||||
try {
|
||||
await setUpdateChannel(target);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
// 写入失败时回滚 UI,免得用户以为切成功了。
|
||||
setChannel(target === 'beta' ? 'stable' : 'beta');
|
||||
return;
|
||||
}
|
||||
if (target === 'beta') {
|
||||
void fetchBeta();
|
||||
} else {
|
||||
setLatest(null);
|
||||
setStatus('idle');
|
||||
setErrorMessage('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Row label={t('settings.about.betaChannelLabel')} desc={t('settings.about.betaChannelDesc')}>
|
||||
<Toggle on={channel === 'beta'} onToggle={onToggle} />
|
||||
</Row>
|
||||
{channel === 'beta' && (
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-3)', marginTop: -4, marginBottom: 12, lineHeight: 1.6 }}>
|
||||
{status === 'fetching' && <span>{t('settings.about.betaChannelFetching')}</span>}
|
||||
{status === 'empty' && <span>{t('settings.about.betaChannelNoBeta')}</span>}
|
||||
{status === 'error' && (
|
||||
<span style={{ color: 'var(--ol-err)' }} title={errorMessage}>
|
||||
{t('settings.about.betaChannelFetchError')}
|
||||
</span>
|
||||
)}
|
||||
{status === 'idle' && latest && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>
|
||||
{t('settings.about.betaChannelLatestPrefix')} <code style={{ fontFamily: 'var(--ol-font-mono)' }}>{latest.tagName}</code>
|
||||
</span>
|
||||
<button style={btnGhost} onClick={() => openExternal(latest.htmlUrl)}>
|
||||
{t('settings.about.betaChannelDownloadBtn')}
|
||||
</button>
|
||||
<button style={btnGhost} onClick={fetchBeta} title={t('settings.about.betaChannelRefresh')}>
|
||||
<Icon name="refresh" size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === 'idle' && !latest && (
|
||||
<button style={btnGhost} onClick={fetchBeta}>
|
||||
{t('settings.about.betaChannelFetchBtn')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const btnGhost: CSSProperties = {
|
||||
padding: '5px 10px', fontSize: 12, borderRadius: 6,
|
||||
border: '0.5px solid var(--ol-line-strong)',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '新しいバージョンがあります',
|
||||
|
||||
@@ -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: '새 버전 발견',
|
||||
|
||||
@@ -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: '发现新版本',
|
||||
|
||||
@@ -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: '發現新版本',
|
||||
|
||||
@@ -152,6 +152,29 @@ export function setSettings(prefs: UserPreferences): Promise<void> {
|
||||
return invokeOrMock('set_settings', { prefs }, () => undefined);
|
||||
}
|
||||
|
||||
// ── Release channel (Beta opt-in) ──────────────────────────────────────
|
||||
// 渠道偏好与 fetch_latest_beta_release 实际效果只在 Tauri runtime 内有意义;
|
||||
// 浏览器开发模式下走 mock,避免设置页因 invoke 抛错而白屏。
|
||||
export type UpdateChannel = 'stable' | 'beta';
|
||||
|
||||
export interface LatestBetaRelease {
|
||||
tagName: string;
|
||||
htmlUrl: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
export function getUpdateChannel(): Promise<UpdateChannel> {
|
||||
return invokeOrMock('get_update_channel', undefined, () => 'stable' as UpdateChannel);
|
||||
}
|
||||
|
||||
export function setUpdateChannel(channel: UpdateChannel): Promise<void> {
|
||||
return invokeOrMock('set_update_channel', { channel }, () => undefined);
|
||||
}
|
||||
|
||||
export function fetchLatestBetaRelease(): Promise<LatestBetaRelease | null> {
|
||||
return invokeOrMock('fetch_latest_beta_release', undefined, () => null);
|
||||
}
|
||||
|
||||
export function getHotkeyStatus(): Promise<HotkeyStatus> {
|
||||
return invokeOrMock('get_hotkey_status', undefined, () => mockHotkeyStatus);
|
||||
}
|
||||
|
||||
@@ -1010,7 +1010,7 @@ function AutostartRow() {
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => void }) {
|
||||
export function Toggle({ on, onToggle }: { on: boolean; onToggle?: (next: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onToggle?.(!on)}
|
||||
|
||||
Reference in new issue
Block a user