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
98 changes: 92 additions & 6 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,14 @@ async fn run_streaming_polish(
#[cfg(target_os = "windows")]
let sendinput_options = windows_sendinput_options_from_prefs(&inner.prefs.get());
#[cfg(target_os = "macos")]
let macos_newline_mode = inner.prefs.get().macos_newline_mode;
let macos_newline_mode = {
let configured = inner.prefs.get().macos_newline_mode;
let resolved = resolve_macos_newline_mode(configured, front_app);
log::info!(
"[coord] streaming_insert: macOS newline mode configured={configured:?} resolved={resolved:?}"
);
resolved
};
let typer_handle = tokio::task::spawn_blocking(move || {
#[cfg(target_os = "windows")]
{
Expand Down Expand Up @@ -699,6 +706,33 @@ async fn run_streaming_polish(
}
}

/// 把 Auto 解析成单次听写实际使用的模式。前台应用在听写开始时已经捕获,整个流式上屏
/// 过程使用同一结果;未知应用保守使用 Shift+Return,避免聊天框换行时误发送。
#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))]
fn resolve_macos_newline_mode(
configured: crate::types::MacosNewlineMode,
front_app: Option<&str>,
) -> crate::types::MacosNewlineMode {
use crate::types::MacosNewlineMode;

if configured != MacosNewlineMode::Auto {
return configured;
}

let bundle_id = front_app.and_then(|label| {
let front = crate::types::split_front_app_label(label, true);
front.bundle_id.or(front.name)
});
if bundle_id
.as_deref()
.is_some_and(crate::host_document::is_terminal_bundle_id)
{
MacosNewlineMode::LineFeed
} else {
MacosNewlineMode::ShiftReturn
}
}

#[cfg(target_os = "windows")]
pub(super) fn windows_sendinput_options_from_prefs(
prefs: &crate::types::UserPreferences,
Expand Down Expand Up @@ -5363,19 +5397,71 @@ mod tests {
append_typed_prefix, batch_asr_chunk_limit_ms, build_transcribe_failed_session,
coding_agent_mode_from_pref, default_done_message, drain_streaming_insert_deltas_with,
eligible_polish_context_turns, finalize_polished_text, flush_streaming_insert_buffer_with,
pcm_duration_ms, pcm_from_wav_bytes, retry_error_outcome, should_arm_edit_watch,
should_attempt_silent_retry, should_read_cursor_context, insert_delivery_failed,
resolve_less_computer_run_outcome, streaming_insert_eligible,
SilentRetryOutcome,
insert_delivery_failed, pcm_duration_ms, pcm_from_wav_bytes,
resolve_less_computer_run_outcome, resolve_macos_newline_mode, retry_error_outcome,
should_arm_edit_watch, should_attempt_silent_retry, should_read_cursor_context,
streaming_insert_eligible, SilentRetryOutcome,
};
#[cfg(any(target_os = "macos", target_os = "linux"))]
use super::{desktop_keyless_dictation_provider, DesktopKeylessDictationProvider};
use crate::coordinator::RetranscribeError;
use crate::types::{
ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, PolishMode,
ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, MacosNewlineMode,
PolishMode,
};
use uuid::Uuid;

#[test]
fn macos_auto_newline_uses_line_feed_in_known_terminals() {
for front_app in [
"Terminal (com.apple.Terminal)",
"iTerm2 (com.googlecode.iterm2)",
"Warp (dev.warp.Warp-Stable)",
"WezTerm (com.github.wez.wezterm)",
"Alacritty (io.alacritty)",
"Alacritty (org.alacritty)",
"kitty (net.kovidgoyal.kitty)",
"Hyper (co.zeit.hyper)",
"Tabby (org.tabby)",
"Tabby (com.tabby)",
"Ghostty (com.mitchellh.ghostty)",
] {
assert_eq!(
resolve_macos_newline_mode(MacosNewlineMode::Auto, Some(front_app)),
MacosNewlineMode::LineFeed,
"{front_app} should use U+000A"
);
}
}

#[test]
fn macos_auto_newline_uses_shift_return_outside_known_terminals() {
for front_app in [
None,
Some("Terminal"),
Some("Safari (com.apple.Safari)"),
Some("Slack (com.tinyspeck.slackmacgap)"),
] {
assert_eq!(
resolve_macos_newline_mode(MacosNewlineMode::Auto, front_app),
MacosNewlineMode::ShiftReturn,
"{front_app:?} should use the chat-safe fallback"
);
}
}

#[test]
fn macos_explicit_newline_modes_override_target_app_detection() {
let terminal = Some("Terminal (com.apple.Terminal)");
for configured in [
MacosNewlineMode::ShiftReturn,
MacosNewlineMode::LineFeed,
MacosNewlineMode::Return,
] {
assert_eq!(resolve_macos_newline_mode(configured, terminal), configured);
}
}

#[test]
fn sandbox_providers_legacy_permission_modes_fail_closed_to_read_only() {
use crate::coding_agent::{CodingAgentPermissionMode as M, CodingAgentProvider as P};
Expand Down
29 changes: 18 additions & 11 deletions openless-all/app/src-tauri/src/host_document/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,19 +183,15 @@ pub struct GateInputs {
/// AX 里表示「密码输入框」的 role/subrole 值。
const AX_SECURE_TEXT_FIELD: &str = "axsecuretextfield";

/// 一律不读的 app(bundle id 前缀,小写比较)。
/// 一律不读的敏感 app(bundle id 前缀,小写比较)。
///
/// 不做 UI —— 黑名单 UI 会给用户「配一下就安全了」的错觉,而真正的防线是默认关闭
/// 加这里的硬编码。这份清单只覆盖「内容几乎必然敏感」的两类:
///
/// - **密码管理器 / 钥匙串**:正文就是凭据本身。
/// - **终端**:命令行里混着 token、私钥路径、内网地址,而且很多终端的 AX 会把整个
/// scrollback 当作一个文本元素返回 —— 一读就是几千行历史命令。
///
/// 前缀匹配,所以 `com.1password` 能同时盖住 `com.1password.1password` 和其
/// helper 进程。
const BLOCKED_BUNDLE_PREFIXES: &[&str] = &[
// 密码管理器 / 钥匙串
const SENSITIVE_BUNDLE_PREFIXES: &[&str] = &[
"com.1password",
"com.agilebits.onepassword",
"com.apple.keychainaccess",
Expand All @@ -207,7 +203,11 @@ const BLOCKED_BUNDLE_PREFIXES: &[&str] = &[
"in.sinew.enpass",
"com.sinew.enpass",
"com.apple.passwords",
// 终端
];

/// 终端 app 的 bundle id 前缀。除了禁止读取 scrollback,也供 macOS 自动换行模式判断:
/// 已知终端发送 U+000A,其它应用保守发送 Shift+Return。
const TERMINAL_BUNDLE_PREFIXES: &[&str] = &[
"com.apple.terminal",
"com.googlecode.iterm2",
"dev.warp.warp",
Expand All @@ -221,6 +221,15 @@ const BLOCKED_BUNDLE_PREFIXES: &[&str] = &[
"com.mitchellh.ghostty",
];

fn bundle_id_starts_with_any(bundle_id: &str, prefixes: &[&str]) -> bool {
let lowered = bundle_id.to_ascii_lowercase();
prefixes.iter().any(|prefix| lowered.starts_with(prefix))
}

pub(crate) fn is_terminal_bundle_id(bundle_id: &str) -> bool {
bundle_id_starts_with_any(bundle_id, TERMINAL_BUNDLE_PREFIXES)
}

/// 闸门判定。返回 `Some(reason)` 表示拦下,`None` 表示放行。
///
/// 判定顺序按「代价从低到高」:Secure Input 和 bundle 前缀不需要 AX,先判;
Expand All @@ -230,10 +239,8 @@ pub fn evaluate_gate(inputs: &GateInputs) -> Option<BlockReason> {
return Some(BlockReason::SecureInput);
}
if let Some(bundle) = inputs.bundle_id.as_deref() {
let lowered = bundle.to_ascii_lowercase();
if BLOCKED_BUNDLE_PREFIXES
.iter()
.any(|prefix| lowered.starts_with(prefix))
if bundle_id_starts_with_any(bundle, SENSITIVE_BUNDLE_PREFIXES)
|| is_terminal_bundle_id(bundle)
{
return Some(BlockReason::BlockedApp);
}
Expand Down
29 changes: 25 additions & 4 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,17 +132,22 @@ pub enum WindowsSendInputNewlineMode {

/// macOS 逐字上屏时换行符怎么发。仅流式插入路径生效。
///
/// 默认 `ShiftReturn`:macOS 把 U+000A 当 Return 键,而聊天框里 Return 就是「发送」——
/// 一条带空行的两段话会被从中间劈开发出去。Shift+Return 在聊天框是软换行,在编辑器 /
/// 终端 / 网页输入框里就是普通换行。
/// 默认 `Auto`:已知终端应用发送 U+000A,其它或未知应用发送 Shift+Return。
///
/// Terminal.app 无法区分 Shift+Return 和 Return,里面的 Codex / Claude Code 等 TUI
/// 会把它当成「提交」。`LineFeed` 恢复发送 U+000A,让这些 TUI 将其识别为 Ctrl+J 软换行。
///
/// 保留 `Return` 是因为风格市场里有靠换行发多条消息的风格包,那种效果需要真回车。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum MacosNewlineMode {
/// Shift+Return:聊天框软换行,不发送
/// 按听写开始时捕获的前台应用自动选择;未知应用安全回退到 Shift+Return。
#[default]
Auto,
/// Shift+Return:聊天框软换行,不发送。
ShiftReturn,
/// U+000A:Terminal.app / CLI Agent 中作为 Ctrl+J 软换行。
LineFeed,
/// Return:聊天框里等于发送 —— 想要「一段话拆成多条消息」的风格包用这个。
Return,
}
Expand Down Expand Up @@ -3706,6 +3711,22 @@ mod tests {
assert!(!json.contains("windowsSendinputNewlineMode"));
}

#[test]
fn macos_newline_mode_defaults_to_auto() {
let prefs: UserPreferences = serde_json::from_str("{}").unwrap();
assert_eq!(prefs.macos_newline_mode, MacosNewlineMode::Auto);
}

#[test]
fn macos_newline_mode_round_trips_line_feed() {
let prefs: UserPreferences =
serde_json::from_str(r#"{"macosNewlineMode":"lineFeed"}"#).unwrap();
assert_eq!(prefs.macos_newline_mode, MacosNewlineMode::LineFeed);

let json = serde_json::to_string(&prefs).unwrap();
assert!(json.contains(r#""macosNewlineMode":"lineFeed""#));
}

#[test]
fn windows_sendinput_insertion_only_serializes_frontend_wire_key() {
let enabled = UserPreferences {
Expand Down
55 changes: 47 additions & 8 deletions openless-all/app/src-tauri/src/unicode_keystroke.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ mod macos_impl {
pub(super) enum MacKeystroke {
/// 换行:发真实的 Shift+Return 按键(聊天框软换行)。
ShiftReturn,
/// 换行:发送 Unicode U+000A(Terminal.app / CLI Agent 中作为 Ctrl+J 软换行)。
LineFeed,
/// 换行:发真实的 Return 按键(聊天框里等于发送)。
Return,
/// CR:不发任何键。`\r\n` 里它只是 `\n` 的前缀,发了会变成两个换行;
Expand All @@ -113,22 +115,29 @@ mod macos_impl {
Unicode,
}

/// **换行必须走真实按键,不能当普通 Unicode 字符发。**
/// 默认用真实 Shift+Return;Terminal.app / CLI Agent 可显式选择 Unicode U+000A。
///
/// macOS 的文本输入系统看到 U+000A 就当作 Return —— 在微信 / Slack / Telegram
/// 这类聊天框里等价于「发送」。曾经有一条带空行的两段话被逐字上屏,第一个 `\n`
/// 直接把上半句发了出去,下半句留在了输入框里。
///
/// 默认发 Shift+Return:在聊天框是「软换行」(不发送),在编辑器 / 终端 / 网页
/// textarea 里就是普通换行 —— 两边都对。Windows 侧早有同款结论(见
/// 默认发 Shift+Return:在聊天框是「软换行」(不发送),在编辑器 / 网页 textarea
/// 里就是普通换行。Windows 侧早有同款结论(见
/// `WindowsSendInputNewlineMode::ShiftEnter`,设置文案直接写着「聊天框选它」)。
///
/// Terminal.app 不区分 Shift+Return 和 Return,Codex / Claude Code 等 TUI 会把两者
/// 都当作「提交」。`LineFeed` 发送 U+000A,让这些 TUI 将其识别为 Ctrl+J 软换行。
///
/// 用户可以在设置里改成 `Return`:风格市场上有靠换行把一段话拆成多条消息的风格包,
/// 那种效果要的正是真回车。
pub(super) fn classify_mac_keystroke(ch: char, mode: MacosNewlineMode) -> MacKeystroke {
match ch {
'\n' => match mode {
// `Auto` 通常会在逐字上屏任务启动前解析;缺少前台应用上下文的调用方
// 使用对聊天框安全的 Shift+Return 兜底。
MacosNewlineMode::Auto => MacKeystroke::ShiftReturn,
MacosNewlineMode::ShiftReturn => MacKeystroke::ShiftReturn,
MacosNewlineMode::LineFeed => MacKeystroke::LineFeed,
MacosNewlineMode::Return => MacKeystroke::Return,
},
'\r' => MacKeystroke::Swallow,
Expand Down Expand Up @@ -162,6 +171,7 @@ mod macos_impl {
for ch in text.chars() {
let sent = match classify_mac_keystroke(ch, newline_mode) {
MacKeystroke::ShiftReturn => send_shift_return(),
MacKeystroke::LineFeed => send_line_feed(),
MacKeystroke::Return => send_return(),
// 吞掉的 char 也要计数:调用方(`flush_streaming_insert_buffer_with`)
// 拿 `typed_chars` 和 `delta.chars().count()` 比对,少一个就判定
Expand Down Expand Up @@ -204,6 +214,12 @@ mod macos_impl {
post_key_event(KEY_RETURN, KCG_EVENT_FLAG_MASK_SHIFT, None)
}

/// 发送 Unicode U+000A。Terminal.app 会把它转给 TUI,Codex / Claude Code 等将其
/// 识别为 Ctrl+J 软换行,而不是普通 Return 的「提交」。
fn send_line_feed() -> Result<(), TypeError> {
send_one_codepoint('\n')
}

/// 发一次不带修饰键的 Return。聊天框里这等于「发送」——只有用户在设置里明确选了
/// [`MacosNewlineMode::Return`] 才会走到这里。
fn send_return() -> Result<(), TypeError> {
Expand Down Expand Up @@ -666,16 +682,16 @@ mod linux_impl {
mod tests {
use super::TypeError;

/// 默认模式下换行走 Shift+Return —— macOS 把 U+000A 当 Return,聊天框里等于
/// 「发送」,一条带空行的两段话会被从中间劈开发出去
/// 没有前台应用上下文时,未解析的 Auto 安全回退到 Shift+Return,避免聊天框里
/// U+000A 被当作 Return 后直接发送
#[test]
#[cfg(target_os = "macos")]
fn newline_defaults_to_shift_return() {
fn unresolved_auto_mode_falls_back_to_shift_return() {
use super::macos_impl::{classify_mac_keystroke, MacKeystroke};
use crate::types::MacosNewlineMode;

let mode = MacosNewlineMode::default();
assert_eq!(mode, MacosNewlineMode::ShiftReturn, "默认必须是不发送的那个");
assert_eq!(mode, MacosNewlineMode::Auto);
assert_eq!(
classify_mac_keystroke('\n', mode),
MacKeystroke::ShiftReturn
Expand Down Expand Up @@ -710,6 +726,24 @@ mod tests {
);
}

/// Terminal.app 不区分 Shift+Return 和 Return;显式 LineFeed 模式必须改发
/// Unicode U+000A,供 Codex / Claude Code 等 TUI 识别为 Ctrl+J 软换行。
#[test]
#[cfg(target_os = "macos")]
fn line_feed_mode_sends_unicode_lf_for_terminal_cli_agents() {
use super::macos_impl::{classify_mac_keystroke, MacKeystroke};
use crate::types::MacosNewlineMode;

assert_eq!(
classify_mac_keystroke('\n', MacosNewlineMode::LineFeed),
MacKeystroke::LineFeed
);
assert_eq!(
classify_mac_keystroke('中', MacosNewlineMode::LineFeed),
MacKeystroke::Unicode
);
}

/// 计数契约:`type_unicode_chunk` 返回的 typed_chars 必须等于输入的 char 数,
/// 连被吞掉的 `\r` 也要算 —— 调用方拿它跟 `delta.chars().count()` 比对,
/// 少一个就判定「部分失败」并丢弃后面所有 delta。
Expand All @@ -719,7 +753,12 @@ mod tests {
use super::macos_impl::classify_mac_keystroke;
use crate::types::MacosNewlineMode;

for mode in [MacosNewlineMode::ShiftReturn, MacosNewlineMode::Return] {
for mode in [
MacosNewlineMode::Auto,
MacosNewlineMode::ShiftReturn,
MacosNewlineMode::LineFeed,
MacosNewlineMode::Return,
] {
let text = "上半句\r\n\r\n下半句";
// 每个 char 都会被分类成某一种处理方式,没有漏网的。
let counted = text
Expand Down
4 changes: 3 additions & 1 deletion openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,8 +879,10 @@ export const en: typeof zhCN = {
windowsInsertionModeSendInput: 'SendInput keystroke simulation',
windowsInsertionModePaste: 'Clipboard paste (Ctrl+V, etc.)',
macosNewlineModeLabel: 'Line breaks',
macosNewlineModeDesc: 'Which key a line break sends while typing out text. A plain Return means "send" in chat apps — keep Shift+Return unless your style pack deliberately splits one dictation into several messages.',
macosNewlineModeDesc: 'Auto uses Line Feed (U+000A / Ctrl+J) in known terminal apps and Shift+Return elsewhere. Plain Return sends the message.',
macosNewlineModeAuto: 'Auto (Line Feed in terminals)',
macosNewlineModeShiftReturn: 'Shift+Return (newline in chat)',
macosNewlineModeLineFeed: 'Line Feed (terminal CLI / Ctrl+J)',
macosNewlineModeReturn: 'Return (split into messages)',
windowsSendInputNewlineModeLabel: 'SendInput newline simulation',
windowsSendInputNewlineModeDesc: 'How SendInput turns line breaks into keys. Use Shift+Enter for chat boxes; Enter for Notepad / VS Code and most editors.',
Expand Down
4 changes: 3 additions & 1 deletion openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -881,8 +881,10 @@ export const ja: typeof zhCN = {
windowsInsertionModeSendInput: 'SendInput キー入力シミュレーション',
windowsInsertionModePaste: 'クリップボード貼り付け(Ctrl+V など)',
macosNewlineModeLabel: '改行の送り方',
macosNewlineModeDesc: '逐次入力時に改行をどのキーで送るか。チャットでは通常の Return は「送信」になります。改行で複数メッセージに分けるスタイルパックを使う場合以外は Shift+Return のままに。',
macosNewlineModeDesc: '自動では既知のターミナルアプリに Line Feed(U+000A / Ctrl+J)、それ以外に Shift+Return を送ります。通常の Return は送信になります。',
macosNewlineModeAuto: '自動(ターミナルでは Line Feed)',
macosNewlineModeShiftReturn: 'Shift+Return(チャットで改行)',
macosNewlineModeLineFeed: 'Line Feed(ターミナル CLI / Ctrl+J)',
macosNewlineModeReturn: 'Return(複数メッセージに分割)',
windowsSendInputNewlineModeLabel: 'SendInput 改行シミュレーション',
windowsSendInputNewlineModeDesc: 'SendInput で改行をどのキーとして送るか。チャット入力は Shift+Enter、メモ帳 / VS Code などは Enter。',
Expand Down
Loading
Loading