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
2 changes: 1 addition & 1 deletion openless-all/app/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
152 changes: 140 additions & 12 deletions openless-all/app/src-tauri/src/selection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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)]
Expand Down Expand Up @@ -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")]
{
Expand All @@ -99,19 +118,30 @@ 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()
}
}

/// 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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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::{
Expand Down
6 changes: 4 additions & 2 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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>
Expand Down
Loading