From dae2b744a76e12fa16dc5b3f34d6bae418945387 Mon Sep 17 00:00:00 2001 From: eerraa Date: Sat, 19 Sep 2026 12:51:50 +0900 Subject: [PATCH] fix(windows): copy OAuth links using the Unicode clipboard --- .github/workflows/windows-clipboard.yml | 30 +++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/oauth_server.rs | 7 ++ src-tauri/src/windows_clipboard.rs | 105 ++++++++++++++++++++++++ 4 files changed, 144 insertions(+) create mode 100644 .github/workflows/windows-clipboard.yml create mode 100644 src-tauri/src/windows_clipboard.rs diff --git a/.github/workflows/windows-clipboard.yml b/.github/workflows/windows-clipboard.yml new file mode 100644 index 0000000..8ee1610 --- /dev/null +++ b/.github/workflows/windows-clipboard.yml @@ -0,0 +1,30 @@ +name: Windows clipboard +on: + pull_request: + paths: ['src-tauri/src/windows_clipboard.rs', 'src-tauri/src/oauth_server.rs', '.github/workflows/windows-clipboard.yml'] + push: + branches: ['fix/windows-compat'] + workflow_dispatch: +permissions: + contents: read +jobs: + clipboard: + runs-on: windows-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + - uses: dtolnay/rust-toolchain@1.94.0 + - uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + - run: npm ci + - run: npm run build + - run: cargo check --locked --all-targets --manifest-path src-tauri/Cargo.toml + - run: cargo test --locked --manifest-path src-tauri/Cargo.toml --lib windows_clipboard::tests + - env: + CODEX_SWITCHER_DISPOSABLE_TESTS: '1' + run: cargo test --locked --manifest-path src-tauri/Cargo.toml --lib disposable_windows_clipboard_roundtrip -- --ignored diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6ae6eb2..344aefe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -35,6 +35,8 @@ mod switch_log; mod token_tracker; mod tray; mod usage; +#[cfg(windows)] +mod windows_clipboard; use account::{Account, AccountStore}; use chrono::Utc; diff --git a/src-tauri/src/oauth_server.rs b/src-tauri/src/oauth_server.rs index a10dfce..3d38bcf 100644 --- a/src-tauri/src/oauth_server.rs +++ b/src-tauri/src/oauth_server.rs @@ -268,9 +268,16 @@ fn parse_callback_input(input: &str) -> (Option, Option) { (None, None) } +#[cfg(windows)] +#[tauri::command] +pub async fn copy_to_clipboard(text: String) -> Result<(), String> { + crate::windows_clipboard::write_text(&text).await +} + /// macOS 端剪贴板写入:webview 的 `navigator.clipboard.writeText` 在跨过 await /// 后会丢失 user-gesture,触发 NotAllowedError;改走 pbcopy 通过 Tauri IPC 写入, /// 不依赖 user gesture,也避开 webview 权限提示。 +#[cfg(not(windows))] #[tauri::command] pub async fn copy_to_clipboard(text: String) -> Result<(), String> { use std::io::Write; diff --git a/src-tauri/src/windows_clipboard.rs b/src-tauri/src/windows_clipboard.rs new file mode 100644 index 0000000..0f53d1f --- /dev/null +++ b/src-tauri/src/windows_clipboard.rs @@ -0,0 +1,105 @@ +use std::process::Stdio; +use tokio::{ + io::AsyncWriteExt, + process::Command, + time::{timeout, Duration}, +}; + +fn encode_text(text: &str) -> Vec { + // ASCII stdin avoids codepage conversion; user text is never PowerShell syntax. + use base64::Engine; + base64::engine::general_purpose::STANDARD + .encode(text.as_bytes()) + .into_bytes() +} + +pub async fn write_text(text: &str) -> Result<(), String> { + let root = std::env::var_os("SystemRoot").ok_or("无法定位 Windows 系统目录")?; + let mut command = Command::new( + std::path::PathBuf::from(root).join("System32/WindowsPowerShell/v1.0/powershell.exe"), + ); + command.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-STA", "-Command", + "$ErrorActionPreference='Stop'; $text=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String([Console]::In.ReadToEnd())); Add-Type -AssemblyName System.Windows.Forms; if ($text.Length -eq 0) { [Windows.Forms.Clipboard]::Clear() } else { [Windows.Forms.Clipboard]::SetText($text, [Windows.Forms.TextDataFormat]::UnicodeText) }"]); + command.creation_flags(0x08000000); // CREATE_NO_WINDOW + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .map_err(|e| format!("无法启动剪贴板工具: {}", e))?; + let result = timeout(Duration::from_secs(20), async { + let mut stdin = child.stdin.take().ok_or("剪贴板输入不可写")?; + stdin + .write_all(&encode_text(text)) + .await + .map_err(|e| format!("写入剪贴板失败: {}", e))?; + drop(stdin); + let status = child + .wait() + .await + .map_err(|e| format!("等待剪贴板工具失败: {}", e))?; + if !status.success() { + return Err(format!("剪贴板工具退出失败: {}", status)); + } + Ok(()) + }) + .await; + match result { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => { + let _ = child.kill().await; + Err(error) + } + Err(_) => { + let _ = child.kill().await; + Err("剪贴板写入超时,请手动复制".into()) + } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn transport_preserves_unicode_and_metacharacters() { + use base64::Engine; + for text in ["한글 😀\r\n'$();<>|!&", "\u{feff}intentional BOM", ""] { + let encoded = super::encode_text(text); + assert!(encoded.is_ascii()); + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .unwrap(); + assert_eq!(decoded, text.as_bytes()); + } + } + + #[tokio::test] + #[ignore = "Writes the OS clipboard; disposable GitHub Actions runner only"] + async fn disposable_windows_clipboard_roundtrip() { + assert_eq!( + std::env::var("CODEX_SWITCHER_DISPOSABLE_TESTS").as_deref(), + Ok("1") + ); + assert_eq!(std::env::var("GITHUB_ACTIONS").as_deref(), Ok("true")); + let long_url = format!( + "https://example.invalid/authorize?state={}&extra=%25%26%22%3C", + "a".repeat(1800) + ); + for text in [ + "한글 😀 https://example.invalid/?code=a&state=b%20c\r\n'$();<>|!", + "\u{feff}intentional BOM", + long_url.as_str(), + "", + ] { + super::write_text(text).await.unwrap(); + let root = std::env::var_os("SystemRoot").unwrap(); + let output = tokio::process::Command::new(std::path::PathBuf::from(root) + .join("System32/WindowsPowerShell/v1.0/powershell.exe")) + .args(["-NoProfile", "-NonInteractive", "-STA", "-Command", + "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); [Console]::Write((Get-Clipboard -Raw))"]) + .output().await.unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8(output.stdout).unwrap(), text); + } + } +}