fix(updater): Beta 构建默认跟随 Beta 渠道 (#1041)

* fix(updater): default beta builds to beta channel (#1034)

* fix(updater): persist channel switches atomically
This commit is contained in:
Chris233 authored and GitHub committed 2026-09-08 15:41:36 +08:00
1 parent af9eaa3e16
commit a9ee22f334
18 files changed
+269 -44

No files matched your search

@@ -566,6 +566,10 @@ pub struct UserPreferences {
/// 手动检查按钮显式指定 channel,与此 pref 解耦。
#[serde(default)]
pub update_channel: UpdateChannel,
/// 是否由用户明确选择过更新渠道。旧版默认会把 Stable 写入配置,单看
/// `update_channel` 无法区分默认值与主动切换;历史 Beta 则必然来自用户 opt-in。
#[serde(default)]
pub update_channel_explicit: bool,
/// 历史记录保留天数。0 = 不按时间清理(仅受 200 条上限)。默认 7 天。
/// 写入新条目时执行清理,避免后台轮询。
#[serde(default = "default_history_retention_days")]
@@ -887,6 +891,8 @@ struct UserPreferencesWire {
sherpa_onnx_keep_loaded_secs: u32,
#[serde(default)]
update_channel: UpdateChannel,
#[serde(default)]
update_channel_explicit: Option<bool>,
#[serde(default = "default_history_retention_days")]
history_retention_days: u32,
#[serde(default = "default_polish_context_window_minutes")]
@@ -1054,6 +1060,8 @@ impl Default for UserPreferencesWire {
sherpa_onnx_language_hint: prefs.sherpa_onnx_language_hint,
sherpa_onnx_keep_loaded_secs: prefs.sherpa_onnx_keep_loaded_secs,
update_channel: prefs.update_channel,
// None 保留旧配置缺少标记的信息;反序列化时只有历史 Beta 视为显式选择。
update_channel_explicit: None,
history_retention_days: prefs.history_retention_days,
polish_context_window_minutes: prefs.polish_context_window_minutes,
start_minimized: prefs.start_minimized,
@@ -1121,6 +1129,9 @@ impl<'de> Deserialize<'de> for UserPreferences {
};
let (local_asr_active_model, local_whisper_active_model) =
migrate_local_asr_models(wire.local_asr_active_model, wire.local_whisper_active_model);
let update_channel_explicit = wire
.update_channel_explicit
.unwrap_or(matches!(wire.update_channel, UpdateChannel::Beta));
Ok(Self {
hotkey: wire.hotkey,
@@ -1216,6 +1227,7 @@ impl<'de> Deserialize<'de> for UserPreferences {
sherpa_onnx_language_hint: wire.sherpa_onnx_language_hint,
sherpa_onnx_keep_loaded_secs: wire.sherpa_onnx_keep_loaded_secs,
update_channel: wire.update_channel,
update_channel_explicit,
history_retention_days: wire.history_retention_days,
polish_context_window_minutes: wire.polish_context_window_minutes,
start_minimized: wire.start_minimized,
@@ -1556,6 +1568,7 @@ impl Default for UserPreferences {
sherpa_onnx_language_hint: String::new(),
sherpa_onnx_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
update_channel: UpdateChannel::default(),
update_channel_explicit: false,
history_retention_days: default_history_retention_days(),
polish_context_window_minutes: default_polish_context_window_minutes(),
start_minimized: false,
@@ -3118,6 +3131,32 @@ mod tests {
assert!(!prefs.streaming_insert_save_clipboard);
}
#[test]
fn update_channel_migration_preserves_only_explicit_legacy_beta_opt_in() {
let legacy_stable: UserPreferences =
serde_json::from_str(r#"{ "updateChannel": "stable" }"#).unwrap();
assert_eq!(legacy_stable.update_channel, UpdateChannel::Stable);
assert!(!legacy_stable.update_channel_explicit);
let legacy_beta: UserPreferences =
serde_json::from_str(r#"{ "updateChannel": "beta" }"#).unwrap();
assert_eq!(legacy_beta.update_channel, UpdateChannel::Beta);
assert!(legacy_beta.update_channel_explicit);
let explicit_stable: UserPreferences = serde_json::from_str(
r#"{
"updateChannel": "stable",
"updateChannelExplicit": true
}"#,
)
.unwrap();
assert!(explicit_stable.update_channel_explicit);
let round_trip: UserPreferences =
serde_json::from_str(&serde_json::to_string(&explicit_stable).unwrap()).unwrap();
assert!(round_trip.update_channel_explicit);
}
#[test]
fn paste_shortcut_round_trips_explicit_values() {
for (raw, expected) in [
+1
View File
@@ -4241,6 +4241,7 @@ dependencies = [
"regex",
"reqwest 0.12.28",
"rustls",
"semver",
"serde",
"serde_json",
"sha1",
+1
View File
@@ -30,6 +30,7 @@ tauri-plugin-shell = "2.3.5"
tauri-plugin-dialog = "2.7.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
semver = "1"
# OpenRouter ASR 把音频以标准 base64(带 padding)放进 JSON body(issue #582)。
base64 = "0.22"
sha2 = "0.10"
@@ -22,25 +22,13 @@ pub fn map_abi_to_arch(abi: &str) -> &'static str {
}
pub fn version_is_newer(remote: &str, current: &str) -> bool {
fn parts(v: &str) -> Vec<u32> {
v.split(|c| c == '.' || c == '-')
.filter_map(|p| p.parse().ok())
.collect()
}
let remote_parts = parts(remote);
let current_parts = parts(current);
let max = remote_parts.len().max(current_parts.len());
for i in 0..max {
let r = remote_parts.get(i).copied().unwrap_or(0);
let c = current_parts.get(i).copied().unwrap_or(0);
if r > c {
return true;
}
if r < c {
return false;
}
}
false
let (Ok(remote), Ok(current)) = (
semver::Version::parse(remote),
semver::Version::parse(current),
) else {
return false;
};
remote > current
}
pub fn stable_manifest_urls(arch: &str) -> Vec<String> {
@@ -78,9 +66,12 @@ mod tests {
}
#[test]
fn version_is_newer_handles_beta_suffix() {
assert!(version_is_newer("1.3.8-1", "1.3.8"));
assert!(!version_is_newer("1.3.8", "1.3.8-1"));
fn version_is_newer_uses_semver_prerelease_ordering() {
assert!(version_is_newer("1.3.18", "1.3.18-Beta.7"));
assert!(version_is_newer("1.3.18-Beta.8", "1.3.18-Beta.7"));
assert!(!version_is_newer("1.3.18-Beta.7", "1.3.18-Beta.7"));
assert!(!version_is_newer("1.3.17", "1.3.18-Beta.7"));
assert!(!version_is_newer("not-a-version", "1.3.18-Beta.7"));
}
#[test]
@@ -2,7 +2,9 @@ use super::*;
#[tauri::command]
pub fn get_settings(core: CoreState<'_>) -> UserPreferences {
core.get_preferences()
let mut prefs = core.get_preferences();
prefs.update_channel = effective_update_channel(None, &prefs, env!("CARGO_PKG_VERSION"));
prefs
}
#[tauri::command]
@@ -143,8 +145,10 @@ impl openless_core::SettingsRuntime for TauriSettingsRuntime<'_> {
}
}
pub(crate) fn persist_settings(coord: &Coordinator, prefs: UserPreferences) -> Result<(), String> {
let _host_guard = coord.lock_settings_host();
fn persist_settings_with_host_lock_held(
coord: &Coordinator,
prefs: UserPreferences,
) -> Result<(), String> {
coord
.backend()
.update_settings(
@@ -163,11 +167,22 @@ pub(crate) fn persist_settings(coord: &Coordinator, prefs: UserPreferences) -> R
.map_err(|error| error.to_string())
}
pub(crate) fn persist_strict_settings(
fn persist_settings_preserving_update_channel(
coord: &Coordinator,
prefs: UserPreferences,
mut prefs: UserPreferences,
) -> Result<(), String> {
let _host_guard = coord.lock_settings_host();
// 在同一把写锁内读取并回填,避免并发渠道切换被旧设置快照覆盖。
preserve_update_channel_preferences(&mut prefs, &coord.backend().get_preferences());
persist_settings_with_host_lock_held(coord, prefs)
}
pub(crate) fn persist_strict_settings(
coord: &Coordinator,
mut prefs: UserPreferences,
) -> Result<(), String> {
let _host_guard = coord.lock_settings_host();
preserve_update_channel_preferences(&mut prefs, &coord.backend().get_preferences());
coord
.backend()
.update_settings(
@@ -198,7 +213,7 @@ pub async fn set_settings(
// 广播给所有 webview。issue #205:QaPanel 跑在独立 webview,
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
persist_settings(&*coord, prefs)?;
persist_settings_preserving_update_channel(&*coord, prefs)?;
let prefs = coord.backend().get_preferences();
// 保存即同步胶囊样式原子:下一次录音的入场帧就携带新样式,不依赖 emit_capsule
// 主线程闭包的 ~30Hz 同步(Windows 主线程拥塞时闭包延迟 → 整场显示旧样式)。
@@ -257,7 +272,7 @@ pub fn set_settings(coord: CoordinatorState<'_>, mut prefs: UserPreferences) ->
.map_err(|e| e.to_string())?;
sync_style_pack_preferences(&mut prefs, &packs);
prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized();
persist_settings(&*coord, prefs)?;
persist_settings_preserving_update_channel(&*coord, prefs)?;
let prefs = coord.backend().get_preferences();
// 保存即同步胶囊样式原子(Android 通知胶囊 payload 同源,见 emit_capsule)。
coord.sync_capsule_style_from_preferences();
@@ -297,6 +312,63 @@ mod tests {
);
assert_eq!(stale_settings_payload.default_mode, PolishMode::Light);
}
#[test]
fn update_channel_defaults_to_build_channel_until_user_selects_one() {
let mut prefs = UserPreferences::default();
assert_eq!(
effective_update_channel(None, &prefs, "2.0.0-Beta.1"),
UpdateChannel::Beta
);
assert_eq!(
effective_update_channel(None, &prefs, "2.0.0"),
UpdateChannel::Stable
);
let legacy_beta = UserPreferences {
update_channel: UpdateChannel::Beta,
update_channel_explicit: true,
..UserPreferences::default()
};
assert_eq!(
effective_update_channel(None, &legacy_beta, "2.0.0"),
UpdateChannel::Beta
);
assert_eq!(
effective_update_channel(Some(UpdateChannel::Stable), &prefs, "2.0.0-Beta.1"),
UpdateChannel::Stable
);
assert!(select_update_channel(&mut prefs, UpdateChannel::Stable));
assert!(prefs.update_channel_explicit);
assert_eq!(
effective_update_channel(None, &prefs, "2.0.0-Beta.1"),
UpdateChannel::Stable
);
assert!(!select_update_channel(&mut prefs, UpdateChannel::Stable));
assert!(select_update_channel(&mut prefs, UpdateChannel::Beta));
assert_eq!(prefs.update_channel, UpdateChannel::Beta);
assert!(prefs.update_channel_explicit);
}
#[test]
fn general_settings_save_preserves_dedicated_update_channel_fields() {
let current = UserPreferences {
update_channel: UpdateChannel::Stable,
update_channel_explicit: true,
..UserPreferences::default()
};
let mut stale_payload = UserPreferences {
update_channel: UpdateChannel::Beta,
update_channel_explicit: false,
..UserPreferences::default()
};
preserve_update_channel_preferences(&mut stale_payload, &current);
assert_eq!(stale_payload.update_channel, UpdateChannel::Stable);
assert!(stale_payload.update_channel_explicit);
}
}
// ─────────────────────────── release channel (Beta opt-in) ───────────────────────────
@@ -312,9 +384,38 @@ mod tests {
// (Beta tag 的 manifest 文件名带 `-beta` 后缀,跟 Stable manifest 在 GitHub
// Release assets 里物理分离)。
fn effective_update_channel(
requested: Option<UpdateChannel>,
prefs: &UserPreferences,
app_version: &str,
) -> UpdateChannel {
requested.unwrap_or_else(|| {
if prefs.update_channel_explicit {
prefs.update_channel
} else if app_version.contains('-') {
UpdateChannel::Beta
} else {
UpdateChannel::Stable
}
})
}
fn select_update_channel(prefs: &mut UserPreferences, channel: UpdateChannel) -> bool {
let changed = prefs.update_channel != channel || !prefs.update_channel_explicit;
prefs.update_channel = channel;
prefs.update_channel_explicit = true;
changed
}
fn preserve_update_channel_preferences(incoming: &mut UserPreferences, current: &UserPreferences) {
incoming.update_channel = current.update_channel;
incoming.update_channel_explicit = current.update_channel_explicit;
}
#[tauri::command]
pub fn get_update_channel(core: CoreState<'_>) -> UpdateChannel {
core.get_preferences().update_channel
let prefs = core.get_preferences();
effective_update_channel(None, &prefs, env!("CARGO_PKG_VERSION"))
}
#[tauri::command]
@@ -322,12 +423,13 @@ pub fn set_update_channel(
coord: CoordinatorState<'_>,
channel: UpdateChannel,
) -> Result<(), String> {
// 渠道读取和持久化必须同属一个写临界区,避免反向覆盖并发常规设置。
let _host_guard = coord.lock_settings_host();
let mut prefs = coord.backend().get_preferences();
if prefs.update_channel == channel {
if !select_update_channel(&mut prefs, channel) {
return Ok(());
}
prefs.update_channel = channel;
persist_settings(&*coord, prefs)?;
persist_settings_with_host_lock_held(&*coord, prefs)?;
Ok(())
}
@@ -465,7 +567,7 @@ pub struct AppUpdateMetadata {
/// 决定 manifest 来源后走 plugin-updater 的标准 check 流程。
/// 渠道:显式传入 `channel` 时用它(关于页固定查 Stable、高级页 Beta 区查 Beta);
/// 不传则回落到 `prefs.update_channel`(后台 AutoUpdateGate 自动检查走这条)。
/// 不传则使用用户明确选择的渠道;尚未选择时跟随当前构建类型。
/// 返回 None = 当前是最新;Some(metadata) = 有新版可装。
#[tauri::command]
#[cfg(not(mobile))]
@@ -477,7 +579,8 @@ pub async fn app_check_update_with_channel<R: tauri::Runtime>(
) -> Result<Option<AppUpdateMetadata>, String> {
use tauri_plugin_updater::UpdaterExt;
let channel = channel.unwrap_or_else(|| coord.backend().get_preferences().update_channel);
let prefs = coord.backend().get_preferences();
let channel = effective_update_channel(channel, &prefs, env!("CARGO_PKG_VERSION"));
let mut builder = webview.updater_builder();
if let Some(ms) = timeout_ms {
builder = builder.timeout(std::time::Duration::from_millis(ms));
@@ -541,7 +644,8 @@ pub async fn app_check_update_with_channel(
) -> Result<Option<AppUpdateMetadata>, String> {
#[cfg(target_os = "android")]
{
let channel = channel.unwrap_or_else(|| coord.backend().get_preferences().update_channel);
let prefs = coord.backend().get_preferences();
let channel = effective_update_channel(channel, &prefs, env!("CARGO_PKG_VERSION"));
return crate::android::updater::check_update(channel).await;
}
#[cfg(not(target_os = "android"))]
+25 -2
View File
@@ -19,9 +19,11 @@ import {
logClientError,
openExternal,
restartApp,
setUpdateChannel,
type AppUpdateMetadata,
type UpdateChannel,
} from '../lib/ipc';
import { isStableChannelSwitch } from '../lib/appVersion';
import { Btn } from '../pages/_atoms';
const UPDATE_CHECK_TIMEOUT_MS = 15_000;
@@ -50,6 +52,7 @@ export type CheckUpdateOptions = {
export interface UseAutoUpdate {
status: UpdateStatus;
currentVersion: string;
version: string;
progress: number | null;
downloaded: number;
@@ -72,6 +75,7 @@ export function useAutoUpdate(): UseAutoUpdate {
const updateRef = useRef<Update | null>(null);
const androidUpdateRef = useRef<AndroidUpdatePayload | null>(null);
const [status, setStatus] = useState<UpdateStatus>('idle');
const [currentVersion, setCurrentVersion] = useState('');
const [version, setVersion] = useState('');
const [downloaded, setDownloaded] = useState(0);
const [contentLength, setContentLength] = useState<number | null>(null);
@@ -140,6 +144,7 @@ export function useAutoUpdate(): UseAutoUpdate {
const checkForUpdates = async (channel?: UpdateChannel, options?: CheckUpdateOptions) => {
setStatus('checking');
setCurrentVersion('');
setVersion('');
setErrorMessage(null);
resetProgress();
@@ -157,6 +162,7 @@ export function useAutoUpdate(): UseAutoUpdate {
setStatus('none');
return;
}
setCurrentVersion(metadata.currentVersion);
if (isAndroid()) {
storeAndroidMetadata(metadata);
setVersion(metadata.version);
@@ -203,12 +209,18 @@ export function useAutoUpdate(): UseAutoUpdate {
};
const installUpdate = async () => {
const persistStableChannelSwitch = () =>
isStableChannelSwitch(currentVersion, version)
? setUpdateChannel('stable')
: Promise.resolve();
if (isAndroid()) {
const payload = androidUpdateRef.current;
if (!payload) return;
resetProgress();
setStatus('downloading');
try {
await persistStableChannelSwitch();
await appDownloadAndInstallAndroidUpdate(payload);
androidUpdateRef.current = null;
setStatus('downloaded');
@@ -227,6 +239,7 @@ export function useAutoUpdate(): UseAutoUpdate {
resetProgress();
setStatus('downloading');
try {
await persistStableChannelSwitch();
await update.download((event: DownloadEvent) => {
if (event.event === 'Started') {
resetProgress();
@@ -255,12 +268,14 @@ export function useAutoUpdate(): UseAutoUpdate {
if (busy) return;
await closeUpdate();
setStatus('idle');
setCurrentVersion('');
setVersion('');
resetProgress();
};
return {
status,
currentVersion,
version,
progress,
downloaded,
@@ -280,6 +295,7 @@ export function isDialogStatus(status: UpdateStatus): status is 'available' | 'd
export function UpdateDialog({
status,
currentVersion,
version,
progress,
downloaded,
@@ -289,6 +305,7 @@ export function UpdateDialog({
onClose,
}: {
status: 'available' | 'downloading' | 'installing' | 'downloaded' | 'installError';
currentVersion: string;
version: string;
progress: number | null;
downloaded: number;
@@ -302,6 +319,8 @@ export function UpdateDialog({
const installing = status === 'installing';
const installError = status === 'installError';
const androidInstalled = isAndroid() && status === 'downloaded';
const switchingToStable = status === 'available'
&& isStableChannelSwitch(currentVersion, version);
// Portal 到 document.body:WindowChrome / 设置弹窗带常驻 transform + will-change,
// 会创建 containing block——`position: fixed` 的遮罩会相对设置面板定位,只压暗
// 白色内容区(侧边栏深色看不出,形成「内容变灰、断层感」,见 Modal.tsx 同款注释)。
@@ -309,13 +328,17 @@ export function UpdateDialog({
return createPortal(
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.22)', display: 'grid', placeItems: 'center', zIndex: 40, animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)' }}>
<div style={{ width: 360, borderRadius: 16, background: 'var(--ol-surface)', border: '0.5px solid var(--ol-line-strong)', boxShadow: 'var(--ol-shadow-lg)', padding: 18 }}>
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>{t(`settings.about.updateDialog.${status}.title`)}</div>
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>
{t(`settings.about.updateDialog.${switchingToStable ? 'stableChannelSwitch' : status}.title`)}
</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6, marginBottom: 14, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{androidInstalled
? t('settings.about.updateDialog.androidInstalled.desc', { version, defaultValue: '系统安装器已打开,请按提示完成安装。安装后重新打开 OpenLess 即可使用 {{version}}。' })
: installError
? t('settings.about.updateDialog.installError.desc', { error: errorMessage || t('settings.about.updateError') })
: t(`settings.about.updateDialog.${status}.desc`, { version })}
: switchingToStable
? t('settings.about.updateDialog.stableChannelSwitch.desc', { currentVersion, version })
: t(`settings.about.updateDialog.${status}.desc`, { version })}
</div>
{(downloading || installing || status === 'downloaded') && (
<div style={{ marginBottom: 14 }}>
@@ -53,6 +53,7 @@ export function AutoUpdateGate() {
return (
<UpdateDialog
status={u.status}
currentVersion={u.currentVersion}
version={u.version}
progress={u.progress}
downloaded={u.downloaded}
+4
View File
@@ -1445,6 +1445,10 @@ export const en: typeof zhCN = {
title: 'Update available',
desc: 'OpenLess {{version}} is available. Update now?',
},
stableChannelSwitch: {
title: 'Switch to Stable',
desc: 'Current version: OpenLess {{currentVersion}}\nTarget version: OpenLess {{version}}\nThis switches from the Beta channel to Stable. Continue?',
},
downloading: {
title: 'Downloading update',
desc: 'Downloading OpenLess {{version}}. Keep the app open.',
+4
View File
@@ -1413,6 +1413,10 @@ export const ja: typeof zhCN = {
title: '新しいバージョンがあります',
desc: 'OpenLess {{version}} が見つかりました。今すぐ更新しますか?',
},
stableChannelSwitch: {
title: '正式版に切り替える',
desc: '現在のバージョン:OpenLess {{currentVersion}}\n対象バージョン:OpenLess {{version}}\nBeta チャンネルから正式版に切り替えます。続行しますか?',
},
downloading: {
title: 'アップデートをダウンロード中',
desc: 'OpenLess {{version}} をダウンロード中です。アプリを開いたままにしてください。',
+4
View File
@@ -1413,6 +1413,10 @@ export const ko: typeof zhCN = {
title: '새 버전 발견',
desc: 'OpenLess {{version}} 을(를) 발견했습니다. 지금 업데이트하시겠습니까?',
},
stableChannelSwitch: {
title: '정식 버전으로 전환',
desc: '현재 버전: OpenLess {{currentVersion}}\n대상 버전: OpenLess {{version}}\nBeta 채널에서 정식 버전으로 전환합니다. 계속하시겠습니까?',
},
downloading: {
title: '업데이트 다운로드 중',
desc: 'OpenLess {{version}} 을(를) 다운로드 중입니다. 앱을 열어 두세요.',
+4
View File
@@ -1443,6 +1443,10 @@ export const zhCN = {
title: '发现新版本',
desc: '发现 OpenLess {{version}},是否现在更新?',
},
stableChannelSwitch: {
title: '切换到正式版',
desc: '当前版本:OpenLess {{currentVersion}}\n目标版本:OpenLess {{version}}\n这是从 Beta 渠道切换到正式版,是否继续?',
},
downloading: {
title: '正在下载更新',
desc: '正在下载 OpenLess {{version}},请保持应用打开。',
+4
View File
@@ -1411,6 +1411,10 @@ export const zhTW: typeof zhCN = {
title: '發現新版本',
desc: '發現 OpenLess {{version}},是否現在更新?',
},
stableChannelSwitch: {
title: '切換到正式版',
desc: '目前版本:OpenLess {{currentVersion}}\n目標版本:OpenLess {{version}}\n這是從 Beta 頻道切換到正式版,是否繼續?',
},
downloading: {
title: '正在下載更新',
desc: '正在下載 OpenLess {{version}},請保持應用打開。',
@@ -0,0 +1,22 @@
import { en } from '../i18n/en';
import { ja } from '../i18n/ja';
import { ko } from '../i18n/ko';
import { zhCN } from '../i18n/zh-CN';
import { zhTW } from '../i18n/zh-TW';
import { isStableChannelSwitch } from './appVersion';
function assert(condition: boolean, message: string) {
if (!condition) throw new Error(message);
}
assert(isStableChannelSwitch('1.3.18-Beta.7', '1.3.18'), 'Beta → Stable should be a channel switch');
assert(!isStableChannelSwitch('1.3.18-Beta.7', '1.3.18-Beta.8'), 'Beta → Beta should be a regular update');
assert(!isStableChannelSwitch('1.3.18', '1.3.19'), 'Stable → Stable should be a regular update');
for (const resources of [zhCN, zhTW, en, ja, ko]) {
const description = resources.settings.about.updateDialog.stableChannelSwitch.desc;
assert(description.includes('{{currentVersion}}'), 'switch copy should show the current version');
assert(description.includes('{{version}}'), 'switch copy should show the target version');
}
console.log('appVersion tests passed');
+3
View File
@@ -6,3 +6,6 @@ export const APP_VERSION_LABEL = `v${APP_VERSION}`;
// 例如 `1.2.24-1` / `1.2.24-2`;正式版没有 `-`,例如 `1.2.23` / `1.2.24`。
// 用于 UI 条件渲染——Beta 标签只在 Beta build 出现。
export const IS_BETA_BUILD = APP_VERSION.includes('-');
export const isStableChannelSwitch = (currentVersion: string, targetVersion: string) =>
currentVersion.includes('-') && !targetVersion.includes('-');
@@ -109,6 +109,7 @@ export let mockSettings: UserPreferences = {
startMinimized: false,
themeMode: "system",
updateChannel: "stable",
updateChannelExplicit: false,
streamingInsert: true,
streamingInsertDefaultMigrated: true,
streamingInsertSaveClipboard: true,
+5 -4
View File
@@ -256,8 +256,7 @@ export interface WindowsImeStatus {
dllPath: string | null;
}
/** 后台自动更新渠道。stable = 查正式版 manifest(默认);beta = 查
* latest-android-{arch}-beta.json。手动「检查正式版/Beta 更新」按钮不受此字段影响。 */
/** 后台自动更新渠道。未明确选择时跟随构建类型;手动检查按钮不受此字段影响。 */
export type UpdateChannel = 'stable' | 'beta';
export type ThemeMode = 'system' | 'light' | 'dark';
@@ -482,9 +481,11 @@ export interface UserPreferences {
startMinimized: boolean;
/** UI theme preference: follow OS, light, or dark. */
themeMode: ThemeMode;
/** 后台自动更新渠道。stable(默认)= AutoUpdateGate 查正式版 manifest;
* beta = 查 Beta manifest。About / Advanced 的手动检查按钮各自固定 stable/beta。 */
/** 后台自动更新渠道。用户未明确选择时跟随当前构建类型;
* About / Advanced 的手动检查按钮各自固定 stable/beta。 */
updateChannel: UpdateChannel;
/** 是否由用户明确选择过更新渠道;缺失时由当前构建类型决定默认渠道。 */
updateChannelExplicit?: boolean;
/** 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到
* 的处理时延显著降低。v1 限定 macOS + OpenAI-compatible provider,其他配置自动回落
* 到原一次性插入。默认 true。 */
@@ -13,6 +13,7 @@ import { CheckUpdateButton } from './CheckUpdateButton';
export function BetaChannelSection() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannel>('stable');
const [autoCheckChannel, setAutoCheckChannel] = useState<UpdateChannel | null>(null);
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
useEffect(() => {
@@ -34,7 +35,9 @@ export function BetaChannelSection() {
await setUpdateChannel(target);
} catch {
setChannel(target === 'beta' ? 'stable' : 'beta');
return;
}
setAutoCheckChannel(target);
};
if (platformCaps?.supportsAutoUpdate !== true) return null;
@@ -49,7 +52,7 @@ export function BetaChannelSection() {
<Toggle on={channel === 'beta'} onToggle={onToggle} />
</SettingRow>
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
<CheckUpdateButton channel="beta" />
<CheckUpdateButton channel="beta" autoCheckChannel={autoCheckChannel} />
</div>
</Card>
);
@@ -8,7 +8,15 @@ import { Icon } from '../../components/Icon';
import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../../components/AutoUpdate';
import type { UpdateChannel } from '../../lib/ipc';
export function CheckUpdateButton({ channel, compact = false }: { channel: UpdateChannel; compact?: boolean }) {
export function CheckUpdateButton({
channel,
compact = false,
autoCheckChannel,
}: {
channel: UpdateChannel;
compact?: boolean;
autoCheckChannel?: UpdateChannel | null;
}) {
const { t } = useTranslation();
const updater = useAutoUpdate();
const { status, checking, busy } = updater;
@@ -31,6 +39,12 @@ export function CheckUpdateButton({ channel, compact = false }: { channel: Updat
: 'settings.about.checkStableUpdateBtn';
const label = checking ? t('settings.about.checkingUpdate') : t(labelKey);
useEffect(() => {
if (autoCheckChannel) void updater.checkForUpdates(autoCheckChannel);
// checkForUpdates changes with updater state; this effect is driven only by a channel switch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [autoCheckChannel]);
return (
<>
<button
@@ -92,6 +106,7 @@ export function CheckUpdateButton({ channel, compact = false }: { channel: Updat
{isDialogStatus(status) && (
<UpdateDialog
status={status}
currentVersion={updater.currentVersion}
version={updater.version}
progress={updater.progress}
downloaded={updater.downloaded}