diff --git a/scripts/windows-cli.test.mjs b/scripts/windows-cli.test.mjs new file mode 100644 index 0000000..3e6c03f --- /dev/null +++ b/scripts/windows-cli.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import ts from 'typescript'; + +function load(platform) { + const code = ts.transpileModule(fs.readFileSync('src/utils/codexCommand.ts', 'utf8'), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const context = { exports: {}, navigator: { platform } }; + vm.runInNewContext(code, context); + return context.exports; +} + +test('Windows produces PowerShell syntax and quotes apostrophes as literal data', () => { + const api = load('Win32'); + assert.equal(api.isWindows, true); + assert.equal(api.codexLaunchCommand("http://localhost:18080/v1?x='&y=$()"), "$env:OPENAI_BASE_URL='http://localhost:18080/v1?x=''&y=$()'; codex"); +}); + +test('non-Windows launch command retains the existing POSIX format', () => { + for (const platform of ['MacIntel', 'Linux x86_64']) { + const api = load(platform); + assert.equal(api.isWindows, false); + assert.equal(api.codexLaunchCommand('http://localhost:18080/v1'), 'OPENAI_BASE_URL=http://localhost:18080/v1 codex'); + } +}); + +test('displayed and copied local/LAN commands use the same formatter', () => { + const source = fs.readFileSync('src/components/Proxy.tsx', 'utf8'); + assert.equal(source.match(/codexLaunchCommand\(/g)?.length, 4); +}); diff --git a/src-tauri/src/codex_ua.rs b/src-tauri/src/codex_ua.rs index 5f2e5ff..bb8793d 100644 --- a/src-tauri/src/codex_ua.rs +++ b/src-tauri/src/codex_ua.rs @@ -46,24 +46,40 @@ fn codex_version() -> String { .clone() } +#[cfg(windows)] +fn windows_version_command() -> Option { + use std::os::windows::process::CommandExt; + let root = std::env::var_os("SystemRoot")?; + let mut command = Command::new(std::path::PathBuf::from(root).join("System32/cmd.exe")); + // Fixed arguments only; cmd resolves both npm's codex.cmd and native codex.exe. + command.args(["/D", "/V:OFF", "/C", "codex", "--version"]); + command.creation_flags(0x08000000); + Some(command) +} + fn detect_codex_version() -> Option { - // GUI(.app)进程 PATH 很窄,手动补常见安装目录后再找 codex。 - let mut path = std::env::var("PATH").unwrap_or_default(); - for extra in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] { - path.push(':'); - path.push_str(extra); - } - if let Ok(home) = std::env::var("HOME") { - for sub in [".local/bin", ".bun/bin", ".npm-global/bin", ".volta/bin"] { + #[cfg(windows)] + let out = windows_version_command()?.output().ok()?; + #[cfg(not(windows))] + let out = { + // GUI(.app)进程 PATH 很窄,手动补常见安装目录后再找 codex。 + let mut path = std::env::var("PATH").unwrap_or_default(); + for extra in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] { path.push(':'); - path.push_str(&format!("{home}/{sub}")); + path.push_str(extra); } - } - let out = Command::new("codex") - .arg("--version") - .env("PATH", path) - .output() - .ok()?; + if let Ok(home) = std::env::var("HOME") { + for sub in [".local/bin", ".bun/bin", ".npm-global/bin", ".volta/bin"] { + path.push(':'); + path.push_str(&format!("{home}/{sub}")); + } + } + Command::new("codex") + .arg("--version") + .env("PATH", path) + .output() + .ok()? + }; if !out.status.success() { return None; } @@ -158,3 +174,18 @@ fn sanitize_header(s: String) -> String { .collect() } } + +#[cfg(all(test, windows))] +mod windows_tests { + #[test] + fn version_probe_preserves_windows_path_and_uses_fixed_batch_arguments() { + let command = super::windows_version_command().unwrap(); + let args: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect(); + assert_eq!(args, ["/D", "/V:OFF", "/C", "codex", "--version"]); + assert_eq!(command.get_envs().count(), 0); + assert!(command.get_program().to_string_lossy().ends_with("cmd.exe")); + } +} diff --git a/src/components/Proxy.tsx b/src/components/Proxy.tsx index 51edf77..4646970 100644 --- a/src/components/Proxy.tsx +++ b/src/components/Proxy.tsx @@ -1,3 +1,4 @@ +import { codexLaunchCommand, isWindows } from '../utils/codexCommand'; import { useState, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; @@ -265,19 +266,19 @@ export function Proxy() {
手动启动 - 复制命令到终端运行 + {isWindows ? '复制命令到 PowerShell 运行' : '复制命令到终端运行'}
@@ -292,13 +293,13 @@ export function Proxy() { className="copy-command-button" onClick={() => { navigator.clipboard.writeText( - `OPENAI_BASE_URL=${status.lan_base_url} codex` + codexLaunchCommand(status.lan_base_url!) ); setCopied(true); setTimeout(() => setCopied(false), 2000); }} > - OPENAI_BASE_URL={status.lan_base_url} codex + {codexLaunchCommand(status.lan_base_url)} {copied ? : } diff --git a/src/utils/codexCommand.ts b/src/utils/codexCommand.ts new file mode 100644 index 0000000..3dafed1 --- /dev/null +++ b/src/utils/codexCommand.ts @@ -0,0 +1,6 @@ +export const isWindows = typeof navigator !== 'undefined' && navigator.platform.startsWith('Win'); + +export function codexLaunchCommand(baseUrl: string, windows = isWindows): string { + if (windows) return `$env:OPENAI_BASE_URL='${baseUrl.replace(/'/g, "''")}'; codex`; + return `OPENAI_BASE_URL=${baseUrl} codex`; +}