From b0c469dbd440890acd68573fb0704d084c3cbb17 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Fri, 15 May 2026 17:49:12 +0800 Subject: [PATCH] Keep streaming insertion enabled for migrated prefs Older builds could persist the old default streamingInsert=false into preferences.json, which is indistinguishable from a user opt-out if the value is treated as an ordinary bool. Add a one-time migration marker, persist normalized legacy prefs on load, and keep later user opt-outs durable after migration. Constraint: issue #440 requests streaming input/output defaults on for fresh installs and updates, plus release notes for the behavior change. Rejected: treating any stored false as manual opt-out | old default writes would keep update users off forever. Rejected: overriding every false forever | would erase a user's post-migration manual opt-out. Confidence: high Scope-risk: moderate Directive: Keep UserPreferencesWire serde defaults and migration markers aligned with persisted UserPreferences fields. Tested: cargo test --manifest-path src-tauri/Cargo.toml --lib streaming_insert -- --nocapture Tested: cargo test --manifest-path src-tauri/Cargo.toml --lib legacy_streaming_insert_false_is_migrated_and_marker_is_persisted -- --nocapture Tested: npm run build Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib Tested: git diff --check Not-tested: cargo fmt --check fails on pre-existing unrelated formatting drift in volcengine.rs and commands.rs. --- .github/workflows/release-tauri.yml | 5 ++ openless-all/app/src-tauri/src/persistence.rs | 83 ++++++++++++++++++- openless-all/app/src-tauri/src/types.rs | 63 +++++++++++++- openless-all/app/src/lib/ipc.ts | 1 + openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 5 +- 6 files changed, 152 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 8192bc96..05079cf4 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -505,6 +505,11 @@ jobs: - 以 `-tauri` 结尾的 tag 是**正式版**(自动推送给所有 in-app 检查更新的用户)。 - 以 `-beta-tauri` 结尾的 tag 是 **Beta 版**(GitHub 标 pre-release,**不**通过 in-app updater 推送给正式版用户;只对在「设置 → 关于 → 加入 Beta 渠道」开关切到 Beta 的用户可见,且需要手动从此页面下载安装)。 + ### 行为变更提示 + + - 流式输入默认开启;不兼容场景会自动回落到一次性插入。可在「设置 → 高级」关闭。 + - 流式输入成功后默认把最终文本同步到剪贴板,方便再次粘贴;可在「设置 → 高级」关闭。 + append_body: true # Matrix jobs all upload assets to the same release. Generate notes once # so macOS, Windows, and Linux jobs do not duplicate the release body. diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index cd662f8e..d65a3791 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -284,6 +284,45 @@ fn read_or_default Deserialize<'de> + Default>(path: &Path) -> Resul .with_context(|| format!("decode failed: {}", path.display())) } +fn read_preferences(path: &Path) -> Result { + if !path.exists() { + return Ok(UserPreferences::default()); + } + let bytes = fs::read(path).with_context(|| format!("read failed: {}", path.display()))?; + if bytes.is_empty() { + return Ok(UserPreferences::default()); + } + let prefs = serde_json::from_slice::(&bytes) + .with_context(|| format!("decode failed: {}", path.display()))?; + + // issue #440:老版本可能已把旧默认 `streamingInsert:false` 写进 preferences.json。 + // 反序列化会在内存里迁到 true,但还必须把迁移标记落盘,否则每次启动都停留在 + // “旧文件”状态,无法表达用户后续手动关闭后的 durable opt-out。 + let streaming_default_migrated = serde_json::from_slice::(&bytes) + .ok() + .and_then(|value| { + value + .get("streamingInsertDefaultMigrated") + .and_then(|flag| flag.as_bool()) + }) + .unwrap_or(false); + if !streaming_default_migrated { + match serde_json::to_vec_pretty(&prefs) + .context("encode prefs failed") + .and_then(|json| atomic_write(path, &json)) + { + Ok(()) => log::info!("[prefs] migrated streamingInsert default marker"), + Err(err) => log::warn!( + "[prefs] failed to persist streamingInsert migration marker for {}: {}", + path.display(), + err + ), + } + } + + Ok(prefs) +} + // ───────────────────────── credentials vault ───────────────────────── // // 正常读写走系统凭据库;旧 plaintext JSON 只作为迁移来源。为保持多 provider @@ -966,7 +1005,7 @@ impl PreferencesStore { ensure_dir(&dir)?; let path = dir.join(PREFERENCES_FILE); let prefs = if path.exists() { - read_or_default::(&path).unwrap_or_else(|e| { + read_preferences(&path).unwrap_or_else(|e| { log::warn!( "[prefs] load {} failed, using defaults: {}", path.display(), @@ -2176,8 +2215,8 @@ impl CredentialsVault { #[cfg(test)] mod tests { use super::{ - chunk_json_payload, list_vocab_presets, save_vocab_presets, sync_style_pack_preferences, - validate_correction_rule_syntax, KEYRING_CHUNK_MAX_UTF16_UNITS, + chunk_json_payload, list_vocab_presets, read_preferences, save_vocab_presets, + sync_style_pack_preferences, validate_correction_rule_syntax, KEYRING_CHUNK_MAX_UTF16_UNITS, }; use crate::types::{builtin_style_packs, CustomStylePrompts, VocabPreset, VocabPresetStore}; use std::fs; @@ -2199,6 +2238,44 @@ mod tests { .all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS)); } + #[test] + fn legacy_streaming_insert_false_is_migrated_and_marker_is_persisted() { + let tmp: PathBuf = + std::env::temp_dir().join(format!("openless-prefs-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&tmp).expect("create temp dir"); + let path = tmp.join("preferences.json"); + fs::write( + &path, + r#"{ + "streamingInsert": false, + "streamingInsertSaveClipboard": true + }"#, + ) + .expect("write legacy prefs"); + + let prefs = read_preferences(&path).expect("read prefs"); + assert!(prefs.streaming_insert); + assert!(prefs.streaming_insert_default_migrated); + + let saved: serde_json::Value = + serde_json::from_slice(&fs::read(&path).expect("read saved prefs")) + .expect("decode saved prefs"); + assert_eq!( + saved + .get("streamingInsert") + .and_then(|value| value.as_bool()), + Some(true) + ); + assert_eq!( + saved + .get("streamingInsertDefaultMigrated") + .and_then(|value| value.as_bool()), + Some(true) + ); + + let _ = fs::remove_dir_all(&tmp); + } + #[test] fn vocab_presets_roundtrip_json_file() { let tmp: PathBuf = diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 632882f7..4a9c79fb 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -597,6 +597,11 @@ pub struct UserPreferences { /// 用户无感。详见上面「限制」段。 #[serde(default = "default_true")] pub streaming_insert: bool, + /// issue #440 的一次性迁移标记。老版本会把默认 `streamingInsert:false` + /// 写进 preferences.json,升级后仅看 bool 无法区分「老默认」和「用户手动关」。 + /// 缺少此标记的旧文件统一迁到 true;迁移后用户再关会带着标记保存,后续保留 false。 + #[serde(default)] + pub streaming_insert_default_migrated: bool, /// 流式输入成功后是否把最终润色文本写回剪贴板。一次性路径天然走剪贴板,所以 /// Cmd+V 可以重复粘贴;流式路径直接合成键盘事件、不动剪贴板,会让用户失去这层 /// 兜底。开启后流式成功收尾时把 final text 写到系统剪贴板,跟一次性行为对齐。 @@ -731,8 +736,10 @@ struct UserPreferencesWire { polish_context_window_minutes: u32, #[serde(default)] start_minimized: bool, - #[serde(default)] + #[serde(default = "default_true")] streaming_insert: bool, + #[serde(default)] + streaming_insert_default_migrated: bool, #[serde(default = "default_true")] streaming_insert_save_clipboard: bool, #[serde(default = "default_true")] @@ -792,6 +799,7 @@ impl Default for UserPreferencesWire { polish_context_window_minutes: prefs.polish_context_window_minutes, start_minimized: prefs.start_minimized, streaming_insert: prefs.streaming_insert, + streaming_insert_default_migrated: prefs.streaming_insert_default_migrated, streaming_insert_save_clipboard: prefs.streaming_insert_save_clipboard, auto_update_check: prefs.auto_update_check, history_max_entries: prefs.history_max_entries, @@ -814,6 +822,13 @@ impl<'de> Deserialize<'de> for UserPreferences { None => default_dictation_hotkey_from_legacy(&wire.hotkey, &wire.custom_combo_hotkey) .map_err(serde::de::Error::custom)?, }; + let streaming_insert_default_migrated = wire.streaming_insert_default_migrated; + let streaming_insert = if streaming_insert_default_migrated { + wire.streaming_insert + } else { + true + }; + Ok(Self { hotkey: wire.hotkey, dictation_hotkey, @@ -865,7 +880,8 @@ impl<'de> Deserialize<'de> for UserPreferences { history_retention_days: wire.history_retention_days, polish_context_window_minutes: wire.polish_context_window_minutes, start_minimized: wire.start_minimized, - streaming_insert: wire.streaming_insert, + streaming_insert, + streaming_insert_default_migrated: true, streaming_insert_save_clipboard: wire.streaming_insert_save_clipboard, auto_update_check: wire.auto_update_check, history_max_entries: wire.history_max_entries, @@ -1221,6 +1237,7 @@ impl Default for UserPreferences { polish_context_window_minutes: default_polish_context_window_minutes(), start_minimized: false, streaming_insert: true, + streaming_insert_default_migrated: true, streaming_insert_save_clipboard: true, auto_update_check: true, history_max_entries: None, @@ -1912,6 +1929,48 @@ mod tests { assert_eq!(from_empty.paste_shortcut, PasteShortcut::CtrlV); } + /// issue #440: 老版本会把默认 `streamingInsert:false` 写进 preferences.json。 + /// 缺少迁移标记的旧文件统一迁到 true;带有迁移标记后,用户再手动关掉的 false + /// 必须保留。 + #[test] + fn streaming_insert_defaults_to_enabled_for_missing_or_legacy_unmigrated_pref() { + let prefs = UserPreferences::default(); + assert!(prefs.streaming_insert); + assert!(prefs.streaming_insert_default_migrated); + assert!(prefs.streaming_insert_save_clipboard); + + let from_empty: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(from_empty.streaming_insert); + assert!(from_empty.streaming_insert_default_migrated); + assert!(from_empty.streaming_insert_save_clipboard); + + let from_legacy_false: UserPreferences = serde_json::from_str( + r#"{ + "streamingInsert": false, + "streamingInsertSaveClipboard": true + }"#, + ) + .unwrap(); + assert!(from_legacy_false.streaming_insert); + assert!(from_legacy_false.streaming_insert_default_migrated); + } + + #[test] + fn streaming_insert_preserves_explicit_disabled_value() { + let prefs: UserPreferences = serde_json::from_str( + r#"{ + "streamingInsert": false, + "streamingInsertDefaultMigrated": true, + "streamingInsertSaveClipboard": false + }"#, + ) + .unwrap(); + + assert!(!prefs.streaming_insert); + assert!(prefs.streaming_insert_default_migrated); + assert!(!prefs.streaming_insert_save_clipboard); + } + #[test] fn paste_shortcut_round_trips_explicit_values() { for (raw, expected) in [ diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 93052442..a75c2d22 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -98,6 +98,7 @@ let mockSettings: UserPreferences = { startMinimized: false, updateChannel: 'stable', streamingInsert: true, + streamingInsertDefaultMigrated: true, streamingInsertSaveClipboard: true, autoUpdateCheck: true, historyMaxEntries: null, diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index a028c348..3f93ca38 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -60,6 +60,7 @@ const previousPrefs: UserPreferences = { startMinimized: false, updateChannel: 'stable', streamingInsert: true, + streamingInsertDefaultMigrated: true, streamingInsertSaveClipboard: true, autoUpdateCheck: true, historyMaxEntries: null, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 1b044898..aae4f156 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -276,8 +276,11 @@ export interface UserPreferences { updateChannel: UpdateChannel; /** 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到 * 的处理时延显著降低。v1 限定 macOS + OpenAI-compatible provider,其他配置自动回落 - * 到原一次性插入。默认 false 与历史行为一致。 */ + * 到原一次性插入。默认 true。 */ streamingInsert: boolean; + /** issue #440 一次性迁移标记:旧配置缺少该字段时后端会把老默认 false 迁到 true; + * 迁移后用户再手动关掉 streamingInsert 时保留 false。 */ + streamingInsertDefaultMigrated: boolean; /** 流式输入成功后是否把最终润色文本写回剪贴板。开启后 Cmd+V 还能重复粘贴该次输出, * 与一次性路径行为对齐。默认 true。 */ streamingInsertSaveClipboard: boolean;