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:
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 [
|
||||
|
||||
Generated
+1
@@ -4241,6 +4241,7 @@ dependencies = [
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
|
||||
@@ -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, ¤t);
|
||||
|
||||
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"))]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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}} をダウンロード中です。アプリを開いたままにしてください。',
|
||||
|
||||
@@ -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}} 을(를) 다운로드 중입니다. 앱을 열어 두세요.',
|
||||
|
||||
@@ -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}},请保持应用打开。',
|
||||
|
||||
@@ -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');
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user