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.
This commit is contained in:
H-Chris233 committed 2026-05-15 18:59:45 +08:00
1 parent 74208322bb
commit b0c469dbd4
6 files changed
+152 -6

No files matched your search

+5
View File
@@ -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.
+80 -3
View File
@@ -284,6 +284,45 @@ fn read_or_default<T: for<'de> Deserialize<'de> + Default>(path: &Path) -> Resul
.with_context(|| format!("decode failed: {}", path.display()))
}
fn read_preferences(path: &Path) -> Result<UserPreferences> {
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::<UserPreferences>(&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::<serde_json::Value>(&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::<UserPreferences>(&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 =
+61 -2
View File
@@ -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 [
+1
View File
@@ -98,6 +98,7 @@ let mockSettings: UserPreferences = {
startMinimized: false,
updateChannel: 'stable',
streamingInsert: true,
streamingInsertDefaultMigrated: true,
streamingInsertSaveClipboard: true,
autoUpdateCheck: true,
historyMaxEntries: null,
@@ -60,6 +60,7 @@ const previousPrefs: UserPreferences = {
startMinimized: false,
updateChannel: 'stable',
streamingInsert: true,
streamingInsertDefaultMigrated: true,
streamingInsertSaveClipboard: true,
autoUpdateCheck: true,
historyMaxEntries: null,
+4 -1
View File
@@ -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;