Compare commits

...
6 Commits
48 changed files with 6978 additions and 341 deletions
+13
View File
@@ -2,6 +2,19 @@
记录 Codex-X 的新功能、体验改进和问题修复。
## [v0.3.27] - 2026-10-17
- 会话同步期间可启用或切换供应商;每次同步固定开始时的目标,完成后按当前供应商更新状态。
- 识别 Codex 保留 `.jsonl` 逻辑路径、实际使用 `.jsonl.zst` 的压缩会话;同步、预览、导出及删除兼容压缩存储,保留流式处理、备份和并发修改保护。
- 状态检查省去整文件改写快照与重压,减少同步中重复的完整日志扫描。
- 会话按历史工作目录分组时保留原路径拼写,不再探测或打开项目目录,减少触发“文稿”等目录的权限提示。
- 新增「Codex 简体中文」开关,读取 Codex 实际语言配置,重启后应用待生效的选择,关闭时恢复原语言或自动检测;支持 macOS,以及经过官方身份验证的 Windows Store 版和独立安装版(Store 版仅支持默认 `CODEX_HOME`)。
- 统一设置页和操作提示中的「重启 Codex」入口,同时应用待生效的语言设置;改进进程退出与启动确认,减少首次重启失败、再次点击才成功的问题。
- 供应商卡片新增 DeepSeek、OpenRouter、硅基流动和 Moonshot 余额查询,区分账户余额与 Key 剩余额度,支持后台定时更新、手动刷新和查看最近查询时间。
- 余额刷新复用同一凭据的并发请求,按实际查询时间安排后续刷新,避免手动查询或读取缓存推迟后台更新;金额保留十进制精度,查询失败时保留此前余额并显示失败状态。
- 官方账号编辑支持读取单个标准 `auth.json` 文件到编辑区,校验后由用户保存。
- 重启 Codex 失败时保留错误详情,支持手动关闭,便于定位失败原因。
## [v0.3.26] - 2026-10-09
- 安装版在线更新的主通道失败后,自动通过官方 GitHub API 获取更新清单和安装包,继续由原生更新器检查版本、平台和签名;不再因一次检查失败就静默改为仅手动下载。
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.26",
"version": "0.3.27",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+74 -1
View File
@@ -492,7 +492,7 @@ dependencies = [
[[package]]
name = "codex-x"
version = "0.3.26"
version = "0.3.27"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -518,6 +518,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"toml_edit 0.22.27",
"tungstenite",
"ureq",
"windows-sys 0.61.2",
"zip 2.4.2",
@@ -719,6 +720,12 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "dbus"
version = "0.9.12"
@@ -2720,6 +2727,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@@ -2817,6 +2833,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -3388,6 +3433,17 @@ dependencies = [
"stable_deref_trait",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.10.9"
@@ -4394,6 +4450,23 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "typeid"
version = "1.0.3"
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.26"
version = "0.3.27"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
@@ -19,7 +19,7 @@ tauri = { version = "2.8.2", features = ["tray-icon"] }
tauri-plugin-process = "2.3.1"
tauri-plugin-updater = "2.10.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_json = { version = "1.0", features = ["arbitrary_precision"] }
base64 = "0.22"
toml_edit = "0.22"
chrono = { version = "0.4", features = ["serde"] }
@@ -46,6 +46,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "time", "net"] }
flate2 = "1"
zstd = "0.13"
tempfile = "3.27"
tungstenite = { version = "0.28", default-features = false, features = ["handshake"] }
[dev-dependencies]
tauri = { version = "2.8.2", features = ["test"] }
@@ -0,0 +1,488 @@
//! Windows desktop discovery and activation. This module deliberately uses
//! Windows' registered package/signature/process APIs, never executable names
//! alone, before stopping a process or attaching to its debugging endpoint.
#![cfg_attr(not(target_os = "windows"), allow(dead_code))]
use base64::Engine;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
#[derive(Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct WindowsDesktopApp {
pub(crate) path: PathBuf,
pub(crate) executable: PathBuf,
pub(crate) bundle_id: String,
pub(crate) name: String,
pub(crate) version: String,
}
fn stamps() -> &'static Mutex<HashMap<u32, String>> {
static STAMPS: OnceLock<Mutex<HashMap<u32, String>>> = OnceLock::new();
STAMPS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn powershell(script: &str, data: Value, timeout: Duration) -> Result<Value, String> {
let system_root = std::env::var_os("SystemRoot").ok_or("无法定位 Windows 系统目录")?;
let executable =
PathBuf::from(system_root).join("System32/WindowsPowerShell/v1.0/powershell.exe");
let encoded = base64::engine::general_purpose::STANDARD
.encode(serde_json::to_vec(&data).map_err(|_| "无法编码 Windows 客户端操作参数")?);
// All variable input is JSON data. Paths and config values are never
// interpolated as PowerShell source or passed through a command shell.
let source = format!(
"$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue'; \
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); \
$data=([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded}')) | ConvertFrom-Json);\n\
{COMMON}\n{script}"
);
let output = crate::platform::desktop_command_output(
&executable,
&[
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-Command",
&source,
],
timeout,
)?;
if !output.status.success() {
let details = String::from_utf8_lossy(&output.stderr)
.chars()
.take(1800)
.collect::<String>();
return Err(format!("Windows Codex 操作未完成:{}", details.trim()));
}
serde_json::from_slice(&output.stdout).map_err(|_| "Windows 未返回有效的客户端操作结果".into())
}
const COMMON: &str = r#"
function FullPath($path) { [IO.Path]::GetFullPath([string]$path).TrimEnd('\') }
function SamePath($left, $right) {
[string]::Equals((FullPath $left), (FullPath $right), [StringComparison]::OrdinalIgnoreCase)
}
function ProcessStamp($process) {
if ($null -eq $process.CreationDate) { throw 'Cannot verify the process creation time.' }
$process.CreationDate.ToUniversalTime().Ticks.ToString([Globalization.CultureInfo]::InvariantCulture)
}
function AppProcesses($exe) {
$processes=@(Get-CimInstance Win32_Process -Filter "Name='Codex.exe' OR Name='ChatGPT.exe'")
if (@($processes | Where-Object { -not $_.ExecutablePath }).Count -gt 0) {
throw 'A Codex/ChatGPT process path is inaccessible. Use matching non-elevated permissions before restarting.'
}
@($processes | Where-Object { SamePath $_.ExecutablePath $exe })
}
function MainProcesses($exe) {
@(AppProcesses $exe | Where-Object {
if ($null -eq $_.CommandLine) { throw 'Cannot verify the desktop process command line.' }
$_.CommandLine -notmatch '(?i)(?:^|\s)--type(?:=|\s)' -and
$_.CommandLine -notmatch '(?i)(?:^|\s)--crashpad-handler(?:\s|$)'
})
}
function BoundProcess($entry, $exe) {
$process=Get-Process -Id $entry.ProcessId -ErrorAction SilentlyContinue
if ($null -eq $process) { return $null }
try {
# Holding this handle pins the process object/PID through the subsequent
# identity checks and CloseMainWindow/Kill call.
$null=$process.Handle
$created=$process.StartTime.ToUniversalTime().Ticks
$expected=[long](ProcessStamp $entry)
if (-not (SamePath $process.MainModule.FileName $exe) -or
[Math]::Abs($created-$expected) -ge 10) { $process.Dispose(); return $null }
# CIM timestamps have microsecond precision, while StartTime has 100ns.
return $process
} catch { $process.Dispose(); return $null }
}
function IsDesktopExe($exe) {
$leaf=[IO.Path]::GetFileName([string]$exe)
($leaf -ieq 'Codex.exe' -or $leaf -ieq 'ChatGPT.exe') -and
(Test-Path -LiteralPath $exe -PathType Leaf) -and
(Test-Path -LiteralPath (Join-Path ([IO.Path]::GetDirectoryName($exe)) 'resources\app.asar') -PathType Leaf)
}
function TrustedStandalone($exe) {
if (-not (IsDesktopExe $exe)) { return $false }
$signature=Get-AuthenticodeSignature -LiteralPath $exe
if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate) { return $false }
$publisher=$signature.SignerCertificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName,$false)
@('OpenAI OpCo, LLC','OpenAI, L.L.C.') -ccontains $publisher
}
function TrustedPackage($package) {
$null -ne $package -and
@('OpenAI.Codex','OpenAI.CodexBeta','OpenAI.ChatGPT-Desktop') -ccontains [string]$package.Name -and
[string]$package.PublisherId -ceq '2p2nqsd0c76g0' -and
[string]$package.PackageFamilyName -ceq ($package.Name+'_2p2nqsd0c76g0') -and
[string]$package.Status -eq 'Ok' -and [string]$package.SignatureKind -eq 'Store' -and
-not $package.IsDevelopmentMode
}
function PackageApp($package) {
$root=FullPath $package.InstallLocation
$manifest=Get-AppxPackageManifest -Package $package.PackageFullName
foreach ($entry in @($manifest.Package.Applications.Application)) {
if (-not $entry.Executable -or -not $entry.Id) { continue }
$exe=FullPath (Join-Path $root ([string]$entry.Executable))
if (-not $exe.StartsWith($root+'\',[StringComparison]::OrdinalIgnoreCase)) { continue }
if (-not (IsDesktopExe $exe)) { continue }
return [pscustomobject]@{path=$root;executable=$exe;bundleId=($package.PackageFamilyName+'!'+$entry.Id);
name=$(if($package.Name -eq 'OpenAI.ChatGPT-Desktop'){'ChatGPT'}else{'Codex'});version=[string]$package.Version}
}
return $null
}
function ValidateApp($app) {
if (-not (IsDesktopExe $app.executable)) { throw 'The selected path is not the official Electron desktop application.' }
if ([string]$app.bundleId -eq 'OpenAI.Codex.Standalone') {
if (-not (SamePath ([IO.Path]::GetDirectoryName($app.executable)) $app.path) -or
-not (TrustedStandalone $app.executable)) { throw 'The desktop executable has no valid OpenAI publisher signature.' }
return
}
$principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent())
if ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Microsoft Store activation requires Codex-X to run without administrator elevation. No client was stopped.'
}
$family=([string]$app.bundleId).Split('!')[0]
$package=@(Get-AppxPackage | Where-Object { [string]$_.PackageFamilyName -ceq $family }) | Select-Object -First 1
if (-not (TrustedPackage $package)) { throw 'The official signed Microsoft Store package is no longer registered.' }
$registered=PackageApp $package
if ($null -eq $registered -or $registered.bundleId -cne $app.bundleId -or
-not (SamePath $registered.path $app.path) -or
-not (SamePath $registered.executable $app.executable)) { throw 'The registered Store application identity has changed.' }
}
function Emit($result) { ConvertTo-Json -InputObject $result -Depth 6 -Compress }
"#;
pub(crate) fn detect() -> Result<WindowsDesktopApp, String> {
type Cached = (Instant, Result<WindowsDesktopApp, String>);
static CACHE: OnceLock<Mutex<Option<Cached>>> = OnceLock::new();
let mut cache = CACHE
.get_or_init(|| Mutex::new(None))
.lock()
.map_err(|_| "Windows 客户端检测状态忙")?;
if let Some((recorded, value)) = cache.as_ref() {
let ttl = if value.is_ok() { 30 } else { 10 };
if recorded.elapsed() < Duration::from_secs(ttl) {
return value.clone();
}
}
let value = detect_fresh();
*cache = Some((Instant::now(), value.clone()));
value
}
pub(crate) fn detect_fresh() -> Result<WindowsDesktopApp, String> {
let result = powershell(
r#"
$candidates=New-Object 'System.Collections.Generic.List[object]'
foreach ($package in @(Get-AppxPackage | Sort-Object -Property Version -Descending)) {
if (-not (TrustedPackage $package)) { continue }
try { $app=PackageApp $package } catch { continue }
if ($null -ne $app) { $candidates.Add($app) }
}
if ($env:LOCALAPPDATA) {
$roots=@('OpenAI\Codex\bin','OpenAI\Codex','Programs\OpenAI\Codex','Programs\Codex')
$seen=@{}
foreach ($relative in $roots) {
$root=Join-Path $env:LOCALAPPDATA $relative
$directories=@($root,(Join-Path $root 'app'))
if (Test-Path -LiteralPath $root -PathType Container) {
foreach ($child in @(Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue | Select-Object -First 32)) {
$directories+=@($child.FullName,(Join-Path $child.FullName 'app'))
}
}
foreach ($directory in $directories) {
foreach ($leaf in @('Codex.exe','ChatGPT.exe')) {
$exe=Join-Path $directory $leaf
$key=$exe.ToLowerInvariant()
if ($seen.ContainsKey($key)) { continue }; $seen[$key]=$true
if (-not (TrustedStandalone $exe)) { continue }
$version=(Get-Item -LiteralPath $exe).VersionInfo.ProductVersion
$candidates.Add([pscustomobject]@{path=(FullPath $directory);executable=(FullPath $exe);
bundleId='OpenAI.Codex.Standalone';name='Codex';version=[string]$version})
}
}
}
}
$running=@()
foreach ($app in $candidates) {
$main=@(MainProcesses $app.executable)
if ($main.Count -gt 1) { throw 'Multiple desktop instances are running. Keep one instance before restarting.' }
if ($main.Count -eq 1) { $running+=,$app }
}
if ($running.Count -gt 1) { throw 'Multiple official desktop installations are running. Keep one before restarting.' }
if ($running.Count -eq 1) { Emit $running[0]; exit }
if ($candidates.Count -eq 0) { throw 'No verified OpenAI Codex Store package or signed standalone installation was found.' }
Emit $candidates[0]
"#,
Value::Null,
Duration::from_secs(25),
)?;
serde_json::from_value(result).map_err(|_| "Windows 客户端安装信息无效".into())
}
pub(crate) fn validate(app: &WindowsDesktopApp) -> Result<(), String> {
powershell(
"ValidateApp $data.app; Emit $true",
json!({"app":app}),
Duration::from_secs(15),
)
.map(|_| ())
}
pub(crate) fn validate_launch(app: &WindowsDesktopApp, home: &Path) -> Result<(), String> {
if app.bundle_id != "OpenAI.Codex.Standalone" {
let default = dirs::home_dir()
.ok_or("无法确认 Windows 默认 Codex 配置目录")?
.join(".codex");
let requested = crate::paths::normalized_path_scope(home);
let expected = crate::paths::normalized_path_scope(&default);
if requested != expected {
return Err("Windows Store 版无法通过安全激活传入自定义 CODEX_HOME;请使用默认 .codex 目录或官方独立安装版。客户端尚未退出。".into());
}
}
Ok(())
}
pub(crate) fn pids(app: &WindowsDesktopApp) -> Result<Vec<u32>, String> {
let value = powershell(
"Emit @(AppProcesses $data.app.executable | ForEach-Object { [uint32]$_.ProcessId })",
json!({"app":app}),
Duration::from_secs(8),
)?;
serde_json::from_value(value).map_err(|_| "无法确认 Windows 客户端进程".into())
}
pub(crate) fn quit(app: &WindowsDesktopApp) -> Result<(), String> {
powershell(
r#"
ValidateApp $data.app
$main=@(MainProcesses $data.app.executable)
if ($main.Count -gt 1) { throw 'Multiple desktop instances are running; none was stopped.' }
foreach ($entry in $main) {
$process=BoundProcess $entry $data.app.executable
if ($null -ne $process) {
try { $null=$process.CloseMainWindow() } finally { $process.Dispose() }
}
}
$grace=[DateTime]::UtcNow.AddSeconds(2)
while ([DateTime]::UtcNow -lt $grace -and @(AppProcesses $data.app.executable).Count -gt 0) { Start-Sleep -Milliseconds 100 }
# The user explicitly requested a restart. If a tray/renderer remains, stop
# only processes whose live executable path still matches this verified app.
foreach ($entry in @(AppProcesses $data.app.executable)) {
$process=BoundProcess $entry $data.app.executable
if ($null -ne $process) {
try { $process.Kill() } catch { } finally { $process.Dispose() }
}
}
$deadline=[DateTime]::UtcNow.AddSeconds(8); $emptySince=$null
do {
if (@(AppProcesses $data.app.executable).Count -eq 0) {
if ($null -eq $emptySince) { $emptySince=[DateTime]::UtcNow }
if (([DateTime]::UtcNow-$emptySince).TotalMilliseconds -ge 500) { Emit $true; exit }
} else { $emptySince=$null }
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
throw 'The selected desktop processes did not stop; relaunch was cancelled.'
"#,
json!({"app":app}),
Duration::from_secs(20),
)
.map(|_| ())
}
pub(crate) fn launch(
app: &WindowsDesktopApp,
home: Option<&Path>,
port: Option<u16>,
) -> Result<u32, String> {
if let Some(home) = home {
validate_launch(app, home)?;
}
if app.bundle_id == "OpenAI.Codex.Standalone" {
return launch_standalone(app, home, port);
}
let result = powershell(
r#"
ValidateApp $data.app
if (@(AppProcesses $data.app.executable).Count -gt 0) { throw 'The desktop application is still stopping; no duplicate instance was launched.' }
$arguments=''
if ($null -ne $data.port) { $arguments='--remote-debugging-port='+[string]$data.port+' --remote-debugging-address=127.0.0.1' }
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
[ComImport, Guid("2e941141-7f97-4756-ba1d-9decde894a3d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
interface ICodexActivationManager {
[PreserveSig] int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string appId,
[MarshalAs(UnmanagedType.LPWStr)] string arguments, uint options, out uint processId);
}
public static class CodexPackageActivation {
public static uint Start(string appId, string arguments) {
var manager=(ICodexActivationManager)Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("45ba127d-10a8-46ea-8ab7-56ea9078943c")));
try { uint processId; int result=manager.ActivateApplication(appId, arguments, 0, out processId);
Marshal.ThrowExceptionForHR(result); return processId; }
finally { Marshal.FinalReleaseComObject(manager); }
}
}
'@ | Out-Null
$started=[CodexPackageActivation]::Start([string]$data.app.bundleId,$arguments)
if ($started -eq 0) { throw 'Windows did not return an activated process id.' }
$created=''; $deadline=[DateTime]::UtcNow.AddSeconds(5)
do {
$process=Get-CimInstance Win32_Process -Filter ('ProcessId='+$started)
if ($null -ne $process -and $process.ExecutablePath) {
if (-not (SamePath $process.ExecutablePath $data.app.executable)) { throw 'Windows activated a different executable; no debugging connection was made.' }
$created=ProcessStamp $process; break
}
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
if (-not $created) { throw 'Windows activation returned but the official desktop process did not start.' }
Emit @{pid=$started;created=$created}
"#,
json!({"app":app,"home":home,"port":port}),
Duration::from_secs(20),
)?;
remember_started(result)
}
fn remember_started(result: Value) -> Result<u32, String> {
let pid = result["pid"]
.as_u64()
.and_then(|value| u32::try_from(value).ok())
.filter(|pid| *pid != 0)
.ok_or("Windows 未确认已启动的客户端进程")?;
let created = result["created"]
.as_str()
.filter(|value| !value.is_empty())
.ok_or("Windows 未确认客户端进程身份")?;
let mut tracked = stamps().lock().map_err(|_| "Windows 进程状态忙")?;
if tracked.len() >= 16 {
tracked.clear();
}
tracked.insert(pid, created.to_owned());
Ok(pid)
}
fn launch_standalone(
app: &WindowsDesktopApp,
home: Option<&Path>,
port: Option<u16>,
) -> Result<u32, String> {
powershell(
"ValidateApp $data.app; if (@(AppProcesses $data.app.executable).Count -gt 0) { throw 'The desktop is still stopping; no duplicate was launched.' }; Emit $true",
json!({"app":app}),
Duration::from_secs(15),
)?;
let arguments = port
.map(|port| {
vec![
format!("--remote-debugging-port={port}"),
"--remote-debugging-address=127.0.0.1".to_owned(),
]
})
.unwrap_or_default();
let args: Vec<_> = arguments.iter().map(String::as_str).collect();
let mut command = crate::platform::program_command(&app.executable, &args);
command
.current_dir(&app.path)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
if let Some(home) = home {
command.env("CODEX_HOME", home);
}
let mut child = command
.spawn()
.map_err(|_| "无法启动已验证的 Windows Codex 客户端")?;
let pid = child.id();
let result = powershell(
r#"
$deadline=[DateTime]::UtcNow.AddSeconds(5)
do {
$process=Get-CimInstance Win32_Process -Filter ('ProcessId='+[uint32]$data.pid)
if ($null -ne $process -and $process.ExecutablePath) {
if (-not (SamePath $process.ExecutablePath $data.app.executable)) { throw 'The newly started process has an unexpected executable.' }
Emit @{pid=[uint32]$data.pid;created=(ProcessStamp $process)}; exit
}
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
throw 'The signed desktop executable did not remain running.'
"#,
json!({"app":app,"pid":pid}),
Duration::from_secs(10),
).and_then(remember_started);
if result.is_err() {
// Child owns the original Windows process handle, so cleanup cannot
// terminate an unrelated process after a recycled PID.
let _ = child.kill();
let _ = child.try_wait();
}
// Windows has no zombie-process wait requirement. Dropping Child releases
// our process/thread handles while the GUI keeps its own lifetime.
drop(child);
result
}
pub(crate) fn verify_process(app: &WindowsDesktopApp, pid: u32) -> Result<(), String> {
let created = stamps()
.lock()
.map_err(|_| "Windows 进程状态忙")?
.get(&pid)
.cloned()
.ok_or("该客户端进程不属于本次受控启动")?;
powershell(
r#"
$process=Get-CimInstance Win32_Process -Filter ('ProcessId='+[uint32]$data.pid)
if ($null -eq $process -or -not $process.ExecutablePath -or
-not (SamePath $process.ExecutablePath $data.app.executable) -or
(ProcessStamp $process) -cne [string]$data.created) { throw 'The activated desktop process identity has changed.' }
Emit $true
"#,
json!({"app":app,"pid":pid,"created":created}),
Duration::from_secs(8),
)
.map(|_| ())
}
pub(crate) fn verify_listener(app: &WindowsDesktopApp, pid: u32, port: u16) -> Result<(), String> {
let created = stamps()
.lock()
.map_err(|_| "Windows 进程状态忙")?
.get(&pid)
.cloned()
.ok_or("该客户端进程不属于本次受控启动")?;
powershell(
r#"
$process=Get-CimInstance Win32_Process -Filter ('ProcessId='+[uint32]$data.pid)
if ($null -eq $process -or -not $process.ExecutablePath -or
-not (SamePath $process.ExecutablePath $data.app.executable) -or
(ProcessStamp $process) -cne [string]$data.created) { throw 'The activated desktop process identity has changed.' }
$listeners=@(Get-NetTCPConnection -State Listen -LocalPort ([uint16]$data.port) -ErrorAction SilentlyContinue)
if ($listeners.Count -eq 0) { throw 'The official desktop has not opened its debugging port.' }
foreach ($listener in $listeners) {
if ($listener.LocalAddress -cne '127.0.0.1' -or [uint32]$listener.OwningProcess -ne [uint32]$data.pid) {
throw 'The debugging port is not exclusively owned by the activated process on 127.0.0.1.'
}
}
$after=Get-CimInstance Win32_Process -Filter ('ProcessId='+[uint32]$data.pid)
if ($null -eq $after -or -not $after.ExecutablePath -or
-not (SamePath $after.ExecutablePath $data.app.executable) -or
(ProcessStamp $after) -cne [string]$data.created) { throw 'The desktop process changed while its port was being verified.' }
Emit $true
"#,
json!({"app":app,"pid":pid,"port":port,"created":created}),
Duration::from_secs(8),
)
.map(|_| ())
}
pub(crate) fn restart() -> Result<(String, bool), String> {
let app = detect_fresh()?;
validate(&app)?;
let was_running = !pids(&app)?.is_empty();
quit(&app)?;
launch(&app, None, None)?;
Ok((app.name, was_running))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,280 @@
use serde::Deserialize;
use serde_json::{json, Value};
use std::io::Read;
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
use std::time::{Duration, Instant};
use tungstenite::{client, Message, WebSocket};
const MAX_RESPONSE: usize = 512 * 1024;
#[derive(Clone, Deserialize)]
pub(super) struct Target {
pub(super) id: String,
#[serde(rename = "type")]
kind: String,
url: String,
#[serde(rename = "webSocketDebuggerUrl")]
socket: Option<String>,
}
pub(super) fn app_origin(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|url| {
url.scheme() == "app"
&& url.host_str() == Some("-")
&& url.port().is_none()
&& url.username().is_empty()
&& url.password().is_none()
&& matches!(url.path(), "/" | "/index.html")
})
}
fn socket_url(value: &str, port: u16) -> Result<reqwest::Url, String> {
let url = reqwest::Url::parse(value).map_err(|_| "调试目标地址无效")?;
if url.scheme() != "ws"
|| url.host_str() != Some("127.0.0.1")
|| url.port() != Some(port)
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| !url.path().starts_with("/devtools/page/")
|| url.path().trim_start_matches("/devtools/page/").is_empty()
{
return Err("调试目标超出指定本机端口,未执行语言脚本。".into());
}
Ok(url)
}
pub(super) fn targets(port: u16) -> Result<Vec<Target>, String> {
crate::remote::ensure_crypto_provider();
let client = reqwest::blocking::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(2))
.build()
.map_err(|_| "无法初始化本机语言兼容连接")?;
let response = client
.get(format!("http://127.0.0.1:{port}/json/list"))
.send()
.map_err(|_| "客户端未开放本机调试通道;此版本可能不支持兼容模式。")?;
if !response.status().is_success() {
return Err("客户端调试通道尚未就绪".into());
}
let mut bytes = Vec::new();
response
.take((MAX_RESPONSE + 1) as u64)
.read_to_end(&mut bytes)
.map_err(|_| "无法读取调试目标")?;
if bytes.len() > MAX_RESPONSE {
return Err("客户端返回的调试目标超过安全限制".into());
}
let targets: Vec<Target> =
serde_json::from_slice(&bytes).map_err(|_| "客户端未返回有效的调试目标")?;
if targets.len() > 128 {
return Err("客户端调试目标过多,未继续连接".into());
}
Ok(targets
.into_iter()
.filter(|target| {
target.kind == "page" && app_origin(&target.url) && target.socket.is_some()
})
.collect())
}
pub(super) struct Session {
socket: WebSocket<TcpStream>,
next_id: u64,
script: String,
}
impl Session {
pub(super) fn connect(target: &Target, port: u16) -> Result<Self, String> {
if target.kind != "page" || !app_origin(&target.url) {
return Err("不是官方客户端的本地界面目标".into());
}
let url = socket_url(target.socket.as_deref().ok_or("目标没有调试连接")?, port)?;
let stream = TcpStream::connect_timeout(
&SocketAddr::from((Ipv4Addr::LOCALHOST, port)),
Duration::from_secs(2),
)
.map_err(|_| "本机调试连接未建立")?;
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.map_err(|_| "无法设置调试读取超时")?;
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.map_err(|_| "无法设置调试写入超时")?;
let (mut socket, _) = client(url.as_str(), stream)
.map_err(|_| "客户端拒绝了安全调试连接;未降低客户端安全设置。")?;
socket.set_config(|config| {
config.max_message_size = Some(MAX_RESPONSE);
config.max_frame_size = Some(MAX_RESPONSE);
});
Ok(Self {
socket,
next_id: 0,
script: String::new(),
})
}
pub(super) fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.next_id += 1;
let id = self.next_id;
self.socket
.send(Message::Text(
json!({"id":id,"method":method,"params":params})
.to_string()
.into(),
))
.map_err(|_| "语言兼容命令发送失败")?;
let deadline = Instant::now() + Duration::from_secs(4);
while Instant::now() < deadline {
match self.socket.read().map_err(|_| "语言兼容连接中断或超时")? {
Message::Text(text) => {
if text.len() > MAX_RESPONSE {
return Err("调试返回内容超过限制".into());
}
let event: Value =
serde_json::from_str(&text).map_err(|_| "调试返回格式无效")?;
if event.get("id").and_then(Value::as_u64) != Some(id) {
continue;
}
if event.get("error").is_some() {
return Err("当前客户端不支持所需的界面兼容命令".into());
}
return Ok(event.get("result").cloned().unwrap_or(Value::Null));
}
Message::Ping(bytes) => {
self.socket
.send(Message::Pong(bytes))
.map_err(|_| "调试连接中断")?;
}
Message::Close(_) => return Err("客户端关闭了语言兼容连接".into()),
_ => {}
}
}
Err("等待界面兼容结果超时".into())
}
pub(super) fn install(&mut self, source: &str) -> Result<(), String> {
let origin=self.evaluate("(()=>{try{const u=new URL(location.href);return {allowed:window===window.top&&u.protocol==='app:'&&u.host==='-'&&!u.username&&!u.password&&['/','/index.html'].includes(u.pathname)};}catch{return {allowed:false};}})()")?;
if origin.get("allowed").and_then(Value::as_bool) != Some(true) {
return Err("目标页面来源在连接后已改变,未注册脚本或重载页面。".into());
}
self.call("Page.enable", json!({}))?;
let result = self.call(
"Page.addScriptToEvaluateOnNewDocument",
json!({"source":source}),
)?;
self.script = result
.get("identifier")
.and_then(Value::as_str)
.ok_or("客户端未确认兼容脚本注册")?
.to_owned();
self.call("Page.reload", json!({"ignoreCache":false}))?;
Ok(())
}
pub(super) fn evaluate(&mut self, source: &str) -> Result<Value, String> {
let result=self.call("Runtime.evaluate",json!({"expression":source,"returnByValue":true,"awaitPromise":true,"userGesture":false}))?;
if result.get("exceptionDetails").is_some() {
return Err("客户端界面不支持此语言兼容模式,未报告应用成功。".into());
}
Ok(result
.pointer("/result/value")
.cloned()
.unwrap_or(Value::Null))
}
}
impl Drop for Session {
fn drop(&mut self) {
if !self.script.is_empty() {
let script = self.script.clone();
let _ = self.call(
"Page.removeScriptToEvaluateOnNewDocument",
json!({"identifier":script}),
);
}
let _ = self.socket.close(None);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_browser_targets_remote_websockets_and_credentials() {
assert!(app_origin("app://-/index.html?initialRoute=%2Fsettings"));
for origin in [
"https://chatgpt.com/",
"http://127.0.0.1/index.html",
"app://other/index.html",
"app://user@-/index.html",
"app://-/browser.html",
] {
assert!(!app_origin(origin));
}
assert!(socket_url("ws://127.0.0.1:4567/devtools/page/fixture", 4567).is_ok());
for url in [
"ws://localhost:4567/devtools/page/x",
"ws://127.0.0.1:4568/devtools/page/x",
"ws://127.0.0.1:4567/devtools/browser/x",
"ws://user@127.0.0.1:4567/devtools/page/x",
"wss://127.0.0.1:4567/devtools/page/x",
] {
assert!(socket_url(url, 4567).is_err());
}
}
#[test]
fn simulated_cdp_registers_main_world_script_and_returns_only_fixture_status() {
let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
let port = listener.local_addr().unwrap().port();
let server = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut socket = tungstenite::accept(stream).unwrap();
for (index, expected) in [
"Runtime.evaluate",
"Page.enable",
"Page.addScriptToEvaluateOnNewDocument",
"Page.reload",
"Runtime.evaluate",
"Page.removeScriptToEvaluateOnNewDocument",
]
.into_iter()
.enumerate()
{
let Message::Text(text) = socket.read().unwrap() else {
panic!("expected command")
};
let request: Value = serde_json::from_str(&text).unwrap();
assert_eq!(request["method"], expected);
let result = match expected {
"Page.addScriptToEvaluateOnNewDocument" => {
assert!(request["params"].get("worldName").is_none());
json!({"identifier":"fixture-script"})
}
"Runtime.evaluate" if index == 0 => {
json!({"result":{"value":{"allowed":true}}})
}
"Runtime.evaluate" => json!({"result":{"value":{"ready":true}}}),
_ => json!({}),
};
socket
.send(Message::Text(
json!({"id":request["id"],"result":result})
.to_string()
.into(),
))
.unwrap();
}
});
let target = Target {
id: "fixture".into(),
kind: "page".into(),
url: "app://-/index.html".into(),
socket: Some(format!("ws://127.0.0.1:{port}/devtools/page/fixture")),
};
{
let mut session = Session::connect(&target, port).unwrap();
session.install("/* fixture i18n script */").unwrap();
assert_eq!(session.evaluate("fixtureStatus()").unwrap()["ready"], true);
}
server.join().unwrap();
}
}
@@ -0,0 +1,332 @@
//! The locale compatibility launcher never modifies or re-signs the app bundle.
use std::path::{Path, PathBuf};
#[cfg(test)]
static REAPED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
fn process_reaper() -> Result<&'static std::sync::mpsc::Sender<std::process::Child>, String> {
static REAPER: std::sync::OnceLock<
Result<std::sync::mpsc::Sender<std::process::Child>, String>,
> = std::sync::OnceLock::new();
REAPER
.get_or_init(|| {
let (sender, receiver) = std::sync::mpsc::channel::<std::process::Child>();
std::thread::Builder::new()
.name("codex-language-process-reaper".into())
.spawn(move || {
let mut children: Vec<std::process::Child> = Vec::new();
loop {
match receiver.recv_timeout(std::time::Duration::from_millis(200)) {
Ok(child) => children.push(child),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => break,
}
children.retain_mut(|child| match child.try_wait() {
Ok(None) => true,
Ok(Some(_)) => {
#[cfg(test)]
REAPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
false
}
Err(_) => false,
});
}
})
.map_err(|_| "无法初始化客户端进程回收线程,尚未退出客户端。".to_owned())?;
Ok(sender)
})
.as_ref()
.map_err(Clone::clone)
}
pub(crate) fn prepare_launch() -> Result<(), String> {
process_reaper().map(|_| ())
}
#[derive(Clone)]
pub(crate) struct DesktopApp {
pub(crate) path: PathBuf,
pub(crate) executable: PathBuf,
pub(crate) bundle_id: String,
pub(crate) name: String,
pub(crate) version: String,
}
#[cfg(target_os = "windows")]
fn windows_app(app: &DesktopApp) -> crate::platform::desktop_windows::WindowsDesktopApp {
crate::platform::desktop_windows::WindowsDesktopApp {
path: app.path.clone(),
executable: app.executable.clone(),
bundle_id: app.bundle_id.clone(),
name: app.name.clone(),
version: app.version.clone(),
}
}
#[cfg(target_os = "macos")]
fn command(program: &str, args: &[&str]) -> Result<String, String> {
let output = crate::platform::locale_platform_command(Path::new(program), args)?;
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
}
pub(crate) fn detect() -> Result<DesktopApp, String> {
#[cfg(target_os = "macos")]
{
let app = crate::platform::locale_desktop_app()?;
Ok(DesktopApp {
path: app.0,
executable: app.1,
bundle_id: app.2,
name: app.3,
version: app.4,
})
}
#[cfg(target_os = "windows")]
{
let app = crate::platform::desktop_windows::detect()?;
Ok(DesktopApp {
path: app.path,
executable: app.executable,
bundle_id: app.bundle_id,
name: app.name,
version: app.version,
})
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
Err("当前平台尚未实现可验证官方进程的界面语言启动器。".into())
}
}
pub(crate) fn detect_for_operation() -> Result<DesktopApp, String> {
#[cfg(target_os = "windows")]
{
let app = crate::platform::desktop_windows::detect_fresh()?;
Ok(DesktopApp {
path: app.path,
executable: app.executable,
bundle_id: app.bundle_id,
name: app.name,
version: app.version,
})
}
#[cfg(not(target_os = "windows"))]
{
detect()
}
}
pub(crate) fn validate(app: &DesktopApp) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
if !matches!(
app.bundle_id.as_str(),
"com.openai.codex" | "com.openai.codex.beta"
) {
return Err("不是受支持的官方 Codex 客户端".into());
}
let path = app.path.to_str().ok_or("客户端路径无效")?;
crate::platform::locale_platform_command(
Path::new("/usr/bin/codesign"),
&["--verify", "--strict", path],
)?;
let details = crate::platform::locale_platform_command(
Path::new("/usr/bin/codesign"),
&["--display", "--verbose=4", path],
)?;
let info = String::from_utf8_lossy(&details.stderr);
if !info.lines().any(|line| line == "TeamIdentifier=2DC432GLL2") {
return Err("无法确认客户端为 OpenAI 官方签名,未重启或连接客户端。".into());
}
Ok(())
}
#[cfg(target_os = "windows")]
{
crate::platform::desktop_windows::validate(&windows_app(app))
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = app;
Err("当前平台不支持安全的语言兼容启动".into())
}
}
pub(crate) fn validate_launch(app: &DesktopApp, home: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
crate::platform::desktop_windows::validate_launch(&windows_app(app), home)
}
#[cfg(not(target_os = "windows"))]
{
let _ = (app, home);
Ok(())
}
}
pub(crate) fn quit(app: &DesktopApp) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
crate::platform::locale_quit_desktop(&app.executable, &app.bundle_id)
}
#[cfg(target_os = "windows")]
{
crate::platform::desktop_windows::quit(&windows_app(app))
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = app;
Err("当前平台不支持安全的语言兼容启动".into())
}
}
pub(crate) fn ensure_stopped(app: &DesktopApp) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
if !crate::platform::locale_desktop_pids(&app.executable)?.is_empty() {
return Err("客户端在保存语言前重新启动,已取消写入;请完全退出后重试。".into());
}
Ok(())
}
#[cfg(target_os = "windows")]
{
if !crate::platform::desktop_windows::pids(&windows_app(app))?.is_empty() {
return Err("客户端在保存语言前重新启动,已取消写入;请完全退出后重试。".into());
}
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = app;
Err("当前平台不支持安全的语言兼容启动".into())
}
}
pub(crate) fn quit_spawned(app: &DesktopApp, pid: u32) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
if !crate::platform::locale_desktop_pids(&app.executable)?.contains(&pid) {
return Err("刚启动的官方进程身份已改变,未自动退出其它实例;请手动恢复。".into());
}
quit(app)
}
#[cfg(target_os = "windows")]
{
crate::platform::desktop_windows::verify_process(&windows_app(app), pid)?;
quit(app)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = (app, pid);
Err("当前平台不支持安全的客户端恢复".into())
}
}
pub(crate) fn launch(app: &DesktopApp, home: &Path, port: Option<u16>) -> Result<u32, String> {
#[cfg(target_os = "windows")]
{
return crate::platform::desktop_windows::launch(&windows_app(app), Some(home), port);
}
#[cfg(not(target_os = "windows"))]
{
let mut command = std::process::Command::new(&app.executable);
command
.env("CODEX_HOME", home)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
if let Some(port) = port {
command.args([
format!("--remote-debugging-port={port}"),
"--remote-debugging-address=127.0.0.1".into(),
]);
}
let reaper = process_reaper()?;
let child = command
.spawn()
.map_err(|_| "无法启动官方客户端,请手动打开客户端后重试。")?;
let pid = child.id();
reaper
.send(child)
.map_err(|_| "客户端已启动,但进程监督不可用;请手动恢复并重试。")?;
Ok(pid)
}
}
pub(crate) fn verify_listener(app: &DesktopApp, pid: u32, port: u16) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let ids = crate::platform::locale_desktop_pids(&app.executable)?;
if !ids.contains(&pid) {
return Err("官方客户端进程已经改变,已停止语言兼容连接。".into());
}
let output = command(
"/usr/sbin/lsof",
&["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-Fpn"],
)?;
verify_listener_output(&output, pid, port)
}
#[cfg(target_os = "windows")]
{
crate::platform::desktop_windows::verify_listener(&windows_app(app), pid, port)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
let _ = (app, pid, port);
Err("当前平台无法验证调试端口归属".into())
}
}
fn verify_listener_output(output: &str, pid: u32, port: u16) -> Result<(), String> {
let mut current = None;
let mut verified = false;
for line in output.lines() {
if let Some(p) = line.strip_prefix('p') {
current = p.parse::<u32>().ok();
}
if let Some(address) = line.strip_prefix('n') {
if current != Some(pid) || address != format!("127.0.0.1:{port}") {
return Err("调试端口不属于指定官方进程或未限制在本机,未执行任何注入。".into());
}
verified = true;
}
}
if !verified {
return Err("官方客户端尚未开放可验证的本机调试端口;此构建可能不支持运行时兼容。".into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn listener_must_match_pid_and_ipv4_loopback() {
assert!(verify_listener_output("p123\nn127.0.0.1:4567\n", 123, 4567).is_ok());
for input in [
"p456\nn127.0.0.1:4567\n",
"p123\nn*:4567\n",
"p123\nn192.168.1.2:4567\n",
"p123\nn127.0.0.1:8888\n",
"",
] {
assert!(verify_listener_output(input, 123, 4567).is_err());
}
}
#[test]
fn spawned_fixture_children_are_waited_by_the_reaper() {
let before = REAPED.load(std::sync::atomic::Ordering::Relaxed);
let child = std::process::Command::new(std::env::current_exe().unwrap())
.arg("--list")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.unwrap();
process_reaper().unwrap().send(child).unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
while REAPED.load(std::sync::atomic::Ordering::Relaxed) <= before
&& std::time::Instant::now() < deadline
{
std::thread::sleep(std::time::Duration::from_millis(25));
}
assert!(REAPED.load(std::sync::atomic::Ordering::Relaxed) > before);
}
}
@@ -0,0 +1,197 @@
/* Independently implemented locale compatibility. No application files change. */
(function (options) {
"use strict";
let page;
try { page = new URL(location.href); } catch { return { installed: false, reason: "unsupported_origin" }; }
if (page.protocol !== "app:" || page.hostname !== "-" || page.port || page.username || page.password ||
!["/", "/index.html"].includes(page.pathname) || window !== window.top) {
return { installed: false, reason: "unsupported_origin" };
}
if (!options || !["zh-CN", "en-US"].includes(options.locale) ||
typeof options.owner !== "string" || !/^[a-f0-9]{64}$/.test(options.owner)) {
return { installed: false, reason: "invalid_options" };
}
const marker = "__CODEXX_LOCALE_RUNTIME_V1__";
if (window[marker]?.owner === options.owner) return window[marker].status();
if (window[marker]) return { installed: false, reason: "another_owner" };
const layerName = "72216192";
const changes = [];
const clients = new Map();
const views = new WeakMap();
const registries = new WeakSet();
const observed = new WeakMap();
let enabled = true;
let timer;
let messagesSeen = false;
function isMessages(name) {
try { const url = new URL(name); return url.protocol === "app:" && url.host === "-" && !url.username && !url.password && /^\/assets\/zh-CN-[a-zA-Z0-9_-]+\.js$/.test(url.pathname); }
catch { return false; }
}
const resourceObserver = typeof PerformanceObserver === "function" ? new PerformanceObserver(list => {
messagesSeen ||= list.getEntries().some(entry => isMessages(entry.name));
}) : null;
try { resourceObserver?.observe({ type: "resource", buffered: true }); } catch { /* optional API */ }
function change(object, name, value) {
const descriptor = Object.getOwnPropertyDescriptor(object, name);
if (descriptor && !descriptor.configurable && !descriptor.writable) return false;
try {
Object.defineProperty(object, name, descriptor && !descriptor.configurable ? { value } : { configurable: true, writable: true, value });
changes.push(() => {
if (object[name] !== value) return;
if (descriptor) Object.defineProperty(object, name, descriptor);
else delete object[name];
});
return true;
} catch { return false; }
}
function translatedLayer(layer) {
if (!layer || typeof layer.get !== "function") return layer;
if (views.has(layer)) return views.get(layer);
const read = layer.get.bind(layer);
const translatedRead = (parameter, ...args) =>
enabled && parameter === "enable_i18n" ? true : read(parameter, ...args);
const methods = new Map();
// A forwarding facade also works for frozen SDK results. A proxy directly
// wrapping a frozen own `get` would violate JavaScript proxy invariants.
const view = new Proxy(Object.create(Object.getPrototypeOf(layer)), {
get(_target, key) {
if (key === "get") return translatedRead;
const result = Reflect.get(layer, key, layer);
if (typeof result !== "function") return result;
if (methods.get(key)?.original !== result) methods.set(key, { original: result, bound: result.bind(layer) });
return methods.get(key).bound;
},
has(_target, key) { return key in layer; },
ownKeys() { return Reflect.ownKeys(layer); },
getOwnPropertyDescriptor(_target, key) {
const descriptor = Object.getOwnPropertyDescriptor(layer, key);
return descriptor ? { ...descriptor, configurable: true } : undefined;
},
});
views.set(layer, view);
return view;
}
function hook(client) {
if (!enabled) return;
if (!client || typeof client !== "object") return;
const previous = clients.get(client);
if (previous?.waiting) return;
if (previous && previous.replacement === client.getLayer) return;
// The SDK registers its client from the base constructor, before the
// derived constructor assigns getLayer. Intercept that later assignment so
// React's first memoized language evaluation already sees the override.
if (typeof client.getLayer !== "function") {
const descriptor = Object.getOwnPropertyDescriptor(client, "getLayer");
if (descriptor && !descriptor.configurable) return;
let raw;
let wrapped;
const getter = () => wrapped;
const setter = next => {
raw = next;
wrapped = typeof next === "function" ? wrap(client, next) : next;
clients.set(client, { replacement: wrapped, waiting: false });
};
Object.defineProperty(client, "getLayer", { configurable: true, enumerable: descriptor?.enumerable ?? true, get: getter, set: setter });
clients.set(client, { waiting: true });
changes.push(() => {
if (Object.getOwnPropertyDescriptor(client, "getLayer")?.get !== getter) return;
if (descriptor && Object.hasOwn(descriptor, "value")) Object.defineProperty(client, "getLayer", { ...descriptor, value: raw });
else if (descriptor) Object.defineProperty(client, "getLayer", descriptor);
else if (raw === undefined) delete client.getLayer;
else Object.defineProperty(client, "getLayer", { configurable: true, enumerable: true, writable: true, value: raw });
});
return;
}
const original = client.getLayer;
const replacement = wrap(client, original);
if (change(client, "getLayer", replacement)) {
clients.set(client, { replacement });
try { client.$emt?.({ name: "values_updated" }); } catch { /* optional SDK hook */ }
}
}
function wrap(_client, original) {
return function (name, ...args) {
const result = original.call(this, name, ...args);
return enabled && name === layerName ? translatedLayer(result) : result;
};
}
function observe(object, key, receive) {
let keys = observed.get(object);
if (keys?.has(key)) { receive(object[key]); return; }
const descriptor = Object.getOwnPropertyDescriptor(object, key);
if (descriptor && !descriptor.configurable) { receive(object[key]); return; }
if (!keys) { keys = new Set(); observed.set(object, keys); }
keys.add(key);
let current = object[key];
const getter = () => current;
const setter = next => {
if (descriptor?.set) descriptor.set.call(object, next);
current = descriptor?.get ? descriptor.get.call(object) : next;
if (enabled) receive(current);
};
Object.defineProperty(object, key, { configurable: true, enumerable: descriptor?.enumerable ?? true, get: getter, set: setter });
changes.push(() => {
if (Object.getOwnPropertyDescriptor(object, key)?.get !== getter) return;
if (descriptor && !Object.hasOwn(descriptor, "value")) Object.defineProperty(object, key, descriptor);
else if (descriptor) Object.defineProperty(object, key, { ...descriptor, value: current });
else if (current === undefined) delete object[key];
else Object.defineProperty(object, key, { configurable: true, enumerable: true, writable: true, value: current });
});
receive(current);
}
function registryAppeared(registry) {
if (!enabled || !registry || typeof registry !== "object" || registries.has(registry)) return;
registries.add(registry);
observe(registry, "firstInstance", hook);
observe(registry, "instances", instances => {
if (!instances || typeof instances !== "object") return;
// Observe SDK registration without changing the dictionary's identity.
for (const [key, client] of Object.entries(instances).slice(0, 16)) {
hook(client);
observe(instances, key, hook);
}
});
}
function scan() {
const current = new Set();
if (!enabled) return current;
const registry = window.__STATSIG__;
if (!registry || typeof registry !== "object") return current;
if (registry.firstInstance) { hook(registry.firstInstance); current.add(registry.firstInstance); }
if (registry.instances && typeof registry.instances === "object") {
for (const client of Object.values(registry.instances).slice(0, 16)) { hook(client); current.add(client); }
}
return current;
}
const state = {
owner: options.owner,
status() {
const current = scan();
let gate = false;
for (const client of current) {
try { gate ||= client.getLayer(layerName, { disableExposureLog: true }).get("enable_i18n", false) === true; }
catch { /* unsupported client instance */ }
}
const language = document.documentElement?.lang || "";
const normalized = String(language).replace(/_/g, "-").toLowerCase();
const locale = options.locale === "zh-CN" ? ["zh-cn", "zh-hans", "zh-hans-cn"].includes(normalized) : /^en(?:-|$)/i.test(normalized);
const messages = options.locale === "en-US" || messagesSeen || performance.getEntriesByType("resource").some(entry => isMessages(entry.name));
return { installed: enabled, ready: enabled && gate && locale && messages,
locale: options.locale, clients: current.size, gate, languageMatches: locale, messagesLoaded: messages };
},
restore() {
enabled = false;
clearInterval(timer);
resourceObserver?.disconnect();
for (const undo of changes.reverse()) { try { undo(); } catch { /* preserve later changes */ } }
if (window[marker] === state) delete window[marker];
return { restored: true };
},
};
Object.defineProperty(window, marker, { configurable: true, value: state });
observe(window, "__STATSIG__", registryAppeared);
scan();
timer = setInterval(scan, 100);
return state.status();
})(__CODEXX_LANGUAGE_CONFIG__)
+71 -20
View File
@@ -603,26 +603,77 @@ fn converted_stopped_update_handoff_resumes_the_same_protocol_and_settings() {
#[test]
fn converted_shutdown_keeps_external_selection_and_does_not_reattach_it() {
let _guard = crate::app_db::test_db_guard();
let fixture = converted_fixture("gemini");
let mut doc = parsed(&fixture);
doc["model_provider"] = value("external");
let mut table = Table::new();
table["name"] = value("External fixture");
table["base_url"] = value("https://external-fixture.example.test/v1");
table["wire_api"] = value("responses");
table["requires_openai_auth"] = value(false);
replace_table(&mut doc, "external", table).unwrap();
let external = doc.to_string();
fs::write(crate::config_path(&fixture.dir), &external).unwrap();
shutdown_all().unwrap();
assert_eq!(fixture.text(), external);
assert!(!load_record(&fixture.dir).unwrap().settings.takeover_enabled);
SHUTTING_DOWN.store(false, Ordering::Release);
initialize().unwrap();
assert_eq!(fixture.text(), external);
let runtime = lock_manager().unwrap().remove(&fixture.dir).unwrap();
assert!(runtime.journal.is_none());
runtime.proxy.shutdown();
for detach_before_shutdown in [false, true] {
let fixture = converted_fixture("gemini");
let journal = lock_manager()
.unwrap()
.get(&fixture.dir)
.unwrap()
.journal
.clone()
.unwrap();
let auth = fs::read(fixture.dir.join("auth.json")).unwrap();
if !detach_before_shutdown {
// Freeze the watcher before the external edit so this case covers
// shutdown seeing the converted journal itself, deterministically.
begin_update_handoff().unwrap();
}
let mut doc = parsed(&fixture);
doc["model_provider"] = value("external");
let mut table = Table::new();
table["name"] = value("External fixture");
table["base_url"] = value("https://external-fixture.example.test/v1");
table["wire_api"] = value("responses");
table["requires_openai_auth"] = value(false);
replace_table(&mut doc, "external", table).unwrap();
let external = doc.to_string();
let detached_doc = restored_document(&doc, &journal).unwrap();
// Independently verify that the projection only changes our inactive
// provider table, not the external selection/table or any user config.
let mut unmanaged_before = doc.clone();
let mut unmanaged_after = detached_doc.clone();
for document in [&mut unmanaged_before, &mut unmanaged_after] {
document
.get_mut("model_providers")
.and_then(Item::as_table_mut)
.unwrap()
.remove(&journal.provider_key);
}
assert_eq!(unmanaged_after.to_string(), unmanaged_before.to_string());
let detached = detached_doc.to_string();
fs::write(crate::config_path(&fixture.dir), &external).unwrap();
let expected = if detach_before_shutdown {
// The production watcher is allowed to clean its inactive owned
// overlay after an external selection. Complete that same path
// synchronously rather than racing its three-second scan.
refresh_saved_routes().unwrap();
assert_eq!(fixture.text(), detached);
assert!(lock_manager()
.unwrap()
.get(&fixture.dir)
.unwrap()
.journal
.is_none());
detached
} else {
external
};
shutdown_all().unwrap();
assert_eq!(fixture.text(), expected);
assert_eq!(
parsed(&fixture)["model_provider"].as_str(),
Some("external")
);
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
assert!(!load_record(&fixture.dir).unwrap().settings.takeover_enabled);
SHUTTING_DOWN.store(false, Ordering::Release);
initialize().unwrap();
assert_eq!(fixture.text(), expected);
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
let runtime = lock_manager().unwrap().remove(&fixture.dir).unwrap();
assert!(runtime.journal.is_none());
runtime.proxy.shutdown();
}
}
#[test]
+80 -5
View File
@@ -18,6 +18,7 @@ mod app_update;
mod app_update_channel;
mod backups;
mod ccswitch;
mod codex_language;
mod config_health;
mod config_migration;
mod constants;
@@ -30,6 +31,7 @@ mod live_config;
mod paths;
mod platform;
mod prompts;
mod provider_balance;
mod providers;
mod remote;
mod sessions;
@@ -575,6 +577,67 @@ async fn open_codex_config_file(config_dir: Option<String>) -> Result<()> {
.map_err(|_| CodexxError::Config("打开配置文件失败,请稍后重试。".to_string()))?
}
#[tauri::command]
async fn refresh_provider_balance(
config_dir: Option<String>,
source: String,
provider_id: String,
) -> Result<provider_balance::ProviderBalanceResult> {
tauri::async_runtime::spawn_blocking(move || {
provider_balance::refresh_provider_balance_inner(config_dir, source, provider_id)
})
.await
.map_err(|_| CodexxError::Config("刷新供应商余额失败,请稍后重试。".into()))?
}
#[tauri::command]
async fn get_provider_balances(
config_dir: Option<String>,
) -> Result<Vec<provider_balance::ProviderBalanceResult>> {
tauri::async_runtime::spawn_blocking(move || {
provider_balance::get_provider_balances_inner(config_dir)
})
.await
.map_err(|_| CodexxError::Config("读取供应商余额失败,请稍后重试。".into()))?
}
#[tauri::command]
async fn get_codex_language_status(
config_dir: Option<String>,
) -> Result<codex_language::LanguageStatus> {
tauri::async_runtime::spawn_blocking(move || codex_language::get_status(config_dir))
.await
.map_err(|_| CodexxError::Config("读取 Codex 界面语言状态失败,请重试。".into()))?
}
#[tauri::command]
async fn save_codex_language_preferences(
config_dir: Option<String>,
locale: codex_language::Locale,
enabled: bool,
) -> Result<codex_language::LanguageStatus> {
tauri::async_runtime::spawn_blocking(move || {
codex_language::save_preferences(config_dir, locale, enabled)
})
.await
.map_err(|_| CodexxError::Config("保存 Codex 界面语言偏好失败,请重试。".into()))?
}
#[tauri::command]
async fn apply_codex_language_preferences(
config_dir: Option<String>,
restore: bool,
) -> Result<codex_language::LanguageStatus> {
tauri::async_runtime::spawn_blocking(move || {
let codex_dir = resolve_codex_dir(config_dir.clone())?;
sessions::with_session_maintenance(&codex_dir, || {
codex_language::apply(config_dir, restore)
})
})
.await
.map_err(|_| CodexxError::Config("应用 Codex 界面语言失败,请查看状态后重试。".into()))?
}
#[tauri::command]
async fn get_session_sync_status(
config_dir: Option<String>,
@@ -660,11 +723,18 @@ async fn get_about_info(config_dir: Option<String>) -> Result<AboutInfo> {
}
#[tauri::command]
async fn restart_codex_desktop() -> std::result::Result<CodexDesktopRestartResult, String> {
let (app_name, was_running) =
tauri::async_runtime::spawn_blocking(platform::restart_codex_desktop)
.await
.map_err(|error| format!("重启 Codex 桌面客户端失败: {error}"))??;
async fn restart_codex_desktop(
config_dir: Option<String>,
) -> std::result::Result<CodexDesktopRestartResult, String> {
let (app_name, was_running) = tauri::async_runtime::spawn_blocking(move || {
let codex_dir = resolve_codex_dir(config_dir.clone()).map_err(|e| e.to_string())?;
sessions::with_session_maintenance(&codex_dir, || {
codex_language::restart_client(config_dir)
})
.map_err(|e| e.to_string())
})
.await
.map_err(|error| format!("重启 Codex 桌面客户端失败: {error}"))??;
Ok(CodexDesktopRestartResult {
app_name,
was_running,
@@ -1879,6 +1949,11 @@ pub fn run() {
check_codex_config,
repair_codex_config,
open_codex_config_file,
refresh_provider_balance,
get_provider_balances,
get_codex_language_status,
save_codex_language_preferences,
apply_codex_language_preferences,
get_session_sync_status,
sync_sessions_provider,
delete_codex_sessions,
+192 -104
View File
@@ -7,6 +7,9 @@ use std::sync::{mpsc, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
#[path = "codex_desktop_windows.rs"]
pub(crate) mod desktop_windows;
#[cfg(target_os = "windows")]
use std::env;
@@ -36,7 +39,7 @@ const CODEX_DESKTOP_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
const CODEX_DESKTOP_QUIT_TIMEOUT: Duration = Duration::from_secs(8);
#[cfg(target_os = "macos")]
const CODEX_DESKTOP_POLL_INTERVAL: Duration = Duration::from_millis(100);
#[cfg(any(target_os = "windows", test))]
#[cfg(test)]
const WINDOWS_RESTART_RESULT_PREFIX: &str = "CODEX_X_DESKTOP_RESTART";
#[cfg(any(target_os = "macos", test))]
@@ -816,96 +819,209 @@ fn macos_main_process_ids(
))
}
pub(crate) fn desktop_command_output(
program: &Path,
args: &[&str],
timeout: Duration,
) -> Result<Output, String> {
run_program_with_timeout(program, args, Some(Instant::now() + timeout), timeout)
.ok_or_else(|| "客户端系统操作超时,未继续重启或连接客户端。".into())
}
#[cfg(target_os = "macos")]
fn wait_macos_stopped(executable: &Path) -> Result<(), String> {
let deadline = Instant::now() + CODEX_DESKTOP_QUIT_TIMEOUT;
let mut empty_since = None;
while Instant::now() < deadline {
let pids = match macos_main_process_ids(executable, Some(deadline)) {
Ok(pids) => pids,
Err(_) if Instant::now() >= deadline => break,
Err(error) => return Err(error),
};
if pids.is_empty() {
let since = empty_since.get_or_insert_with(Instant::now);
if since.elapsed() >= Duration::from_millis(500) {
return Ok(());
}
} else {
empty_since = None;
}
thread::sleep(CODEX_DESKTOP_POLL_INTERVAL);
}
Err("Codex/ChatGPT 客户端未正常退出,已取消重新启动;请先结束当前任务后重试。".into())
}
#[cfg(target_os = "macos")]
fn launch_macos_codex_app(app: &MacosCodexApp) -> Result<(), String> {
let deadline = Instant::now()
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
.ok_or_else(|| "创建 Codex/ChatGPT 启动超时时间失败".to_string())?;
let bundle_path = app
.bundle_path
.to_str()
.ok_or_else(|| "Codex/ChatGPT 客户端路径不是有效文本".to_string())?;
let output = run_program_with_timeout(
Path::new("/usr/bin/open"),
&["-a", bundle_path],
Some(deadline),
CODEX_DESKTOP_COMMAND_TIMEOUT,
.ok_or("Codex 客户端路径不是有效文本")?;
let mut last_error = "未确认 Codex 客户端已启动".to_owned();
for attempt in 0..2 {
let output = desktop_command_output(
Path::new("/usr/bin/open"),
&["-n", "-a", bundle_path],
CODEX_DESKTOP_COMMAND_TIMEOUT,
);
let accepted = output.as_ref().is_ok_and(|output| output.status.success());
if let Ok(output) = &output {
if !output.status.success() {
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
if !message.is_empty() {
last_error = message;
}
}
} else if let Err(error) = output {
last_error = error;
}
// LaunchServices can acknowledge a launch before a process appears,
// or report a stale quit/launch error after the new process starts.
let deadline = Instant::now()
+ if accepted {
CODEX_DESKTOP_QUIT_TIMEOUT
} else {
Duration::from_millis(750)
};
let mut observed = None;
while Instant::now() < deadline {
let pids = match macos_main_process_ids(&app.executable_path, Some(deadline)) {
Ok(pids) => pids,
Err(_) if Instant::now() >= deadline => break,
Err(error) => return Err(error),
};
if !pids.is_empty() {
let (previous, since) =
observed.get_or_insert_with(|| (pids.clone(), Instant::now()));
if *previous == pids && since.elapsed() >= Duration::from_millis(200) {
return Ok(());
}
if *previous != pids {
*previous = pids;
*since = Instant::now();
}
} else {
observed = None;
}
thread::sleep(CODEX_DESKTOP_POLL_INTERVAL);
}
if accepted || attempt == 1 {
break;
}
// Retry only an unacknowledged launch with no surviving main process.
// Never open a second instance after LaunchServices confirmed one.
}
Err(format!("启动 Codex/ChatGPT 未完成:{last_error}"))
}
// Narrow helpers shared with the opt-in locale launcher. They do not quit or
// launch anything until the explicit locale apply command calls them.
#[cfg(target_os = "macos")]
pub(crate) fn locale_platform_command(program: &Path, args: &[&str]) -> Result<Output, String> {
let deadline = Instant::now() + CODEX_DESKTOP_COMMAND_TIMEOUT;
let output =
run_program_with_timeout(program, args, Some(deadline), CODEX_DESKTOP_COMMAND_TIMEOUT)
.ok_or("客户端操作超时,未继续应用语言兼容。")?;
if !output.status.success() {
return Err("客户端操作未完成,请检查官方客户端状态后重试。".into());
}
Ok(output)
}
#[cfg(target_os = "macos")]
pub(crate) fn locale_desktop_app() -> Result<(PathBuf, PathBuf, String, String, String), String> {
let deadline = Some(Instant::now() + CODEX_DESKTOP_COMMAND_TIMEOUT);
let apps: Vec<_> = macos_codex_apps(deadline)?
.into_iter()
.filter(|app| {
matches!(
app.bundle_id.as_str(),
"com.openai.codex" | "com.openai.codex.beta"
)
})
.collect();
let mut running = Vec::new();
for app in &apps {
if !macos_main_process_ids(&app.executable_path, deadline)?.is_empty() {
running.push(app);
}
}
if running.len() > 1 {
return Err("检测到多个官方客户端实例,请先保留一个实例再应用界面语言。".into());
}
let app = running
.first()
.copied()
.or_else(|| apps.first())
.ok_or("未找到官方 Codex 客户端")?;
let version = macos_info_plist_version(&app.bundle_path).unwrap_or_else(|| "unknown".into());
Ok((
app.bundle_path.clone(),
app.executable_path.clone(),
app.bundle_id.clone(),
app.app_name.clone(),
version,
))
}
#[cfg(target_os = "macos")]
pub(crate) fn locale_desktop_pids(executable: &Path) -> Result<Vec<u32>, String> {
macos_main_process_ids(
executable,
Some(Instant::now() + CODEX_DESKTOP_COMMAND_TIMEOUT),
)
.ok_or_else(|| "启动 Codex/ChatGPT 客户端失败或超时".to_string())?;
if output.status.success() {
return Ok(());
}
#[cfg(target_os = "macos")]
pub(crate) fn locale_quit_desktop(executable: &Path, bundle_id: &str) -> Result<(), String> {
if !MACOS_CODEX_BUNDLE_IDS.contains(&bundle_id) {
return Err("不支持的客户端身份".into());
}
if locale_desktop_pids(executable)?.is_empty() {
return wait_macos_stopped(executable);
}
let requested = locale_platform_command(
Path::new("/usr/bin/osascript"),
&[
"-e",
&format!("tell application id \"{bundle_id}\" to quit"),
],
);
// Apple Events may lose the connection while the application exits. An
// error from osascript is not proof that the exact selected process lives.
match wait_macos_stopped(executable) {
Ok(()) => Ok(()),
Err(error) => Err(requested
.err()
.map(|request| format!("{request};{error}"))
.unwrap_or(error)),
}
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if message.is_empty() {
"启动 Codex/ChatGPT 客户端失败".to_string()
} else {
format!("启动 Codex/ChatGPT 客户端失败: {message}")
})
}
#[cfg(target_os = "macos")]
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
let detection_deadline = Instant::now()
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
.ok_or_else(|| "创建 Codex/ChatGPT 检测超时时间失败".to_string())?;
let apps = macos_codex_apps(Some(detection_deadline))?;
let detection_deadline = Some(Instant::now() + CODEX_DESKTOP_COMMAND_TIMEOUT);
let apps = macos_codex_apps(detection_deadline)?;
let mut first_installed = None;
let mut running_app = None;
let mut running = Vec::new();
for app in apps {
let is_running =
!macos_main_process_ids(&app.executable_path, Some(detection_deadline))?.is_empty();
if is_running {
running_app = Some(app);
break;
}
if first_installed.is_none() {
if !macos_main_process_ids(&app.executable_path, detection_deadline)?.is_empty() {
running.push(app);
} else if first_installed.is_none() {
first_installed = Some(app);
}
}
let was_running = running_app.is_some();
let app = running_app
if running.len() > 1 {
return Err("检测到多个官方 Codex 客户端,请先保留一个再重启。".into());
}
let was_running = !running.is_empty();
let app = running
.pop()
.or(first_installed)
.ok_or_else(|| "未找到可重启的 Codex/ChatGPT 桌面客户端".to_string())?;
if !was_running {
launch_macos_codex_app(&app)?;
return Ok((app.app_name, false));
}
let quit_script = format!("tell application id \"{}\" to quit", app.bundle_id);
let command_deadline = Instant::now()
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
.ok_or_else(|| "创建 Codex/ChatGPT 退出命令超时时间失败".to_string())?;
let output = run_program_with_timeout(
Path::new("/usr/bin/osascript"),
&["-e", quit_script.as_str()],
Some(command_deadline),
CODEX_DESKTOP_COMMAND_TIMEOUT,
)
.ok_or_else(|| "请求 Codex/ChatGPT 客户端正常退出失败或超时".to_string())?;
if !output.status.success() {
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if message.is_empty() {
"请求 Codex/ChatGPT 客户端正常退出失败".to_string()
} else {
format!("请求 Codex/ChatGPT 客户端正常退出失败: {message}")
});
}
let quit_deadline = Instant::now()
.checked_add(CODEX_DESKTOP_QUIT_TIMEOUT)
.ok_or_else(|| "创建 Codex/ChatGPT 退出等待时间失败".to_string())?;
loop {
if macos_main_process_ids(&app.executable_path, Some(quit_deadline))?.is_empty() {
break;
}
if Instant::now() >= quit_deadline {
return Err("Codex/ChatGPT 客户端未在限定时间内正常退出,已取消重新启动".to_string());
}
thread::sleep(CODEX_DESKTOP_POLL_INTERVAL);
}
.ok_or("未找到可重启的 Codex 客户端")?;
locale_quit_desktop(&app.executable_path, &app.bundle_id)?;
launch_macos_codex_app(&app)?;
Ok((app.app_name, true))
Ok((app.app_name, was_running))
}
#[cfg(not(target_os = "macos"))]
@@ -913,7 +1029,7 @@ fn macos_app_version(_deadline: Option<Instant>) -> Option<String> {
None
}
#[cfg(any(target_os = "windows", test))]
#[cfg(test)]
fn parse_windows_restart_result(output: &str) -> Option<(String, bool)> {
output.lines().rev().find_map(|line| {
let payload = line
@@ -934,7 +1050,7 @@ fn parse_windows_restart_result(output: &str) -> Option<(String, bool)> {
})
}
#[cfg(any(target_os = "windows", test))]
#[cfg(test)]
fn windows_restart_script() -> String {
let identities = WINDOWS_CODEX_PACKAGE_IDENTITIES
.iter()
@@ -1026,35 +1142,7 @@ $runningFlag = if ($wasRunning) { '1' } else { '0' }
#[cfg(target_os = "windows")]
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
let script = windows_restart_script();
let timeout = CODEX_DESKTOP_QUIT_TIMEOUT + CODEX_DESKTOP_COMMAND_TIMEOUT;
let deadline = Instant::now()
.checked_add(timeout)
.ok_or_else(|| "创建 Codex/ChatGPT 重启超时时间失败".to_string())?;
let output = run_program_with_timeout(
Path::new("powershell.exe"),
&[
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script.as_str(),
],
Some(deadline),
timeout,
)
.ok_or_else(|| "重启 Codex/ChatGPT 客户端失败或超时".to_string())?;
if !output.status.success() {
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(if message.is_empty() {
"重启 Codex/ChatGPT 客户端失败".to_string()
} else {
format!("重启 Codex/ChatGPT 客户端失败: {message}")
});
}
parse_windows_restart_result(&String::from_utf8_lossy(&output.stdout))
.ok_or_else(|| "无法确认 Codex/ChatGPT 客户端的重启结果".to_string())
desktop_windows::restart()
}
#[cfg(target_os = "windows")]
@@ -0,0 +1,860 @@
//! Read-only balances for saved providers and the current native provider.
//! Request destinations are fixed official URLs, never supplied over IPC.
use crate::error::{CodexxError, Result};
use crate::providers::{self, SavedProvider};
use chrono::{SecondsFormat, Utc};
use reqwest::blocking::Client;
use reqwest::header::{HeaderValue, ACCEPT, AUTHORIZATION};
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex, OnceLock};
use std::time::{Duration, Instant};
use toml_edit::{DocumentMut, Item};
const SUCCESS_TTL: Duration = Duration::from_secs(300);
const FAILURE_TTL: Duration = Duration::from_secs(60);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_RESPONSE_BYTES: u64 = 64 * 1024;
const MAX_CACHE_ENTRIES: usize = 256;
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct BalanceAmount {
pub(crate) currency: String,
/// Exact base-10 text. Never route financial values through f64.
pub(crate) amount: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ProviderBalanceResult {
pub(crate) source: String,
pub(crate) provider_id: String,
pub(crate) vendor: Option<&'static str>,
pub(crate) supported: bool,
pub(crate) balance_kind: Option<&'static str>,
pub(crate) balances: Vec<BalanceAmount>,
pub(crate) checked_at: Option<String>,
pub(crate) error_code: Option<&'static str>,
}
#[derive(Clone, Copy)]
enum Vendor {
DeepSeek,
OpenRouter,
SiliconFlow,
Moonshot,
}
impl Vendor {
fn name(self) -> &'static str {
match self {
Self::DeepSeek => "deepseek",
Self::OpenRouter => "openrouter",
Self::SiliconFlow => "siliconflow",
Self::Moonshot => "moonshot",
}
}
fn endpoint(self) -> &'static str {
match self {
Self::DeepSeek => "https://api.deepseek.com/user/balance",
Self::OpenRouter => "https://openrouter.ai/api/v1/key",
Self::SiliconFlow => "https://api.siliconflow.cn/v1/user/info",
Self::Moonshot => "https://api.moonshot.cn/v1/users/me/balance",
}
}
}
fn vendor_for_provider(provider: &SavedProvider) -> Option<Vendor> {
// This flag controls Codex authentication, and legacy third-party routes
// can also set it. Eligibility comes from the official API URL below;
// official login profiles are never enumerated by this module.
let url = reqwest::Url::parse(provider.base_url.trim()).ok()?;
if url.scheme() != "https"
|| !url.username().is_empty()
|| url.password().is_some()
|| url.port().is_some_and(|port| port != 443)
|| url.query().is_some()
|| url.fragment().is_some()
{
return None;
}
let path = url.path().trim_end_matches('/');
let normal_path = matches!(
path,
"" | "/v1" | "/v1/chat/completions" | "/v1/responses" | "/v1/messages"
);
match url.host_str()? {
"api.deepseek.com" if normal_path => Some(Vendor::DeepSeek),
"api.siliconflow.cn" if normal_path => Some(Vendor::SiliconFlow),
"api.moonshot.cn" if normal_path => Some(Vendor::Moonshot),
"openrouter.ai"
if matches!(
path,
"" | "/api/v1"
| "/api/v1/chat/completions"
| "/api/v1/responses"
| "/api/v1/messages"
) =>
{
Some(Vendor::OpenRouter)
}
_ => None,
}
}
fn environment_value(name: &str) -> std::result::Result<String, &'static str> {
let mut bytes = name.bytes();
if !bytes
.next()
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
|| !bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
{
return Err("invalid_credential");
}
std::env::var(name).map_err(|_| "missing_key")
}
fn credential(
provider: &SavedProvider,
detected: bool,
) -> std::result::Result<String, &'static str> {
let doc = provider
.toml_config
.as_deref()
.filter(|text| !text.trim().is_empty())
.map(|text| {
text.parse::<DocumentMut>()
.map_err(|_| "invalid_configuration")
})
.transpose()?;
let table = doc.as_ref().and_then(|doc| {
let id = doc
.get("model_provider")
.and_then(Item::as_str)
.unwrap_or(&provider.id);
doc.get("model_providers")?
.as_table_like()?
.get(id)?
.as_table_like()
});
// Only the official API's Bearer credential is relevant. Do not forward
// arbitrary provider headers (Host, cookies, routing headers, etc.).
let mut custom_authorization = None;
if let Some(table) = table {
for (field, from_env) in [("http_headers", false), ("env_http_headers", true)] {
if let Some(item) = table.get(field) {
let headers = item.as_table_like().ok_or("invalid_configuration")?;
for (name, item) in headers.iter() {
if name.eq_ignore_ascii_case("authorization") {
if custom_authorization.is_some() {
return Err("invalid_credential");
}
let value = item.as_str().ok_or("invalid_credential")?;
custom_authorization = Some(if from_env {
environment_value(value)?
} else {
value.to_string()
});
}
}
}
}
}
// The detected-provider helper may carry a legacy auth.json fallback key.
// An explicit native env_key must take precedence over that fallback, so an
// unrelated credential cannot win over the route's own environment key.
let native_key = if detected && custom_authorization.is_none() {
if let Some(key) = table
.and_then(|table| table.get("experimental_bearer_token"))
.and_then(Item::as_str)
.filter(|key| !key.trim().is_empty())
{
Some(key.to_string())
} else if let Some(name) = table
.and_then(|table| table.get("env_key"))
.and_then(Item::as_str)
{
Some(environment_value(name)?)
} else {
None
}
} else {
None
};
let key = if let Some(authorization) = custom_authorization {
let (scheme, key) = authorization.split_once(' ').ok_or("invalid_credential")?;
if !scheme.eq_ignore_ascii_case("bearer") {
return Err("invalid_credential");
}
key.to_string()
} else if let Some(key) = native_key {
key
} else if let Some(key) = provider
.api_key
.as_deref()
.filter(|key| !key.trim().is_empty())
{
key.to_string()
} else if let Some(key) = table
.and_then(|table| table.get("experimental_bearer_token"))
.and_then(Item::as_str)
.filter(|key| !key.trim().is_empty())
.map(ToString::to_string)
.or_else(|| {
doc.as_ref()
.and_then(|doc| providers::experimental_bearer_token_from_doc(doc, None))
})
{
key
} else if let Some(name) = table
.and_then(|table| table.get("env_key"))
.and_then(Item::as_str)
{
environment_value(name)?
} else {
return Err("missing_key");
};
let key = key.trim();
if key.is_empty() {
return Err("missing_key");
}
if key.len() > 16 * 1024 || key.bytes().any(|b| b <= 0x20 || b == 0x7f) {
return Err("invalid_credential");
}
Ok(key.to_string())
}
#[derive(Clone)]
struct BalanceValue {
kind: Option<&'static str>,
balances: Vec<BalanceAmount>,
checked_at: String,
error: Option<&'static str>,
}
impl BalanceValue {
fn failure(error: &'static str) -> Self {
Self {
kind: None,
balances: Vec::new(),
checked_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
error: Some(error),
}
}
}
type CacheEntry = Mutex<Option<(Instant, BalanceValue)>>;
fn cache() -> &'static Mutex<HashMap<String, Arc<CacheEntry>>> {
static CACHE: OnceLock<Mutex<HashMap<String, Arc<CacheEntry>>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn cached_balance(vendor: Vendor, key: &str, force: bool, requested_at: Instant) -> BalanceValue {
let mut digest = Sha256::new();
digest.update(vendor.endpoint().as_bytes());
digest.update([0]);
digest.update(key.as_bytes());
let identity = format!("{:x}", digest.finalize());
let slot = {
let mut entries = cache().lock().unwrap_or_else(|p| p.into_inner());
if !entries.contains_key(&identity) && entries.len() >= MAX_CACHE_ENTRIES {
entries.retain(|_, entry| {
Arc::strong_count(entry) > 1
|| entry.try_lock().map_or(true, |value| {
value
.as_ref()
.is_some_and(|(at, _)| at.elapsed() < SUCCESS_TTL)
})
});
}
if !entries.contains_key(&identity) && entries.len() >= MAX_CACHE_ENTRIES {
return BalanceValue::failure("busy");
}
Arc::clone(
entries
.entry(identity)
.or_insert_with(|| Arc::new(Mutex::new(None))),
)
};
// A lock per credential, held for the bounded request, deduplicates copies
// and overlapping IPC calls without blocking unrelated accounts.
let mut entry = slot.lock().unwrap_or_else(|p| p.into_inner());
if let Some((at, value)) = entry.as_ref() {
let ttl = if value.error.is_some() {
FAILURE_TTL
} else {
SUCCESS_TTL
};
// A manual refresh bypasses an older TTL entry, but not a request
// that finished after this IPC call began. Queued copies of the same
// credential therefore share one refresh rather than serially forcing
// another HTTP request after acquiring this lock.
if *at >= requested_at || (!force && at.elapsed() < ttl) {
return value.clone();
}
}
let value = request_balance(vendor, key).unwrap_or_else(BalanceValue::failure);
*entry = Some((Instant::now(), value.clone()));
value
}
fn json_request(
client: &Client,
url: &'static str,
key: &str,
) -> std::result::Result<Value, &'static str> {
let mut authorization =
HeaderValue::from_str(&format!("Bearer {key}")).map_err(|_| "invalid_credential")?;
authorization.set_sensitive(true);
let response = client
.get(url)
.header(ACCEPT, "application/json")
.header(AUTHORIZATION, authorization)
.send()
.map_err(|error| {
if error.is_timeout() {
"timeout"
} else {
"network_error"
}
})?;
let status = response.status();
if !status.is_success() {
return Err(match status.as_u16() {
300..=399 => "redirect_blocked",
401 => "authentication_failed",
403 => "permission_denied",
429 => "rate_limited",
_ => "http_error",
});
}
if response
.content_length()
.is_some_and(|size| size > MAX_RESPONSE_BYTES)
{
return Err("response_too_large");
}
let mut bytes = Vec::new();
response
.take(MAX_RESPONSE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|_| "network_error")?;
if bytes.len() as u64 > MAX_RESPONSE_BYTES {
return Err("response_too_large");
}
// serde_json's arbitrary_precision feature preserves the original numeric
// lexeme, including OpenRouter and Moonshot's JSON number balances.
serde_json::from_slice(&bytes).map_err(|_| "invalid_response")
}
fn amount(currency: &str, value: &Value) -> std::result::Result<BalanceAmount, &'static str> {
Ok(BalanceAmount {
currency: currency.to_string(),
amount: Decimal::from_value(value)?.text(),
})
}
fn request_balance(vendor: Vendor, key: &str) -> std::result::Result<BalanceValue, &'static str> {
crate::remote::ensure_crypto_provider();
// Same fresh-client system/ENV proxy behavior and five-second timeout as
// remote_client, with credentials additionally confined by no redirects.
let client = Client::builder()
.timeout(REQUEST_TIMEOUT)
.user_agent("Codex-X")
.https_only(true)
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| "network_error")?;
let payload = json_request(&client, vendor.endpoint(), key)?;
let mut kind = "balance";
let balances = match vendor {
// https://api-docs.deepseek.com/api/get-user-balance
Vendor::DeepSeek => {
let infos = payload
.get("balance_infos")
.and_then(Value::as_array)
.filter(|items| !items.is_empty() && items.len() <= 4)
.ok_or("invalid_response")?;
let mut balances = Vec::new();
for info in infos {
let currency = info
.get("currency")
.and_then(Value::as_str)
.filter(|currency| matches!(*currency, "CNY" | "USD"))
.ok_or("invalid_response")?;
if balances
.iter()
.any(|item: &BalanceAmount| item.currency == currency)
{
return Err("invalid_response");
}
balances.push(amount(
currency,
info.get("total_balance").ok_or("invalid_response")?,
)?);
}
balances
}
// https://openrouter.ai/docs/api/api-reference/api-keys/get-current-key
// https://openrouter.ai/docs/api/api-reference/credits/get-credits
Vendor::OpenRouter => {
let data = payload.get("data").ok_or("invalid_response")?;
let remaining = data.get("limit_remaining").ok_or("invalid_response")?;
if !remaining.is_null() {
kind = "credit_limit";
vec![amount("USD", remaining)?]
} else {
// Management permission may be unavailable to ordinary model
// keys. Surface its short error code, never request a new key.
let credits = json_request(&client, "https://openrouter.ai/api/v1/credits", key)?;
let data = credits.get("data").ok_or("invalid_response")?;
let total =
Decimal::from_value(data.get("total_credits").ok_or("invalid_response")?)?;
let usage =
Decimal::from_value(data.get("total_usage").ok_or("invalid_response")?)?;
vec![BalanceAmount {
currency: "USD".into(),
amount: total.subtract(usage)?.text(),
}]
}
}
// Official CN OpenAPI: github.com/siliconflow/siliconcloud/openapi.yaml
// CN monetary units: docs.siliconflow.cn/docs/userguide/faqs/rate-limit-and-upgradation
Vendor::SiliconFlow => {
if payload.get("status").and_then(Value::as_bool) != Some(true)
|| payload.get("code").and_then(Value::as_u64) != Some(20000)
{
return Err("api_error");
}
vec![amount(
"CNY",
payload
.get("data")
.and_then(|data| data.get("totalBalance"))
.ok_or("invalid_response")?,
)?]
}
// https://platform.moonshot.cn/docs/api/balance (CN keys only)
Vendor::Moonshot => {
if payload.get("status").and_then(Value::as_bool) != Some(true)
|| payload.get("code").and_then(Value::as_u64) != Some(0)
{
return Err("api_error");
}
vec![amount(
"CNY",
payload
.get("data")
.and_then(|data| data.get("available_balance"))
.ok_or("invalid_response")?,
)?]
}
};
Ok(BalanceValue {
kind: Some(kind),
balances,
checked_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
error: None,
})
}
struct Decimal {
units: i128,
scale: u32,
}
impl Decimal {
fn from_value(value: &Value) -> std::result::Result<Self, &'static str> {
let text = match value {
Value::String(text) => text.clone(),
Value::Number(number) => number.to_string(),
_ => return Err("invalid_response"),
};
Self::parse(&text)
}
fn parse(text: &str) -> std::result::Result<Self, &'static str> {
if text.is_empty() || text.len() > 128 || text.trim() != text {
return Err("invalid_response");
}
let (negative, unsigned) = text
.strip_prefix('-')
.map_or((false, text), |text| (true, text));
let (mantissa, exponent) = match unsigned.find(['e', 'E']) {
Some(index) => (
&unsigned[..index],
unsigned[index + 1..]
.parse::<i32>()
.map_err(|_| "invalid_response")?,
),
None => (unsigned, 0),
};
if exponent.unsigned_abs() > 36 {
return Err("invalid_response");
}
let (integer, fraction) = mantissa
.split_once('.')
.map_or((mantissa, ""), |parts| parts);
if integer.is_empty()
|| !integer.bytes().all(|b| b.is_ascii_digit())
|| !fraction.bytes().all(|b| b.is_ascii_digit())
|| (mantissa.contains('.') && fraction.is_empty())
{
return Err("invalid_response");
}
let digits = format!("{integer}{fraction}");
let mut units = digits.parse::<i128>().map_err(|_| "invalid_response")?;
if negative {
units = -units;
}
let scale = fraction.len() as i32 - exponent;
let scale = if scale < 0 {
units = units
.checked_mul(
10_i128
.checked_pow((-scale) as u32)
.ok_or("invalid_response")?,
)
.ok_or("invalid_response")?;
0
} else if scale > 36 {
return Err("invalid_response");
} else {
scale as u32
};
Ok(Self { units, scale }.normalized())
}
fn normalized(mut self) -> Self {
while self.scale > 0 && self.units % 10 == 0 {
self.units /= 10;
self.scale -= 1;
}
self
}
fn subtract(self, other: Self) -> std::result::Result<Self, &'static str> {
let scale = self.scale.max(other.scale);
let align = |number: Self| {
number
.units
.checked_mul(10_i128.checked_pow(scale - number.scale)?)
};
let units = align(self)
.and_then(|left| align(other).and_then(|right| left.checked_sub(right)))
.ok_or("invalid_response")?;
Ok(Self { units, scale }.normalized())
}
fn text(&self) -> String {
let digits = self.units.unsigned_abs().to_string();
let sign = if self.units < 0 { "-" } else { "" };
if self.scale == 0 {
return format!("{sign}{digits}");
}
let scale = self.scale as usize;
if digits.len() <= scale {
format!("{sign}0.{}{digits}", "0".repeat(scale - digits.len()))
} else {
let at = digits.len() - scale;
format!("{sign}{}.{}", &digits[..at], &digits[at..])
}
}
}
fn provider_result(
source: &str,
provider: &SavedProvider,
force: bool,
requested_at: Instant,
) -> ProviderBalanceResult {
let mut result = ProviderBalanceResult {
source: source.into(),
provider_id: if source == "detected" {
format!("detected-{}", provider.id)
} else {
provider.id.clone()
},
vendor: None,
supported: false,
balance_kind: None,
balances: Vec::new(),
checked_at: None,
error_code: None,
};
let Some(vendor) = vendor_for_provider(provider) else {
return result;
};
result.vendor = Some(vendor.name());
result.supported = true;
let key = match credential(provider, source == "detected") {
Ok(key) => key,
Err(code) => {
result.error_code = Some(code);
return result;
}
};
let value = cached_balance(vendor, &key, force, requested_at);
result.balance_kind = value.kind;
result.balances = value.balances;
result.checked_at = Some(value.checked_at);
result.error_code = value.error;
result
}
fn read_balances_at(
home: &Path,
continue_query: &(dyn Fn() -> bool + Sync),
) -> Result<Vec<ProviderBalanceResult>> {
let mut targets: Vec<_> = providers::list_saved_providers_inner()
.map_err(|_| CodexxError::Config("读取已保存供应商失败,请稍后重试。".into()))?
.into_iter()
.map(|provider| ("local", provider))
.collect();
if let Some(provider) = providers::detected_live_custom_provider(home)
.map_err(|_| CodexxError::Config("读取当前供应商配置失败,请检查配置格式后重试。".into()))?
{
targets.push(("detected", provider));
}
if targets.is_empty() {
return Ok(Vec::new());
}
// Bound concurrent network work. Duplicate account credentials still share
// the same cache lock even when represented by multiple cards/sources.
let workers = targets.len().min(4);
let batches = std::thread::scope(|scope| {
let handles: Vec<_> = (0..workers)
.map(|offset| {
let targets = &targets;
scope.spawn(move || {
(offset..targets.len())
.step_by(workers)
.take_while(|_| continue_query())
.map(|index| {
(
index,
provider_result(
targets[index].0,
&targets[index].1,
false,
Instant::now(),
),
)
})
.collect::<Vec<_>>()
})
})
.collect();
handles
.into_iter()
.map(|handle| {
handle
.join()
.map_err(|_| CodexxError::Config("读取供应商余额失败,请稍后重试。".into()))
})
.collect::<Result<Vec<_>>>()
})?;
let mut results: Vec<_> = batches.into_iter().flatten().collect();
results.sort_by_key(|(index, _)| *index);
Ok(results.into_iter().map(|(_, result)| result).collect())
}
#[derive(Clone)]
struct MonitorTarget {
home: PathBuf,
scope: String,
generation: u64,
}
#[derive(Default)]
struct MonitorState {
target: Option<MonitorTarget>,
next_refresh: Option<Instant>,
}
struct BalanceMonitor {
state: Mutex<MonitorState>,
changed: Condvar,
}
fn refresh_delay(result: &Result<Vec<ProviderBalanceResult>>) -> Duration {
let Ok(rows) = result else {
return FAILURE_TTL;
};
let now = Utc::now();
rows.iter()
.filter(|row| row.supported)
.map(|row| {
let ttl = if row.error_code.is_some() {
FAILURE_TTL
} else {
SUCCESS_TTL
};
let elapsed = row
.checked_at
.as_deref()
.and_then(|text| chrono::DateTime::parse_from_rfc3339(text).ok())
.map(|checked| {
now.signed_duration_since(checked.with_timezone(&Utc))
.to_std()
.unwrap_or(Duration::ZERO)
});
elapsed.map_or(ttl, |elapsed| {
// checkedAt is second-precision; allow one extra second so
// the credential cache's monotonic TTL has certainly expired.
// Reading warm/manual-refresh results must not reset a full TTL.
ttl.saturating_sub(elapsed)
.saturating_add(Duration::from_secs(1))
})
})
.min()
.unwrap_or(SUCCESS_TTL)
}
impl BalanceMonitor {
fn select(&self, home: PathBuf) -> u64 {
let scope = crate::paths::normalized_path_scope(&home);
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
if let Some(target) = &state.target {
if target.scope == scope {
return target.generation;
}
}
let generation = state
.target
.as_ref()
.map_or(1, |target| target.generation.wrapping_add(1));
state.target = Some(MonitorTarget {
home,
scope,
generation,
});
// The selecting IPC call performs the first query. Schedule only once
// it has completed, rather than racing it or counting from its start.
state.next_refresh = None;
self.changed.notify_one();
generation
}
fn is_current(&self, generation: u64) -> bool {
self.state
.lock()
.unwrap_or_else(|p| p.into_inner())
.target
.as_ref()
.is_some_and(|target| target.generation == generation)
}
fn completed(&self, generation: u64, delay: Duration, native: bool) {
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
if state
.target
.as_ref()
.is_some_and(|target| target.generation == generation)
&& (native || state.next_refresh.is_none())
{
// Reading a warm cache from the renderer must not continually
// postpone the independent native deadline.
state.next_refresh = Some(Instant::now() + delay);
self.changed.notify_one();
}
}
fn run(&self) {
loop {
let target = {
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
loop {
if let (Some(target), Some(deadline)) =
(state.target.clone(), state.next_refresh)
{
let now = Instant::now();
if now >= deadline {
state.next_refresh = None;
break target;
}
let (next, _) = self
.changed
.wait_timeout(state, deadline.saturating_duration_since(now))
.unwrap_or_else(|p| p.into_inner());
state = next;
} else {
state = self.changed.wait(state).unwrap_or_else(|p| p.into_inner());
}
}
};
// Re-enumerate current saved/native providers every round. A scope
// change cancels queued old work between cards; active HTTP calls
// retain their existing bounded timeout and official-only URLs.
let result = read_balances_at(&target.home, &|| self.is_current(target.generation));
self.completed(target.generation, refresh_delay(&result), true);
}
}
}
fn native_monitor() -> Result<&'static Arc<BalanceMonitor>> {
static MONITOR: OnceLock<std::result::Result<Arc<BalanceMonitor>, String>> = OnceLock::new();
MONITOR
.get_or_init(|| {
let monitor = Arc::new(BalanceMonitor {
state: Mutex::new(MonitorState::default()),
changed: Condvar::new(),
});
let worker = Arc::clone(&monitor);
std::thread::Builder::new()
.name("provider-balance-monitor".into())
.spawn(move || worker.run())
.map_err(|_| "无法启动供应商余额后台刷新,请重启 Codex-X 后重试。".to_string())?;
Ok(monitor)
})
.as_ref()
.map_err(|message| CodexxError::Config(message.clone()))
}
/// The IPC wrapper should run this blocking function on spawn_blocking.
/// No URL, API key, arbitrary header or official account ID is accepted.
pub(crate) fn get_provider_balances_inner(
config_dir: Option<String>,
) -> Result<Vec<ProviderBalanceResult>> {
let home = crate::resolve_codex_dir(config_dir)?;
let monitor = native_monitor()?;
let generation = monitor.select(home.clone());
let result = read_balances_at(&home, &|| monitor.is_current(generation));
monitor.completed(generation, refresh_delay(&result), false);
result
}
/// Refresh a single persisted/current card. All connection details are read
/// again from trusted local provider storage, never accepted from the caller.
pub(crate) fn refresh_provider_balance_inner(
config_dir: Option<String>,
source: String,
provider_id: String,
) -> Result<ProviderBalanceResult> {
// Include provider lookup in the concurrency window: overlapping calls
// may spend different amounts of time reading the store/current config.
let requested_at = Instant::now();
if !matches!(source.as_str(), "local" | "detected") {
return Err(CodexxError::Config(
"仅支持刷新已保存或当前检测到的第三方供应商余额。".into(),
));
}
let home = crate::resolve_codex_dir(config_dir)?;
let provider = if source == "local" {
providers::list_saved_providers_inner()
.map_err(|_| CodexxError::Config("读取已保存供应商失败,请稍后重试。".into()))?
.into_iter()
.find(|provider| provider.id == provider_id)
} else {
providers::detected_live_custom_provider(&home)
.map_err(|_| {
CodexxError::Config("读取当前供应商配置失败,请检查配置格式后重试。".into())
})?
.filter(|provider| format!("detected-{}", provider.id) == provider_id)
}
.ok_or_else(|| {
CodexxError::Config("该供应商已不存在或当前配置已改变,请刷新供应商列表。".into())
})?;
if vendor_for_provider(&provider).is_none() {
return Err(CodexxError::Config("该供应商不支持余额刷新。".into()));
}
// Deliberately leave the independent five-minute monitor deadline alone.
Ok(provider_result(&source, &provider, true, requested_at))
}
@@ -0,0 +1,409 @@
// Every source, SQLite database, backup and lock in these tests is temporary.
fn compress_rollout(logical: &Path) -> std::path::PathBuf {
let compressed = logical.with_extension("jsonl.zst");
let mut source = fs::File::open(logical).unwrap();
let output = fs::File::create(&compressed).unwrap();
let mut encoder = zstd::stream::write::Encoder::new(output, 1).unwrap();
std::io::copy(&mut source, &mut encoder).unwrap();
encoder.finish().unwrap();
drop(source);
fs::remove_file(logical).unwrap();
compressed
}
fn decoded(path: &Path) -> Vec<u8> {
let mut source = super::super::super::rollout_stream::decoded_rollout_reader(
fs::File::open(path).unwrap(),
path,
)
.unwrap();
let mut bytes = Vec::new();
source.read_to_end(&mut bytes).unwrap();
bytes
}
fn indexed_compressed_fixture(
label: &str,
) -> (
std::path::PathBuf,
String,
std::path::PathBuf,
std::path::PathBuf,
) {
let dir = temp_codex_dir(label);
write_config(&dir, SHARED_SESSION_PROVIDER);
let id = "01a0e854-0000-7000-8000-000000000001".to_string();
let logical = write_rollout(&dir, &id, "openai");
create_thread_database_with_rollout(&dir.join("state_5.sqlite"), &id, "openai", &logical);
let compressed = compress_rollout(&logical);
(dir, id, logical, compressed)
}
#[test]
fn compressed_logical_reference_syncs_and_keeps_sqlite_alias_and_content() {
let (dir, id, logical, compressed) = indexed_compressed_fixture("zst-logical-alias");
let mut text = decoded(&compressed);
text.extend_from_slice(b"\r\n{\"type\":\"event_msg\",\"payload\":{\"message\":\"untouched\\ntext\",\"unicode\":\"\xe4\xb8\xad\xe6\x96\x87\"}}");
fs::write(
&compressed,
zstd::stream::encode_all(text.as_slice(), 1).unwrap(),
)
.unwrap();
let original = fs::read(&compressed).unwrap();
let database = dir.join("state_5.sqlite");
Connection::open(&database)
.unwrap()
.execute("UPDATE threads SET title='renamed title'", [])
.unwrap();
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(status.needs_sync);
assert_eq!(status.rollout_files, 1);
assert_eq!(status.sessions[0].title, "renamed title");
assert_eq!(
fs::read(&compressed).unwrap(),
original,
"status is read-only"
);
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
assert!(result.status.scan_complete);
assert!(!result.status.needs_sync);
assert_eq!(result.updated_rollouts, 1);
assert_eq!(thread_provider(&database, &id), SHARED_SESSION_PROVIDER);
let expected = String::from_utf8(text).unwrap().replace(
"\"model_provider\":\"openai\"",
"\"model_provider\":\"custom\"",
);
assert_eq!(decoded(&compressed), expected.as_bytes());
let stored: String = Connection::open(&database)
.unwrap()
.query_row(
"SELECT rollout_path FROM threads WHERE id=?1",
[&id],
|row| row.get(0),
)
.unwrap();
assert_eq!(stored, logical.display().to_string());
assert!(
!logical.exists(),
"sync must not expand or rename logical storage"
);
let next = fs::read(&compressed).unwrap();
let again = sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
assert_eq!(again.updated_rollouts, 0);
assert_eq!(again.updated_threads, 0);
assert!(again.backup_dir.is_empty());
assert_eq!(
fs::read(&compressed).unwrap(),
next,
"matched compressed bytes stay identical"
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn plain_rollout_wins_over_stale_compressed_sibling() {
let (dir, id, logical, compressed) = indexed_compressed_fixture("zst-plain-priority");
let stale = fs::read(&compressed).unwrap();
write_rollout_at(&logical, &id, "openai");
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
assert!(
result.status.scan_complete,
"{:?}",
result.status.scan_failures
);
assert_eq!(result.updated_rollouts, 1);
assert_eq!(result.status.rollout_files, 1);
assert_eq!(fs::read(&compressed).unwrap(), stale);
assert!(fs::read_to_string(&logical)
.unwrap()
.contains("\"model_provider\":\"custom\""));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn compressed_archive_and_internal_threads_are_not_promoted_or_modified() {
let (dir, id, logical, compressed) = indexed_compressed_fixture("zst-hidden-threads");
let database = dir.join("state_5.sqlite");
let archived_id = "01a0e854-0000-7000-8000-000000000002";
let archived_logical = dir.join(format!(
"archived_sessions/rollout-test-{archived_id}.jsonl"
));
write_rollout_at(&archived_logical, archived_id, "openai");
let archived = compress_rollout(&archived_logical);
let internal_id = "01a0e854-0000-7000-8000-000000000003";
let internal_logical = write_rollout(&dir, internal_id, "openai");
fs::write(&internal_logical, format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{internal_id}\",\"source\":{{\"internal\":\"guardian\"}},\"model_provider\":\"openai\"}}}}\n")).unwrap();
let internal = compress_rollout(&internal_logical);
let archive_bytes = fs::read(&archived).unwrap();
let internal_bytes = fs::read(&internal).unwrap();
let conn = Connection::open(&database).unwrap();
for (thread, path, archived) in [
(archived_id, &archived_logical, 1),
(internal_id, &internal_logical, 0),
] {
conn.execute("INSERT INTO threads(id,model_provider,rollout_path,archived) VALUES(?1,'openai',?2,?3)", (thread, path.display().to_string(), archived)).unwrap();
}
drop(conn);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[]);
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
assert!(
result.status.scan_complete,
"{:?}",
result.status.scan_failures
);
assert!(!result.status.needs_sync);
assert_eq!(result.updated_rollouts, 1);
assert_eq!(thread_provider(&database, &id), SHARED_SESSION_PROVIDER);
assert_eq!(thread_provider(&database, internal_id), "openai");
assert_eq!(thread_provider(&database, archived_id), "openai");
assert_eq!(fs::read(&archived).unwrap(), archive_bytes);
assert_eq!(fs::read(&internal).unwrap(), internal_bytes);
assert!(catalog_provider_and_visibility(&catalog, internal_id).is_none());
assert!(catalog_provider_and_visibility(&catalog, archived_id).is_none());
assert_eq!(
result
.status
.sessions
.iter()
.find(|row| row.id == internal_id)
.unwrap()
.is_subagent,
true
);
assert!(!logical.exists());
assert!(compressed.exists());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn compressed_revert_uses_thread_id_and_current_alias_for_internal_classification() {
let dir = temp_codex_dir("zst-revert-identity");
write_config(&dir, SHARED_SESSION_PROVIDER);
let thread = "01a0e854-0000-7000-8000-000000000004";
let rollout = "01a0e854-0000-7000-8000-000000000005";
let old = write_rollout(&dir, thread, "openai");
fs::write(&old, format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{thread}\",\"source\":\"subagent\",\"model_provider\":\"openai\"}}}}\n")).unwrap();
let old = compress_rollout(&old);
let old_bytes = fs::read(&old).unwrap();
let current = dir.join(format!(
"sessions/2026/10/01/rollout-2026-10-01-{thread}_{rollout}.jsonl"
));
write_rollout_at(&current, thread, "openai");
let compressed = compress_rollout(&current);
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, thread, "openai", &current);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[]);
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
assert!(
result.status.scan_complete,
"{:?}",
result.status.scan_failures
);
assert_eq!(result.updated_rollouts, 1);
assert_eq!(thread_provider(&database, thread), SHARED_SESSION_PROVIDER);
assert!(catalog_provider_and_visibility(&catalog, rollout).is_none());
assert_eq!(fs::read(&old).unwrap(), old_bytes);
assert!(String::from_utf8(decoded(&compressed))
.unwrap()
.contains("\"model_provider\":\"custom\""));
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn corrupted_or_truncated_compressed_reference_never_reports_synced() {
for invalid in [b"not zstandard".to_vec(), {
let mut compressed = zstd::stream::encode_all(&b"{\"type\":\"session_meta\",\"payload\":{\"id\":\"t\",\"model_provider\":\"custom\"}}\n"[..], 1).unwrap();
compressed.truncate(compressed.len() - 4);
compressed
}] {
let (dir, id, _logical, compressed) = indexed_compressed_fixture("zst-corrupt");
fs::write(&compressed, &invalid).unwrap();
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert!(!status.scan_complete);
assert!(!status.scan_failures.is_empty());
sync_sessions_provider_inner(Some(dir.display().to_string()), None)
.expect_err("invalid compressed data blocks every mutation");
assert_eq!(thread_provider(&dir.join("state_5.sqlite"), &id), "openai");
assert_eq!(fs::read(&compressed).unwrap(), invalid);
fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn compressed_transaction_rollback_restores_exact_original_compressed_bytes() {
let (dir, id, _logical, compressed) = indexed_compressed_fixture("zst-rollback");
let original = fs::read(&compressed).unwrap();
let error = sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
if point == MutationPoint::AfterSqliteCommit(0) {
assert_ne!(fs::read(&compressed).unwrap(), original);
return Err(CodexxError::Config("fixture rollback".into()));
}
Ok(())
})
.unwrap_err();
assert!(error.to_string().contains("fixture rollback"));
assert_eq!(fs::read(&compressed).unwrap(), original);
assert_eq!(thread_provider(&dir.join("state_5.sqlite"), &id), "openai");
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn compressed_source_replacement_or_expansion_during_sync_aborts_without_overwrite() {
for expansion in [false, true] {
for point_to_change in [
MutationPoint::BeforeRolloutMutation,
MutationPoint::BeforeEachRolloutMutation(0),
] {
let (dir, id, logical, compressed) =
indexed_compressed_fixture("zst-concurrent-representation");
let original = fs::read(&compressed).unwrap();
let external = format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"external\"}}}}\n").into_bytes();
let external_compressed = zstd::stream::encode_all(external.as_slice(), 1).unwrap();
sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
if point == point_to_change {
if expansion {
fs::write(&logical, &external).unwrap();
} else {
fs::write(&compressed, &external_compressed).unwrap();
}
}
Ok(())
})
.expect_err("external replacement/expansion must abort synchronization");
assert_eq!(thread_provider(&dir.join("state_5.sqlite"), &id), "openai");
if expansion {
assert_eq!(fs::read(&logical).unwrap(), external);
assert_eq!(fs::read(&compressed).unwrap(), original);
} else {
assert_eq!(fs::read(&compressed).unwrap(), external_compressed);
}
fs::remove_dir_all(dir).unwrap();
}
}
}
#[test]
fn compressed_reference_with_wrong_metadata_id_blocks_sync_without_uuid_reassociation() {
let (dir, id, logical, compressed) = indexed_compressed_fixture("zst-wrong-identity");
let other = "01a0e854-0000-7000-8000-000000000009";
let wrong = format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{other}\",\"model_provider\":\"openai\"}}}}\n");
let bytes = zstd::stream::encode_all(wrong.as_bytes(), 1).unwrap();
fs::write(&compressed, &bytes).unwrap();
// A different file with the right ID must not replace an explicit current alias.
let fallback = dir.join(format!("sessions/history/rollout-old-{id}.jsonl"));
write_rollout_at(&fallback, &id, "openai");
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert!(!status.scan_complete);
assert!(status
.scan_failures
.iter()
.any(|failure| failure.contains("线程 ID 不一致")));
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
assert_eq!(fs::read(&compressed).unwrap(), bytes);
assert_eq!(thread_provider(&dir.join("state_5.sqlite"), &id), "openai");
assert!(!logical.exists());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn active_reference_moved_to_compressed_archive_is_reported_as_archive_conflict() {
let (dir, id, logical, compressed) = indexed_compressed_fixture("zst-archive-conflict");
let archived = dir
.join("archived_sessions")
.join(compressed.file_name().unwrap());
fs::create_dir_all(archived.parent().unwrap()).unwrap();
fs::rename(&compressed, &archived).unwrap();
let bytes = fs::read(&archived).unwrap();
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert!(!status.scan_complete);
assert!(status
.scan_failures
.iter()
.any(|failure| failure.contains("归档状态不一致")
&& failure.contains(&id)
&& failure.contains(&logical.display().to_string())
&& failure.contains(&archived.display().to_string())));
assert!(!status
.scan_failures
.iter()
.any(|failure| failure.contains("均缺失")));
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
assert_eq!(fs::read(&archived).unwrap(), bytes);
assert_eq!(thread_provider(&dir.join("state_5.sqlite"), &id), "openai");
assert!(!logical.exists());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn compressed_expansion_after_commit_preserves_external_plain_log_and_retains_backup() {
let (dir, id, logical, compressed) = indexed_compressed_fixture("zst-expansion-after-commit");
let external = format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"external\"}}}}\n");
let error = sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
if point == MutationPoint::AfterSqliteCommit(0) {
fs::write(&logical, external.as_bytes()).unwrap();
}
Ok(())
})
.expect_err("postcommit expansion must not be mistaken for a successful synchronization");
assert!(error.to_string().contains("变化") || error.to_string().contains("回滚"));
assert_eq!(fs::read(&logical).unwrap(), external.as_bytes());
assert_eq!(thread_provider(&dir.join("state_5.sqlite"), &id), "openai");
assert!(compressed.exists());
let backups = crate::sessions::provider_sync_backup_root(&dir);
assert!(fs::read_dir(backups).unwrap().next().is_some());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn provider_can_really_switch_during_sync_while_session_target_stays_fixed() {
let _app_db_guard = crate::app_db::test_db_guard();
let dir = temp_codex_dir("provider-switch-during-session-sync");
write_config(&dir, "openai");
let id = "01a0e854-0000-7000-8000-000000000011";
let logical = write_rollout(&dir, id, "historical-provider");
let compressed = compress_rollout(&logical);
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, "historical-provider", &logical);
let mut switched = false;
let result = sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
if point == MutationPoint::BeforeSqliteLock {
crate::providers::switch_provider_inner(crate::providers::ProviderInput {
config_dir: Some(dir.display().to_string()),
provider_id: Some("session-sync-switch-fixture".into()),
provider_name: "Session sync switch fixture".into(),
base_url: "https://session-sync-fixture.invalid/v1".into(),
model: "fixture-model".into(),
api_key: Some("fixture-key-never-sent".into()),
wire_api: Some("responses".into()),
requires_openai_auth: None,
})?;
assert_eq!(current_model_provider(&dir, None)?, "custom");
switched = true;
}
Ok(())
})
.unwrap();
assert!(
switched,
"the real provider switch ran while the maintenance lock was held"
);
assert_eq!(result.status.target_provider, "openai");
assert!(!result.status.needs_sync);
assert_eq!(thread_provider(&database, id), "openai");
assert!(String::from_utf8(decoded(&compressed))
.unwrap()
.contains("\"model_provider\":\"openai\""));
assert_eq!(current_model_provider(&dir, None).unwrap(), "custom");
let current = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert_eq!(current.target_provider, "custom");
assert!(
current.needs_sync,
"a new explicit status read sees the new target, without stale scan caching"
);
let live_config = fs::read_to_string(dir.join("config.toml")).unwrap();
assert!(live_config.contains("https://session-sync-fixture.invalid/v1"));
fs::remove_dir_all(dir).unwrap();
}
+28 -4
View File
@@ -1,5 +1,7 @@
use super::app_server::delete_sessions_via_codex_app_server;
use super::rollout_stream::{for_each_jsonl_record, record_matches_top_level_strings};
use super::rollout_stream::{
for_each_jsonl_record, record_matches_top_level_strings, resolved_rollout_path,
};
use super::storage::{
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
is_canonical_rollout_storage_path, rollout_filename_matches_id, scan_rollouts,
@@ -404,9 +406,11 @@ fn canonical_rollout_path(codex_dir: &Path, value: &str, id: &str) -> Result<Opt
} else {
codex_dir.join(raw)
};
if !path.exists() {
return Ok(None);
}
let path = match resolved_rollout_path(&path) {
Ok(path) => path,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(io_err(&path, error)),
};
if !rollout_filename_matches_id(&path, id) {
return Err(CodexxError::Config(format!(
"会话文件名与 ID 不匹配,已拒绝删除: {}",
@@ -1399,6 +1403,26 @@ mod tests {
.expect("insert thread");
}
#[test]
fn deletion_target_resolves_compressed_logical_alias_without_selecting_another_history() {
let codex_dir = temp_codex_dir();
let id = "01a0e854-0000-7000-8000-000000000001";
let logical = codex_dir.join(format!("sessions/rollout-current-{id}.jsonl"));
fs::create_dir_all(logical.parent().unwrap()).unwrap();
let compressed = logical.with_extension("jsonl.zst");
fs::write(
&compressed,
zstd::stream::encode_all(&b"{}\n"[..], 1).unwrap(),
)
.unwrap();
let found = canonical_rollout_path(&codex_dir, &logical.display().to_string(), id).unwrap();
assert_eq!(found, Some(compressed.canonicalize().unwrap()));
fs::write(&logical, b"{}\n").unwrap();
let found = canonical_rollout_path(&codex_dir, &logical.display().to_string(), id).unwrap();
assert_eq!(found, Some(logical.canonicalize().unwrap()));
fs::remove_dir_all(codex_dir).unwrap();
}
#[test]
fn deletion_verification_checks_second_visible_database() {
let codex_dir = temp_codex_dir();
@@ -175,6 +175,7 @@ fn directory_lookup(path: &str) -> DirectoryIdentity {
.unwrap_or_else(|_| unresolved_directory_identity(path))
}
#[cfg(test)]
fn directory_identity(path: &str) -> String {
directory_lookup(path).key
}
@@ -187,9 +188,11 @@ fn pin_scope_key(path: &str, identity: &DirectoryIdentity) -> String {
}
}
/// Resolve the paths represented by this status only. The raw spelling remains
/// the map key. Persistent home scope uses native identity only when birth time
/// was verified; otherwise it conservatively follows the original home path.
/// The explicitly selected Codex home may use its native identity. Historical
/// workspace paths remain lexical: opening or probing them merely to group
/// sessions can request access to protected macOS folders such as Documents.
/// Preserve their complete spelling on every platform; without inspecting a
/// directory, volume defaults cannot exclude per-directory case sensitivity.
pub(super) fn session_directory_identities(
codex_dir: &Path,
sessions: &[SessionPreview],
@@ -202,7 +205,7 @@ pub(super) fn session_directory_identities(
for path in sessions.iter().filter_map(|session| session.cwd.as_deref()) {
identities
.entry(path.to_string())
.or_insert_with(|| directory_identity(path));
.or_insert_with(|| unresolved_directory_identity(path).key);
}
(identities, pin_scope)
}
+131 -4
View File
@@ -1,3 +1,4 @@
use super::rollout_stream::{decoded_rollout_reader, resolved_rollout_path};
use super::storage::{
is_canonical_rollout_storage_path, list_session_previews_with_paths, session_project_title,
sqlite_candidate_paths,
@@ -187,6 +188,7 @@ struct JsonLine<'a, R: BufRead> {
buffered: usize,
buffer_ends_line: bool,
has_content: bool,
too_large: bool,
}
impl<R: BufRead> Read for JsonLine<'_, R> {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
@@ -199,6 +201,7 @@ impl<R: BufRead> Read for JsonLine<'_, R> {
return Ok(0);
}
if self.consumed >= MAX_RECORD_BYTES {
self.too_large = true;
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"record too large",
@@ -240,13 +243,16 @@ fn drain_line<R: BufRead>(reader: &mut R) -> std::io::Result<()> {
fn visit_records(
file: &mut File,
path: &Path,
length: u64,
expected_id: &str,
mut visitor: impl FnMut(Message) -> Result<()>,
) -> Result<usize> {
file.seek(SeekFrom::Start(0))
.map_err(|error| CodexxError::Config(format!("无法读取会话:{error}")))?;
let mut reader = BufReader::new(file.take(length));
let decoded =
decoded_rollout_reader(file.take(length), path).map_err(|error| io_err(path, error))?;
let mut reader = BufReader::new(decoded);
let mut malformed = 0;
let mut verified_meta = false;
loop {
@@ -264,8 +270,14 @@ fn visit_records(
buffered: 0,
buffer_ends_line: false,
has_content: false,
too_large: false,
};
let parsed = serde_json::from_reader::<_, Record>(&mut line);
if let Err(error) = &parsed {
if error.is_io() && !line.too_large {
return Err(CodexxError::Config(format!("无法读取会话:{error}")));
}
}
let ended = line.ended;
let has_content = line.has_content;
if !ended {
@@ -315,10 +327,12 @@ fn safe_rollout_path(codex_dir: &Path, session: &SessionPreview) -> Result<PathB
} else {
codex_dir.join(path)
};
let path = resolved_rollout_path(&path).map_err(|error| io_err(&path, error))?;
let metadata = fs::symlink_metadata(&path).map_err(|error| io_err(&path, error))?;
if !metadata.is_file()
|| metadata.file_type().is_symlink()
|| !path.to_string_lossy().ends_with(".jsonl")
|| !(path.to_string_lossy().ends_with(".jsonl")
|| path.to_string_lossy().ends_with(".jsonl.zst"))
{
return Err(CodexxError::Config("不支持此会话文件类型".to_string()));
}
@@ -351,7 +365,7 @@ fn write_markdown(codex_dir: &Path, session: &SessionPreview, output: &mut File)
let mut file = File::open(&path).map_err(|error| io_err(&path, error))?;
let length = file.metadata().map_err(|error| io_err(&path, error))?.len();
let mut event_counts = HashMap::<[u8; 32], usize>::new();
let malformed = visit_records(&mut file, length, &session.id, |message| {
let malformed = visit_records(&mut file, &path, length, &session.id, |message| {
if message.event {
if event_counts.len() >= MAX_MESSAGE_KEYS {
return Err(CodexxError::Config(
@@ -388,7 +402,7 @@ fn write_markdown(codex_dir: &Path, session: &SessionPreview, output: &mut File)
markdown_label(&session.id)
))?;
let mut count = 0;
visit_records(&mut file, length, &session.id, |message| {
visit_records(&mut file, &path, length, &session.id, |message| {
if !message.event {
if let Some(remaining) = event_counts.get_mut(&message_key(&message)) {
if *remaining > 0 {
@@ -712,6 +726,119 @@ mod tests {
json!({"type":"response_item","timestamp":"2026-09-12T10:00:00Z","payload":{"type":"message","role":role,"content":[{"type":if role == "user" { "input_text" } else { "output_text" },"text":body}]}})
}
fn compress_fixture(path: &Path) -> PathBuf {
let compressed = path.with_extension("jsonl.zst");
let mut input = File::open(path).unwrap();
let mut encoder =
zstd::stream::write::Encoder::new(File::create(&compressed).unwrap(), 1).unwrap();
encoder.include_checksum(true).unwrap();
std::io::copy(&mut input, &mut encoder).unwrap();
encoder.finish().unwrap();
drop(input);
fs::remove_file(path).unwrap();
compressed
}
#[test]
fn exports_compressed_logical_reference_with_same_deduplication_and_no_storage_changes() {
let fixture = Fixture::new();
let path = fixture.add(
"compressed",
"已改名的会话",
&[
event("user", "中文问题"),
response("user", "中文问题"),
response("assistant", "完整回答"),
],
false,
);
let expected = fixture.export(&["compressed"], "plain.md").unwrap();
let compressed = compress_fixture(&path);
let raw_before = fs::read(&compressed).unwrap();
let db_before = fs::read(&fixture.database).unwrap();
let result = fixture.export(&["compressed"], "compressed.md").unwrap();
assert_eq!(
fs::read(result.path).unwrap(),
fs::read(expected.path).unwrap()
);
assert_eq!(result.failed_sessions, 0);
assert!(result.warnings.is_empty());
assert!(!path.exists());
assert_eq!(fs::read(compressed).unwrap(), raw_before);
assert_eq!(fs::read(&fixture.database).unwrap(), db_before);
}
#[test]
fn export_prefers_current_plain_file_over_stale_compressed_sibling() {
let fixture = Fixture::new();
let path = fixture.add("current", "当前会话", &[event("user", "stale body")], false);
let compressed = compress_fixture(&path);
fs::write(
&path,
format!(
"{}\n{}\n",
json!({"type":"session_meta","payload":{"id":"current"}}),
event("user", "current body")
),
)
.unwrap();
let before = fs::read(&compressed).unwrap();
let result = fixture.export(&["current"], "current.md").unwrap();
let text = fs::read_to_string(result.path).unwrap();
assert!(text.contains("current body"));
assert!(!text.contains("stale body"));
assert_eq!(fs::read(compressed).unwrap(), before);
}
#[test]
fn corrupt_compression_is_an_error_and_preserves_existing_export() {
let fixture = Fixture::new();
let path = fixture.add(
"corrupt",
"损坏的压缩会话",
&[event("user", "must not publish a partial export")],
false,
);
let compressed = compress_fixture(&path);
let mut bytes = fs::read(&compressed).unwrap();
let last = bytes.len() - 1;
bytes[last] ^= 0x80;
fs::write(&compressed, &bytes).unwrap();
let destination = fixture.root.join("protected.md");
fs::write(&destination, "existing export").unwrap();
assert!(fixture.export(&["corrupt"], "protected.md").is_err());
assert_eq!(fs::read_to_string(destination).unwrap(), "existing export");
assert_eq!(fs::read(compressed).unwrap(), bytes);
assert!(fs::read_dir(&fixture.root).unwrap().all(|entry| !entry
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".codex-x-export-")));
}
#[test]
fn compressed_identity_mismatch_does_not_export_another_conversation() {
let fixture = Fixture::new();
let path = fixture.add(
"selected",
"所选会话",
&[event("user", "private fixture")],
false,
);
fs::write(
&path,
format!(
"{}\n{}\n",
json!({"type":"session_meta","payload":{"id":"different"}}),
event("user", "private fixture")
),
)
.unwrap();
compress_fixture(&path);
assert!(fixture.export(&["selected"], "mismatch.md").is_err());
assert!(!fixture.root.join("mismatch.md").exists());
}
#[test]
fn exports_markdown_titles_times_and_code_without_system_tools_or_reasoning() {
let fixture = Fixture::new();
@@ -31,3 +31,56 @@ pub(crate) use storage::{
};
pub(crate) use sync::{session_sync_status_inner, sync_sessions_provider_inner};
pub(crate) use types::{SessionSyncResult, SessionSyncStatus};
/// Serialize operations that can restart the official client with session
/// synchronization and deletion, including callers from other windows or IPC.
/// Provider activation remains independent of this maintenance lock.
pub(crate) fn with_session_maintenance<T>(
codex_dir: &std::path::Path,
operation: impl FnOnce() -> crate::error::Result<T>,
) -> crate::error::Result<T> {
let _guard = sync::acquire_session_maintenance_lock(codex_dir)?;
operation()
}
#[cfg(test)]
mod maintenance_tests {
use super::*;
use std::cell::Cell;
#[test]
fn maintenance_conflict_never_executes_callback_and_release_allows_retry() {
let directory = tempfile::tempdir().unwrap();
let held = sync::acquire_session_maintenance_lock(directory.path()).unwrap();
let called = Cell::new(false);
let error = with_session_maintenance(directory.path(), || {
called.set(true);
Ok(())
})
.expect_err("another session operation must block the callback");
assert!(!called.get());
assert!(error.to_string().contains("会话维护正在进行"));
drop(held);
let result = with_session_maintenance(directory.path(), || {
called.set(true);
Ok(17)
})
.unwrap();
assert!(called.get());
assert_eq!(result, 17);
let _available = sync::acquire_session_maintenance_lock(directory.path()).unwrap();
}
#[test]
fn maintenance_guard_releases_when_callback_returns_an_error() {
let directory = tempfile::tempdir().unwrap();
let error = with_session_maintenance(directory.path(), || {
Err::<(), _>(crate::error::CodexxError::Config(
"fixture operation failed".into(),
))
})
.unwrap_err();
assert!(error.to_string().contains("fixture operation failed"));
let _available = sync::acquire_session_maintenance_lock(directory.path()).unwrap();
}
}
@@ -19,6 +19,66 @@ const MAX_KEY_CAPTURE_BYTES: usize = 128;
const MAX_METADATA_STRING_BYTES: usize = 4096;
const MAX_CWD_BYTES: usize = 32 * 1024;
pub(super) fn is_compressed_rollout(path: &Path) -> bool {
path.to_string_lossy().ends_with(".jsonl.zst")
}
pub(super) fn logical_rollout_path(path: &Path) -> PathBuf {
if is_compressed_rollout(path) {
path.with_extension("")
} else {
path.to_path_buf()
}
}
/// Codex keeps the logical .jsonl reference after compressing the same file.
/// An existing plain representation always wins; other IO failures must not
/// silently select a possibly stale compressed sibling.
pub(super) fn resolved_rollout_path(path: &Path) -> std::io::Result<PathBuf> {
let logical = logical_rollout_path(path);
match fs::symlink_metadata(&logical) {
Ok(_) => Ok(logical),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let mut compressed = logical.into_os_string();
compressed.push(".zst");
let compressed = PathBuf::from(compressed);
fs::symlink_metadata(&compressed)?;
Ok(compressed)
}
Err(error) => Err(error),
}
}
pub(super) fn decoded_rollout_reader<'a, R: Read + 'a>(
reader: R,
path: &Path,
) -> std::io::Result<Box<dyn Read + 'a>> {
if is_compressed_rollout(path) {
Ok(Box::new(zstd::stream::read::Decoder::new(reader)?))
} else {
Ok(Box::new(reader))
}
}
/// Never overwrite a compressed history after Codex has expanded its current
/// logical file. This is checked again at transaction and atomic-write edges.
pub(super) fn ensure_current_rollout_representation(path: &Path) -> Result<()> {
if is_compressed_rollout(path) {
let logical = logical_rollout_path(path);
match fs::symlink_metadata(&logical) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(io_err(&logical, error)),
Ok(_) => {
return Err(format_error(
path,
"会话压缩状态已变化,已停止同步;请重新检查会话",
))
}
}
}
Ok(())
}
#[derive(Debug)]
pub(super) struct FirstMeta {
pub(super) id: String,
@@ -729,9 +789,9 @@ pub(super) fn record_matches_top_level_strings(
JsonReader::new(record, path).matching_fields(field_names, expected, max_decoded_bytes)
}
fn copy_hashed<R: Read>(
fn copy_hashed<R: Read, W: Write>(
mut reader: R,
output: &mut File,
output: &mut W,
hash: &mut Sha256,
path: &Path,
) -> Result<u64> {
@@ -752,11 +812,11 @@ fn copy_hashed<R: Read>(
}
}
fn patched_record(
fn patched_record<W: Write>(
record: &mut File,
metadata: &Metadata,
provider: &[u8],
next: &mut File,
next: &mut W,
hash: &mut Sha256,
path: &Path,
) -> Result<()> {
@@ -858,23 +918,14 @@ pub(super) fn read_first_meta<R: Read>(reader: R, path: &Path) -> Result<FirstMe
}
}
/// Validate the whole rollout and stage byte-preserving Provider patches. The
/// only retained buffers are fixed IO buffers and bounded identity fields.
pub(super) fn scan_rollout_stream(
file: &mut File,
fn scan_records<R: Read, W: Write>(
reader: R,
path: &Path,
directory: &Path,
target_provider: &str,
) -> Result<StreamedRollout> {
if target_provider.len() > MAX_METADATA_STRING_BYTES {
return Err(format_error(path, "目标 Provider 标识过长"));
}
file.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
let directory = private_directory("codex-x-rollout-snapshot-", path)?;
let original_path = directory.path().join("original.jsonl");
let next_path = directory.path().join("next.jsonl");
let mut original = private_file(&original_path)?;
let mut next = private_file(&next_path)?;
mut original: Option<&mut File>,
next: &mut W,
) -> Result<(FirstMeta, usize, usize, [u8; 32], [u8; 32])> {
let provider_json = serde_json::to_vec(target_provider)
.map_err(|_| format_error(path, "目标 Provider 无法编码"))?;
let mut original_hash = Sha256::new();
@@ -882,11 +933,13 @@ pub(super) fn scan_rollout_stream(
let mut first_meta = None;
let mut session_meta_count = 0;
let mut mismatch_count = 0;
frame_records(&mut *file, directory.path(), path, |record, _length| {
frame_records(reader, directory, path, |record, _length| {
record
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
copy_hashed(&mut *record, &mut original, &mut original_hash, path)?;
if let Some(original) = original.as_deref_mut() {
copy_hashed(&mut *record, original, &mut original_hash, path)?;
}
record
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
@@ -909,7 +962,7 @@ pub(super) fn scan_rollout_stream(
record,
&metadata,
&provider_json,
&mut next,
next,
&mut next_hash,
path,
)?;
@@ -919,17 +972,147 @@ pub(super) fn scan_rollout_stream(
record
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
copy_hashed(record, &mut next, &mut next_hash, path)?;
copy_hashed(record, next, &mut next_hash, path)?;
Ok(())
})?;
let first_meta = first_meta.ok_or_else(|| format_error(path, "缺少起始 session_meta"))?;
Ok((
first_meta,
session_meta_count,
mismatch_count,
original_hash.finalize().into(),
next_hash.finalize().into(),
))
}
/// Validate decoded JSONL with bounded memory. Compressed histories retain
/// their exact original compressed bytes and receive a streamed compressed
/// replacement, so ordinary hashes, atomic writes and rollback stay exact.
pub(super) fn scan_rollout_stream(
file: &mut File,
path: &Path,
target_provider: &str,
) -> Result<StreamedRollout> {
scan_rollout_stream_inner(file, path, target_provider, true)
}
pub(super) fn inspect_rollout_stream(
file: &mut File,
path: &Path,
target_provider: &str,
) -> Result<StreamedRollout> {
scan_rollout_stream_inner(file, path, target_provider, false)
}
struct HashingReader<'a> {
source: &'a mut File,
hash: &'a mut Sha256,
}
impl Read for HashingReader<'_> {
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
let count = self.source.read(bytes)?;
self.hash.update(&bytes[..count]);
Ok(count)
}
}
fn scan_rollout_stream_inner(
file: &mut File,
path: &Path,
target_provider: &str,
stage_changes: bool,
) -> Result<StreamedRollout> {
if target_provider.len() > MAX_METADATA_STRING_BYTES {
return Err(format_error(path, "目标 Provider 标识过长"));
}
ensure_current_rollout_representation(path)?;
file.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
let directory = private_directory("codex-x-rollout-snapshot-", path)?;
if !stage_changes {
let mut raw_hash = Sha256::new();
let reader = HashingReader {
source: &mut *file,
hash: &mut raw_hash,
};
let reader = decoded_rollout_reader(reader, path).map_err(|error| io_err(path, error))?;
let (first_meta, session_meta_count, mismatch_count, _, _) = scan_records(
reader,
path,
directory.path(),
target_provider,
None,
&mut std::io::sink(),
)?;
return Ok(StreamedRollout {
first_meta,
session_meta_count,
mismatch_count,
original_hash: raw_hash.finalize().into(),
original_mtime: file
.metadata()
.map_err(|error| io_err(path, error))?
.modified()
.ok(),
staged: None,
});
}
let extension = if is_compressed_rollout(path) {
"jsonl.zst"
} else {
"jsonl"
};
let original_path = directory.path().join(format!("original.{extension}"));
let next_path = directory.path().join(format!("next.{extension}"));
let mut original = private_file(&original_path)?;
let mut next = private_file(&next_path)?;
let (first_meta, session_meta_count, mismatch_count, original_hash, next_hash) =
if is_compressed_rollout(path) {
let mut original_hash = Sha256::new();
copy_hashed(&mut *file, &mut original, &mut original_hash, path)?;
original
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
let decoder =
decoded_rollout_reader(&mut original, path).map_err(|error| io_err(path, error))?;
let mut encoder = zstd::stream::write::Encoder::new(&mut next, 3)
.map_err(|error| io_err(path, error))?;
let (first, count, mismatches, _, _) = scan_records(
decoder,
path,
directory.path(),
target_provider,
None,
&mut encoder,
)?;
encoder.finish().map_err(|error| io_err(path, error))?;
next.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
let mut next_hash = Sha256::new();
copy_hashed(&mut next, &mut std::io::sink(), &mut next_hash, path)?;
(
first,
count,
mismatches,
original_hash.finalize().into(),
next_hash.finalize().into(),
)
} else {
scan_records(
&mut *file,
path,
directory.path(),
target_provider,
Some(&mut original),
&mut next,
)?
};
let original_mtime = file
.metadata()
.map_err(|error| io_err(path, error))?
.modified()
.ok();
let original_hash: [u8; 32] = original_hash.finalize().into();
let next_hash: [u8; 32] = next_hash.finalize().into();
let staged = if mismatch_count != 0 {
original
.sync_all()
@@ -1005,6 +1188,111 @@ mod tests {
}
}
#[test]
fn compressed_inspection_keeps_full_validation_and_raw_hash_without_snapshot_or_recompression()
{
let original = b"{\"type\":\"session_meta\",\"payload\":{\"id\":\"thread\",\"model_provider\":\"openai\"}}\r\n{\"type\":\"event_msg\",\"payload\":{\"message\":\"kept\"}}";
let fixture = Fixture::bytes(original);
let compressed = fixture.path.with_extension("jsonl.zst");
fs::write(
&compressed,
zstd::stream::encode_all(&original[..], 1).unwrap(),
)
.unwrap();
fs::remove_file(&fixture.path).unwrap();
for target in ["openai", "custom"] {
let checked =
inspect_rollout_stream(&mut File::open(&compressed).unwrap(), &compressed, target)
.unwrap();
let staged =
scan_rollout_stream(&mut File::open(&compressed).unwrap(), &compressed, target)
.unwrap();
assert!(checked.staged.is_none());
assert_eq!(checked.original_hash, file_hash(&compressed));
assert_eq!(checked.original_hash, staged.original_hash);
assert_eq!(checked.session_meta_count, staged.session_meta_count);
assert_eq!(checked.mismatch_count, staged.mismatch_count);
assert_eq!(checked.first_meta.id, staged.first_meta.id);
}
// A valid metadata prefix must not make a damaged later frame healthy.
let mut raw = zstd::stream::encode_all(&original[..], 1).unwrap();
let mut tail = zstd::stream::encode_all(
&b"\n{\"type\":\"event_msg\",\"payload\":{\"x\":1}}\n"[..],
1,
)
.unwrap();
tail.truncate(tail.len() - 2);
raw.extend(tail);
fs::write(&compressed, raw).unwrap();
assert!(inspect_rollout_stream(
&mut File::open(&compressed).unwrap(),
&compressed,
"openai"
)
.is_err());
}
#[test]
fn compressed_records_above_legacy_limit_are_streamed_and_byte_preserved() {
let fixture = Fixture::bytes(b"");
let compressed = fixture.path.with_extension("jsonl.zst");
fs::remove_file(&fixture.path).unwrap();
let mut encoder =
zstd::stream::write::Encoder::new(File::create(&compressed).unwrap(), 1).unwrap();
encoder.write_all(b"{\"type\":\"session_meta\",\"payload\":{\"id\":\"thread\",\"model_provider\":\"openai\"}}\n{\"type\":\"event_msg\",\"payload\":{\"huge\":\"").unwrap();
let buffer = [b'x'; IO_BUFFER_BYTES];
for _ in 0..(33 * 1024 * 1024 / IO_BUFFER_BYTES) {
encoder.write_all(&buffer).unwrap();
}
encoder.write_all(b"\"}}\r\n").unwrap();
encoder.finish().unwrap();
let result =
scan_rollout_stream(&mut File::open(&compressed).unwrap(), &compressed, "custom")
.unwrap();
let snapshot = result.staged.unwrap();
assert_eq!(file_hash(&compressed), snapshot.original_hash);
assert_eq!(file_hash(&snapshot.original_path), snapshot.original_hash);
let mut decoded = decoded_rollout_reader(
File::open(&snapshot.next_path).unwrap(),
&snapshot.next_path,
)
.unwrap();
let meta = read_first_meta(&mut decoded, &snapshot.next_path).unwrap();
assert_eq!(meta.provider.as_deref(), Some("custom"));
let mut original = BufReader::new(
decoded_rollout_reader(File::open(&compressed).unwrap(), &compressed).unwrap(),
);
let mut original_hash = Sha256::new();
let mut next_hash = Sha256::new();
let mut first_record = Vec::new();
original.read_until(b'\n', &mut first_record).unwrap();
copy_hashed(
&mut original,
&mut std::io::sink(),
&mut original_hash,
&compressed,
)
.unwrap();
let mut next = BufReader::new(
decoded_rollout_reader(
File::open(&snapshot.next_path).unwrap(),
&snapshot.next_path,
)
.unwrap(),
);
first_record.clear();
next.read_until(b'\n', &mut first_record).unwrap();
let count =
copy_hashed(&mut next, &mut std::io::sink(), &mut next_hash, &compressed).unwrap();
assert!(count > 32 * 1024 * 1024);
assert_eq!(
original_hash.finalize(),
next_hash.finalize(),
"all nonmetadata bytes stay exact after decompression"
);
assert_eq!(snapshot.next_hash, file_hash(&snapshot.next_path));
}
#[test]
fn provider_patch_preserves_other_bytes_crlf_and_missing_final_lf() {
let original = b"\r\n{\"type\":\"session_meta\",\"payload\":{\"id\":\"thread\",\"cwd\":\"/tmp/work\",\"model_provider\" : \"openai\",\"source\":\"vscode\"}}\r\n{\"type\":\"event_msg\",\"payload\":{\"text\":\"openai chat content \\\\n\"}}";
@@ -0,0 +1,204 @@
// Manual release-profile benchmark. It never accepts a user-supplied Codex
// directory; all databases, config, histories and backups belong to TempDir.
use super::super::super::rollout_stream::decoded_rollout_reader;
use std::io::BufReader;
use std::time::Instant;
fn random_ascii(buffer: &mut [u8], seed: &mut u64) {
const ASCII: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-";
for byte in buffer {
*seed ^= *seed << 13;
*seed ^= *seed >> 7;
*seed ^= *seed << 17;
*byte = ASCII[(*seed >> 8) as usize & 63];
}
}
fn event_payload(writer: &mut impl Write, size: usize, seed: &mut u64) -> u64 {
let prefix = b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_message\",\"message\":\"";
let suffix = b"\"}}\n";
writer.write_all(prefix).unwrap();
let mut buffer = [0; 64 * 1024];
let mut remaining = size;
while remaining != 0 {
let count = remaining.min(buffer.len());
random_ascii(&mut buffer[..count], seed);
writer.write_all(&buffer[..count]).unwrap();
remaining -= count;
}
writer.write_all(suffix).unwrap();
(prefix.len() + size + suffix.len()) as u64
}
fn content_hash(path: &Path) -> [u8; 32] {
let file = fs::File::open(path).unwrap();
let mut reader = BufReader::new(decoded_rollout_reader(file, path).unwrap());
let mut first = Vec::new();
reader.read_until(b'\n', &mut first).unwrap();
assert!(first.len() < 4096);
let first: serde_json::Value = serde_json::from_slice(&first).unwrap();
assert_eq!(first["type"], "session_meta");
let mut hash = Sha256::new();
let mut buffer = [0; 64 * 1024];
loop {
let count = reader.read(&mut buffer).unwrap();
if count == 0 {
break;
}
hash.update(&buffer[..count]);
}
hash.finalize().into()
}
fn timed<T>(action: impl FnOnce() -> T) -> (T, f64) {
let started = Instant::now();
let value = action();
(value, started.elapsed().as_secs_f64() * 1000.0)
}
#[test]
#[ignore = "manual release-profile measurement using temporary session fixtures only"]
fn measure_session_sync_on_temporary_plain_and_compressed_logs() {
const FILES: usize = 24;
const LARGE_RECORD_BYTES: usize = 34 * 1024 * 1024;
const SMALL_RECORDS_PER_FILE: usize = 1000;
const SMALL_RECORD_BYTES: usize = 512;
let directory = tempfile::tempdir().unwrap();
let dir = directory.path();
write_config(dir, SHARED_SESSION_PROVIDER);
let database = dir.join("state_5.sqlite");
let conn = Connection::open(&database).unwrap();
conn.execute_batch("CREATE TABLE threads(id TEXT PRIMARY KEY, model_provider TEXT NOT NULL, title TEXT, rollout_path TEXT, archived INTEGER NOT NULL DEFAULT 0)").unwrap();
let mut raw_bytes = 0u64;
let mut raw_compressed_bytes = 0u64;
let mut plain_bytes = 0u64;
let mut compressed_bytes = 0u64;
let mut record_count = 0usize;
let mut fixtures = Vec::new();
let mut seed = 0x1357_2468_90ab_cdef;
for index in 0..FILES {
let id = format!("01a0e854-0000-7000-8000-{index:012}");
let logical = dir.join(format!("sessions/2026/10/16/rollout-benchmark-{id}.jsonl"));
fs::create_dir_all(logical.parent().unwrap()).unwrap();
let compressed = index % 2 == 1;
let path = if compressed {
logical.with_extension("jsonl.zst")
} else {
logical.clone()
};
let mut file = fs::File::create(&path).unwrap();
let mut encoder = if compressed {
Some(zstd::stream::write::Encoder::new(file.try_clone().unwrap(), 1).unwrap())
} else {
None
};
let mut output = std::io::BufWriter::new(if let Some(encoder) = encoder.as_mut() {
encoder as &mut dyn Write
} else {
&mut file as &mut dyn Write
});
let meta = format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"source\":\"cli\",\"model_provider\":\"openai\"}}}}\n");
output.write_all(meta.as_bytes()).unwrap();
let mut bytes = meta.len() as u64;
record_count += 1;
if index < 2 {
bytes += event_payload(&mut output, LARGE_RECORD_BYTES, &mut seed);
record_count += 1;
} else {
for _ in 0..SMALL_RECORDS_PER_FILE {
bytes += event_payload(&mut output, SMALL_RECORD_BYTES, &mut seed);
record_count += 1;
}
}
output.flush().unwrap();
drop(output);
if let Some(encoder) = encoder {
encoder.finish().unwrap();
}
drop(file);
let stored_bytes = fs::metadata(&path).unwrap().len();
raw_bytes += bytes;
if compressed {
compressed_bytes += stored_bytes;
raw_compressed_bytes += bytes;
} else {
plain_bytes += stored_bytes;
}
conn.execute(
"INSERT INTO threads(id,model_provider,title,rollout_path) VALUES(?1,'openai',?1,?2)",
(&id, logical.display().to_string()),
)
.unwrap();
let hash = content_hash(&path);
fixtures.push((id, logical, path, hash));
}
drop(conn);
let config_dir = Some(dir.display().to_string());
let (before, status_before_ms) =
timed(|| session_sync_status_inner(config_dir.clone(), None).unwrap());
assert!(before.scan_complete, "{:?}", before.scan_failures);
assert!(before.needs_sync);
assert_eq!(before.rollout_files, FILES);
assert_eq!(before.mismatched_sessions, FILES);
let (result, sync_ms) =
timed(|| sync_sessions_provider_inner(config_dir.clone(), None).unwrap());
assert!(
result.status.scan_complete,
"{:?}",
result.status.scan_failures
);
assert!(!result.status.needs_sync);
assert_eq!(result.updated_rollouts, FILES);
assert_eq!(result.updated_threads, FILES);
let (after, status_after_ms) =
timed(|| session_sync_status_inner(config_dir.clone(), None).unwrap());
assert!(after.scan_complete);
assert!(!after.needs_sync);
let raw_hashes = fixtures
.iter()
.map(|(_, _, path, _)| rollout_digest(path))
.collect::<Vec<_>>();
let (repeat, idempotent_sync_ms) =
timed(|| sync_sessions_provider_inner(config_dir, None).unwrap());
assert_eq!(repeat.updated_rollouts, 0);
assert_eq!(repeat.updated_threads, 0);
assert!(repeat.backup_dir.is_empty());
assert!(repeat.status.scan_complete);
assert!(!repeat.status.needs_sync);
let conn = Connection::open(&database).unwrap();
for ((id, logical, path, expected_content_hash), raw_hash) in fixtures.iter().zip(raw_hashes) {
assert_eq!(content_hash(path), *expected_content_hash);
assert_eq!(
rollout_digest(path),
raw_hash,
"idempotent sync must not recompress matching storage"
);
let (provider, reference): (String, String) = conn
.query_row(
"SELECT model_provider,rollout_path FROM threads WHERE id=?1",
[id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(provider, SHARED_SESSION_PROVIDER);
assert_eq!(reference, logical.display().to_string());
}
println!(
"SESSION_SYNC_BENCHMARK {}",
serde_json::json!({
"profile": if cfg!(debug_assertions) { "debug" } else { "release" },
"fixture_only": true, "files": FILES, "plain_files": FILES / 2,
"compressed_files": FILES / 2, "jsonl_records": record_count,
"largest_record_payload_bytes": LARGE_RECORD_BYTES,
"decoded_jsonl_bytes": raw_bytes, "plain_stored_bytes": plain_bytes,
"compressed_decoded_bytes": raw_compressed_bytes, "compressed_stored_bytes": compressed_bytes,
"total_stored_bytes": plain_bytes + compressed_bytes,
"status_before_ms": status_before_ms, "sync_ms": sync_ms,
"status_after_ms": status_after_ms, "idempotent_sync_ms": idempotent_sync_ms,
"updated_rollouts": result.updated_rollouts, "updated_threads": result.updated_threads,
"all_content_hashes_preserved": true, "logical_references_unchanged": true,
"cache_context": "warm local filesystem after fixture generation; no cache flush",
"comparison": "current implementation only; previous implementation did not support compressed rollouts"
})
);
}
+118 -64
View File
@@ -1,5 +1,9 @@
use super::global_state::normalize_workspace_path;
use super::rollout_stream::{read_first_meta, scan_rollout_stream};
use super::rollout_stream::{
decoded_rollout_reader, ensure_current_rollout_representation, inspect_rollout_stream,
is_compressed_rollout, logical_rollout_path, read_first_meta, resolved_rollout_path,
scan_rollout_stream,
};
use super::types::{RolloutScan, SessionFileChange, SessionPreview, SqliteScan};
use crate::error::{CodexxError, Result};
use crate::file_io::{io_err, parse_toml_document, read_to_string_if_exists};
@@ -50,7 +54,10 @@ pub(super) fn current_model_provider(codex_dir: &Path, explicit: Option<String>)
fn is_rollout_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("rollout-") && name.ends_with(".jsonl"))
.is_some_and(|name| {
name.starts_with("rollout-")
&& (name.ends_with(".jsonl") || name.ends_with(".jsonl.zst"))
})
}
pub(super) fn rollout_filename_matches_id(path: &Path, id: &str) -> bool {
@@ -80,6 +87,30 @@ pub(super) fn is_canonical_rollout_storage_path(codex_dir: &Path, path: &Path) -
.any(|root| path.starts_with(root))
}
fn missing_rollout_reference_failure(codex_dir: &Path, path: &Path, thread_id: &str) -> String {
let mut archived = Vec::new();
collect_rollout_paths(
&codex_dir.join("archived_sessions"),
&mut archived,
&mut Vec::new(),
);
let logical_name = logical_rollout_path(path);
archived.sort();
if let Some(archived) = archived.into_iter().find(|candidate| {
logical_rollout_path(candidate).file_name() == logical_name.file_name()
&& read_rollout_identity(candidate).is_ok_and(|identity| {
identity.payload.id.as_deref().map(str::trim) == Some(thread_id)
})
}) {
return format!(
"活动 SQLite 引用的会话文件已位于归档目录,活动索引与归档状态不一致,未自动恢复为活动会话: {} [线程 {thread_id};归档文件 {}]",
path.display(), archived.display());
}
format!(
"活动 SQLite 引用的会话文件不存在(原文件及同名压缩文件均缺失,无法确认是否已删除): {} [线程 {thread_id}]",
path.display())
}
fn referenced_rollout_paths(
codex_dir: &Path,
rollout_paths_by_thread_id: &HashMap<String, String>,
@@ -87,13 +118,31 @@ fn referenced_rollout_paths(
failures: &mut Vec<String>,
) -> HashMap<PathBuf, HashSet<String>> {
let mut referenced = HashMap::<PathBuf, HashSet<String>>::new();
for (thread_id, value) in rollout_paths_by_thread_id {
let mut ordered = rollout_paths_by_thread_id.iter().collect::<Vec<_>>();
ordered.sort_by(|left, right| left.0.cmp(right.0));
for (thread_id, value) in ordered {
let raw = PathBuf::from(value.trim());
let path = if raw.is_absolute() {
raw
} else {
codex_dir.join(raw)
};
let path = match resolved_rollout_path(&path) {
Ok(path) => path,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
failures.push(missing_rollout_reference_failure(
codex_dir, &path, thread_id,
));
continue;
}
Err(error) => {
failures.push(format!(
"无法检查活动会话文件: {} ({error})",
path.display()
));
continue;
}
};
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
@@ -159,6 +208,34 @@ fn referenced_rollout_paths(
referenced
}
/// The full provider scan below validates every eligible log. This index pass
/// only needs to check its explicit references and first-record identities;
/// creating and parsing complete snapshots here would duplicate all log IO.
pub(super) fn validate_rollout_references(
codex_dir: &Path,
rollout_paths_by_thread_id: &HashMap<String, String>,
) -> Vec<String> {
let mut failures = Vec::new();
let referenced =
referenced_rollout_paths(codex_dir, rollout_paths_by_thread_id, false, &mut failures);
let mut referenced = referenced.into_iter().collect::<Vec<_>>();
referenced.sort_by(|left, right| left.0.cmp(&right.0));
for (path, expected) in referenced {
match read_rollout_identity(&path) {
Ok(identity)
if expected.len() == 1
&& expected
.contains(identity.payload.id.as_deref().unwrap_or_default().trim()) => {}
Ok(_) => failures.push(format!(
"活动 SQLite 引用的会话文件与线程 ID 不一致: {}",
path.display()
)),
Err(failure) => failures.push(failure),
}
}
failures
}
fn collect_rollout_paths(root: &Path, out: &mut Vec<PathBuf>, failures: &mut Vec<String>) {
let entries = match fs::read_dir(root) {
Ok(entries) => entries,
@@ -205,7 +282,7 @@ fn is_locked_io_error(error: &std::io::Error) -> bool {
}
pub(crate) fn scan_rollouts(codex_dir: &Path, target_provider: &str) -> Result<RolloutScan> {
scan_rollouts_with_thread_filter(codex_dir, target_provider, None, None, true, None, false)
scan_rollouts_with_thread_filter(codex_dir, target_provider, None, true, None, false, true)
}
pub(super) fn scan_provider_rollouts(
@@ -217,43 +294,42 @@ pub(super) fn scan_provider_rollouts(
scan_rollouts_with_thread_filter(
codex_dir,
target_provider,
None,
Some(authoritative_rollout_paths),
false,
Some(excluded_thread_ids),
true,
true,
)
}
pub(super) fn scan_rollouts_for_thread_ids(
pub(super) fn inspect_provider_rollouts(
codex_dir: &Path,
target_provider: &str,
thread_ids: &HashSet<String>,
rollout_paths_by_thread_id: &HashMap<String, String>,
excluded_thread_ids: &HashSet<String>,
authoritative_rollout_paths: &HashMap<String, String>,
) -> Result<RolloutScan> {
scan_rollouts_with_thread_filter(
codex_dir,
target_provider,
Some(thread_ids),
Some(rollout_paths_by_thread_id),
Some(authoritative_rollout_paths),
false,
None,
Some(excluded_thread_ids),
true,
false,
)
}
fn scan_rollouts_with_thread_filter(
codex_dir: &Path,
target_provider: &str,
allowed_thread_ids: Option<&HashSet<String>>,
rollout_paths_by_thread_id: Option<&HashMap<String, String>>,
include_archived_storage: bool,
excluded_thread_ids: Option<&HashSet<String>>,
exclude_source_marked_subagents: bool,
stage_changes: bool,
) -> Result<RolloutScan> {
let mut paths = Vec::new();
let mut scan = RolloutScan::default();
let mut referenced = HashMap::new();
collect_rollout_paths(
&codex_dir.join("sessions"),
&mut paths,
@@ -266,47 +342,26 @@ fn scan_rollouts_with_thread_filter(
&mut scan.scan_failures,
);
}
// Codex may leave a compressed history beside a newly expanded current
// log. Resolve the pair once and never synchronize both representations.
paths.retain(|path| {
!is_compressed_rollout(path) || fs::symlink_metadata(logical_rollout_path(path)).is_err()
});
paths.sort();
scan.discovered_rollout_files = paths.len();
if let Some(excluded_thread_ids) = excluded_thread_ids {
paths.retain(|path| {
if let Some(id) = rollout_filename_thread_id(path) {
return !excluded_thread_ids.contains(id);
}
!excluded_thread_ids
.iter()
.any(|id| rollout_filename_matches_id(path, id))
});
}
if let Some(allowed_thread_ids) = allowed_thread_ids {
let empty_rollout_paths = HashMap::new();
let rollout_paths_by_thread_id = rollout_paths_by_thread_id.unwrap_or(&empty_rollout_paths);
let explicitly_referenced_thread_ids = rollout_paths_by_thread_id
.keys()
.filter(|id| allowed_thread_ids.contains(*id))
.cloned()
.collect::<HashSet<_>>();
referenced = referenced_rollout_paths(
codex_dir,
rollout_paths_by_thread_id,
include_archived_storage,
&mut scan.scan_failures,
);
paths.retain(|path| {
let is_unreferenced_thread_rollout = allowed_thread_ids
.iter()
.filter(|id| !explicitly_referenced_thread_ids.contains(*id))
.any(|id| rollout_filename_matches_id(path, id));
is_unreferenced_thread_rollout
|| path
.canonicalize()
.is_ok_and(|canonical| referenced.contains_key(&canonical))
});
}
scan.rollout_files = paths.len();
for path in paths {
let expected_thread_ids = path
.canonicalize()
.ok()
.and_then(|canonical| referenced.get(&canonical));
let mut rollout_file = match fs::File::open(&path) {
Ok(file) => file,
Err(error) => {
@@ -325,15 +380,6 @@ fn scan_rollouts_with_thread_filter(
}
};
let identity_id = identity.payload.id.as_deref().unwrap_or_default().trim();
if expected_thread_ids
.is_some_and(|expected| expected.len() != 1 || !expected.contains(identity_id))
{
scan.scan_failures.push(format!(
"活动 SQLite 引用的会话文件与线程 ID 不一致: {}",
path.display()
));
continue;
}
let authoritative_identity = rollout_paths_by_thread_id.is_none_or(|authority| {
is_authoritative_rollout(codex_dir, &path, identity_id, authority)
});
@@ -345,12 +391,14 @@ fn scan_rollouts_with_thread_filter(
continue;
}
}
if excluded_thread_ids.is_some_and(|excluded| excluded.contains(identity_id))
|| allowed_thread_ids.is_some_and(|allowed| !allowed.contains(identity_id))
{
if excluded_thread_ids.is_some_and(|excluded| excluded.contains(identity_id)) {
continue;
}
let streamed = match scan_rollout_stream(&mut rollout_file, &path, target_provider) {
let streamed = match if stage_changes {
scan_rollout_stream(&mut rollout_file, &path, target_provider)
} else {
inspect_rollout_stream(&mut rollout_file, &path, target_provider)
} {
Ok(streamed) => streamed,
Err(error) => {
scan.scan_failures.push(format!(
@@ -385,10 +433,12 @@ fn scan_rollouts_with_thread_filter(
{
scan.cwd_by_thread_id.insert(thread_id.clone(), cwd);
}
if let Some(snapshot) = streamed.staged {
if streamed.mismatch_count > 0 {
scan.mismatched_rollouts += 1;
scan.mismatched_session_meta += streamed.mismatch_count;
scan.mismatched_thread_ids.insert(thread_id);
}
if let Some(snapshot) = streamed.staged {
scan.changes.push(SessionFileChange {
path,
original_text: String::new(),
@@ -426,6 +476,7 @@ fn rollout_file_is_open(_path: &Path) -> bool {
}
fn ensure_rollout_snapshot_unchanged(path: &Path, expected: &[u8; 32]) -> Result<()> {
ensure_current_rollout_representation(path)?;
if hash_rollout_file(path).map_err(|error| io_err(path, error))? == *expected {
Ok(())
} else {
@@ -1273,12 +1324,15 @@ fn read_rollout_identity_from_reader<R: Read>(
reader: R,
path: &Path,
) -> std::result::Result<RolloutIdentityRecord, String> {
let first = read_first_meta(reader, path).map_err(|error| {
format!(
"会话文件包含无法解析的 JSON 或无法读取会话文件来源信息: {} ({error})",
path.display()
)
})?;
let first = decoded_rollout_reader(reader, path)
.map_err(|error| io_err(path, error))
.and_then(|reader| read_first_meta(reader, path))
.map_err(|error| {
format!(
"会话文件包含无法解析的 JSON 或无法读取会话文件来源信息: {} ({error})",
path.display()
)
})?;
let record = RolloutIdentityRecord {
kind: "session_meta".into(),
payload: RolloutIdentityPayload {
@@ -1330,8 +1384,8 @@ fn is_authoritative_rollout(
} else {
codex_dir.join(raw)
};
resolved
.canonicalize()
resolved_rollout_path(&resolved)
.and_then(|path| path.canonicalize())
.ok()
.zip(path.canonicalize().ok())
.is_some_and(|(expected, actual)| expected == actual)
+31 -40
View File
@@ -3,8 +3,8 @@ use super::catalog::{scan_catalog_internal_thread_ids, scan_catalog_sync, Catalo
use super::directory_identity::session_directory_identities;
use super::storage::{
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
list_session_previews_with_paths, rollout_internal_thread_ids, scan_provider_rollouts,
scan_rollouts_for_thread_ids, scan_sqlite_with_paths, SqliteDiscovery,
inspect_provider_rollouts, list_session_previews_with_paths, rollout_internal_thread_ids,
scan_provider_rollouts, scan_sqlite_with_paths, validate_rollout_references, SqliteDiscovery,
};
use super::transaction::{
execute_provider_sync_mutation, mutation_error, prepare_sqlite_updates, rollback_mutation,
@@ -28,22 +28,6 @@ fn scan_failure_error(failures: &[String]) -> CodexxError {
)
}
fn scan_provider_buckets(
codex_dir: &Path,
target_provider: &str,
sqlite: &SqliteScan,
) -> Result<RolloutScan> {
let mut rollouts = scan_rollouts_for_thread_ids(
codex_dir,
target_provider,
&sqlite.syncable_thread_ids,
&sqlite.rollout_paths_by_thread_id,
)?;
// Provider synchronization must not repair or rewrite the independent cwd index.
rollouts.cwd_by_thread_id.clear();
Ok(rollouts)
}
fn scan_legacy_index_warnings(
codex_dir: &Path,
target_provider: &str,
@@ -62,8 +46,7 @@ fn scan_legacy_index_warnings(
return Vec::new();
}
let failures = scan_sqlite_with_paths(&legacy_paths, &RolloutScan::default(), target_provider)
.and_then(|sqlite| scan_provider_buckets(codex_dir, target_provider, &sqlite))
.map(|scan| scan.scan_failures)
.map(|sqlite| validate_rollout_references(codex_dir, &sqlite.rollout_paths_by_thread_id))
.unwrap_or_else(|error| vec![error.to_string()]);
failures
.into_iter()
@@ -85,6 +68,7 @@ fn scan_provider_sync_data(
codex_dir: &Path,
target_provider: &str,
discovery: &SqliteDiscovery,
stage_changes: bool,
) -> Result<ProviderSyncScan> {
let active_unclassified = scan_sqlite_with_paths(
&discovery.active_paths,
@@ -128,18 +112,21 @@ fn scan_provider_sync_data(
sqlite.mismatched_thread_ids = mismatched_thread_ids;
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
// Only diagnostics from this authority scan are needed below. Drop its
// planned file copies before scanning the complete provider history.
let (indexed_rollout_failures, indexed_blocked_failures) = {
let rollouts = if active_sqlite.sqlite_dbs > 0 {
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
let indexed_rollout_failures = {
let authority = if active_sqlite.sqlite_dbs > 0 {
&active_sqlite
} else {
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
&sqlite
};
(rollouts.scan_failures, rollouts.blocked_failures)
validate_rollout_references(codex_dir, &authority.rollout_paths_by_thread_id)
};
let legacy_index_warnings = scan_legacy_index_warnings(codex_dir, target_provider, discovery);
let mut rollouts = scan_provider_rollouts(
let scan_rollouts = if stage_changes {
scan_provider_rollouts
} else {
inspect_provider_rollouts
};
let mut rollouts = scan_rollouts(
codex_dir,
target_provider,
&excluded_thread_ids,
@@ -176,7 +163,6 @@ fn scan_provider_sync_data(
scan_failures.extend(active_sqlite.scan_failures.iter().cloned());
scan_failures.extend(sqlite.scan_failures.iter().cloned());
scan_failures.extend(indexed_rollout_failures.iter().cloned());
scan_failures.extend(indexed_blocked_failures);
// A size/buffer limit means the rollout was not completely inspected.
// This is blocking even for an orphan, rather than a success with a warning.
scan_failures.extend(rollouts.blocked_failures.iter().cloned());
@@ -224,7 +210,7 @@ pub(super) fn session_sync_status_with_discovery(
target: String,
discovery: &SqliteDiscovery,
) -> Result<SessionSyncStatus> {
let scan = scan_provider_sync_data(codex_dir, &target, discovery)?;
let scan = scan_provider_sync_data(codex_dir, &target, discovery, false)?;
let display_sqlite = if scan.active_sqlite.sqlite_dbs > 0 {
&scan.active_sqlite
} else {
@@ -385,14 +371,10 @@ where
if !initial_status.scan_complete {
return Err(scan_failure_error(&initial_status.scan_failures));
}
let preflight_scan = scan_provider_sync_data(&codex_dir, &target_provider, &discovery)?;
if !preflight_scan.scan_failures.is_empty() {
return Err(scan_failure_error(&preflight_scan.scan_failures));
}
if preflight_scan.rollouts.changes.is_empty()
&& preflight_scan.sqlite.mismatched_threads == 0
&& preflight_scan.catalog.total_updates() == 0
{
// This status already completed the same full scan. Repeating it before
// acquiring SQLite locks neither establishes a newer protected snapshot
// nor improves the mutation guard; the locked scan below does that.
if !initial_status.needs_sync {
return Ok(SessionSyncResult {
status: initial_status,
updated_rollouts: 0,
@@ -400,11 +382,10 @@ where
backup_dir: String::new(),
});
}
drop(preflight_scan);
hook(MutationPoint::BeforeSqliteLock)?;
let mut pending_sqlite = prepare_sqlite_updates(&discovery.related_paths)?;
let scan = match scan_provider_sync_data(&codex_dir, &target_provider, &discovery) {
let scan = match scan_provider_sync_data(&codex_dir, &target_provider, &discovery, true) {
Ok(scan) => scan,
Err(error) => {
rollback_open_transactions(&mut pending_sqlite);
@@ -521,6 +502,16 @@ mod tests {
const SHARED_SESSION_PROVIDER: &str = "custom";
const LEGACY_LIMIT: u64 = 32 * 1024 * 1024;
mod compressed {
use super::*;
include!("compressed_sync_tests.rs");
}
mod benchmark {
use super::*;
include!("session_sync_benchmark.rs");
}
fn temp_codex_dir(label: &str) -> std::path::PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
@@ -2,6 +2,7 @@ use super::catalog::{
apply_catalog_updates, catalog_columns, create_catalog_rollback_tables,
restore_catalog_updates, CatalogRepairThread,
};
use super::rollout_stream::ensure_current_rollout_representation;
use super::storage::{
apply_session_changes_with_hook, hash_rollout_file, restore_session_changes,
rollout_path_has_syncable_identity, rollout_text_is_internal, sqlite_subagent_thread_ids,
@@ -492,6 +493,7 @@ fn expected_rollout_hashes(rollouts: &RolloutScan) -> Result<HashMap<PathBuf, [u
fn validate_rollout_snapshots(expected: &HashMap<PathBuf, [u8; 32]>) -> Result<()> {
for (path, expected_hash) in expected {
ensure_current_rollout_representation(path)?;
if hash_rollout_file(path).map_err(|error| io_err(path, error))? != *expected_hash {
return Err(CodexxError::Config(format!(
"会话文件已发生变化,已停止同步;请重新检查会话:{}",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.26",
"version": "0.3.27",
"identifier": "com.yynxxxxx.codexx",
"build": {
"frontendDist": "../dist",
+9
View File
@@ -0,0 +1,9 @@
import type { CodexLanguageStatus } from "./codexLanguageTypes";
// Keep the last detected setting across settings-page mounts. Never invent an
// initial "off" state while the native configuration is still being read.
const statuses = new Map<string, CodexLanguageStatus>();
export function cachedCodexLanguageStatus(configDir: string) { return statuses.get(configDir) ?? null; }
export function rememberCodexLanguageStatus(configDir: string, status: CodexLanguageStatus) {
statuses.set(configDir, status);
}
+13
View File
@@ -0,0 +1,13 @@
export type CodexLocale = "auto" | "zh-CN" | "en-US";
export type CodexLanguageStatus = {
locale: string;
enabled: boolean;
pendingEnabled: boolean | null;
supported: boolean;
appName: string | null;
appVersion: string | null;
phase: "inactive" | "pending" | "starting" | "active" | "failed" | "unsupported";
message: string;
readyPages: number;
restoreAvailable: boolean;
};
+11 -1
View File
@@ -8,6 +8,7 @@ import {
RotateCcw,
Settings,
Sparkles,
X,
} from "lucide-react";
import { INITIAL_APP_UPDATER_STATE, isAppUpdateBusy, type AppUpdaterState } from "../appUpdater";
@@ -20,6 +21,8 @@ export type AppToastProps = {
message: string;
error: string;
loading?: boolean;
persistent?: boolean;
action?: { label: string; onClick: () => void; disabled?: boolean };
onDismissMessage: () => void;
onDismissError: () => void;
};
@@ -29,6 +32,8 @@ export function AppToast({
message,
error,
loading = false,
action,
persistent = false,
onDismissMessage,
onDismissError,
}: AppToastProps) {
@@ -40,11 +45,12 @@ export function AppToast({
const [firstLine, ...remainingLines] = activeText.split("\n");
const detail = remainingLines.join("\n").trim();
const dismiss = isError ? onDismissError : onDismissMessage;
const visibleAction = !loading ? action : undefined;
return (
<div
key={`${status}:${activeText}`}
className={`cx-app-toast cx-app-toast--${status}`}
className={`cx-app-toast cx-app-toast--${status}${visibleAction ? " cx-app-toast--actionable" : ""}${persistent ? " cx-app-toast--persistent" : ""}`}
role={isError ? "alert" : "status"}
aria-live={isError ? "assertive" : "polite"}
onAnimationEnd={(event) => {
@@ -59,6 +65,10 @@ export function AppToast({
<strong>{firstLine || (isError ? (lang === "zh" ? "操作失败" : "Action failed") : "Codex-X")}</strong>
{detail && <span>{detail}</span>}
</div>
{visibleAction && <>
<button type="button" className="cx-app-toast-action" onClick={visibleAction.onClick} disabled={visibleAction.disabled}>{visibleAction.label}</button>
<button type="button" className="cx-app-toast-dismiss" onClick={dismiss} aria-label={lang === "zh" ? "关闭提示" : "Dismiss notification"}><X size={14} aria-hidden="true" /></button>
</>}
</div>
);
}
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Languages, Loader2 } from "lucide-react";
import type { CodexLanguageStatus } from "../codexLanguageTypes";
import { Toggle } from "./ui";
import { cachedCodexLanguageStatus, rememberCodexLanguageStatus } from "../codexLanguageState";
import "../styles/codex-language.css";
export function CodexLanguageControl({ lang, configDir, active = true, disabled = false, onBusyChange, onPreferencesSaved }: {
lang: "zh" | "en";
configDir: string;
active?: boolean;
disabled?: boolean;
onBusyChange?: (busy: boolean) => void;
onPreferencesSaved?: (status: CodexLanguageStatus) => void;
}) {
const text = (zh: string, en: string) => lang === "zh" ? zh : en;
const [detected, setDetected] = useState(() => ({ configDir, value: cachedCodexLanguageStatus(configDir) }));
const status = detected.configDir === configDir ? detected.value : cachedCodexLanguageStatus(configDir);
const setStatus = (value: CodexLanguageStatus) => {
rememberCodexLanguageStatus(configDir, value);
setDetected({ configDir, value });
};
const [busy, setBusy] = useState(false);
const [readError, setReadError] = useState("");
const [actionError, setActionError] = useState("");
const parentBusy = useRef(disabled);
parentBusy.current = disabled;
const generation = useRef(0);
const operationInFlight = useRef(false);
const statusRequest = useRef(0);
useEffect(() => {
generation.current += 1;
setDetected({ configDir, value: cachedCodexLanguageStatus(configDir) });
setReadError(""); setActionError(""); setBusy(false);
}, [configDir]);
useEffect(() => () => { generation.current += 1; }, []);
useEffect(() => {
const current = generation.current;
let disposed = false;
let loading = false;
const load = async () => {
if (loading || disposed || !active || operationInFlight.current || parentBusy.current) return;
loading = true;
const request = ++statusRequest.current;
try {
const next = await invoke<CodexLanguageStatus>("get_codex_language_status", { configDir });
if (!disposed && generation.current === current && request === statusRequest.current && !operationInFlight.current && !parentBusy.current) {
setStatus(next); setReadError("");
}
} catch (cause) {
if (!disposed && generation.current === current && request === statusRequest.current && !operationInFlight.current && !parentBusy.current) setReadError(String(cause));
} finally { loading = false; }
};
if (active) void load();
const interval = active ? window.setInterval(() => { void load(); }, 1800) : undefined;
return () => { disposed = true; if (interval !== undefined) window.clearInterval(interval); };
}, [configDir, active]);
const save = async (enabled: boolean) => {
if (busy || disabled || operationInFlight.current) return;
operationInFlight.current = true;
statusRequest.current += 1;
onBusyChange?.(true);
const current = generation.current;
setBusy(true); setActionError("");
try {
const next = await invoke<CodexLanguageStatus>("save_codex_language_preferences", { configDir, locale: enabled ? "zh-CN" : "auto", enabled });
if (current === generation.current) { setStatus(next); setReadError(""); onPreferencesSaved?.(next); }
} catch (cause) {
if (current === generation.current) setActionError(String(cause));
} finally {
operationInFlight.current = false;
onBusyChange?.(false);
if (current === generation.current) setBusy(false);
}
};
const pending = status?.pendingEnabled != null;
const enabled = status?.pendingEnabled ?? status?.enabled ?? false;
const starting = status?.phase === "starting";
const blocked = busy || disabled || starting;
const error = actionError || readError || (status?.phase === "failed" ? status.message : "");
const description = error || (status && !status.supported ? status.message : text(
"为 Codex 应用简体中文,重启生效。自动检测当前配置。",
"Use Simplified Chinese in Codex after restart. Detects current settings.",
));
const stateLabel = busy ? text("处理中", "Saving")
: readError ? text("检测失败", "Unavailable")
: actionError ? text("操作失败", "Failed")
: !status ? text("检测中", "Checking")
: status.phase === "failed" ? text("应用失败", "Failed")
: starting ? text("应用中", "Applying")
: pending ? text("待重启", "Restart needed")
: enabled ? text("已开启", "On") : text("已关闭", "Off");
return <section className="cx-page-setting-row cx-codex-language" aria-labelledby="codex-language-title" aria-busy={busy || starting}>
<div className="cx-page-setting-icon" aria-hidden="true"><Languages size={18} strokeWidth={1.9} /></div>
<div className="cx-page-setting-copy">
<strong id="codex-language-title">{text("Codex 简体中文", "Codex Simplified Chinese")}</strong>
<p id="codex-language-description" title={description} role={error ? "alert" : undefined} className={error ? "cx-codex-language-error" : undefined}>{description}</p>
</div>
<div className="cx-page-setting-action cx-codex-language-action">
<div className="cx-codex-language-switch">
<span className={enabled && !pending && !error ? "cx-codex-language-enabled" : undefined} aria-live="polite">{stateLabel}</span>
{status ? <Toggle checked={enabled} disabled={blocked || !status.supported || Boolean(readError)} aria-labelledby="codex-language-title" aria-describedby="codex-language-description" onCheckedChange={next => void save(next)} />
: <span className="cx-codex-language-loading" aria-hidden="true"><Loader2 size={15} className="cx-page-spin" /></span>}
</div>
</div>
</section>;
}
@@ -0,0 +1,64 @@
import { AlertCircle, RefreshCw } from "lucide-react";
import type { ProviderBalance } from "../providerBalance";
import "../styles/provider-balance.css";
function errorLabel(code: string | null, zh: boolean) {
if (code === "permission_denied") return zh ? "查询权限不足" : "Access denied";
if (code === "invalid_credential" || code === "authentication_failed") return zh ? "请检查 API Key" : "Check API key";
if (code === "missing_key") return zh ? "未设置 API Key" : "API key missing";
if (code === "rate_limited") return zh ? "请求较多,请稍后刷新" : "Rate limited. Try again shortly.";
if (code === "timeout" || code === "network_error") return zh ? "网络异常,请稍后刷新" : "Check your connection and retry.";
return zh ? "暂时无法查询" : "Temporarily unavailable";
}
function amountText(amount: string) {
const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(amount);
if (!match) return amount;
const integer = match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const fraction = (match[3] || "").replace(/0+$/, "").padEnd(2, "0");
// Group the exact decimal string; financial values never pass through Number.
return `${match[1] === "-" ? "−" : ""}${integer}.${fraction}`;
}
export function ProviderBalanceBadge({ balance, loading, refreshing = false, failed, lang, onRefresh }: {
balance?: ProviderBalance;
loading: boolean;
refreshing?: boolean;
failed: boolean;
lang: "zh" | "en";
onRefresh?: () => void;
}) {
const zh = lang === "zh";
const hasBalance = Boolean(balance?.balances.length);
const unavailable = failed || Boolean(balance?.errorCode);
const busy = loading || refreshing;
const label = balance?.balanceKind === "credit_limit" ? (zh ? "Key 剩余额度" : "Key credit left") : (zh ? "可用余额" : "Available balance");
const updated = balance?.checkedAt ? new Date(balance.checkedAt) : null;
const updateTime = updated && Number.isFinite(updated.getTime())
? updated.toLocaleTimeString(zh ? "zh-CN" : "en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }) : null;
const detail = errorLabel(balance?.errorCode ?? null, zh);
const hint = [balance?.balanceKind === "credit_limit" ? (zh ? "此 API Key 的剩余额度。" : "Remaining credit for this API key.") : "",
unavailable ? detail : "", updateTime ? `${hasBalance ? (zh ? "余额更新于" : "Balance updated") : (zh ? "最近查询于" : "Last checked")} ${updated!.toLocaleString(zh ? "zh-CN" : "en-GB")}` : "",
zh ? "后台每 5 分钟自动刷新,也可点击余额旁的按钮立即查询。" : "Refreshes every 5 minutes in the background. Use the refresh button to query now."].filter(Boolean).join("\n");
return <section className={`cx-provider-balance${unavailable ? " cx-provider-balance--stale" : ""}`} aria-label={label} aria-busy={busy} title={hint}>
<div className="cx-provider-balance-heading">
<span>{label}</span>
</div>
<div className="cx-provider-balance-reading">
{hasBalance ? <div className={`cx-provider-balance-amounts${balance!.balances.length > 1 ? " cx-provider-balance-amounts--multiple" : ""}`}>
{balance!.balances.map(item => <div key={item.currency} className={`cx-provider-balance-value${item.amount.startsWith("-") || /^0(?:\.0+)?$/.test(item.amount) ? " cx-provider-balance-value--empty" : ""}`} title={`${item.amount} ${item.currency}`}>
<strong>{amountText(item.amount)}</strong><span>{item.currency}</span>
</div>)}
</div> : busy ? <div className="cx-provider-balance-placeholder" aria-label={zh ? "正在查询余额" : "Loading balance"}><span /></div>
: <div className="cx-provider-balance-unavailable"><AlertCircle size={14} aria-hidden="true" /><span>{detail}</span></div>}
<button type="button" className="cx-provider-balance-refresh" disabled={busy || !onRefresh} onClick={onRefresh}
title={zh ? "立即刷新余额" : "Refresh balance now"} aria-label={zh ? "刷新余额" : "Refresh balance"}>
<RefreshCw size={13} strokeWidth={1.8} className={busy ? "cx-provider-balance-spin" : undefined} aria-hidden="true" />
</button>
</div>
<div className={`cx-provider-balance-footer${unavailable && !busy ? " cx-provider-balance-footer--error" : ""}`} aria-live="polite">
{busy ? (zh ? "正在刷新…" : "Refreshing…") : <>{unavailable ? (zh ? "刷新失败" : "Refresh failed") : !hasBalance ? (zh ? "尚未更新" : "Not updated") : (zh ? "已更新" : "Updated")}{updateTime && <><span aria-hidden="true"> · </span>{unavailable && hasBalance ? (zh ? "上次 " : "Last ") : ""}<time dateTime={balance!.checkedAt!}>{updateTime}</time></>}</>}
</div>
</section>;
}
+211 -50
View File
@@ -15,6 +15,7 @@ import { OverviewPage } from "./pages/OverviewPage";
import { AboutPage, SettingsPage, TomlConfigPage } from "./pages/UtilityPages";
import { PromptsPage } from "./pages/PromptsPage";
import { SkillsMcpPage, type SkillsMcpNoteKind } from "./pages/SkillsMcpPage";
import { useProviderBalances } from "./providerBalance";
import { ProvidersPage, type ProviderCopy, type ProviderRow } from "./pages/ProvidersPage";
import { AppShell, type AppTab, type AppTheme } from "./components/AppShell";
import {
@@ -30,6 +31,8 @@ import { applyProviderRowOrder, orderProviderRows } from "./providerRowOrder";
import { createPresetProvider, getProviderPreset, getProviderPresetVariant } from "./providerPresets";
import { validateProviderModelMappings } from "./components/ProviderModelMappings";
import { createOfficialProfileMonitor } from "./officialProfileMonitor";
import { createSessionStatusTracker, providerActivationIsBusy, readCurrentSessionStatus, sessionStatusContextKey } from "./sessionOperationState";
import type { LoadingScope, SessionStatusContext } from "./sessionOperationState";
import { useConfigHealth } from "./useConfigHealth";
import { ConfigHealthPanel, ConfigHealthStatus } from "./components/ConfigHealthPanel";
import { ConfigHealthToast } from "./components/ConfigHealthToast";
@@ -798,8 +801,10 @@ function App() {
const [healthConfigDir, setHealthConfigDir] = React.useState("");
const [settingsGeneralRequest, setSettingsGeneralRequest] = React.useState(0);
const [loading, setLoading] = React.useState(false);
const [providerActivationBusy, setProviderActivationBusy] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(true);
const [toast, setToast] = React.useState<string>("");
const [languageRestartNotice, setLanguageRestartNotice] = React.useState<{ configDir: string; message: string } | null>(null);
const [error, setError] = React.useState<string>("");
const [providerForm, setProviderForm] = React.useState<SavedProvider>(defaultProviderForm);
const [providerTomlDraft, setProviderTomlDraft] = React.useState("");
@@ -812,7 +817,9 @@ function App() {
const [providerModelsLoading, setProviderModelsLoading] = React.useState(false);
const [actionBusy, setActionBusy] = React.useState<string>("");
const [skillsMcpNoteBusy, setSkillsMcpNoteBusy] = React.useState("");
const [restartCodexError, setRestartCodexError] = React.useState("");
const [restartCodexBusy, setRestartCodexBusy] = React.useState(false);
React.useEffect(() => { setLanguageRestartNotice(null); setRestartCodexError(""); }, [configDir]);
const [promptSyncing, setPromptSyncing] = React.useState(false);
const [promptDetailLoading, setPromptDetailLoading] = React.useState(false);
const [promptCatalogReady, setPromptCatalogReady] = React.useState(false);
@@ -843,7 +850,8 @@ function App() {
const savedProvidersRequestRef = React.useRef(0);
const providerOrderRequestRef = React.useRef(0);
const loadingGenerationRef = React.useRef(0);
const loadingTokensRef = React.useRef(new Set<number>());
const loadingTokensRef = React.useRef(new Map<number, LoadingScope>());
const languageLoadingTokenRef = React.useRef<number | null>(null);
const actionBusyGenerationRef = React.useRef(0);
const actionBusyTokensRef = React.useRef(new Map<number, string>());
const skillsMcpNoteBusyRef = React.useRef("");
@@ -854,7 +862,16 @@ function App() {
const refreshRequestRef = React.useRef(0);
const aboutLoadKeyRef = React.useRef("");
const sessionAutoLoadKeyRef = React.useRef("");
const sessionLoadRequestRef = React.useRef(0);
const sessionStatusTrackerRef = React.useRef(createSessionStatusTracker());
const sessionStateRef = React.useRef(state);
sessionStateRef.current = state;
const providerActivationRevisionRef = React.useRef(0);
const providerActivationInFlightRef = React.useRef(0);
const sessionMutationInFlightRef = React.useRef(false);
const sessionDeleteBusyRef = React.useRef(false);
const sessionRefreshPendingRef = React.useRef(false);
const sessionSyncToastPendingRef = React.useRef(false);
const refreshSyncedSessionStatusRef = React.useRef<() => Promise<SessionSyncStatus | null>>(async () => null);
const promptRefreshInFlightRef = React.useRef<Promise<BuiltinPromptStatus[]> | null>(null);
const promptAutoRefreshAttemptedRef = React.useRef(false);
const promptCatalogReadyRef = React.useRef(false);
@@ -885,6 +902,10 @@ function App() {
? activeBuiltinTemplateId
: "";
const syncProviderActivationBusy = React.useCallback(() => {
setProviderActivationBusy(providerActivationIsBusy(loadingTokensRef.current.values(), actionBusyTokensRef.current.values()));
}, []);
const syncActionBusy = React.useCallback(() => {
let latestToken = -1;
let latestAction = "";
@@ -895,26 +916,39 @@ function App() {
}
}
setActionBusy(latestAction);
}, []);
syncProviderActivationBusy();
}, [syncProviderActivationBusy]);
const beginLoading = React.useCallback(() => {
const beginLoading = React.useCallback((scope: LoadingScope = "general") => {
const token = ++loadingGenerationRef.current;
loadingTokensRef.current.add(token);
loadingTokensRef.current.set(token, scope);
setLoading(true);
syncProviderActivationBusy();
return token;
}, []);
}, [syncProviderActivationBusy]);
const endLoading = React.useCallback((token: number) => {
if (!loadingTokensRef.current.delete(token)) return;
setLoading(loadingTokensRef.current.size > 0);
}, []);
syncProviderActivationBusy();
}, [syncProviderActivationBusy]);
const beginActionBusy = React.useCallback((action: string) => {
const token = ++actionBusyGenerationRef.current;
actionBusyTokensRef.current.set(token, action);
setActionBusy(action);
syncProviderActivationBusy();
return token;
}, []);
}, [syncProviderActivationBusy]);
const handleLanguageBusyChange = React.useCallback((busy: boolean) => {
if (busy && languageLoadingTokenRef.current === null) {
languageLoadingTokenRef.current = beginLoading();
} else if (!busy && languageLoadingTokenRef.current !== null) {
endLoading(languageLoadingTokenRef.current);
languageLoadingTokenRef.current = null;
}
}, [beginLoading, endLoading]);
const endActionBusy = React.useCallback((token: number) => {
if (!actionBusyTokensRef.current.delete(token)) return;
@@ -931,6 +965,36 @@ function App() {
if (changed) syncActionBusy();
}, [syncActionBusy]);
const invalidateSessionStatus = React.useCallback(() => {
sessionStatusTrackerRef.current.invalidate();
sessionAutoLoadKeyRef.current = "";
setSessionStatus(null);
}, []);
const getSessionStatusContext = React.useCallback((): SessionStatusContext | null => {
const current = sessionStateRef.current;
if (!current || providerActivationInFlightRef.current) return null;
return {
configDir: current.codexDir,
targetProvider: current.modelProvider || "openai",
providerRevision: providerActivationRevisionRef.current,
};
}, []);
const loadCurrentSessionStatus = React.useCallback(() => readCurrentSessionStatus(
sessionStatusTrackerRef.current,
getSessionStatusContext,
(context) => invoke<SessionSyncStatus>("get_session_sync_status", {
configDir: context.configDir,
targetProvider: context.targetProvider,
}),
(status) => {
setSessionStatus(status);
const context = getSessionStatusContext();
if (context) sessionAutoLoadKeyRef.current = sessionStatusContextKey(context);
},
), [getSessionStatusContext]);
const invalidatePromptDetail = React.useCallback(() => {
promptDetailRequestRef.current += 1;
setPromptDetailLoading(false);
@@ -1290,6 +1354,13 @@ function App() {
};
}), [findLocalProviderForRow, lang, providerCopySourceForRow, providerRows, providerOrder, state?.codexDir]);
const balanceRevision = React.useMemo(() => JSON.stringify([
providerRows.filter(row => row.source !== "official").map(row => [row.source, row.id, row.baseUrl, row.apiKey]),
savedProviders.map(provider => [provider.id, provider.tomlConfig]),
state?.configText,
]), [providerRows, savedProviders, state?.configText]);
const providerBalances = useProviderBalances(state?.codexDir || configDir, balanceRevision, Boolean(state) && !refreshing);
const saveProviderOrder = async (order: string[]): Promise<boolean> => {
const directory = state?.codexDir;
if (!directory || providerOrder.directory !== directory || loadingTokensRef.current.size || actionBusyTokensRef.current.size) return false;
@@ -1405,6 +1476,7 @@ function App() {
const activeCodexDir = state?.codexDir || configDir;
setRefreshing(true);
setError("");
sessionStateRef.current = null;
setState(null);
setOfficialProfiles([]);
setSessionStatus(null);
@@ -1414,7 +1486,7 @@ function App() {
setAboutInfo(null);
aboutLoadKeyRef.current = "";
sessionAutoLoadKeyRef.current = "";
sessionLoadRequestRef.current += 1;
sessionStatusTrackerRef.current.invalidate();
if (includeDiagnostics) {
setStartupDiagnostics(null);
@@ -1468,6 +1540,7 @@ function App() {
setConfigDir(next.codexDir);
setHealthConfigDir(next.codexDir);
setConfigDirDraft(next.codexDir);
sessionStateRef.current = next;
setState(next);
setRefreshing(false);
@@ -1678,10 +1751,9 @@ function App() {
const handleActionResult = (result: ActionResult) => {
const profilesRequestId = ++officialProfilesRequestRef.current;
const providersRequestId = ++savedProvidersRequestRef.current;
sessionStateRef.current = result.state;
setState(result.state);
setSessionStatus(null);
sessionAutoLoadKeyRef.current = "";
sessionLoadRequestRef.current += 1;
invalidateSessionStatus();
setToast(result.message);
return Promise.allSettled([
invoke<SavedPrompt[]>("list_saved_prompts"),
@@ -2009,8 +2081,25 @@ function App() {
);
};
const activateProvider = async (apply: () => Promise<ActionResult>, success: (result: ActionResult) => void) => {
if (!getSessionStatusContext() || providerActivationInFlightRef.current || sessionDeleteBusyRef.current
|| providerActivationIsBusy(loadingTokensRef.current.values(), actionBusyTokensRef.current.values())) return;
providerActivationInFlightRef.current += 1;
providerActivationRevisionRef.current += 1;
if (Array.from(actionBusyTokensRef.current.values()).includes("syncSessions")) sessionRefreshPendingRef.current = true;
invalidateSessionStatus();
try {
await call(apply, success);
} finally {
providerActivationInFlightRef.current -= 1;
if (sessionRefreshPendingRef.current && !sessionMutationInFlightRef.current) {
await refreshSyncedSessionStatusRef.current();
}
}
};
const switchProvider = (provider: SavedProvider) =>
call(
activateProvider(
() => applyProviderConfig(provider),
(result) => {
localStorage.setItem(ACTIVE_PROVIDER_KEY, provider.id);
@@ -2083,7 +2172,7 @@ function App() {
const saveProviderConfig = saveProviderOnly;
const switchOfficialProvider = (profileId = DEFAULT_OFFICIAL_PROFILE_ID) =>
call(
activateProvider(
() => invoke<ActionResult>("switch_official_profile", { configDir: configDir || null, profileId }),
(result) => {
localStorage.removeItem(ACTIVE_PROVIDER_KEY);
@@ -2440,19 +2529,22 @@ function App() {
};
const restartCodexDesktop = async () => {
if (restartCodexBusyRef.current) return false;
if (restartCodexBusyRef.current || loadingTokensRef.current.size || actionBusyTokensRef.current.size) return false;
restartCodexBusyRef.current = true;
setRestartCodexBusy(true);
const actionToken = beginActionBusy("restartCodexDesktop");
setError("");
setRestartCodexError("");
try {
const result = await invoke<CodexDesktopRestartResult>("restart_codex_desktop");
const result = await invoke<CodexDesktopRestartResult>("restart_codex_desktop", { configDir });
setLanguageRestartNotice(null);
setToast(result.wasRunning
? (lang === "zh" ? `${result.appName} 已重新启动` : `${result.appName} restarted`)
: (lang === "zh" ? `${result.appName} 已启动` : `${result.appName} started`));
return true;
} catch (e) {
setError(String(e));
setRestartCodexError(String(e));
return false;
} finally {
endActionBusy(actionToken);
@@ -2845,13 +2937,14 @@ function App() {
};
const checkSessions = async () => {
sessionLoadRequestRef.current += 1;
if (!getSessionStatusContext() || loadingTokensRef.current.size || actionBusyTokensRef.current.size || sessionDeleteBusyRef.current) return;
sessionStatusTrackerRef.current.invalidate();
const actionToken = beginActionBusy("checkSessions");
setSessionStatus(null);
await call(
() => invoke<SessionSyncStatus>("get_session_sync_status", { configDir: configDir || null, targetProvider: null }),
loadCurrentSessionStatus,
(status) => {
setSessionStatus(status);
if (!status) return;
if (!status.scanComplete) {
setToast(status.scanFailures[0] || (lang === "zh" ? "无法确认会话同步状态" : "Unable to verify session sync status"));
return;
@@ -2867,52 +2960,90 @@ function App() {
};
React.useEffect(() => {
if (tab !== "sessions" || refreshing || !state?.codexDir) return;
const loadKey = state.codexDir;
if (tab !== "sessions" || refreshing || loading) return;
const context = getSessionStatusContext();
if (!context) return;
const loadKey = sessionStatusContextKey(context);
if (sessionAutoLoadKeyRef.current === loadKey) return;
const requestId = ++sessionLoadRequestRef.current;
const actionToken = beginActionBusy("checkSessions");
sessionAutoLoadKeyRef.current = loadKey;
void invoke<SessionSyncStatus>("get_session_sync_status", {
configDir: loadKey,
targetProvider: null,
})
.then((status) => {
if (requestId === sessionLoadRequestRef.current) setSessionStatus(status);
})
void loadCurrentSessionStatus()
.catch((sessionError) => {
if (requestId !== sessionLoadRequestRef.current) return;
sessionAutoLoadKeyRef.current = "";
setError(String(sessionError));
})
.finally(() => {
endActionBusy(actionToken);
});
}, [beginActionBusy, endActionBusy, refreshing, state?.codexDir, tab]);
}, [beginActionBusy, endActionBusy, getSessionStatusContext, loadCurrentSessionStatus, loading, refreshing, state?.codexDir, state?.modelProvider, state?.activeSavedProviderId, state?.activeOfficialProfileId, tab]);
const syncSessions = async () => {
sessionLoadRequestRef.current += 1;
const refreshSyncedSessionStatus = async () => {
if (sessionMutationInFlightRef.current || !getSessionStatusContext()) {
sessionRefreshPendingRef.current = true;
return null;
}
sessionRefreshPendingRef.current = false;
const loadingToken = beginLoading("sessionSync");
const actionToken = beginActionBusy("syncSessions");
await call(
() => invoke<SessionSyncResult>("sync_sessions_provider", { configDir: configDir || null, targetProvider: null }),
(result) => {
setSessionStatus(result.status);
setSelectedSessionIds([]);
if (!result.status.scanComplete) {
setToast(result.status.scanFailures[0] || (lang === "zh" ? "无法确认会话同步状态" : "Unable to verify session sync status"));
} else if (result.status.needsSync) {
const remaining = sessionMismatchCount(result.status);
try {
const status = await loadCurrentSessionStatus();
if (status && sessionSyncToastPendingRef.current) {
sessionSyncToastPendingRef.current = false;
if (!status.scanComplete) {
setToast(status.scanFailures[0] || (lang === "zh" ? "无法确认会话同步状态" : "Unable to verify session sync status"));
} else if (status.needsSync) {
const remaining = sessionMismatchCount(status);
setToast(lang === "zh"
? `已同步可写的会话索引,仍有 ${remaining} 条需要重试;聊天内容未改动`
: `Writable session indexes were synced; ${remaining} still need retrying. Chat content was not changed.`);
? `本轮同步已完成,当前供应商仍有 ${remaining} 条会话需要同步;聊天内容未改动`
: `This sync finished; ${remaining} session(s) still need syncing for the current provider. Chat content was not changed.`);
} else {
setToast(lang === "zh"
? "会话索引已全部同步,聊天内容未改动"
: "All session indexes are synced. Chat content was not changed.");
}
},
);
endActionBusy(actionToken);
}
return status;
} catch (sessionError) {
sessionSyncToastPendingRef.current = false;
setError(String(sessionError));
return null;
} finally {
endLoading(loadingToken);
endActionBusy(actionToken);
}
};
refreshSyncedSessionStatusRef.current = refreshSyncedSessionStatus;
const syncSessions = async () => {
const context = getSessionStatusContext();
if (!context || loadingTokensRef.current.size || actionBusyTokensRef.current.size || sessionDeleteBusyRef.current) return;
// Capture primitives at the click, before the IPC worker can be queued
// behind a provider switch. The backend keeps this target for the whole job.
const target = { ...context };
sessionStatusTrackerRef.current.invalidate();
const actionToken = beginActionBusy("syncSessions");
const loadingToken = beginLoading("sessionSync");
sessionMutationInFlightRef.current = true;
sessionSyncToastPendingRef.current = false;
setError("");
try {
await invoke<SessionSyncResult>("sync_sessions_provider", {
configDir: target.configDir,
targetProvider: target.targetProvider,
});
setSelectedSessionIds([]);
sessionSyncToastPendingRef.current = true;
} catch (syncError) {
if (target.configDir === sessionStateRef.current?.codexDir) setError(String(syncError));
} finally {
sessionMutationInFlightRef.current = false;
sessionRefreshPendingRef.current = true;
// Never apply the old job's status to a newly active provider. Re-read
// the current context, or let the in-flight activation do so on completion.
await refreshSyncedSessionStatusRef.current();
endLoading(loadingToken);
endActionBusy(actionToken);
}
};
const toggleSessionSelected = (id: string) => {
@@ -2937,7 +3068,10 @@ function App() {
};
const deleteSelectedSessions = async () => {
if (!selectedSessionIds.length || sessionDeleteBusy || !sessionDeleteSafetyConfirmed) return;
if (!selectedSessionIds.length || sessionDeleteBusyRef.current || !sessionDeleteSafetyConfirmed
|| loadingTokensRef.current.size || actionBusyTokensRef.current.size) return;
sessionDeleteBusyRef.current = true;
const loadingToken = beginLoading();
setSessionDeleteBusy(true);
setToast("");
setError("");
@@ -2966,7 +3100,9 @@ function App() {
} catch (e) {
setError(String(e));
} finally {
sessionDeleteBusyRef.current = false;
setSessionDeleteBusy(false);
endLoading(loadingToken);
}
};
@@ -3025,8 +3161,18 @@ function App() {
message={toast}
error={error}
loading={Boolean(providerTestingId || providerModelsLoading) && Boolean(toast)}
onDismissMessage={() => setToast("")}
onDismissError={() => setError("")}
persistent={Boolean(error) && error === restartCodexError}
action={error && error === restartCodexError ? {
label: lang === "zh" ? "重试" : "Retry",
onClick: () => { void restartCodexDesktop(); },
disabled: restartCodexBusy || loading || Boolean(actionBusy),
} : !error && languageRestartNotice?.configDir === configDir && languageRestartNotice.message === toast ? {
label: lang === "zh" ? "点我重启" : "Restart now",
onClick: () => { void restartCodexDesktop(); },
disabled: restartCodexBusy || loading || Boolean(actionBusy),
} : undefined}
onDismissMessage={() => { setToast(""); setLanguageRestartNotice(null); }}
onDismissError={() => { setError(""); setRestartCodexError(""); }}
/>
{configHealth.noticeVisible && configHealth.notice && <ConfigHealthToast
lang={lang}
@@ -3136,10 +3282,12 @@ function App() {
officialProfileIsDefault={editingOfficialProfileId === DEFAULT_OFFICIAL_PROFILE_ID}
canLoadCurrentOfficial={Boolean(state.isOfficialProvider)}
providerRows={providerPageRows}
providerBalances={providerBalances}
configDir={configDir}
loading={loading}
testingId={providerTestingId}
actionBusy={actionBusy}
activationBusy={providerActivationBusy || refreshing || sessionDeleteBusy}
orderBusy={providerOrder.directory !== state.codexDir}
onReorderProviders={saveProviderOrder}
editingProviderId={editingProviderId || (editingDetectedProvider ? providerForm.id : null)}
@@ -3490,6 +3638,19 @@ function App() {
configDir={configDir}
onChange={async () => { routingRequestRefreshRef.current(); }}
generalRequest={settingsGeneralRequest}
interactionBusy={loading || refreshing || Boolean(actionBusy) || restartCodexBusy || isAppUpdateBusy(updater.state.phase)}
onLanguageBusyChange={handleLanguageBusyChange}
onCodexLanguageSaved={(status) => {
setError("");
if (status.pendingEnabled == null) {
setLanguageRestartNotice(null);
setToast(lang === "zh" ? "已取消待重启的语言更改" : "Pending language change cancelled");
return;
}
const message = lang === "zh" ? "语言设置已保存,重启 Codex / ChatGPT 后生效" : "Language setting saved. Restart Codex / ChatGPT to apply.";
setLanguageRestartNotice({ configDir, message });
setToast(message);
}}
configHealthStatus={<ConfigHealthStatus
lang={lang}
report={configHealth.report}
+37
View File
@@ -0,0 +1,37 @@
export const MAX_AUTH_IMPORT_BYTES = 1024 * 1024;
type ImportErrorCode = "too_large" | "invalid_text" | "invalid_json" | "unsupported_shape";
export class OfficialAuthImportError extends Error {
readonly code: ImportErrorCode;
constructor(code: ImportErrorCode) {
super(code);
this.name = "OfficialAuthImportError";
this.code = code;
}
}
export function parseOfficialAuthImport(bytes: Uint8Array): string {
if (bytes.byteLength > MAX_AUTH_IMPORT_BYTES) throw new OfficialAuthImportError("too_large");
let text: string;
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/^\uFEFF/, "").trim(); }
catch { throw new OfficialAuthImportError("invalid_text"); }
let value: unknown;
try { value = JSON.parse(text); }
catch { throw new OfficialAuthImportError("invalid_json"); }
if (!value || typeof value !== "object" || Array.isArray(value)) throw new OfficialAuthImportError("unsupported_shape");
const auth = value as Record<string, unknown>;
const tokens = auth.tokens && typeof auth.tokens === "object" && !Array.isArray(auth.tokens)
? auth.tokens as Record<string, unknown> : null;
const nonempty = (value: unknown) => typeof value === "string" && Boolean(value.trim());
if (["base_url", "baseUrl", "api_base", "endpoint"].some(key => Object.prototype.hasOwnProperty.call(auth, key))
|| !(nonempty(auth.OPENAI_API_KEY) || (tokens && [tokens.access_token, tokens.id_token, tokens.refresh_token].some(nonempty)))) {
throw new OfficialAuthImportError("unsupported_shape");
}
// Only load the editor draft. The existing save command validates credentials.
return text;
}
export async function readOfficialAuthImport(file: Pick<File, "size" | "arrayBuffer">): Promise<string> {
if (file.size > MAX_AUTH_IMPORT_BYTES) throw new OfficialAuthImportError("too_large");
return parseOfficialAuthImport(new Uint8Array(await file.arrayBuffer()));
}
+49 -4
View File
@@ -23,10 +23,13 @@ import type { LucideIcon } from "lucide-react";
import type { Ref } from "react";
import { PageTransition } from "../components/PageTransition";
import { OfficialQuotaDialog } from "../components/OfficialQuotaDialog";
import { ProviderBalanceBadge } from "../components/ProviderBalanceBadge";
import { supportsProviderBalance, type ProviderBalanceState } from "../providerBalance";
import { OfficialAccountBadge } from "../components/OfficialAccountBadge";
import { ProviderModelMappings, validateProviderModelMappings } from "../components/ProviderModelMappings";
import { ProviderHeadersControl } from "../components/ProviderHeadersControl";
import { providerUpstreamApi } from "../providerProtocol";
import { OfficialAuthImportError, readOfficialAuthImport } from "../officialAuthImport";
import { ProviderPresetPicker } from "../components/ProviderPresetPicker";
import { PROVIDER_PRESETS, getProviderPreset, getProviderPresetVariant } from "../providerPresets";
import { Button, Checkbox, ModalShell } from "../components/ui";
@@ -153,9 +156,11 @@ export type ProvidersPageProps = {
copy: ProviderCopy;
mode: ProviderMode;
providerRows: readonly ProviderRow[];
providerBalances?: ProviderBalanceState;
loading: boolean;
testingId: string;
actionBusy?: string;
activationBusy?: boolean;
orderBusy?: boolean;
onReorderProviders: (order: string[]) => Promise<boolean>;
editingProviderId: string | null;
@@ -401,9 +406,11 @@ function ListPage({
configDir,
copy,
providerRows,
providerBalances,
loading,
testingId,
actionBusy,
activationBusy,
orderBusy,
onImportCcSwitch,
onAddProvider,
@@ -413,11 +420,12 @@ function ListPage({
onDuplicateProvider,
onDeleteProvider,
onReorderProviders,
}: Pick<ProvidersPageProps, "lang" | "configDir" | "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "orderBusy" | "onReorderProviders" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
}: Pick<ProvidersPageProps, "lang" | "configDir" | "copy" | "providerRows" | "providerBalances" | "loading" | "testingId" | "actionBusy" | "activationBusy" | "orderBusy" | "onReorderProviders" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
const [quotaSelection, setQuotaSelection] = useState<{ id: string; configDir: string; email: string | null } | null>(null);
const [deleting, setDeleting] = useState(false);
const providerActionsBusy = loading || Boolean(actionBusy);
const providerActivationBusy = activationBusy ?? providerActionsBusy;
const sortingDisabled = providerActionsBusy || Boolean(orderBusy) || providerRows.length < 2;
const listRef = useRef<HTMLDivElement>(null);
const pointerDragRef = useRef<{ key: string; pointerId: number; startY: number; moved: boolean } | null>(null);
@@ -534,13 +542,15 @@ function ListPage({
{providerRows.length === 0 ? (
<div className="cx-providers-empty" role="status">{copy.noProviders}</div>
) : providerRows.map((row) => {
const balance = providerBalances?.balances.find(item => item.source === row.source && item.providerId === row.id);
const showBalance = row.source !== "official" && (balance ? balance.supported : supportsProviderBalance(row.baseUrl));
const testingKey = row.testingKey || `${row.source}-${row.id}`;
const isTesting = testingId === testingKey;
const isProtectedOfficial = row.source === "official" && row.isDefaultOfficial === true;
const key = providerRowKey(row);
const modelName = row.modelDisplayName || row.model || (lang === "zh" ? "跟随 Codex" : "Follow Codex");
return (
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}${row.source === "official" ? " cx-providers-row--official" : ""}${draggedKey === key ? " cx-providers-row--dragging" : ""}${dropTarget?.key === key && draggedKey !== key ? ` cx-providers-row--drop-${dropTarget.position}` : ""}`} key={key} role="listitem" data-provider-key={key}>
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}${row.source === "official" ? " cx-providers-row--official" : showBalance ? " cx-providers-row--balance" : ""}${draggedKey === key ? " cx-providers-row--dragging" : ""}${dropTarget?.key === key && draggedKey !== key ? ` cx-providers-row--drop-${dropTarget.position}` : ""}`} key={key} role="listitem" data-provider-key={key}>
<button type="button" className="cx-providers-drag-handle" disabled={sortingDisabled}
aria-label={lang === "zh" ? `排序 ${row.providerName}:拖动或按上下箭头移动` : `Reorder ${row.providerName}: drag or use Up and Down arrows`}
title={lang === "zh" ? "拖动排序,也可按上下箭头移动" : "Drag to reorder, or use Up and Down arrows"}
@@ -598,13 +608,14 @@ function ListPage({
</div>
</div>
{row.source === "official" && <OfficialAccountBadge lang={lang} email={row.email} planType={row.planType} hasAuth={Boolean(row.hasAuth)} canQueryQuota={Boolean(row.canQueryQuota)} className="cx-providers-login-badge" />}
{showBalance && <ProviderBalanceBadge balance={balance} loading={providerBalances?.loading ?? true} refreshing={providerBalances?.refreshingKeys.includes(`${row.source}:${row.id}`)} failed={providerBalances?.failed ?? false} lang={lang} onRefresh={row.source !== "official" && providerBalances ? () => { void providerBalances.refreshBalance(row.source as "local" | "detected", row.id); } : undefined} />}
<div className="cx-providers-row-actions">
{row.isCurrent && <span className="cx-providers-current-badge"><span aria-hidden="true" />{copy.currentLabel}</span>}
<button
type="button"
className="cx-providers-button cx-providers-button--small cx-providers-button--secondary"
onClick={() => onEnableProvider(row)}
disabled={providerActionsBusy || row.isCurrent}
disabled={providerActivationBusy || row.isCurrent}
>
{copy.enableLabel}
</button>
@@ -736,9 +747,15 @@ function OfficialForm({
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect" | "officialForm" | "officialProfileIsDefault" | "canLoadCurrentOfficial" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialNameChange" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "onLoadCcSwitchOfficial" | "onLoadCurrentOfficial" | "onResetOfficial">) {
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
const [contextWindowBusy, setContextWindowBusy] = useState(false);
const [authImportBusy, setAuthImportBusy] = useState(false);
const [authImportError, setAuthImportError] = useState("");
const [authImportName, setAuthImportName] = useState("");
const authFileRef = useRef<HTMLInputElement>(null);
const authImportRequest = useRef(0);
useEffect(() => () => { authImportRequest.current += 1; }, []);
const loadingCcSwitch = actionBusy === "loadCcSwitchOfficial";
const loadingCurrent = actionBusy === "loadCurrentOfficial";
const formBusy = loading || actionBusy === "loadOfficialDraft" || loadingCcSwitch || loadingCurrent || contextWindowBusy;
const formBusy = loading || actionBusy === "loadOfficialDraft" || loadingCcSwitch || loadingCurrent || contextWindowBusy || authImportBusy;
const showDefaultActions = officialProfileIsDefault && !creatingProvider;
const profileHint = lang === "zh"
? "认证内容可留空;保存并启用后,在 Codex 中完成登录。复制官方供应商会保留原认证,并可修改名称。"
@@ -750,6 +767,23 @@ function OfficialForm({
onResetOfficial();
};
const importAuthFile = async (file: File) => {
if (formBusy) return;
const request = ++authImportRequest.current;
setAuthImportBusy(true); setAuthImportError(""); setAuthImportName("");
try {
const text = await readOfficialAuthImport(file);
if (request !== authImportRequest.current) return;
onOfficialAuthChange(text);
setAuthImportName(file.name);
} catch (error) {
if (request !== authImportRequest.current) return;
setAuthImportError(error instanceof OfficialAuthImportError && error.code === "too_large"
? (lang === "zh" ? "认证文件超过 1 MiB,请选择单个 auth.json。" : "Choose a single auth.json file smaller than 1 MiB.")
: (lang === "zh" ? "请选择有效的单个 Codex auth.json;批量账号或其他工具的封装格式暂不支持。" : "Choose a valid single Codex auth.json. Account arrays and other export formats are not supported yet."));
} finally { if (request === authImportRequest.current) setAuthImportBusy(false); }
};
return (
<>
<ModeHeader
@@ -786,6 +820,17 @@ function OfficialForm({
/>
</div>
<Field label={copy.officialAuthLabel} className="cx-providers-editor-field">
<div className="cx-providers-context-heading">
<button type="button" className="cx-providers-button cx-providers-button--secondary" disabled={formBusy} onClick={() => authFileRef.current?.click()}>
<Download size={14} aria-hidden="true" />{lang === "zh" ? "导入 auth.json 文件" : "Import auth.json file"}
</button>
<input ref={authFileRef} type="file" accept=".json,application/json" hidden onChange={event => {
const file = event.currentTarget.files?.[0]; event.currentTarget.value = "";
if (file) void importAuthFile(file);
}} />
</div>
{authImportError && <span className="cx-providers-context-error" role="alert">{authImportError}</span>}
{authImportName && <span className="cx-providers-context-hint" role="status">{lang === "zh" ? `已读入 ${authImportName},保存官方账号后生效。` : `Loaded ${authImportName}. Save the official account to apply it.`}</span>}
<textarea
ref={officialAuthRef}
className="cx-providers-code-editor cx-providers-auth-editor"
+13 -3
View File
@@ -18,6 +18,8 @@ import { PageTransition } from "../components/PageTransition";
import { Button, ModalShell } from "../components/ui";
import { UsageStatisticsPage } from "./UsageStatisticsPage";
import { ProviderFailoverPage } from "./ProviderFailoverPage";
import type { CodexLanguageStatus } from "../codexLanguageTypes";
import { CodexLanguageControl } from "../components/CodexLanguageControl";
import "../styles/utility-pages.css";
export type UtilityLanguage = "zh" | "en";
@@ -114,6 +116,9 @@ export type SettingsPageProps = {
onRestartCodex: () => Promise<boolean>;
recheckBusy?: boolean;
restartBusy?: boolean;
interactionBusy?: boolean;
onLanguageBusyChange?: (busy: boolean) => void;
onCodexLanguageSaved?: (status: CodexLanguageStatus) => void;
generalRequest?: number;
configHealthStatus?: ReactNode;
onChange?: () => void | Promise<void>;
@@ -167,6 +172,9 @@ export function SettingsPage({
onRestartCodex,
recheckBusy = false,
restartBusy = false,
interactionBusy = false,
onLanguageBusyChange,
onCodexLanguageSaved,
generalRequest = 0,
configHealthStatus,
onChange,
@@ -182,7 +190,7 @@ export function SettingsPage({
if (!restartBusy) setRestartConfirmOpen(false);
};
const confirmRestart = async () => {
if (restartBusy) return;
if (restartBusy || interactionBusy) return;
if (await onRestartCodex()) setRestartConfirmOpen(false);
};
@@ -232,6 +240,8 @@ export function SettingsPage({
)}
/>
<CodexLanguageControl lang={lang} configDir={configDir} active={active && tab === "general"} disabled={interactionBusy} onBusyChange={onLanguageBusyChange} onPreferencesSaved={onCodexLanguageSaved} />
<SettingRow
icon={Sparkles}
title={copy.productTitle}
@@ -268,7 +278,7 @@ export function SettingsPage({
type="button"
className="cx-page-button cx-page-button--secondary"
onClick={() => setRestartConfirmOpen(true)}
disabled={restartBusy}
disabled={restartBusy || interactionBusy}
>
{restartBusy ? <Loader2 size={15} className="cx-page-spin" aria-hidden="true" /> : <Power size={15} aria-hidden="true" />}
{restartBusy ? copy.restartingLabel : copy.restartLabel}
@@ -306,7 +316,7 @@ export function SettingsPage({
variant="danger"
icon={restartBusy ? <Loader2 size={16} className="cx-page-spin" /> : <Power size={16} />}
onClick={() => void confirmRestart()}
disabled={restartBusy}
disabled={restartBusy || interactionBusy}
>
{restartBusy ? copy.restartingLabel : copy.restartConfirmLabel}
</Button>
+129
View File
@@ -0,0 +1,129 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
export type ProviderBalance = {
source: "local" | "detected";
providerId: string;
vendor: "deepseek" | "openrouter" | "siliconflow" | "moonshot" | null;
supported: boolean;
balanceKind: "balance" | "credit_limit" | null;
balances: { currency: string; amount: string }[];
checkedAt: string | null;
errorCode: string | null;
};
export type ProviderBalanceState = {
balances: ProviderBalance[];
failed: boolean;
loading: boolean;
refreshingKeys: string[];
refreshBalance: (source: "local" | "detected", providerId: string) => Promise<void>;
};
function keepLastBalance(previous: ProviderBalance | undefined, next: ProviderBalance): ProviderBalance {
if (next.errorCode && previous?.balances.length) {
return { ...next, balances: previous.balances, balanceKind: previous.balanceKind, checkedAt: previous.checkedAt };
}
if (!next.errorCode && previous?.checkedAt && next.checkedAt && previous.checkedAt > next.checkedAt) return previous;
return next;
}
const REFRESH_INTERVAL = 5 * 60 * 1000;
// Lives in App, so navigating away from Providers does not stop refreshing.
export function useProviderBalances(configDir: string, revision: string, ready: boolean) {
const [snapshot, setSnapshot] = useState({ configDir, revision, balances: [] as ProviderBalance[], failed: false, loading: true });
const scope = JSON.stringify([configDir, revision]);
const scopeRef = useRef(scope);
scopeRef.current = scope;
const manualInFlight = useRef(new Set<string>());
const manualVersions = useRef(new Map<string, number>());
const [manualBusy, setManualBusy] = useState<Record<string, boolean>>({});
const refreshBalance = useCallback(async (source: "local" | "detected", providerId: string) => {
if (!ready) return;
const token = JSON.stringify([scope, source, providerId]);
if (manualInFlight.current.has(token)) return;
manualInFlight.current.add(token);
manualVersions.current.set(token, (manualVersions.current.get(token) ?? 0) + 1);
setManualBusy(previous => ({ ...previous, [token]: true }));
try {
const result = await invoke<ProviderBalance>("refresh_provider_balance", { configDir, source, providerId });
if (scopeRef.current !== scope) return;
setSnapshot(previous => {
const matches = (item: ProviderBalance) => item.source === source && item.providerId === providerId;
const updated = keepLastBalance(previous.balances.find(matches), result);
return { ...previous, failed: false, balances: [...previous.balances.filter(item => !matches(item)), updated] };
});
} catch {
if (scopeRef.current === scope) setSnapshot(previous => ({ ...previous, balances: previous.balances.some(item => item.source === source && item.providerId === providerId)
? previous.balances.map(item => item.source === source && item.providerId === providerId ? { ...item, errorCode: "refresh_failed" } : item)
: [...previous.balances, { source, providerId, supported: true, vendor: null, balanceKind: null, balances: [], checkedAt: null, errorCode: "refresh_failed" }] }));
} finally {
manualVersions.current.set(token, (manualVersions.current.get(token) ?? 0) + 1);
manualInFlight.current.delete(token);
setManualBusy(previous => { const next = { ...previous }; delete next[token]; return next; });
}
}, [configDir, ready, scope]);
useEffect(() => {
if (!ready) return;
let disposed = false;
let inFlight = false;
let lastAttempt = 0;
let timer: number | undefined;
setSnapshot(previous => previous.configDir === configDir && previous.revision === revision
? { ...previous, loading: previous.balances.length === 0 }
: { configDir, revision, balances: [], failed: false, loading: true });
const refresh = async () => {
if (disposed || inFlight) return;
inFlight = true;
if (timer !== undefined) window.clearTimeout(timer);
lastAttempt = Date.now();
let nextDelay = REFRESH_INTERVAL;
const versionsAtStart = new Map(manualVersions.current);
const shouldPreserveManual = (item: ProviderBalance) => {
const token = JSON.stringify([scope, item.source, item.providerId]);
return manualInFlight.current.has(token) || (manualVersions.current.get(token) ?? 0) !== (versionsAtStart.get(token) ?? 0);
};
try {
const balances = await invoke<ProviderBalance[]>("get_provider_balances", { configDir });
// A manual query can warm the cache between automatic reads. Follow
// the data's timestamp rather than restarting a full five-minute wait.
const now = Date.now();
for (const item of balances.filter(item => item.supported)) {
const ttl = item.errorCode ? 60_000 : REFRESH_INTERVAL;
const checked = item.checkedAt ? Date.parse(item.checkedAt) : NaN;
const age = Number.isFinite(checked) ? Math.max(0, now - checked) : 0;
nextDelay = Math.min(nextDelay, Math.max(1_000, ttl - age + 1_000));
}
if (!disposed) setSnapshot(previous => ({ configDir, revision, balances: balances.map(next => {
const current = previous.balances.find(item => item.source === next.source && item.providerId === next.providerId);
return current && shouldPreserveManual(next) ? current : keepLastBalance(current, next);
}), failed: false, loading: false }));
} catch {
nextDelay = 60_000;
if (!disposed) setSnapshot(previous => ({ ...previous, loading: false, failed: previous.balances.length === 0, balances: previous.balances.map(item => item.supported && !shouldPreserveManual(item) ? { ...item, errorCode: "refresh_failed" } : item) }));
} finally {
inFlight = false;
lastAttempt = Date.now();
if (!disposed) timer = window.setTimeout(() => { void refresh(); }, nextDelay);
}
};
const onWake = () => { if (Date.now() - lastAttempt >= REFRESH_INTERVAL) void refresh(); };
void refresh();
window.addEventListener("focus", onWake);
document.addEventListener("visibilitychange", onWake);
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); window.removeEventListener("focus", onWake); document.removeEventListener("visibilitychange", onWake); };
}, [configDir, revision, ready, scope]);
const visible = snapshot.configDir === configDir && snapshot.revision === revision
? snapshot : { configDir, revision, balances: [] as ProviderBalance[], loading: true, failed: false };
const refreshingKeys = Object.keys(manualBusy).flatMap(token => {
const [requestScope, source, id] = JSON.parse(token) as string[];
return requestScope === scope ? [`${source}:${id}`] : [];
});
return { ...visible, refreshingKeys, refreshBalance };
}
export function supportsProviderBalance(baseUrl: string) {
try {
const url = new URL(baseUrl);
return url.protocol === "https:" && ["api.deepseek.com", "openrouter.ai", "api.siliconflow.cn", "api.moonshot.cn"].includes(url.hostname);
} catch { return false; }
}
+54
View File
@@ -0,0 +1,54 @@
export type LoadingScope = "general" | "sessionSync";
/** Session maintenance still blocks other UI actions; only activation is exempt. */
export function providerActivationIsBusy(loadingScopes: Iterable<LoadingScope>, actions: Iterable<string>): boolean {
for (const scope of loadingScopes) if (scope !== "sessionSync") return true;
for (const action of actions) if (action !== "syncSessions") return true;
return false;
}
export type SessionStatusContext = {
configDir: string;
targetProvider: string;
providerRevision: number;
};
export function sessionStatusContextKey(context: SessionStatusContext): string {
return JSON.stringify([context.configDir, context.targetProvider, context.providerRevision]);
}
export function createSessionStatusTracker() {
let sequence = 0;
return {
invalidate() { sequence += 1; },
begin(context: SessionStatusContext) {
return Object.freeze({ sequence: ++sequence, context: Object.freeze({ ...context }) });
},
isCurrent(request: { sequence: number; context: SessionStatusContext }, current: SessionStatusContext | null) {
return Boolean(current && request.sequence === sequence
&& request.context.configDir === current.configDir
&& request.context.targetProvider === current.targetProvider
&& request.context.providerRevision === current.providerRevision);
},
};
}
export async function readCurrentSessionStatus<T extends { targetProvider: string }>(
tracker: ReturnType<typeof createSessionStatusTracker>,
getContext: () => SessionStatusContext | null,
read: (context: Readonly<SessionStatusContext>) => Promise<T>,
accept: (status: T) => void,
): Promise<T | null> {
const context = getContext();
if (!context) return null;
const request = tracker.begin(context);
try {
const status = await read(request.context);
if (!tracker.isCurrent(request, getContext()) || status.targetProvider !== request.context.targetProvider) return null;
accept(status);
return status;
} catch (error) {
if (!tracker.isCurrent(request, getContext())) return null;
throw error;
}
}
+11
View File
@@ -420,3 +420,14 @@
@keyframes cx-app-dialog-spin {
to { transform: rotate(360deg); }
}
/* Actionable notices allow time to read and click, using the existing toast. */
.cx-app-toast--actionable { pointer-events: auto; animation: cx-app-toast-enter 180ms ease-out both, cx-app-toast-exit 180ms ease-in 15s forwards; }
.cx-app-toast--actionable:hover, .cx-app-toast--actionable:focus-within { animation-play-state: paused; }
.cx-app-toast-action, .cx-app-toast-dismiss { flex-shrink: 0; border: 0; padding: 4px; background: transparent; color: inherit; cursor: pointer; }
.cx-app-toast-action { font: inherit; font-size: 12px; font-weight: 600; text-decoration: underline; text-underline-offset: 3px; }
.cx-app-toast-dismiss { display: grid; place-items: center; opacity: .65; }
.cx-app-toast-action:disabled { cursor: wait; opacity: .5; }
.cx-app-toast-action:focus-visible, .cx-app-toast-dismiss:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; border-radius: 3px; }
.cx-app-toast--persistent { pointer-events: auto; animation: cx-app-toast-enter 180ms ease-out both; }
@@ -0,0 +1,30 @@
/* Reuse the settings page row, icon, typography and theme tokens. */
.cx-page-setting-row.cx-codex-language {
grid-template-columns: 40px minmax(0, 1fr) auto;
}
.cx-codex-language .cx-page-setting-copy p {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-page-setting-action.cx-codex-language-action {
grid-column: auto;
justify-self: end;
display: grid;
justify-items: end;
gap: 5px;
}
.cx-codex-language-switch {
display: flex;
align-items: center;
gap: 10px;
color: var(--cx-page-muted);
font-size: 12px;
white-space: nowrap;
}
.cx-codex-language-switch .ui-toggle {
--ui-accent: var(--cx-page-blue);
}
.cx-codex-language-enabled { color: var(--cx-page-blue); }
.cx-codex-language-loading { width: 40px; height: 23px; display: grid; place-items: center; }
.cx-codex-language .cx-page-setting-copy .cx-codex-language-error { color: var(--ui-danger); }
@@ -0,0 +1,60 @@
.cx-provider-balance {
--cx-balance-value: #16845a;
min-width: 0;
width: 100%;
max-width: 250px;
justify-self: center;
display: grid;
gap: 3px;
padding: 0 12px 0 14px;
border-left: 1px solid var(--cx-provider-border);
}
:root[data-theme="dark"] .cx-provider-balance { --cx-balance-value: var(--cx-dark-green); }
.cx-provider-balance-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 18px;
color: var(--cx-provider-muted);
font-size: 11px;
line-height: 1.3;
}
.cx-provider-balance-refresh {
display: grid;
place-items: center;
flex: 0 0 22px;
width: 22px;
height: 22px;
margin: 0;
padding: 0;
border: 0;
border-radius: 5px;
color: var(--cx-provider-muted);
background: transparent;
cursor: pointer;
opacity: .75;
transition: opacity 140ms ease, background 140ms ease;
}
.cx-provider-balance-refresh:hover:not(:disabled) { opacity: 1; background: color-mix(in srgb, currentColor 10%, transparent); }
.cx-provider-balance-refresh:focus-visible { outline: 2px solid var(--cx-provider-blue); outline-offset: 2px; opacity: 1; }
.cx-provider-balance-refresh:disabled { cursor: default; opacity: .45; }
.cx-provider-balance-spin { animation: cx-provider-balance-spin 900ms linear infinite; }
.cx-provider-balance-reading { display: flex; align-items: center; gap: 8px; min-width: 0; }
.cx-provider-balance-amounts { display: flex; min-width: 0; gap: 14px; align-items: baseline; }
.cx-provider-balance-amounts--multiple { flex-wrap: wrap; gap: 3px 12px; }
.cx-provider-balance-value { display: flex; align-items: baseline; gap: 6px; min-width: 0; max-width: 100%; }
.cx-provider-balance-value strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--cx-balance-value); font-size: 21px; font-weight: 650; line-height: 1.25; letter-spacing: -.35px; font-variant-numeric: tabular-nums; }
.cx-provider-balance-value > span { flex-shrink: 0; color: var(--cx-provider-muted); font-size: 10px; font-weight: 500; }
.cx-provider-balance-amounts--multiple .cx-provider-balance-value strong { font-size: 17px; }
.cx-provider-balance-value--empty strong { color: var(--ui-danger); }
.cx-provider-balance--stale .cx-provider-balance-value strong { color: var(--cx-provider-text); }
.cx-provider-balance-footer { color: var(--cx-provider-muted); font-size: 10px; line-height: 1.4; font-variant-numeric: tabular-nums; }
.cx-provider-balance-footer--error { color: var(--ui-warning); }
.cx-provider-balance-unavailable { min-height: 26px; display: flex; align-items: center; gap: 6px; color: var(--cx-provider-muted); font-size: 12px; }
.cx-provider-balance-unavailable > svg { flex-shrink: 0; color: var(--ui-warning); }
.cx-provider-balance-placeholder { display: flex; align-items: center; height: 26px; }
.cx-provider-balance-placeholder > span { height: 18px; width: 88px; border-radius: 4px; background: color-mix(in srgb, var(--cx-provider-muted) 14%, transparent); }
@keyframes cx-provider-balance-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .cx-provider-balance-spin { animation: none; } }
@media (max-width: 760px) { .cx-provider-balance { grid-column: 3; justify-self: start; max-width: 250px; padding: 0; border-left: 0; } }
+9 -6
View File
@@ -222,9 +222,9 @@
box-shadow: 0 0 0 1px rgba(147, 197, 253, 0.5), 0 3px 10px rgba(37, 99, 235, 0.08);
}
/* Give official account emails their own centered column. Current-state
badges can change the actions without moving the email in any row. */
.cx-providers-row--official {
/* Keep account details and provider balances in the same centered column. */
.cx-providers-row--official,
.cx-providers-row--balance {
grid-template-columns: 18px 42px minmax(0, 1fr) minmax(200px, 300px) minmax(310px, 1fr);
}
@@ -242,7 +242,8 @@
.cx-providers-row-model>b { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--cx-provider-text); }
.cx-providers-row-model>code { margin: 0; flex: 0 1 auto; }
.cx-providers-row--official > .cx-providers-login-badge {
.cx-providers-row--official > .cx-providers-login-badge,
.cx-providers-row--balance > .cx-providers-login-badge {
width: 100%;
max-width: 100%;
justify-self: center;
@@ -928,11 +929,13 @@
@media (max-width: 760px) {
.cx-providers-row,
.cx-providers-row--official {
.cx-providers-row--official,
.cx-providers-row--balance {
grid-template-columns: 18px 40px minmax(0, 1fr);
}
.cx-providers-row--official > .cx-providers-login-badge {
.cx-providers-row--official > .cx-providers-login-badge,
.cx-providers-row--balance > .cx-providers-login-badge {
grid-column: 3;
width: min(300px, 100%);
justify-self: start;
@@ -0,0 +1,170 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { closeSync, existsSync, openSync, readFileSync, readSync } from "node:fs";
import { createRequire } from "node:module";
import test from "node:test";
import vm from "node:vm";
// Optional local compatibility evidence. No installed application is launched,
// and no bundle is executed or copied into this repository. Only the narrow
// SDK registration / getter / compiled memo fragments are extracted at run time.
const archive = "/Applications/ChatGPT.app/Contents/Resources/app.asar";
const available = process.platform === "darwin" && existsSync(archive);
const runtime = readFileSync(new URL("../src-tauri/src/codex_locale_runtime.js", import.meta.url), "utf8");
const owner = "c".repeat(64);
const marker = "__CODEXX_LOCALE_RUNTIME_V1__";
function readInstalledEvidence() {
const fd = openSync(archive, "r");
try {
const prefix = Buffer.alloc(16);
readSync(fd, prefix, 0, prefix.length, 0);
assert.equal(prefix.readUInt32LE(0), 4);
const headerSize = prefix.readUInt32LE(12);
assert.ok(headerSize > 0 && headerSize < 32 * 1024 * 1024);
const header = Buffer.alloc(headerSize);
assert.equal(readSync(fd, header, 0, header.length, 16), header.length);
const assets = JSON.parse(header.toString()).files.webview.files.assets.files;
const base = 8 + prefix.readUInt32LE(4);
function asset(pattern) {
const matches = Object.entries(assets).filter(([name]) => pattern.test(name));
assert.equal(matches.length, 1, `installed asset shape changed: ${pattern}`);
const [name, entry] = matches[0];
assert.ok(!entry.unpacked && entry.size < 16 * 1024 * 1024);
const bytes = Buffer.alloc(entry.size);
assert.equal(readSync(fd, bytes, 0, bytes.length, base + Number(entry.offset)), bytes.length);
return { name, source: bytes.toString(), sha256: createHash("sha256").update(bytes).digest("hex") };
}
const shared = asset(/^app-shared-.*\.js$/);
// The language consumer's compiled memo is in app-initial, not app-shared.
const initial = asset(/^app-initial-.*\.js$/);
const plist = readFileSync("/Applications/ChatGPT.app/Contents/Info.plist", "utf8");
const plistValue = (key) => plist.match(new RegExp(`<key>${key}</key>\\s*<string>([^<]+)`))?.[1];
return { shared, initial, version: plistValue("CFBundleShortVersionString"), build: plistValue("CFBundleVersion") };
} finally { closeSync(fd); }
}
function extractedFragments(evidence) {
const require = createRequire(new URL("../package.json", import.meta.url));
const ts = require("typescript");
const shared = evidence.shared.source;
function declaration(marker) {
const index = shared.indexOf(marker);
assert.ok(index >= 0, `installed SDK fragment changed: ${marker}`);
// Parse a bounded window; execute only its first complete declaration.
const parsed = ts.createSourceFile("fragment.js", shared.slice(index, index + 4096), ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
assert.ok(ts.isFunctionDeclaration(parsed.statements[0]));
return parsed.statements[0].getText(parsed);
}
const registration = declaration("function p(e,t){if((0,s._isServerEnv)())return");
const useLayer = declaration("function Nyn(");
const readLayer = declaration("function Pyn(");
const memoIndex = shared.indexOf("_memoize(e,t){");
assert.ok(memoIndex >= 0);
const parsedMemo = ts.createSourceFile("memo.js", `class Extracted {${shared.slice(memoIndex, memoIndex + 2048)}`, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS);
const memoize = parsedMemo.statements[0].members[0].getText(parsedMemo);
const assignmentStart = shared.indexOf("this.getLayer=this._memoize(");
const assignmentEnd = shared.indexOf(",this.getParameterStore=", assignmentStart);
assert.ok(assignmentStart >= 0 && assignmentEnd > assignmentStart);
const assignment = shared.slice(assignmentStart, assignmentEnd);
const derivedStart = shared.lastIndexOf("constructor(", assignmentStart);
assert.ok(shared.slice(derivedStart, assignmentStart).includes("super("), "the real derived constructor calls super before assigning getLayer");
const registerCall = shared.indexOf("p(e,this)");
const baseStart = shared.lastIndexOf("constructor(", registerCall);
assert.ok(baseStart > 0 && registerCall > baseStart);
assert.equal(shared.slice(registerCall, registerCall + 10), "p(e,this)}", "registration is at the end of the real base constructor");
const getterStart = shared.indexOf("e._getStatsigGlobal=()=>");
const getterEnd = shared.indexOf(",e._getStatsigGlobalFlag", getterStart);
const globalsStart = shared.indexOf("var n=`__STATSIG__`,", getterEnd);
const globalsEnd = shared.indexOf("a[n]=o", globalsStart) + "a[n]=o".length;
assert.ok(getterStart >= 0 && getterEnd > getterStart && globalsStart > getterEnd && globalsEnd > globalsStart);
const globalInitialization = `${shared.slice(getterStart, getterEnd)};${shared.slice(globalsStart, globalsEnd)};`;
const consumerStart = evidence.initial.source.indexOf("function O9s(");
const expressionStart = evidence.initial.source.indexOf("t[0]===s?c=t[1]", consumerStart);
const expressionEnd = evidence.initial.source.indexOf(";let l=c", expressionStart);
assert.ok(consumerStart >= 0 && expressionStart > consumerStart && expressionEnd > expressionStart);
const consumerMemo = evidence.initial.source.slice(expressionStart, expressionEnd);
assert.ok(consumerMemo.includes("enable_i18n") && consumerMemo.length < 256);
return { registration, useLayer, readLayer, memoize, assignment, globalInitialization, consumerMemo };
}
function isolatedSdk(fragments, installEarly) {
const timers = new Map();
const sandbox = {
URL, location: { href: "app://-/index.html" }, document: { documentElement: { lang: "zh-CN" } },
performance: { getEntriesByType: () => [{ name: "app://-/assets/zh-CN-fixture.js" }] },
setInterval(callback) { timers.set(1, callback); return 1; }, clearInterval(id) { timers.delete(id); },
};
sandbox.window = sandbox; sandbox.top = sandbox;
const context = vm.createContext(sandbox);
const install = () => vm.runInContext(runtime.replace("__CODEXX_LANGUAGE_CONFIG__", JSON.stringify({ locale: "zh-CN", owner })), context, { timeout: 500 });
if (installEarly) install();
// These are independently supplied stubs, not live SDK stores, React, DOM,
// networking, or account data. Extracted code has no bundle/module loader.
vm.runInContext(`
globalThis.fakeLayer={get:(key,fallback)=>key==='enable_i18n'?false:key==='locale_source'?'IDE':fallback};
globalThis.originalCalls=0;
const sdkGlobals=(()=>{const e={_getInstance(){}};${fragments.globalInitialization}return e;})();
const s={_isServerEnv:()=>false},a={Log:{warn(){throw Error('unexpected duplicate fixture key')}}};
const n={_getStatsigGlobal:sdkGlobals._getStatsigGlobal,MemoPrefix:{_layer:'fixture-layer'}};
const o={createMemoKey:(...args)=>JSON.stringify(args)},f=1000;
${fragments.registration}
class Base { constructor(e){this._options={};this._memoCache={};p(e,this)} ${fragments.memoize} }
const originalMemoize=Base.prototype._memoize;
Base.prototype._memoize=function(...args){const raw=originalMemoize.apply(this,args);this.fixtureOriginalGetLayer=raw;return raw;};
class Derived extends Base { constructor(e){super(e);globalThis.registeredBeforeGetter=typeof this.getLayer!=='function';${fragments.assignment}} _getLayerImpl(){originalCalls++;return fakeLayer} }
const hookCache=Array(8).fill(Symbol.for('fixture.uninitialized'));
const consumerCache=Array(33).fill(Symbol.for('fixture.uninitialized'));
const iW={c:()=>hookCache};
function rW(){return {client:globalThis.client,getLayer:globalThis.client.getLayer}}
${fragments.readLayer}
${fragments.useLayer}
globalThis.readCompiledLanguage=()=>{const s=Nyn('72216192');let t=consumerCache,c;${fragments.consumerMemo};return c;};
globalThis.makeClient=key=>new Derived(key);
globalThis.client=makeClient('fixture-key');
`, context, { timeout: 500 });
return { sandbox, context, timers, install, read: () => vm.runInContext("readCompiledLanguage()", context, { timeout: 500 }) };
}
test("installed SDK registration and compiled first language memo see the early override synchronously", { skip: !available }, (t) => {
const evidence = readInstalledEvidence();
t.diagnostic(`Installed build ${evidence.version} (${evidence.build}); ${evidence.shared.name}; SHA256 ${evidence.shared.sha256}`);
const fragments = extractedFragments(evidence);
const f = isolatedSdk(fragments, true);
assert.equal(f.sandbox.registeredBeforeGetter, true);
assert.equal(f.read(), true, "the first compiled memo sees true without timer ticks or SDK events");
assert.equal(f.read(), true);
assert.equal(f.sandbox.originalCalls, 1, "the real SDK and React memo keep the initial layer cached");
assert.equal(f.sandbox.fakeLayer.get("enable_i18n", false), false);
assert.equal(f.sandbox.client.getLayer("72216192").get("locale_source", "wrong"), "IDE");
const registry = f.sandbox.__STATSIG__;
f.sandbox.__STATSIG__ = registry;
f.sandbox.second = vm.runInContext("makeClient('second-fixture-key')", f.context);
assert.equal(f.sandbox.second.getLayer("72216192").get("enable_i18n", false), true, "subsequent SDK registrations remain observed");
assert.equal(f.sandbox[marker].status().ready, true);
f.sandbox[marker].restore();
assert.equal(f.sandbox[marker], undefined);
assert.equal(f.timers.size, 0);
assert.equal(f.sandbox.__STATSIG__, registry);
assert.equal(registry.instances["fixture-key"], f.sandbox.client);
assert.equal(registry.instances["second-fixture-key"], f.sandbox.second);
for (const [object, key] of [[f.sandbox, "__STATSIG__"], [registry, "firstInstance"], [registry, "instances"], [registry.instances, "fixture-key"], [registry.instances, "second-fixture-key"], [f.sandbox.client, "getLayer"], [f.sandbox.second, "getLayer"]]) {
const descriptor = Object.getOwnPropertyDescriptor(object, key);
assert.ok(descriptor && Object.hasOwn(descriptor, "value"), `restore removes the owned accessor for ${key}`);
assert.equal(descriptor.writable, true);
assert.equal(descriptor.configurable, true);
}
assert.equal(f.sandbox.client.getLayer, f.sandbox.client.fixtureOriginalGetLayer);
assert.equal(f.sandbox.second.getLayer, f.sandbox.second.fixtureOriginalGetLayer);
assert.equal(f.sandbox.client.getLayer("72216192").get("enable_i18n", false), false);
assert.equal(f.sandbox.second.getLayer("72216192").get("enable_i18n", false), false);
});
test("installed compiled language memo demonstrates why a timer-only late hook cannot repair its first false value", { skip: !available }, () => {
const f = isolatedSdk(extractedFragments(readInstalledEvidence()), false);
assert.equal(f.read(), false);
f.install();
assert.equal(f.sandbox.client.getLayer("72216192").get("enable_i18n", false), true);
assert.equal(f.read(), false, "the unchanged React client/layer memo retains the pre-hook false value");
f.sandbox[marker].restore();
});
@@ -0,0 +1,322 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
const source = readFileSync(new URL("../src-tauri/src/codex_locale_runtime.js", import.meta.url), "utf8");
const marker = "__CODEXX_LOCALE_RUNTIME_V1__";
const layerName = "72216192";
const owner = "a".repeat(64);
const languageResource = "app://-/assets/zh-CN-fixture.js";
class FixtureLayer {
#values;
constructor(values = {}) { this.#values = { enable_i18n: false, locale_source: "fixture-original", ...values }; this.name = "fixture layer"; }
get(parameter, fallback) { return Object.hasOwn(this.#values, parameter) ? this.#values[parameter] : fallback; }
readPrivate() { return this.#values.locale_source; }
get privateValue() { return this.#values.locale_source; }
}
class FixtureClient {
#layers;
constructor(layer = new FixtureLayer()) {
this.#layers = { [layerName]: layer, other_layer: new FixtureLayer({ enable_i18n: false }) };
this.calls = [];
this.events = [];
this.gateCalls = [];
}
getLayer(name, ...options) { this.calls.push({ name, options, receiver: this }); return this.#layers[name]; }
checkGate(name) { this.gateCalls.push(name); return this.#layers.other_layer.get("enable_i18n", false); }
$emt(event) { this.events.push(event.name); this.onValuesUpdated?.(event); }
}
function fixture({ url = "app://-/index.html", language = "zh-CN", resources = [languageResource], client = new FixtureClient(), registry, iframe = false, observer = true, documentElement = true } = {}) {
const parsed = new URL(url);
// Location deliberately has no username/password fields: browsers expose
// those through new URL(location.href), not through the Location interface.
const location = { href: parsed.href, protocol: parsed.protocol, host: parsed.host, hostname: parsed.hostname, port: parsed.port, pathname: parsed.pathname, search: parsed.search, hash: parsed.hash, origin: parsed.origin };
const timers = new Map(), scheduled = [], observers = [];
let entries = resources.map((name) => ({ name, initiatorType: "script" }));
const sandbox = {
location, URL,
document: { documentElement: documentElement ? { lang: language } : null },
performance: { getEntriesByType(type) { assert.equal(type, "resource"); return entries; } },
setInterval(callback, interval) { assert.equal(interval, 100); const id = scheduled.length + 1; scheduled.push(callback); timers.set(id, callback); return id; },
clearInterval(id) { timers.delete(id); },
};
if (observer) sandbox.PerformanceObserver = class {
constructor(callback) { this.callback = callback; this.active = false; observers.push(this); }
observe(options) { assert.equal(options.type, "resource"); this.active = true; if (options.buffered) this.callback({ getEntries: () => entries }); }
disconnect() { this.active = false; }
};
sandbox.window = sandbox;
sandbox.top = iframe ? {} : sandbox;
sandbox.__STATSIG__ = registry ?? { firstInstance: client, instances: { fixture: client } };
const context = vm.createContext(sandbox);
return {
sandbox, client, timers, observers, scheduled,
install(options = { locale: "zh-CN", owner }) { return vm.runInContext(source.replace("__CODEXX_LANGUAGE_CONFIG__", JSON.stringify(options)), context, { timeout: 500 }); },
state() { return sandbox[marker]; },
tick() { for (const callback of [...timers.values()]) callback(); },
emitResources(names) { const delivered = names.map((name) => ({ name, initiatorType: "script" })); for (const current of observers) if (current.active) current.callback({ getEntries: () => delivered }); },
clearResources() { entries = []; },
};
}
test("only the i18n parameter of the named layer changes; other gates, fields and receivers remain original", () => {
const client = new FixtureClient();
const layer = client.getLayer(layerName);
const other = client.getLayer("other_layer");
client.calls.length = 0;
const checkGate = client.checkGate;
const f = fixture({ client });
assert.equal(f.install().ready, true);
assert.ok(client.calls.every((call) => call.name === layerName && call.receiver === client));
assert.deepEqual(client.gateCalls, [], "the runtime never evaluates unrelated feature gates");
assert.equal(client.checkGate, checkGate);
assert.equal(client.checkGate("unrelated-account-gate"), false);
assert.equal(client.getLayer("other_layer"), other);
const view = client.getLayer(layerName);
assert.equal(view.get("enable_i18n", false), true);
assert.equal(view.get("locale_source", "wrong"), "fixture-original");
assert.equal(view.get("unrelated-field", "original fallback"), "original fallback");
assert.equal(view.readPrivate(), "fixture-original");
assert.equal(view.privateValue, "fixture-original");
assert.equal(view.get, view.get, "the i18n getter has a stable reference for React dependencies");
assert.equal(view.readPrivate, view.readPrivate, "other bound methods have stable references");
const previousBoundMethod = view.readPrivate;
layer.readPrivate = function () { return `${this.privateValue}-updated`; };
assert.notEqual(view.readPrivate, previousBoundMethod, "a later SDK method replacement is observed");
assert.equal(view.readPrivate(), "fixture-original-updated");
assert.equal(layer.get("enable_i18n", false), false, "the actual SDK layer data is not mutated");
assert.equal(Object.getPrototypeOf(view), Object.getPrototypeOf(layer));
f.state().restore();
});
test("frozen layers with immutable own get methods do not violate Proxy invariants", () => {
const layer = new FixtureLayer();
Object.defineProperty(layer, "get", { value: layer.get, configurable: false, writable: false, enumerable: true });
Object.freeze(layer);
const f = fixture({ client: new FixtureClient(layer) });
assert.equal(f.install().ready, true);
const view = f.client.getLayer(layerName);
assert.equal(view.get("enable_i18n", false), true);
assert.equal(view.readPrivate(), "fixture-original");
assert.equal(view.privateValue, "fixture-original");
assert.equal("name" in view, true);
assert.deepEqual(Object.keys(view), Object.keys(layer));
f.state().restore();
assert.equal(view.get("enable_i18n", false), false, "held views stop overriding after restoration");
});
test("writable nonconfigurable methods and configurable accessors restore their exact descriptors", () => {
for (const accessor of [false, true]) {
const client = new FixtureClient();
const original = client.getLayer;
Object.defineProperty(client, "getLayer", accessor
? { configurable: true, enumerable: true, get() { return original; } }
: { configurable: false, enumerable: true, writable: true, value: original });
const descriptor = Object.getOwnPropertyDescriptor(client, "getLayer");
const f = fixture({ client });
assert.equal(f.install().ready, true);
f.state().restore();
assert.deepEqual(Object.getOwnPropertyDescriptor(client, "getLayer"), descriptor);
}
});
test("unpatchable client methods fail closed without reporting the compatibility layer ready", () => {
const client = new FixtureClient();
const original = client.getLayer;
Object.defineProperty(client, "getLayer", { configurable: false, writable: false, value: original });
const f = fixture({ client });
assert.equal(f.install().ready, false);
assert.equal(client.getLayer, original);
assert.deepEqual(client.events, []);
f.state().restore();
});
test("only the top-level official app origin and supported page paths are accepted", () => {
for (const url of ["https://chatgpt.com/", "http://127.0.0.1/index.html", "app://other/index.html", "app://-/browser.html", "app://user@-/index.html", "app://user:password@-/index.html", "app://-:123/index.html"]) {
const f = fixture({ url });
assert.equal(f.install().reason, "unsupported_origin", url);
assert.equal(f.state(), undefined);
assert.equal(f.timers.size, 0);
assert.deepEqual(f.client.events, []);
}
assert.equal(fixture({ iframe: true }).install().reason, "unsupported_origin");
for (const url of ["app://-/", "app://-/index.html?initialRoute=%2Fsettings#fixture"]) {
const f = fixture({ url });
assert.equal(f.install().installed, true);
f.state().restore();
}
});
test("unsupported locales and untrusted option shapes cause no hooks", () => {
for (const options of [null, {}, { locale: "fr-FR", owner }, { locale: "zh-CN", owner: "not-a-session-id" }, { locale: "zh-CN", owner: '";globalThis.fixtureInjected=true;//' }]) {
const f = fixture();
assert.equal(f.install(options).reason, "invalid_options");
assert.equal(f.state(), undefined);
assert.equal(f.sandbox.fixtureInjected, undefined);
assert.deepEqual(f.client.events, []);
}
});
test("a late SDK singleton notifies subscribers after the narrow hook becomes available", () => {
const f = fixture({ registry: {} });
assert.equal(f.install().ready, false);
const observed = [];
f.client.onValuesUpdated = () => observed.push(f.client.getLayer(layerName).get("enable_i18n", false));
f.sandbox.__STATSIG__ = { firstInstance: f.client, instances: { same: f.client } };
f.tick();
assert.equal(f.state().status().ready, true);
assert.deepEqual(observed, [true]);
assert.deepEqual(f.client.events, ["values_updated"]);
f.state().restore();
});
test("early registry and base-constructor registration intercept a later getter assignment before the first read", () => {
const f = fixture({ registry: {} });
delete f.sandbox.__STATSIG__;
assert.equal(f.install().ready, false);
const registry = {};
f.sandbox.__STATSIG__ = registry;
const rawLayer = new FixtureLayer();
const original = function () { assert.equal(this, client); return rawLayer; };
let missingDuringBase = false;
class Base {
constructor() {
const instances = registry.instances ?? {};
instances.fixture = this;
registry.firstInstance ||= this;
registry.instances = instances;
f.sandbox.__STATSIG__ = registry;
missingDuringBase = typeof this.getLayer !== "function";
}
}
class Derived extends Base {
constructor() { super(); this.getLayer = original; }
}
const client = new Derived();
assert.equal(missingDuringBase, true);
assert.equal(client.getLayer(layerName).get("enable_i18n", false), true, "no timer tick or SDK event is needed");
assert.equal(client.getLayer(layerName).get("locale_source", "wrong"), "fixture-original");
assert.equal(rawLayer.get("enable_i18n", false), false);
const laterSdkGetter = function () { return rawLayer; };
client.getLayer = laterSdkGetter;
assert.equal(client.getLayer(layerName).get("enable_i18n", false), true);
f.state().restore();
assert.equal(client.getLayer, laterSdkGetter, "restoration preserves the latest SDK-assigned method exactly");
assert.equal(f.sandbox.__STATSIG__, registry);
assert.equal(registry.instances.fixture, client);
for (const [object, key] of [[f.sandbox, "__STATSIG__"], [registry, "firstInstance"], [registry, "instances"], [registry.instances, "fixture"], [client, "getLayer"]]) {
assert.ok(Object.hasOwn(Object.getOwnPropertyDescriptor(object, key), "value"), `${key} is no longer an owned accessor`);
}
});
test("repeated injection and duplicate registry references never stack hooks or pollers", () => {
const f = fixture();
f.install();
const replacement = f.client.getLayer;
f.install(); f.tick(); f.install();
assert.equal(f.client.getLayer, replacement);
assert.equal(f.timers.size, 1);
assert.equal(f.observers.length, 1);
assert.deepEqual(f.client.events, ["values_updated"]);
assert.equal(f.install({ locale: "zh-CN", owner: "b".repeat(64) }).reason, "another_owner");
assert.equal(f.client.getLayer, replacement);
f.state().restore();
});
test("restoration removes inherited overrides and old handles or queued scans cannot rehook", () => {
const f = fixture();
const original = f.client.getLayer;
f.install();
const state = f.state();
const heldView = f.client.getLayer(layerName);
const queuedScan = f.scheduled[0];
state.restore();
assert.equal(f.state(), undefined);
assert.equal(f.timers.size, 0);
assert.ok(f.observers.every((observer) => !observer.active));
assert.equal(Object.hasOwn(f.client, "getLayer"), false);
assert.equal(f.client.getLayer, original);
assert.equal(heldView.get("enable_i18n", false), false);
const events = f.client.events.length;
assert.equal(state.status().installed, false);
queuedScan(); state.restore();
assert.equal(f.client.getLayer, original);
assert.equal(Object.hasOwn(f.client, "getLayer"), false);
assert.equal(f.client.events.length, events);
});
test("restoration preserves later SDK method replacements", () => {
const f = fixture();
const original = f.client.getLayer;
f.install();
const external = function (name, ...args) { return original.call(this, name, ...args); };
f.client.getLayer = external;
f.tick(); // The SDK replaced its method while compatibility was active.
f.state().restore();
assert.equal(f.client.getLayer, external);
});
test("readiness follows only current SDK registry clients, not past account instances", () => {
const f = fixture();
const old = f.client;
const original = old.getLayer;
f.install();
const next = new FixtureClient();
f.sandbox.__STATSIG__ = { firstInstance: next, instances: { same: next } };
f.tick();
assert.equal(f.state().status().clients, 1);
assert.equal(f.state().status().ready, true);
f.sandbox.__STATSIG__ = {};
assert.equal(f.state().status().ready, false);
assert.equal(f.state().status().clients, 0);
f.state().restore();
assert.equal(old.getLayer, original);
assert.equal(Object.hasOwn(next, "getLayer"), false);
});
test("readiness requires the selected language and the official bundled translation resource", () => {
for (const resources of [[], ["https://other.example/assets/zh-CN-fixture.js"], ["app://other/assets/zh-CN-fixture.js"], ["app://-/different/zh-CN-fixture.js"], ["app://user@-/assets/zh-CN-fixture.js"]]) {
const f = fixture({ resources });
assert.equal(f.install().ready, false);
f.state().restore();
}
for (const language of ["zh-TW", "zh-HK", "en-US"]) {
const f = fixture({ language });
assert.equal(f.install().ready, false);
f.state().restore();
}
const english = fixture({ language: "en-US", resources: [] });
assert.equal(english.install({ locale: "en-US", owner }).ready, true);
english.state().restore();
});
test("observed language resources survive resource-timing buffer eviction", () => {
const f = fixture({ resources: [] });
assert.equal(f.install().ready, false);
f.emitResources([languageResource]);
f.clearResources();
assert.equal(f.state().status().ready, true);
f.state().restore();
});
test("early documents without a documentElement remain unready until the language is present", () => {
const f = fixture({ documentElement: false });
assert.equal(f.install().ready, false);
f.sandbox.document.documentElement = { lang: "zh-CN" };
assert.equal(f.state().status().ready, true);
f.state().restore();
});
test("the runtime does not access account data, messages, credential storage or network APIs", () => {
const f = fixture();
for (const [object, names] of [[f.sandbox, ["fetch", "XMLHttpRequest", "localStorage", "sessionStorage"]], [f.client, ["user", "apiKey", "messages", "account"]], [f.sandbox.document, ["body", "cookie"]]]) {
for (const name of names) Object.defineProperty(object, name, { configurable: true, get() { assert.fail(`runtime accessed forbidden fixture field ${name}`); } });
}
assert.equal(f.install().ready, true);
assert.equal(f.state().status().ready, true);
f.tick();
f.state().restore();
});
+64 -1
View File
@@ -28,6 +28,39 @@
let transferMode = "success";
let lastTransfer = null;
let existingImportAvailable = true;
// Independent of the persisted provider fixture: language controls only model
// the current page's memory, never a real Codex process or configuration.
let codexLanguageEnabled = true;
let codexLanguagePendingEnabled = null;
let codexLanguageMode = "normal";
function codexLanguageStatus() {
return {
locale: codexLanguageEnabled ? "zh-CN" : "auto",
enabled: codexLanguageEnabled,
pendingEnabled: codexLanguagePendingEnabled,
supported: true,
appName: "ChatGPT",
appVersion: "fixture",
phase: codexLanguageEnabled ? "active" : "inactive",
message: "",
readyPages: 0,
restoreAvailable: false,
};
}
function setCodexLanguageActual(enabled) {
if (typeof enabled !== "boolean") throw new Error("Unknown fixture language state");
codexLanguageEnabled = enabled;
codexLanguagePendingEnabled = null;
render();
}
function setCodexLanguageMode(mode) {
if (!["normal", "save-error", "apply-error"].includes(mode)) throw new Error("Unknown fixture language mode");
codexLanguageMode = mode;
render();
}
const defaultRoutingSettings = () => ({ version: 2, routerEnabled: false, takeoverEnabled: false, autoFailoverEnabled: false,
listenAddress: "127.0.0.1", listenPort: 15721, providerIds: [], maxRetries: 3, streamingFirstByteTimeout: 60,
streamingIdleTimeout: 120, nonStreamingTimeout: 600, circuitFailureThreshold: 4, circuitSuccessThreshold: 2,
@@ -277,6 +310,8 @@
function snapshot() {
return {
startupWizardSeen: localStorage.getItem("codexx.startupWizardSeen"),
codexLanguage: codexLanguageStatus(),
codexLanguageMode,
transferMode, lastTransfer,
failoverSettings: clone(failoverSettings), failoverMode,
pendingSync: pending.sync.length,
@@ -838,6 +873,20 @@
async function dispatch(command, args) {
switch (command) {
case "get_codex_language_status": return codexLanguageStatus();
case "save_codex_language_preferences": {
if (codexLanguageMode === "save-error") throw new Error("Fixture:语言设置保存失败,当前配置未改变");
if (typeof args.enabled !== "boolean" || args.locale !== (args.enabled ? "zh-CN" : "auto")) throw new Error("Fixture:语言偏好参数无效");
codexLanguagePendingEnabled = args.enabled === codexLanguageEnabled ? null : args.enabled;
return codexLanguageStatus();
}
case "apply_codex_language_preferences": {
if (codexLanguageMode === "apply-error") throw new Error("Fixture:语言设置应用失败,尚未重启客户端");
if (args.restore === true) throw new Error("Fixture:没有可恢复的语言快照");
codexLanguageEnabled = codexLanguagePendingEnabled ?? codexLanguageEnabled;
codexLanguagePendingEnabled = null;
return codexLanguageStatus();
}
case "get_provider_config_base": {
if (providerBaseReadFailure) throw new Error("Fixture:无法读取供应商配置底稿");
// Model a legacy route-only file with the official integration snapshot
@@ -1088,6 +1137,8 @@
window.__CODEX_X_FIXTURE__ = {
snapshot,
loginSecondOfficialAccount,
setCodexLanguageActual,
setCodexLanguageMode,
setUsageMode,
setQuotaMode,
setProviderOrderMode,
@@ -1109,7 +1160,7 @@
document.addEventListener("DOMContentLoaded", () => {
const panel = document.createElement("aside");
panel.setAttribute("aria-label", "Fixture 控制面板");
panel.style.cssText = "position:fixed;left:8px;bottom:8px;z-index:2147483647;width:224px;max-width:calc(100vw - 16px);box-sizing:border-box;background:#fff;color:#111;border:2px solid #1463d6;border-radius:8px;padding:8px;font:12px monospace;box-shadow:0 2px 8px #0003";
panel.style.cssText = "position:fixed;left:8px;bottom:8px;z-index:2147483647;width:224px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow:auto;box-sizing:border-box;background:#fff;color:#111;border:2px solid #1463d6;border-radius:8px;padding:8px;font:12px monospace;box-shadow:0 2px 8px #0003";
const panelBody = document.createElement("details");
panelBody.open = true;
const label = document.createElement("summary");
@@ -1137,6 +1188,18 @@
const option = document.createElement("option"); option.value = value; option.textContent = label; transferSelect.append(option);
}
transferSelect.addEventListener("change", () => { transferMode = transferSelect.value; render(); }); controls.append(transferSelect);
for (const [text, action] of [
["Fixture:语言外部改为中文", () => setCodexLanguageActual(true)],
["Fixture:语言外部改为自动", () => setCodexLanguageActual(false)],
["Fixture:语言保存失败", () => setCodexLanguageMode("save-error")],
["Fixture:语言应用失败", () => setCodexLanguageMode("apply-error")],
["Fixture:语言恢复正常", () => setCodexLanguageMode("normal")],
]) {
const button = document.createElement("button");
button.type = "button"; button.textContent = text;
button.style.cssText = "font:inherit;padding:5px;border:1px solid #aaa;border-radius:4px;background:#eee;color:#111;cursor:pointer";
button.addEventListener("click", action); controls.append(button);
}
for (const [text, mode] of [["Fixture:配置正常", "healthy"], ["Fixture:缺失供应商配置", "missing"], ["Fixture:配置语法错误", "syntax"]]) {
const button = document.createElement("button");
button.type = "button"; button.textContent = text;
@@ -0,0 +1,36 @@
import assert from "node:assert/strict";
import test from "node:test";
import { MAX_AUTH_IMPORT_BYTES, parseOfficialAuthImport, readOfficialAuthImport } from "../src/officialAuthImport.ts";
const encoded = text => new TextEncoder().encode(text);
const auth = { auth_mode: "chatgpt", tokens: { access_token: "fixture-access", refresh_token: "fixture-refresh", account_id: "fixture-account" }, last_refresh: "2026-10-16", extra: { preserve: true } };
test("reads a standard auth file into a draft while preserving unknown fields", async () => {
const original = JSON.stringify(auth, null, 2);
const bytes = encoded(original);
const result = await readOfficialAuthImport({ size: bytes.length, arrayBuffer: async () => bytes.buffer });
assert.equal(result, original);
assert.deepEqual(JSON.parse(result), auth);
});
test("accepts UTF-8 BOM and a standard OpenAI API key auth file", () => {
assert.deepEqual(JSON.parse(parseOfficialAuthImport(encoded("\uFEFF" + JSON.stringify(auth)))), auth);
assert.deepEqual(JSON.parse(parseOfficialAuthImport(encoded('{"OPENAI_API_KEY":"fixture-key"}'))), { OPENAI_API_KEY: "fixture-key" });
});
test("rejects oversized files before reading their contents", async () => {
let reads = 0;
await assert.rejects(readOfficialAuthImport({ size: MAX_AUTH_IMPORT_BYTES + 1, arrayBuffer: async () => { reads++; return new ArrayBuffer(0); } }), { code: "too_large" });
assert.equal(reads, 0);
});
test("rejects invalid UTF-8 and JSON without echoing credential text", () => {
assert.throws(() => parseOfficialAuthImport(new Uint8Array([0xff, 0xfe])), { code: "invalid_text" });
assert.throws(() => parseOfficialAuthImport(encoded('{"tokens":"private-fixture-token')), error => error.code === "invalid_json" && !error.message.includes("private-fixture-token"));
});
test("does not treat arrays, third party configurations, or arbitrary JSON as official auth", () => {
for (const value of [[], [auth], null, "x", {}, { accounts: [auth] }, { tokens: {} }, { OPENAI_API_KEY: " " }, { ...auth, base_url: "https://fixture.invalid/v1" }]) {
assert.throws(() => parseOfficialAuthImport(encoded(JSON.stringify(value))), { code: "unsupported_shape" });
}
});
test("checks actual decoded byte size even when a file supplies an inaccurate size", async () => {
const bytes = new Uint8Array(MAX_AUTH_IMPORT_BYTES + 1);
await assert.rejects(readOfficialAuthImport({ size: 1, arrayBuffer: async () => bytes.buffer }), { code: "too_large" });
});
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import test from "node:test";
import ts from "typescript";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
const desktop = dirname(dirname(fileURLToPath(import.meta.url)));
let page;
async function loadPage() {
if (page) return page;
page = (async () => {
const scratch = await mkdtemp(join(desktop, "tests/.provider-activation-ui-"));
try {
const emitted = new Map();
async function compile(sourcePath) {
if (emitted.has(sourcePath)) return emitted.get(sourcePath);
const outputPath = join(scratch, relative(desktop, sourcePath).replace(/\.tsx?$/, ".mjs"));
emitted.set(sourcePath, outputPath);
const source = await readFile(sourcePath, "utf8");
// Animation is unrelated to action locks and has browser layout effects.
let output = sourcePath.endsWith("/PageTransition.tsx") ? "export function PageTransition({ children }) { return children; }" : ts.transpileModule(source, {
fileName: sourcePath,
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.ESNext, jsx: ts.JsxEmit.ReactJSX },
}).outputText.replace(/import\s*["'][^"']+\.css["'];?/g, "");
for (const match of [...output.matchAll(/\bfrom\s*["'](\.[^"']+)["']/g)]) {
const specifier = match[1];
const base = resolve(dirname(sourcePath), specifier);
let dependency;
for (const candidate of [`${base}.ts`, `${base}.tsx`, join(base, "index.ts")]) {
try { await readFile(candidate, "utf8"); dependency = candidate; break; } catch { /* Try the next TS module candidate. */ }
}
assert.ok(dependency, `could not resolve ${specifier}`);
const dependencyOutput = await compile(dependency);
let replacement = relative(dirname(outputPath), dependencyOutput).replaceAll("\\", "/");
if (!replacement.startsWith(".")) replacement = `./${replacement}`;
output = output.replaceAll(`"${specifier}"`, `"${replacement}"`).replaceAll(`'${specifier}'`, `'${replacement}'`);
}
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, output);
return outputPath;
}
return (await import(pathToFileURL(await compile(join(desktop, "src/pages/ProvidersPage.tsx"))).href)).ProvidersPage;
} finally { await rm(scratch, { recursive: true, force: true }); }
})();
return page;
}
const baseRow = { baseUrl: "https://fixture.example.test/v1", model: "fixture-model", wireApi: "responses", requiresOpenaiAuth: false, editable: true, duplicable: true, deletable: true, testable: true };
const copy = new Proxy({ enableLabel: "Enable fixture", addLabel: "Add fixture", importLabel: "Import fixture" }, { get: (target, key) => target[key] ?? String(key) });
const props = {
mode: "list", lang: "en", configDir: "/fixture/codex", copy, loading: true, actionBusy: "syncSessions",
providerRows: [
{ ...baseRow, id: "current", source: "local", providerName: "Current fixture", isCurrent: true },
{ ...baseRow, id: "next", source: "local", providerName: "Next fixture", isCurrent: false },
{ ...baseRow, id: "official-next", source: "official", providerName: "Official fixture", isCurrent: false },
],
onImportCcSwitch() {}, onAddProvider() {}, onEnableProvider() {}, onTestProvider() {}, onEditProvider() {}, onDuplicateProvider() {}, onDeleteProvider() {}, onReorderProviders() {},
};
const buttons = (html, label) => [...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].map(([button]) => button).filter((button) => button.includes(label));
test("sync permits switching local and official providers while other provider actions stay disabled", async () => {
const ProvidersPage = await loadPage();
const html = renderToStaticMarkup(createElement(ProvidersPage, { ...props, activationBusy: false }));
const enable = buttons(html, "Enable fixture");
assert.equal(enable.length, 3);
assert.match(enable[0], /\bdisabled=/, "the current provider is never activated again");
assert.doesNotMatch(enable[1], /\bdisabled=/, "another API provider can be enabled during sync");
assert.doesNotMatch(enable[2], /\bdisabled=/, "an official profile can be enabled during sync");
for (const label of ["Add fixture", "Import fixture"]) assert.match(buttons(html, label)[0], /\bdisabled=/);
assert.match(buttons(html, "editLabel")[0], /\bdisabled=/);
assert.match(buttons(html, "duplicateLabel")[0], /\bdisabled=/);
});
test("a real activation or unrelated operation keeps every activation button locked", async () => {
const ProvidersPage = await loadPage();
for (const extra of [{ activationBusy: true }, {}]) {
const html = renderToStaticMarkup(createElement(ProvidersPage, { ...props, ...extra }));
assert.ok(buttons(html, "Enable fixture").every((button) => /\bdisabled=/.test(button)));
}
});
@@ -0,0 +1,132 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createSessionStatusTracker,
providerActivationIsBusy,
readCurrentSessionStatus,
sessionStatusContextKey,
} from "../src/sessionOperationState.ts";
const context = (targetProvider = "custom", providerRevision = 0, configDir = "/fixture/codex") => ({ configDir, targetProvider, providerRevision });
const deferred = () => {
let resolve, reject;
const promise = new Promise((accept, fail) => { resolve = accept; reject = fail; });
return { promise, resolve, reject };
};
test("session sync permits activation while general operations and other actions keep their locks", () => {
assert.equal(providerActivationIsBusy(["sessionSync"], ["syncSessions"]), false);
assert.equal(providerActivationIsBusy(["sessionSync", "sessionSync"], ["syncSessions", "syncSessions"]), false);
assert.equal(providerActivationIsBusy(["sessionSync", "general"], ["syncSessions"]), true);
assert.equal(providerActivationIsBusy(["general"], []), true);
assert.equal(providerActivationIsBusy([], ["checkSessions"]), true);
assert.equal(providerActivationIsBusy([], []), false);
// A newer sync token must not hide an older unrelated operation.
assert.equal(providerActivationIsBusy(["sessionSync"], ["importCcSwitch", "syncSessions"]), true);
});
test("captured targets cannot change when the current context is mutated", () => {
const tracker = createSessionStatusTracker();
const source = context("openai");
const request = tracker.begin(source);
source.targetProvider = "custom";
assert.equal(request.context.targetProvider, "openai");
assert.equal(Object.isFrozen(request.context), true);
assert.equal(tracker.isCurrent(request, source), false);
});
test("an old status cannot overwrite a newly activated provider", async () => {
const tracker = createSessionStatusTracker();
let current = context("openai");
const old = deferred();
const published = [];
const oldRead = readCurrentSessionStatus(tracker, () => current, () => old.promise, (status) => published.push(status));
current = null; // Activation has begun but the new live config is not ready.
tracker.invalidate();
old.resolve({ targetProvider: "openai", needsSync: false });
assert.equal(await oldRead, null);
current = context("custom", 1);
await readCurrentSessionStatus(tracker, () => current, async () => ({ targetProvider: "custom", needsSync: true }), (status) => published.push(status));
assert.deepEqual(published, [{ targetProvider: "custom", needsSync: true }]);
});
test("switching between providers sharing the custom key still invalidates old replies", async () => {
const tracker = createSessionStatusTracker();
let current = context("custom", 10);
const old = deferred();
const published = [];
const pending = readCurrentSessionStatus(tracker, () => current, () => old.promise, (status) => published.push(status));
current = context("custom", 11);
old.resolve({ targetProvider: "custom", needsSync: false });
assert.equal(await pending, null);
assert.deepEqual(published, []);
});
test("completion reads the current provider instead of publishing the completed job's old status", async () => {
const tracker = createSessionStatusTracker();
let current = context("openai");
const capturedTarget = { ...current };
const job = deferred();
const reads = [], published = [];
const completion = job.promise.then(() => readCurrentSessionStatus(tracker, () => current, async (scope) => {
reads.push(scope);
return { targetProvider: scope.targetProvider, needsSync: true };
}, (status) => published.push(status)));
current = context("custom", 1);
job.resolve({ status: { targetProvider: "openai", needsSync: false } });
await completion;
assert.equal(capturedTarget.targetProvider, "openai");
assert.equal(reads[0].targetProvider, "custom");
assert.deepEqual(published, [{ targetProvider: "custom", needsSync: true }]);
});
test("late responses from a different Codex directory are ignored", async () => {
const tracker = createSessionStatusTracker();
let current = context("custom", 0, "/fixture/old");
const task = deferred();
let applied = false;
const read = readCurrentSessionStatus(tracker, () => current, () => task.promise, () => { applied = true; });
current = context("custom", 0, "/fixture/new");
task.resolve({ targetProvider: "custom" });
assert.equal(await read, null);
assert.equal(applied, false);
});
test("the newest status request wins when reads finish out of order", async () => {
const tracker = createSessionStatusTracker();
const current = context();
const old = deferred(), fresh = deferred();
const published = [];
const a = readCurrentSessionStatus(tracker, () => current, () => old.promise, (status) => published.push(status));
const b = readCurrentSessionStatus(tracker, () => current, () => fresh.promise, (status) => published.push(status));
fresh.resolve({ targetProvider: "custom", needsSync: true });
await b;
old.resolve({ targetProvider: "custom", needsSync: false });
assert.equal(await a, null);
assert.deepEqual(published, [{ targetProvider: "custom", needsSync: true }]);
});
test("late failures are ignored and a current failure remains visible", async () => {
const tracker = createSessionStatusTracker();
let current = context();
const task = deferred();
const old = readCurrentSessionStatus(tracker, () => current, () => task.promise, () => assert.fail("stale read applied"));
current = context("openai", 1);
task.reject(new Error("old provider read failed"));
assert.equal(await old, null);
await assert.rejects(readCurrentSessionStatus(tracker, () => current, async () => { throw new Error("current read failed"); }, () => {}), /current read failed/);
});
test("provider mismatches and suspended contexts never publish a misleading synced status", async () => {
const tracker = createSessionStatusTracker();
let reads = 0, applied = false;
assert.equal(await readCurrentSessionStatus(tracker, () => null, async () => { reads += 1; return { targetProvider: "old" }; }, () => { applied = true; }), null);
assert.equal(reads, 0);
assert.equal(await readCurrentSessionStatus(tracker, () => context("custom"), async () => ({ targetProvider: "openai", needsSync: false }), () => { applied = true; }), null);
assert.equal(applied, false);
});
test("context keys distinguish target, directory and activation generation", () => {
const variants = [context(), context("openai"), context("custom", 1), context("custom", 0, "/fixture/other")];
assert.equal(new Set(variants.map(sessionStatusContextKey)).size, variants.length);
});
@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import vm from "node:vm";
const source = readFileSync(new URL("./fixtures/ui-runtime.js", import.meta.url), "utf8");
const plain = (value) => JSON.parse(JSON.stringify(value));
function memoryStorage() {
const values = new Map();
return { getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, String(value)), removeItem: (key) => values.delete(key) };
}
function loadFixture() {
class Element {
constructor(tag) { this.tagName = tag; this.style = {}; this.children = []; this.listeners = new Map(); this.attributes = new Map(); }
append(...children) { this.children.push(...children); }
setAttribute(name, value) { this.attributes.set(name, value); }
addEventListener(name, callback) { this.listeners.set(name, callback); }
click() { this.listeners.get("click")?.({ target: this }); }
}
const ready = [];
const body = new Element("body");
const sandbox = {
structuredClone, URL, URLSearchParams,
location: { search: "", reload() { assert.fail("fixture language commands must not reload the browser"); } },
localStorage: memoryStorage(), sessionStorage: memoryStorage(),
document: { body, createElement: (tag) => new Element(tag), addEventListener(name, callback) { assert.equal(name, "DOMContentLoaded"); ready.push(callback); } },
};
sandbox.window = sandbox;
vm.runInNewContext(source, sandbox, { timeout: 1000 });
const invoke = async (command, args) => plain(await sandbox.__TAURI_INTERNALS__.invoke(command, args));
const get = () => invoke("get_codex_language_status", { configDir: "/fixture/codex" });
const save = (enabled) => invoke("save_codex_language_preferences", { configDir: "/fixture/codex", locale: enabled ? "zh-CN" : "auto", enabled });
const apply = () => invoke("apply_codex_language_preferences", { configDir: "/fixture/codex", restore: false });
return { sandbox, body, get, save, apply, invoke, controls: sandbox.__CODEX_X_FIXTURE__, mount() { for (const callback of ready) callback(); } };
}
test("language fixture starts in Chinese and save stages or cancels without changing actual state", async () => {
const f = loadFixture();
assert.deepEqual(await f.get(), { locale: "zh-CN", enabled: true, pendingEnabled: null, supported: true, appName: "ChatGPT", appVersion: "fixture", phase: "active", message: "", readyPages: 0, restoreAvailable: false });
const staged = await f.save(false);
assert.equal(staged.enabled, true);
assert.equal(staged.locale, "zh-CN");
assert.equal(staged.pendingEnabled, false);
assert.equal((await f.get()).pendingEnabled, false);
assert.equal((await f.save(true)).pendingEnabled, null, "choosing the actual state cancels the pending change");
});
test("language apply changes only the memory state and clears pending intent", async () => {
const f = loadFixture();
await f.save(false);
assert.deepEqual(await f.apply(), { locale: "auto", enabled: false, pendingEnabled: null, supported: true, appName: "ChatGPT", appVersion: "fixture", phase: "inactive", message: "", readyPages: 0, restoreAvailable: false });
assert.equal((await f.apply()).enabled, false, "applying without a new choice keeps the actual state");
await f.save(true);
assert.equal((await f.apply()).enabled, true);
await f.save(false); await f.apply();
assert.equal((await loadFixture().get()).enabled, true, "language state is not persisted outside this fixture instance");
});
test("save and apply failures preserve state and normal mode permits retry", async () => {
const f = loadFixture();
await f.save(false);
const original = await f.get();
f.controls.setCodexLanguageMode("save-error");
await assert.rejects(f.save(true), /保存失败/);
assert.deepEqual(await f.get(), original);
f.controls.setCodexLanguageMode("apply-error");
await assert.rejects(f.apply(), /应用失败/);
assert.deepEqual(await f.get(), original);
f.controls.setCodexLanguageMode("normal");
const result = await f.apply();
assert.equal(result.enabled, false);
assert.equal(result.pendingEnabled, null);
assert.equal(f.controls.snapshot().codexLanguageMode, "normal");
});
test("external state controls are visible and change the next status response", async () => {
const f = loadFixture();
f.mount();
const elements = [];
const visit = (node) => { if (!node || typeof node !== "object") return; elements.push(node); for (const child of node.children ?? []) visit(child); };
visit(f.body);
const button = (label) => {
const found = elements.find((node) => node.tagName === "button" && node.textContent === label);
assert.ok(found, `missing visible control: ${label}`);
return found;
};
button("Fixture:语言外部改为自动").click();
assert.equal((await f.get()).locale, "auto");
await f.save(true);
button("Fixture:语言外部改为中文").click();
assert.equal((await f.get()).enabled, true);
assert.equal((await f.get()).pendingEnabled, null, "external replacement invalidates an old pending choice");
button("Fixture:语言保存失败").click();
await assert.rejects(f.save(false), /保存失败/);
button("Fixture:语言应用失败").click();
await assert.rejects(f.apply(), /应用失败/);
button("Fixture:语言恢复正常").click();
assert.equal((await f.save(false)).pendingEnabled, false);
assert.equal((await f.apply()).locale, "auto");
});
test("invalid language arguments reject without mutating actual or pending state", async () => {
const f = loadFixture();
const original = await f.get();
for (const args of [{ locale: "en-US", enabled: true }, { locale: "auto", enabled: true }, { locale: "zh-CN", enabled: "true" }]) {
await assert.rejects(f.invoke("save_codex_language_preferences", args), /参数无效/);
assert.deepEqual(await f.get(), original);
}
await assert.rejects(f.invoke("apply_codex_language_preferences", { restore: true }), /没有可恢复/);
assert.deepEqual(await f.get(), original);
});
+12
View File
@@ -690,3 +690,15 @@ Tauri 事件循环随最后一个窗口关闭而结束,后台管理能力也
当前 `b-nnett/codex-plusplus` 源码没有 rollout JSONL/SQLite 分桶同步实现,不能把这部分
描述为对其逐行复刻;`custom` 共享分桶语义来自对 CC Switch 可验证实现和本地 Codex
存储行为的交叉检查。
## 2026-10-16:压缩会话、并发切换与客户端中文兼容(本地测试)
- 本机只读核实:报告的 7 个缺失 `.jsonl` 均有同名 `.jsonl.zst`,活动 SQLite 保留逻辑 `.jsonl` 引用;改标题后重新检查暴露了此兼容缺口。未执行真实会话同步或修改真实聊天日志。
- 逻辑路径优先读取普通文件,不存在时定位同名压缩文件;身份仍以权威引用和 `session_meta.id` 校验,不能按相同 UUID 猜测旧副本。保留数据库逻辑路径。同步以压缩原字节做快照/哈希,流式解压、改写、重压;并发展开或换代必须中止并按所有权回滚。
- 前端仅允许供应商启用穿过 sessionSync 忙碌状态;维护、删除、其它配置操作仍互斥。同步入口固定目录与目标,状态请求按目录、目标及供应商变更代次丢弃旧响应。
- 完整日志解析规划由常见的 8 遍减少为 3 遍;备份、提交前哈希复核和逐记录磁盘暂存仍保留。无跨请求状态缓存。24 文件(12 plain/12 zst)、84,088,210 解压字节、22,026 记录、最大单条 34 MiB 的临时数据,release/arm64 温缓存三次中位数:检查 453 ms,实际同步 2.698 s,同步后检查 451 ms,幂等同步 460 ms。旧版不支持该压缩数据集,不据此宣称对比倍率。
- 中文机制参考 BigPizzaV3/CodexPlusPlus 的行为与本机官方 26.1007.71326 资源,独立实现;没有复制 AGPL 代码。官方客户端使用 `[desktop] localeOverride`,API 模式下翻译还受 i18n Layer 控制。兼容模式仅针对界面翻译,保持官方安装文件与签名;经用户确认后以随机本机 CDP 连接受验证的官方进程。当前实现限 macOS,首次实际应用与中文效果由维护者手动验证;mock/静态检查不等于真实客户端验证。
- 语言保存仅存 Codex-X 偏好。应用/恢复需显式确认重启;保持 Codex-X 运行以支持新窗口。状态读取不会启动、连接或修改官方客户端。会话维护锁阻止同步期间由语言操作重启客户端。
- Issue 审查:无开放 PR;#53/#68 按已发布 v0.3.26 完成功能回复并关闭;#67/#56/#49 保留复测;#70 组合提示词与 #48 统一出口代理保留建议;#26 本地新增标准单文件认证导入,批量及其它封装待样本;#66 因没有可复现的加载故障、无法按描述保证模型行为,按未计划处理回复关闭。本轮应用代码未推送或发布。
- 本地最终检查:Rust release 全套 796 通过;唯一默认忽略项为手动性能基准,已单独执行三次并通过。Node 190 通过(含两个本机 SDK 静态片段测试),TypeScript 类型检查与 renderer 生产构建通过。真实官方客户端的 CDP 连接和视觉中文效果未在本任务中运行。
- 本机交付:已构建并用本地 ad-hoc 签名校验 `0.3.27-local.1`,替换 `/Applications/Codex-X.app` 并启动。原 v0.3.26 备份于 `/Users/sky/.codex/app-backups/Codex-X/20261016-011622/Codex-X-before-sync-locale.zip`。未点击实际语言应用或对真实会话执行同步;交维护者手动验证。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.26",
"version": "0.3.27",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",