Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/release-tauri.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
83 changes: 80 additions & 3 deletions openless-all/app/src-tauri/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Expand All @@ -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 =
Expand Down
63 changes: 61 additions & 2 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 写到系统剪贴板,跟一次性行为对齐。
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 [
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ let mockSettings: UserPreferences = {
startMinimized: false,
updateChannel: 'stable',
streamingInsert: true,
streamingInsertDefaultMigrated: true,
streamingInsertSaveClipboard: true,
autoUpdateCheck: true,
historyMaxEntries: null,
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/lib/stylePrefs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const previousPrefs: UserPreferences = {
startMinimized: false,
updateChannel: 'stable',
streamingInsert: true,
streamingInsertDefaultMigrated: true,
streamingInsertSaveClipboard: true,
autoUpdateCheck: true,
historyMaxEntries: null,
Expand Down
5 changes: 4 additions & 1 deletion openless-all/app/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading