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:
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",
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user