feat(selection-polish): macOS 移植——前台应用+选区文本指纹校验与默认热键 (#926)

- 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

Co-authored-by: sim <sim@local>
This commit is contained in:
TRIPandsim authored and GitHub committed 2026-08-06 05:08:58 +08:00
1 parent df1a42c3a9
commit b62e2324c5
4 files changed
+148 -19

No files matched your search

@@ -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",
+140 -12
View File
@@ -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<WindowsSelectionTarget>,
#[cfg(target_os = "macos")]
macos: Option<MacosSelectionTarget>,
}
#[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<String>,
/// 捕获时的前台应用 pid —— 预览确认后用它把焦点交还原应用。
front_app_pid: Option<i32>,
}
/// 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<String> {
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<String> {
(!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<i32> {
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<String> {
use windows::Win32::UI::WindowsAndMessaging::{
+4 -2
View File
@@ -1667,14 +1667,16 @@ fn default_qa_hotkey() -> Option<ShortcutBinding> {
}
fn default_selection_polish_hotkey() -> Option<ShortcutBinding> {
#[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
}
@@ -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<PlatformCapabilities | null>(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 (
<Card>