Merge pull request #106 from Cooper-X-Oak/codex/fix-windows-gnu-export-ordinal

fix(windows-gnu): 禁止 MinGW 为 cdylib 自动导出符号
This commit is contained in:
TRIP authored and GitHub committed 2026-05-01 08:14:03 +08:00
commit 27aa005bec
9 files changed
+251 -7

No files matched your search

+4
View File
@@ -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
+110 -3
View File
@@ -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")]
+129 -1
View File
@@ -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"));
}
}
+1
View File
@@ -32,6 +32,7 @@ impl PolishMode {
#[serde(rename_all = "camelCase")]
pub enum InsertStatus {
Inserted,
PasteSent,
CopiedFallback,
Failed,
}
+1
View File
@@ -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.',
+1
View File
@@ -128,6 +128,7 @@ export const zhCN = {
chars: '{{count}} 字',
vocabHits: '{{count}} 个热词',
inserted: '已插入',
pasteSent: '已尝试粘贴',
copiedFallback: '已复制(需 {{shortcut}})',
insertFailed: '插入失败',
confirmClear: '确定清空全部 {{count}} 条记录?此操作不可恢复。',
+1 -1
View File
@@ -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;
+2
View File
@@ -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')