mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8661ce85a1 | ||
|
|
0381c5351d | ||
|
|
f7aa94cb1b | ||
|
|
d1f0df031d | ||
|
|
ecde8c1344 | ||
|
|
f6feba9a8d |
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.26",
|
||||
"version": "0.3.27",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+74
-1
@@ -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"
|
||||
|
||||
@@ -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__)
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¤t, thread, "openai");
|
||||
let compressed = compress_rollout(¤t);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, thread, "openai", ¤t);
|
||||
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();
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user