Merge pull request #106 from Cooper-X-Oak/codex/fix-windows-gnu-export-ordinal
fix(windows-gnu): 禁止 MinGW 为 cdylib 自动导出符号
This commit is contained in:
9 files changed
+251
-7
No files matched your search
@@ -0,0 +1,4 @@
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
# MinGW's PE linker auto-exports Rust symbols from the desktop-only cdylib,
|
||||
# which overflows the export ordinal table during GNU builds/tests.
|
||||
rustflags = ["-C", "link-arg=-Wl,--exclude-all-symbols"]
|
||||
@@ -388,8 +388,8 @@ try {
|
||||
if ([string]::IsNullOrWhiteSpace($latest.rawTranscript) -or [string]::IsNullOrWhiteSpace($latest.finalText)) {
|
||||
throw "Latest history item is missing rawTranscript or finalText."
|
||||
}
|
||||
if ($latest.insertStatus -ne "copiedFallback") {
|
||||
throw "Expected Windows insertStatus copiedFallback, got '$($latest.insertStatus)'."
|
||||
if (@("copiedFallback", "pasteSent") -notcontains $latest.insertStatus) {
|
||||
throw "Expected Windows insertStatus copiedFallback or pasteSent, got '$($latest.insertStatus)'."
|
||||
}
|
||||
|
||||
Focus-Window $inputTarget.Process | Out-Null
|
||||
|
||||
@@ -59,6 +59,7 @@ struct SessionState {
|
||||
/// 用户在 Processing 阶段按 Esc 取消:end_session 在 polish/insert 检查点跳过插入 +
|
||||
/// 跳过 history.append。issue #52。
|
||||
cancelled: bool,
|
||||
focus_target: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for SessionState {
|
||||
@@ -68,6 +69,7 @@ impl Default for SessionState {
|
||||
started_at: Instant::now(),
|
||||
pending_stop: false,
|
||||
cancelled: false,
|
||||
focus_target: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -434,6 +436,7 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
// 新会话清掉旧 pending_stop / cancelled,避免上一会话遗留触发奇怪行为
|
||||
state.pending_stop = false;
|
||||
state.cancelled = false;
|
||||
state.focus_target = capture_focus_target();
|
||||
}
|
||||
|
||||
#[cfg(any(debug_assertions, test))]
|
||||
@@ -801,6 +804,8 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let focus_target = inner.state.lock().focus_target;
|
||||
restore_focus_target_if_possible(focus_target);
|
||||
let status = inner.inserter.insert(&polished);
|
||||
let inserted_chars = polished.chars().count() as u32;
|
||||
|
||||
@@ -850,6 +855,7 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
} else {
|
||||
match status {
|
||||
InsertStatus::Inserted => None,
|
||||
InsertStatus::PasteSent => Some("已尝试粘贴".to_string()),
|
||||
InsertStatus::CopiedFallback => Some(if cfg!(target_os = "windows") {
|
||||
"已复制,请 Ctrl+V".to_string()
|
||||
} else {
|
||||
@@ -868,7 +874,11 @@ async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
Some(inserted_chars),
|
||||
);
|
||||
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
{
|
||||
let mut state = inner.state.lock();
|
||||
state.phase = SessionPhase::Idle;
|
||||
state.focus_target = None;
|
||||
}
|
||||
schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS);
|
||||
|
||||
Ok(())
|
||||
@@ -902,7 +912,9 @@ fn cancel_session(inner: &Arc<Inner>) {
|
||||
// Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾;
|
||||
// 其他阶段直接转 Idle。
|
||||
if phase != SessionPhase::Processing {
|
||||
inner.state.lock().phase = SessionPhase::Idle;
|
||||
let mut state = inner.state.lock();
|
||||
state.phase = SessionPhase::Idle;
|
||||
state.focus_target = None;
|
||||
}
|
||||
emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None);
|
||||
log::info!("[coord] session cancelled (was {phase:?})");
|
||||
@@ -1233,6 +1245,95 @@ fn schedule_capsule_idle(inner: &Arc<Inner>, delay_ms: u64) {
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn capture_focus_target() -> Option<usize> {
|
||||
use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow;
|
||||
|
||||
let foreground = unsafe { GetForegroundWindow() };
|
||||
if foreground.0.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(foreground.0 as usize)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn capture_focus_target() -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn restore_focus_target_if_possible(target: Option<usize>) {
|
||||
use std::ffi::c_void;
|
||||
use std::time::Duration;
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
GetForegroundWindow, IsIconic, IsWindow, SetForegroundWindow, ShowWindow, SW_RESTORE,
|
||||
};
|
||||
|
||||
let Some(raw_target) = target else { return };
|
||||
let hwnd = HWND(raw_target as *mut c_void);
|
||||
if hwnd.0.is_null() {
|
||||
return;
|
||||
}
|
||||
if !unsafe { IsWindow(hwnd).as_bool() } {
|
||||
return;
|
||||
}
|
||||
|
||||
let foreground = unsafe { GetForegroundWindow() };
|
||||
if foreground == hwnd {
|
||||
return;
|
||||
}
|
||||
|
||||
if unsafe { IsIconic(hwnd).as_bool() } {
|
||||
let _ = unsafe { ShowWindow(hwnd, SW_RESTORE) };
|
||||
}
|
||||
let _ = unsafe { SetForegroundWindow(hwnd) };
|
||||
std::thread::sleep(Duration::from_millis(60));
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn restore_focus_target_if_possible(_target: Option<usize>) {}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn show_capsule_window_no_activate() -> bool {
|
||||
use std::iter::once;
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Foundation::HWND;
|
||||
use windows::Win32::UI::WindowsAndMessaging::{
|
||||
FindWindowW, SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE,
|
||||
SWP_NOSIZE, SWP_SHOWWINDOW, SW_SHOWNOACTIVATE,
|
||||
};
|
||||
|
||||
let title: Vec<u16> = "OpenLess Capsule".encode_utf16().chain(once(0)).collect();
|
||||
let hwnd = match unsafe { FindWindowW(PCWSTR::null(), PCWSTR(title.as_ptr())) } {
|
||||
Ok(hwnd) => hwnd,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if hwnd == HWND::default() || hwnd.0.is_null() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) };
|
||||
let _ = unsafe {
|
||||
SetWindowPos(
|
||||
hwnd,
|
||||
HWND_TOPMOST,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW,
|
||||
)
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn show_capsule_window_no_activate() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn emit_capsule(
|
||||
inner: &Arc<Inner>,
|
||||
state: CapsuleState,
|
||||
@@ -1255,7 +1356,13 @@ fn emit_capsule(
|
||||
if let Some(window) = app.get_webview_window("capsule") {
|
||||
let visible = !matches!(state, CapsuleState::Idle);
|
||||
if show_capsule && visible {
|
||||
let _ = window.show();
|
||||
if cfg!(target_os = "windows") {
|
||||
if !show_capsule_window_no_activate() {
|
||||
let _ = window.show();
|
||||
}
|
||||
} else {
|
||||
let _ = window.show();
|
||||
}
|
||||
// 胶囊 show() 在 macOS 会调 makeKeyAndOrderFront: 抢走主窗口焦点。
|
||||
// 若 OpenLess 已是前台 app,用 makeKeyWindow 还原主窗口(不激活 NSApp)。
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -10,8 +10,13 @@
|
||||
//! → CGEventPost,跟我们这里完全同源。
|
||||
//! 3. 其他平台 (Windows/Linux) 仍用 enigo。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::types::InsertStatus;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(150);
|
||||
|
||||
pub struct TextInserter;
|
||||
|
||||
impl TextInserter {
|
||||
@@ -20,6 +25,16 @@ impl TextInserter {
|
||||
}
|
||||
|
||||
/// Insert `text` at the current cursor position.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn insert(&self, text: &str) -> InsertStatus {
|
||||
if text.is_empty() {
|
||||
return InsertStatus::CopiedFallback;
|
||||
}
|
||||
insert_with_clipboard_restore(text)
|
||||
}
|
||||
|
||||
/// Insert `text` at the current cursor position.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn insert(&self, text: &str) -> InsertStatus {
|
||||
if text.is_empty() {
|
||||
return InsertStatus::CopiedFallback;
|
||||
@@ -41,6 +56,13 @@ impl Default for TextInserter {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[derive(Debug)]
|
||||
struct ClipboardRestorePlan {
|
||||
inserted_text: String,
|
||||
previous_text: Option<String>,
|
||||
}
|
||||
|
||||
fn copy_to_clipboard(text: &str) -> bool {
|
||||
let mut clipboard = match arboard::Clipboard::new() {
|
||||
Ok(c) => c,
|
||||
@@ -56,6 +78,93 @@ fn copy_to_clipboard(text: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn copy_to_clipboard_with_restore_plan(text: &str) -> Result<ClipboardRestorePlan, String> {
|
||||
let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
|
||||
let previous_text = match clipboard.get_text() {
|
||||
Ok(existing) => Some(existing),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[insertion] clipboard get_text failed before overwrite: {}",
|
||||
err
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
clipboard
|
||||
.set_text(text.to_string())
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(ClipboardRestorePlan {
|
||||
inserted_text: text.to_string(),
|
||||
previous_text,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn insert_with_clipboard_restore(text: &str) -> InsertStatus {
|
||||
let restore_plan = match copy_to_clipboard_with_restore_plan(text) {
|
||||
Ok(plan) => plan,
|
||||
Err(err) => {
|
||||
log::error!("[insertion] clipboard write failed: {}", err);
|
||||
return InsertStatus::Failed;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = simulate_paste() {
|
||||
log::warn!("[insertion] simulated paste failed: {}", err);
|
||||
return InsertStatus::CopiedFallback;
|
||||
}
|
||||
|
||||
maybe_restore_clipboard(restore_plan);
|
||||
insertion_success_status()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn maybe_restore_clipboard(plan: ClipboardRestorePlan) {
|
||||
if plan.previous_text.is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
std::thread::sleep(CLIPBOARD_RESTORE_DELAY);
|
||||
|
||||
let mut clipboard = match arboard::Clipboard::new() {
|
||||
Ok(clipboard) => clipboard,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[insertion] clipboard re-open failed during restore: {}",
|
||||
err
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let current_text = match clipboard.get_text() {
|
||||
Ok(current) => Some(current),
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"[insertion] clipboard get_text failed during restore: {}",
|
||||
err
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if !should_restore_clipboard(current_text.as_deref(), &plan.inserted_text) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(previous_text) = plan.previous_text {
|
||||
if let Err(err) = clipboard.set_text(previous_text) {
|
||||
log::warn!("[insertion] clipboard restore failed: {}", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn should_restore_clipboard(current_text: Option<&str>, inserted_text: &str) -> bool {
|
||||
matches!(current_text, Some(current) if current == inserted_text)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn simulate_paste() -> Result<(), String> {
|
||||
if !matches!(
|
||||
@@ -92,7 +201,7 @@ fn insertion_success_status() -> InsertStatus {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn insertion_success_status() -> InsertStatus {
|
||||
// Windows/Linux 的 Ctrl+V 只能证明粘贴快捷键已发送,不能证明目标控件已接收。
|
||||
InsertStatus::CopiedFallback
|
||||
InsertStatus::PasteSent
|
||||
}
|
||||
|
||||
// ─────────────────────────── macOS native CGEvent paste ───────────────────────────
|
||||
@@ -172,3 +281,22 @@ mod macos {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn restore_only_when_clipboard_still_holds_inserted_text() {
|
||||
assert!(should_restore_clipboard(
|
||||
Some("dictated text"),
|
||||
"dictated text"
|
||||
));
|
||||
assert!(!should_restore_clipboard(
|
||||
Some("user changed clipboard"),
|
||||
"dictated text"
|
||||
));
|
||||
assert!(!should_restore_clipboard(None, "dictated text"));
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ impl PolishMode {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum InsertStatus {
|
||||
Inserted,
|
||||
PasteSent,
|
||||
CopiedFallback,
|
||||
Failed,
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ export const en: typeof zhCN = {
|
||||
chars: '{{count}} chars',
|
||||
vocabHits: '{{count}} vocab hits',
|
||||
inserted: 'Inserted',
|
||||
pasteSent: 'Paste sent',
|
||||
copiedFallback: 'Copied (use {{shortcut}})',
|
||||
insertFailed: 'Insert failed',
|
||||
confirmClear: 'Delete all {{count}} history entries? This cannot be undone.',
|
||||
|
||||
@@ -128,6 +128,7 @@ export const zhCN = {
|
||||
chars: '{{count}} 字',
|
||||
vocabHits: '{{count}} 个热词',
|
||||
inserted: '已插入',
|
||||
pasteSent: '已尝试粘贴',
|
||||
copiedFallback: '已复制(需 {{shortcut}})',
|
||||
insertFailed: '插入失败',
|
||||
confirmClear: '确定清空全部 {{count}} 条记录?此操作不可恢复。',
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
export type PolishMode = 'raw' | 'light' | 'structured' | 'formal';
|
||||
|
||||
export type InsertStatus = 'inserted' | 'copiedFallback' | 'failed';
|
||||
export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed';
|
||||
|
||||
export interface DictationSession {
|
||||
id: string;
|
||||
|
||||
@@ -200,6 +200,8 @@ export function History() {
|
||||
<span>{
|
||||
item.insertStatus === 'inserted'
|
||||
? t('history.inserted')
|
||||
: item.insertStatus === 'pasteSent'
|
||||
? t('history.pasteSent')
|
||||
: item.insertStatus === 'copiedFallback'
|
||||
? t('history.copiedFallback', { shortcut: detectOS() === 'win' ? 'Ctrl+V' : '⌘V' })
|
||||
: t('history.insertFailed')
|
||||
|
||||
Reference in new issue
Block a user