From 5fe872551e0593806314aef38a8642d7583785fd Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 6 Aug 2026 04:52:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(selection-polish):=20macOS=20=E7=A7=BB?= =?UTF-8?q?=E6=A4=8D=E2=80=94=E2=80=94=E5=89=8D=E5=8F=B0=E5=BA=94=E7=94=A8?= =?UTF-8?q?+=E9=80=89=E5=8C=BA=E6=96=87=E6=9C=AC=E6=8C=87=E7=BA=B9?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E4=B8=8E=E9=BB=98=E8=AE=A4=E7=83=AD=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - selection.rs:SelectionInsertionTarget 增加 macos 字段(前台应用名 + pid); capture/is_captured/validate/reactivate 四函数实现 macOS 分支: * validate = 前台应用一致性 + 选区重读(AX 直读,失败退化为模拟 Cmd+C)比较 * reactivate = NSRunningApplication activateWithOptions 交还焦点 - types.rs:macOS 默认选中润色热键 = RightAlt(右 Option,CGEventTap keycode 61 可区分左右,不占用 Cmd/Ctrl 常用组合),与 Windows 保持一致 - capabilities:selection-polish-preview 窗口加入默认能力列表(预览窗事件 listen 之前会被 ACL 拒绝) - SelectionPolishSection:设置入口去掉 os==='win' 门控,macOS 可见 - 单测:selection_text_matches 随 cfg 扩展在 macOS 编译,940 pass --- .../app/src-tauri/capabilities/default.json | 2 +- openless-all/app/src-tauri/src/selection.rs | 152 ++++++++++++++++-- openless-all/app/src-tauri/src/types.rs | 6 +- .../pages/settings/SelectionPolishSection.tsx | 7 +- 4 files changed, 148 insertions(+), 19 deletions(-) diff --git a/openless-all/app/src-tauri/capabilities/default.json b/openless-all/app/src-tauri/capabilities/default.json index 759ae4f27..d0379b11f 100644 --- a/openless-all/app/src-tauri/capabilities/default.json +++ b/openless-all/app/src-tauri/capabilities/default.json @@ -3,7 +3,7 @@ "identifier": "default", "description": "Default capabilities for OpenLess windows", "platforms": ["macOS", "windows", "linux"], - "windows": ["main", "capsule", "qa", "less-computer", "less-computer-glow"], + "windows": ["main", "capsule", "qa", "less-computer", "less-computer-glow", "selection-polish-preview"], "permissions": [ "core:default", "core:window:default", diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index 6019758b4..6c5f07bbf 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -40,11 +40,20 @@ pub struct SelectionContext { /// On Windows, a top-level HWND alone is not enough: clicking another editor /// pane in the same app can retain that HWND. We therefore retain both the /// foreground window and the focused child control, plus their process/thread -/// identities. Other platforms retain their existing insertion behavior. +/// identities. +/// +/// On macOS we have no HWND equivalent; the closest robust fingerprint is the +/// frontmost application (name + pid) plus the selected-text snapshot itself. +/// Revalidation re-reads the current selection via AX (with the simulated +/// Cmd+C fallback) and compares it to the captured text — if the user moved to +/// another app or changed the selection during the cloud request, we refuse to +/// paste. #[derive(Debug, Clone, Default)] pub(crate) struct SelectionInsertionTarget { #[cfg(target_os = "windows")] windows: Option, + #[cfg(target_os = "macos")] + macos: Option, } #[cfg(target_os = "windows")] @@ -58,6 +67,15 @@ struct WindowsSelectionTarget { focused_thread_id: u32, } +#[cfg(target_os = "macos")] +#[derive(Debug, Clone)] +struct MacosSelectionTarget { + /// 捕获时的前台应用(NSWorkspace frontmostApplication,`name (bundle)` 形式)。 + front_app: Option, + /// 捕获时的前台应用 pid —— 预览确认后用它把焦点交还原应用。 + front_app_pid: Option, +} + /// Result of the final target/selection revalidation immediately before a /// Selection Polish result could be pasted. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -89,8 +107,9 @@ pub struct SelectionCaptureOutcome { /// Snapshot the insertion target before starting an asynchronous Selection /// Polish request. Windows is intentionally fail-closed when this cannot -/// identify a concrete foreground target; macOS/Linux/mobile keep their -/// existing behavior until they gain an equivalently reliable native check. +/// identify a concrete foreground target; macOS records the frontmost app so +/// it can prove (by app + selection-text fingerprint) that the target did not +/// change before inserting. pub(crate) fn capture_selection_insertion_target() -> SelectionInsertionTarget { #[cfg(target_os = "windows")] { @@ -99,7 +118,17 @@ pub(crate) fn capture_selection_insertion_target() -> SelectionInsertionTarget { }; } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + return SelectionInsertionTarget { + macos: Some(MacosSelectionTarget { + front_app: current_front_app(), + front_app_pid: current_front_app_pid(), + }), + }; + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { SelectionInsertionTarget::default() } @@ -107,11 +136,12 @@ pub(crate) fn capture_selection_insertion_target() -> SelectionInsertionTarget { /// Whether the target snapshot is sufficient to start a Selection Polish /// request. On Windows, do not send selected text to the provider if we cannot -/// later prove where it is safe to replace it. +/// later prove where it is safe to replace it. On macOS the frontmost-app +/// snapshot is always available (there is always a frontmost app), so this +/// passes once we have it. /// -/// 非 Windows(macOS / Linux)尚未实现等效的前台窗口/焦点控件校验,无法保证 -/// 云端等待期间结果不会落到用户切换后的应用或控件上,因此一律 fail-closed: -/// 不把选区文本发给 provider,选区润色在非 Windows 平台不可用。 +/// 非 Windows/macOS(Linux / mobile)尚未实现等效的前台校验:Linux 依赖 +/// PRIMARY selection 重读做轻量校验,移动端不提供选区润色。 pub(crate) fn selection_insertion_target_is_captured( target: &SelectionInsertionTarget, ) -> bool { @@ -120,7 +150,12 @@ pub(crate) fn selection_insertion_target_is_captured( target.windows.is_some() } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + target.macos.is_some() + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { let _ = target; false @@ -163,13 +198,52 @@ pub(crate) fn validate_selection_insertion_target( return SelectionInsertionTargetValidation::Valid; } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + let Some(captured) = target.macos.as_ref() else { + return SelectionInsertionTargetValidation::TargetUnavailable; + }; + // 前台应用一致性:云端等待期间用户切到别的应用 = 目标变更,拒绝粘贴 + //(预览确认模式在 validate 前已 reactivate 回原应用,此处应一致)。 + let front_now = current_front_app(); + if captured + .front_app + .as_deref() + .is_some_and(|name| front_now.as_deref() != Some(name)) + { + return SelectionInsertionTargetValidation::TargetChanged; + } + // 选区文本一致性:AX 直读(与捕获同路径),失败再走模拟 Cmd+C 兜底。 + let current_selection = read_selection_for_validation(); + if !selection_text_matches(expected_selection, current_selection.as_deref()) { + return SelectionInsertionTargetValidation::SelectionChanged; + } + return SelectionInsertionTargetValidation::Valid; + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { let _ = (target, expected_selection); SelectionInsertionTargetValidation::Valid } } +/// macOS 专用:以与捕获时相同的形式(trim + truncate)重读当前选区,供 +/// validate 与 expected_selection 比较。AX 未授权或直读失败时退化为模拟 +/// Cmd+C + 剪贴板快照(与 `capture_selection_with_status` 的兜底一致)。 +#[cfg(target_os = "macos")] +fn read_selection_for_validation() -> Option { + if let Some(text) = macos_ax::read_selected_text() { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(truncate_selection(trimmed)); + } + } + let text = simulate_copy_and_read()?; + let trimmed = text.trim(); + (!trimmed.is_empty()).then(|| truncate_selection(trimmed)) +} + /// 把确认预览后的焦点交还给最初的选区目标。预览窗允许编辑,因此确认时必然不再是 /// 原应用的前台窗口;这里先恢复原目标,再沿用上面的严格选区校验,避免盲目粘贴。 pub(crate) fn reactivate_selection_insertion_target(target: &SelectionInsertionTarget) -> bool { @@ -190,13 +264,47 @@ pub(crate) fn reactivate_selection_insertion_target(target: &SelectionInsertionT return true; } - #[cfg(not(target_os = "windows"))] + #[cfg(target_os = "macos")] + { + let Some(captured) = target.macos.as_ref() else { + return false; + }; + let Some(pid) = captured.front_app_pid else { + return false; + }; + // 预览窗是 OpenLess 自己的窗口,确认后需要把焦点交还原应用再粘贴。 + activate_app_by_pid(pid); + std::thread::sleep(Duration::from_millis(120)); + return true; + } + + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { let _ = target; true } } +/// macOS 专用:把指定 pid 的应用带回前台(NSRunningApplication activate, +/// NSApplicationActivateIgnoringOtherApps = 1)。失败静默——validate 仍会 +/// 以选区文本一致性兜底。 +#[cfg(target_os = "macos")] +fn activate_app_by_pid(pid: i32) { + use objc2::msg_send; + use objc2::runtime::AnyClass; + unsafe { + let Some(cls) = AnyClass::get("NSRunningApplication") else { + return; + }; + let app: *mut objc2::runtime::AnyObject = + msg_send![cls, runningApplicationWithProcessIdentifier: pid]; + if app.is_null() { + return; + } + let _: () = msg_send![app, activateWithOptions: 1u64]; // IgnoringOtherApps + } +} + /// 捕获选区并返回可向用户展示的非阻断平台提醒。 /// 目前仅 Linux 在 `wl-paste`、`xclip`、`xsel` 均未安装时返回提醒码。 pub fn capture_selection_with_status() -> SelectionCaptureOutcome { @@ -369,7 +477,7 @@ fn selected_text_for_validation() -> Option { (!trimmed.is_empty()).then(|| truncate_selection(trimmed)) } -#[cfg(any(target_os = "windows", test))] +#[cfg(any(target_os = "windows", target_os = "macos", test))] fn selection_text_matches(expected: &str, actual: Option<&str>) -> bool { actual.is_some_and(|actual| actual == expected) } @@ -859,6 +967,26 @@ unsafe fn ns_string_to_rust(ns_string: *mut objc2::runtime::AnyObject) -> Option } } +#[cfg(target_os = "macos")] +fn current_front_app_pid() -> Option { + use objc2::msg_send; + use objc2::runtime::AnyClass; + + unsafe { + let cls = AnyClass::get("NSWorkspace")?; + let workspace: *mut objc2::runtime::AnyObject = msg_send![cls, sharedWorkspace]; + if workspace.is_null() { + return None; + } + let app: *mut objc2::runtime::AnyObject = msg_send![workspace, frontmostApplication]; + if app.is_null() { + return None; + } + let pid: i32 = msg_send![app, processIdentifier]; + (pid > 0).then_some(pid) + } +} + #[cfg(target_os = "windows")] fn current_front_app() -> Option { use windows::Win32::UI::WindowsAndMessaging::{ diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index b1a49a8e8..02881c9d5 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1667,14 +1667,16 @@ fn default_qa_hotkey() -> Option { } fn default_selection_polish_hotkey() -> Option { - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] { + // Windows 用右 Alt;macOS 上 RightAlt = 右 Option(CGEventTap keycode 61, + // 可区分左右键,且不占用 Cmd/Ctrl 常用组合)。 Some(ShortcutBinding { primary: "RightAlt".into(), modifiers: Vec::new(), }) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { None } diff --git a/openless-all/app/src/pages/settings/SelectionPolishSection.tsx b/openless-all/app/src/pages/settings/SelectionPolishSection.tsx index 8b9f12cff..7ae6f9074 100644 --- a/openless-all/app/src/pages/settings/SelectionPolishSection.tsx +++ b/openless-all/app/src/pages/settings/SelectionPolishSection.tsx @@ -10,7 +10,6 @@ import { getPlatformCapabilities } from '../../lib/platform'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { Card } from '../_atoms'; import { SectionTitle, SettingRow, chipSelectedStyle, segmentedTrackStyle } from './shared'; -import { detectOS } from '../../components/WindowChrome'; const outputOptions: Array<{ value: SelectionPolishOutputMode }> = [ { value: 'directReplace' }, @@ -19,14 +18,14 @@ const outputOptions: Array<{ value: SelectionPolishOutputMode }> = [ export function SelectionPolishSection() { const { t } = useTranslation(); - const os = detectOS(); const { prefs, capability, refresh, updatePrefs } = useHotkeySettings(); const [platformCaps, setPlatformCaps] = useState(null); useEffect(() => { void getPlatformCapabilities().then(setPlatformCaps); }, []); - // 选区润色的安全替换依赖 Windows 前台窗口/焦点控件校验,macOS/Linux 尚未实现,仅 Windows 提供设置入口。 - if (!prefs || !capability || !platformCaps?.supportsDesktopHotkey || os !== 'win') return null; + // 选区润色的安全替换:Windows 用前台窗口/焦点控件校验,macOS 用前台应用 + + // 选区文本指纹校验;两者都具备后才提供设置入口(Linux 热键接入后同样可用)。 + if (!prefs || !capability || !platformCaps?.supportsDesktopHotkey) return null; return (