ci(release): add portable prerelease builds

This commit is contained in:
lzt404 committed 2026-07-18 17:29:38 +08:00
1 parent c6ea0fecd9
commit a21d38c87b
13 files changed
+447 -109

No files matched your search

+93 -6
View File
@@ -1,7 +1,20 @@
name: Build portable releases
run-name: >-
Portable release ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag || github.ref_name }}
on:
workflow_dispatch:
inputs:
release_tag:
description: Optional tag to create after all builds pass (leave empty to build artifacts only)
required: false
type: string
prerelease:
description: Mark a manually published release as a pre-release
required: true
default: true
type: boolean
push:
tags:
- 'v*'
@@ -10,7 +23,43 @@ permissions:
contents: write
jobs:
validate:
name: Validate source
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install frontend dependencies
run: bun install --frozen-lockfile
- name: Validate desktop icons
shell: bash
run: |
test -s src-tauri/icons/icon.png
test -s src-tauri/icons/icon.ico
test -s src-tauri/icons/icon.icns
- name: Validate frontend
run: |
bun run check
bun test
- name: Validate Rust
run: |
cargo fmt --manifest-path src-tauri/Cargo.toml -- --check
cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings
cargo test --manifest-path src-tauri/Cargo.toml
build:
name: Build ${{ matrix.os }}-${{ matrix.arch }}
needs: validate
strategy:
fail-fast: false
matrix:
@@ -40,8 +89,9 @@ jobs:
arch: aarch64
suffix: zip
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.14
@@ -52,6 +102,8 @@ jobs:
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Build GUI
env:
RUSTFLAGS: -D warnings
run: |
bun install --frozen-lockfile
bun tauri build --no-bundle
@@ -73,22 +125,57 @@ jobs:
if: matrix.os == 'windows'
shell: pwsh
run: Compress-Archive -Path "release/cpa-gui-${{ matrix.os }}-${{ matrix.arch }}" -DestinationPath "cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.zip"
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: cpa-gui-${{ matrix.os }}-${{ matrix.arch }}
path: cpa-gui-${{ matrix.os }}-${{ matrix.arch }}.${{ matrix.suffix }}
if-no-files-found: error
retention-days: 14
publish:
if: startsWith(github.ref, 'refs/tags/')
name: Publish GitHub release
if: startsWith(github.ref, 'refs/tags/') || inputs.release_tag != ''
needs: build
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
path: artifacts
merge-multiple: true
- name: Publish GitHub release
- name: Publish release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "${GITHUB_REF_NAME}" artifacts/* --generate-notes --verify-tag
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }}
MANUAL_PRERELEASE: ${{ inputs.prerelease }}
run: |
set -euo pipefail
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "Invalid release tag: $RELEASE_TAG" >&2
exit 1
fi
prerelease="$MANUAL_PRERELEASE"
release_args=(--title "CPA GUI $RELEASE_TAG" --generate-notes)
if [[ "$GITHUB_REF_TYPE" == "tag" ]]; then
release_args+=(--verify-tag)
if [[ "$RELEASE_TAG" == *-* ]]; then
prerelease=true
else
prerelease=false
fi
else
release_args+=(--target "$GITHUB_SHA")
fi
if [[ "$prerelease" == "true" ]]; then
release_args+=(--prerelease)
fi
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
gh release upload "$RELEASE_TAG" artifacts/* --clobber
else
gh release create "$RELEASE_TAG" "${release_args[@]}" artifacts/*
fi
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "cpa-gui",
"private": true,
"version": "0.1.0",
"version": "0.1.0-pre.4",
"type": "module",
"packageManager": "bun@1.3.14",
"scripts": {
+14 -2
View File
@@ -69,8 +69,20 @@ await copyFile(join(root, 'core-version.txt'), join(output, 'core-version.txt'))
const coreOutput = join(output, 'cpa-core');
if (cleanCore) await rm(coreOutput, { recursive: true, force: true });
await mkdir(coreOutput, { recursive: true });
const extraction = spawnSync('tar', ['-xf', sourceArchive, '-C', coreOutput], { stdio: 'inherit' });
if (extraction.status !== 0) throw new Error(`Failed to extract ${assetName}`);
let extraction;
if (extension === 'zip') {
if (process.platform === 'win32') {
const quotePowerShell = (value) => value.replaceAll("'", "''");
const command = `Expand-Archive -LiteralPath '${quotePowerShell(sourceArchive)}' -DestinationPath '${quotePowerShell(coreOutput)}' -Force`;
extraction = spawnSync('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', command], { stdio: 'inherit' });
} else {
extraction = spawnSync('unzip', ['-q', sourceArchive, '-d', coreOutput], { stdio: 'inherit' });
}
} else {
extraction = spawnSync('tar', ['-xf', sourceArchive, '-C', coreOutput], { stdio: 'inherit' });
}
if (extraction.error) throw new Error(`Failed to start archive extractor for ${assetName}: ${extraction.error.message}`);
if (extraction.status !== 0) throw new Error(`Failed to extract ${assetName} (exit code ${extraction.status})`);
await copyFile(sourceArchive, join(coreOutput, assetName));
if (existsSync(checksumsPath)) await copyFile(checksumsPath, join(coreOutput, 'checksums.txt'));
await writeFile(join(coreOutput, 'cpa-gui-meta.json'), `${JSON.stringify({
+1 -1
View File
@@ -429,7 +429,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636"
[[package]]
name = "cpa-gui"
version = "0.1.0"
version = "0.1.0-pre.4"
dependencies = [
"chrono",
"flate2",
+2 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "cpa-gui"
version = "0.1.0"
version = "0.1.0-pre.4"
description = "CPA desktop management shell"
authors = ["lzt404"]
edition = "2021"
@@ -39,4 +39,5 @@ windows-sys = { version = "0.59", features = [
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_JobObjects",
"Win32_System_Threading",
] }
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

+334 -96
View File
@@ -47,7 +47,12 @@ const AGENT_PHASE_APPLYING: &str = "applying";
const AGENT_PHASE_ACTIVE: &str = "active";
const AGENT_PHASE_RESTORING: &str = "restoring";
const AGENT_PHASE_RECOVERY: &str = "recovery";
const USER_AGENT: &str = "CPA-GUI/0.1.0 (+https://github.com/router-for-me/CLIProxyAPI)";
const AGENT_MODIFICATION_STATE_CONFLICT: &str = "conflict";
const USER_AGENT: &str = concat!(
"CPA-GUI/",
env!("CARGO_PKG_VERSION"),
" (+https://github.com/router-for-me/CLIProxyAPI)"
);
static CORE_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(());
static AGENT_CONFIG_FILE_LOCK: Mutex<()> = Mutex::new(());
@@ -362,7 +367,7 @@ enum AgentClient {
}
#[derive(Clone, Debug)]
#[allow(dead_code)] // The desktop-app variants are constructed only on macOS/Windows builds.
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] // The desktop-app variants are constructed only on macOS/Windows builds.
enum CodexAppTarget {
Application(PathBuf),
#[cfg(target_os = "windows")]
@@ -430,6 +435,9 @@ struct AgentFileUpdate {
after: String,
}
type FileSnapshot = (PathBuf, Option<Vec<u8>>);
type AgentRecordExtension = (AgentModificationRecord, Vec<FileSnapshot>);
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GuiNetworkSettings {
@@ -1413,17 +1421,15 @@ fn agent_config_paths(client: AgentClient, home: &Path) -> Vec<PathBuf> {
let directory = home.join(".claude");
let settings = directory.join("settings.json");
let legacy = directory.join("claude.json");
let settings_state_exists = agent_state_path(&[settings.clone()])
let settings_state_exists = agent_state_path(std::slice::from_ref(&settings))
.map(|path| path.exists())
.unwrap_or(false);
let legacy_state_exists = agent_state_path(&[legacy.clone()])
let legacy_state_exists = agent_state_path(std::slice::from_ref(&legacy))
.map(|path| path.exists())
.unwrap_or(false);
vec![if settings_state_exists {
settings
} else if legacy_state_exists {
legacy
} else if !settings.exists() && legacy.exists() {
} else if legacy_state_exists || (!settings.exists() && legacy.exists()) {
legacy
} else {
settings
@@ -1701,6 +1707,7 @@ fn agent_launch_targets(client: AgentClient, home: &Path) -> Vec<AgentLaunchTarg
targets
}
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn codex_app_target_detail(target: &CodexAppTarget) -> String {
match target {
CodexAppTarget::Application(path) => path_to_string(path),
@@ -1971,7 +1978,7 @@ fn find_claude_desktop_executable(home: &Path) -> Option<PathBuf> {
PathBuf::from("/opt/Claude/claude"),
PathBuf::from("/opt/claude-desktop/claude-desktop"),
]);
return candidates.into_iter().find(|path| path.is_file());
candidates.into_iter().find(|path| path.is_file())
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
@@ -3155,7 +3162,7 @@ fn inspect_agent_modification(
} else {
match conflicts {
Ok(conflicts) if conflicts.is_empty() => "active",
Ok(_) => "conflict",
Ok(_) => AGENT_MODIFICATION_STATE_CONFLICT,
Err(_) => "recovery",
}
};
@@ -3163,7 +3170,7 @@ fn inspect_agent_modification(
if !backup_available {
warnings.push("原配置备份不完整,恢复前请勿删除剩余备份文件".to_string());
}
if state == "conflict" {
if state == AGENT_MODIFICATION_STATE_CONFLICT {
warnings.push("配置已被其他程序修改,关闭接管时需要确认强制恢复".to_string());
} else if state == "recovery" {
warnings.push("上次配置操作未完整结束,可关闭开关恢复原配置".to_string());
@@ -3487,7 +3494,7 @@ fn prepare_agent_record(
fn extend_agent_record_for_updates(
record: &AgentModificationRecord,
updates: &[AgentFileUpdate],
) -> Result<(AgentModificationRecord, Vec<(PathBuf, Option<Vec<u8>>)>), String> {
) -> Result<AgentRecordExtension, String> {
if record.files.len() > updates.len() {
return Err("智能体配置更新文件数量不匹配".to_string());
}
@@ -3578,7 +3585,7 @@ fn extend_agent_record_for_updates(
Ok((next, backup_snapshots))
}
fn restore_snapshots(snapshots: &[(PathBuf, Option<Vec<u8>>)]) -> Result<(), String> {
fn restore_snapshots(snapshots: &[FileSnapshot]) -> Result<(), String> {
let mut errors = Vec::new();
for (path, bytes) in snapshots.iter().rev() {
let result = match bytes {
@@ -4141,8 +4148,6 @@ struct OAuthStatusResult {
struct OAuthStartApiResponse {
url: Option<String>,
state: Option<String>,
#[allow(dead_code)]
status: Option<String>,
error: Option<String>,
#[serde(rename = "error_message")]
error_message: Option<String>,
@@ -5291,17 +5296,248 @@ fn patch_core_yaml_document<F>(content: &str, update: F) -> Result<Option<String
where
F: FnOnce(&mut serde_norway::Value) -> Result<bool, String>,
{
let mut document = yaml_serde_edit::YamlValue::parse(content)
let original = serde_norway::from_str::<serde_norway::Value>(content)
.map_err(|err| format!("解析内核配置失败: {err}"))?;
let mut updated_value = document.get().clone();
if !update(&mut updated_value)? {
let mut updated = original.clone();
if !update(&mut updated)? {
return Ok(None);
}
document.set(updated_value);
let updated = document.get_string();
serde_norway::from_str::<serde_norway::Value>(&updated)
render_yaml_value_changes(content, &original, &updated).map(Some)
}
struct YamlValueChange {
path: Vec<String>,
value: serde_norway::Value,
}
fn render_yaml_value_changes(
content: &str,
original: &serde_norway::Value,
updated: &serde_norway::Value,
) -> Result<String, String> {
if original == updated {
return Ok(content.to_string());
}
let editable_content = normalize_nested_yaml_comment_indentation(content);
let file = editable_content
.parse::<yaml_edit::YamlFile>()
.map_err(|err| format!("解析可编辑内核配置失败: {err}"))?;
let document = file
.document()
.ok_or_else(|| "内核配置没有 YAML 文档".to_string())?;
let root = document
.as_mapping()
.ok_or_else(|| "内核配置顶层必须是 YAML 映射".to_string())?;
let mut changes = Vec::new();
collect_yaml_value_changes(original, updated, &mut Vec::new(), &mut changes)?;
for change in changes {
set_yaml_edit_mapping_path(&root, &change.path, &change.value)?;
}
let rendered = file.to_string();
let validated = serde_norway::from_str::<serde_norway::Value>(&rendered)
.map_err(|err| format!("验证更新后的内核配置失败: {err}"))?;
Ok(Some(updated))
if validated != *updated {
return Err("更新后的内核配置与预期值不一致,已拒绝写入".to_string());
}
Ok(rendered)
}
fn normalize_nested_yaml_comment_indentation(content: &str) -> String {
let lines = content.split_inclusive('\n').collect::<Vec<_>>();
let mut normalized = String::with_capacity(content.len());
for (index, line) in lines.iter().enumerate() {
let body = line.trim_end_matches(['\r', '\n']);
let ending = &line[body.len()..];
let trimmed = body.trim_start_matches([' ', '\t']);
let current_indent = body.len().saturating_sub(trimmed.len());
if !trimmed.starts_with('#') {
normalized.push_str(line);
continue;
}
let next = lines.iter().skip(index + 1).find_map(|candidate| {
let candidate = candidate.trim_end_matches(['\r', '\n']);
let trimmed = candidate.trim_start_matches([' ', '\t']);
if trimmed.is_empty() || trimmed.starts_with('#') {
None
} else {
Some((candidate.len().saturating_sub(trimmed.len()), trimmed))
}
});
let Some((next_indent, _)) = next else {
normalized.push_str(line);
continue;
};
let belongs_to_nested_mapping = current_indent < next_indent
&& lines[..index].iter().rev().any(|candidate| {
let candidate = candidate.trim_end_matches(['\r', '\n']);
let trimmed = candidate.trim_start_matches([' ', '\t']);
if trimmed.is_empty() || trimmed.starts_with('#') {
return false;
}
let indent = candidate.len().saturating_sub(trimmed.len());
indent < next_indent && trimmed.ends_with(':')
});
if belongs_to_nested_mapping {
normalized.push_str(&" ".repeat(next_indent));
normalized.push_str(trimmed);
normalized.push_str(ending);
} else {
normalized.push_str(line);
}
}
normalized
}
fn collect_yaml_value_changes(
original: &serde_norway::Value,
updated: &serde_norway::Value,
path: &mut Vec<String>,
changes: &mut Vec<YamlValueChange>,
) -> Result<(), String> {
if original == updated {
return Ok(());
}
match (original, updated) {
(
serde_norway::Value::Mapping(original_mapping),
serde_norway::Value::Mapping(updated_mapping),
) => {
for key in original_mapping.keys() {
if !updated_mapping.contains_key(key) {
return Err("当前内核配置更新不支持删除任意 YAML 字段".to_string());
}
}
for (key, updated_value) in updated_mapping {
let key = key
.as_str()
.ok_or_else(|| "内核配置映射键必须是字符串".to_string())?;
path.push(key.to_string());
if let Some(original_value) = original_mapping.get(yaml_key(key)) {
collect_yaml_value_changes(original_value, updated_value, path, changes)?;
} else {
changes.push(YamlValueChange {
path: path.clone(),
value: updated_value.clone(),
});
}
path.pop();
}
Ok(())
}
_ if path.is_empty() => Err("内核配置顶层必须保持为 YAML 映射".to_string()),
_ => {
changes.push(YamlValueChange {
path: path.clone(),
value: updated.clone(),
});
Ok(())
}
}
}
fn set_yaml_edit_mapping_path(
mapping: &yaml_edit::Mapping,
path: &[String],
value: &serde_norway::Value,
) -> Result<(), String> {
let Some((key, remaining)) = path.split_first() else {
return Err("内核配置更新路径不能为空".to_string());
};
if remaining.is_empty() {
return set_yaml_edit_mapping_value(mapping, key, value);
}
if let Some(child) = mapping.get(key.as_str()) {
let child = child
.as_mapping()
.ok_or_else(|| format!("内核配置区段 {key} 必须是 YAML 映射"))?;
return set_yaml_edit_mapping_path(child, remaining, value);
}
let nested_value = nested_yaml_value_for_path(remaining, value.clone());
set_yaml_edit_mapping_value(mapping, key, &nested_value)
}
fn set_yaml_edit_mapping_value(
mapping: &yaml_edit::Mapping,
key: &str,
value: &serde_norway::Value,
) -> Result<(), String> {
match value {
serde_norway::Value::String(value) => {
mapping.set(key, value.as_str());
return Ok(());
}
serde_norway::Value::Bool(value) => {
mapping.set(key, *value);
return Ok(());
}
serde_norway::Value::Number(value) => {
if let Some(value) = value.as_i64() {
mapping.set(key, value);
return Ok(());
}
if let Some(value) = value.as_u64() {
mapping.set(key, value);
return Ok(());
}
if let Some(value) = value.as_f64() {
mapping.set(key, value);
return Ok(());
}
}
_ => {}
}
if let serde_norway::Value::Sequence(values) = value {
if let Some(node) = mapping.get(key) {
if let Some(sequence) = node.as_sequence() {
for (index, value) in values.iter().enumerate() {
let value = yaml_edit_node_from_value(value)?;
if index < sequence.len() {
if !sequence.set(index, value) {
return Err(format!("更新内核配置序列 {key}[{index}] 失败"));
}
} else {
sequence.push(value);
}
}
while sequence.len() > values.len() {
let last = sequence.len() - 1;
if sequence.remove(last).is_none() {
return Err(format!("删除内核配置序列 {key}[{last}] 失败"));
}
}
return Ok(());
}
}
}
match yaml_edit_node_from_value(value)? {
yaml_edit::YamlNode::Scalar(node) => mapping.set(key, node),
yaml_edit::YamlNode::Sequence(node) => mapping.set(key, node),
yaml_edit::YamlNode::Mapping(node) => mapping.set(key, node),
yaml_edit::YamlNode::Alias(node) => mapping.set(key, node),
yaml_edit::YamlNode::TaggedNode(node) => mapping.set(key, node),
}
Ok(())
}
fn nested_yaml_value_for_path(path: &[String], value: serde_norway::Value) -> serde_norway::Value {
path.iter().rev().fold(value, |nested, key| {
let mut mapping = serde_norway::Mapping::new();
mapping.insert(yaml_key(key), nested);
serde_norway::Value::Mapping(mapping)
})
}
fn yaml_edit_node_from_value(value: &serde_norway::Value) -> Result<yaml_edit::YamlNode, String> {
const WRAPPER_KEY: &str = "__cpa_gui_value__";
let value =
serde_json::to_string(value).map_err(|err| format!("序列化内核配置值失败: {err}"))?;
let serialized = format!("{WRAPPER_KEY}: {value}\n");
let file = serialized
.parse::<yaml_edit::YamlFile>()
.map_err(|err| format!("解析内核配置值失败: {err}"))?;
file.document()
.and_then(|document| document.get(WRAPPER_KEY))
.ok_or_else(|| "无法构造内核配置值".to_string())
}
fn set_core_yaml_top_level_value(
@@ -6751,9 +6987,9 @@ fn merge_core_config_yaml(
current: Option<&str>,
config: &GuiConfigFile,
) -> Result<String, String> {
let mut document = yaml_serde_edit::YamlValue::parse(template)
let template_value = serde_norway::from_str::<serde_norway::Value>(template)
.map_err(|err| format!("解析内核配置模板失败: {err}"))?;
let mut merged = document.get().clone();
let mut merged = template_value.clone();
if let Some(current) = current {
let current = serde_norway::from_str::<serde_norway::Value>(current)
@@ -6761,25 +6997,19 @@ fn merge_core_config_yaml(
merge_yaml_values(&mut merged, current);
}
document.set(merged);
apply_gui_managed_settings(&document.get_string(), config)
let merged = render_yaml_value_changes(template, &template_value, &merged)?;
apply_gui_managed_settings(&merged, config)
}
fn patch_core_network_yaml(
content: &str,
config: &GuiConfigFile,
) -> Result<Option<String>, String> {
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|err| format!("解析内核配置失败: {err}"))?;
let mut updated = document.get().clone();
apply_network_settings(&mut updated, config)?;
if updated == *document.get() {
return Ok(None);
}
document.set(updated);
Ok(Some(document.get_string()))
patch_core_yaml_document(content, |document| {
let original = document.clone();
apply_network_settings(document, config)?;
Ok(*document != original)
})
}
fn merge_yaml_values(base: &mut serde_norway::Value, current: serde_norway::Value) {
@@ -6825,58 +7055,57 @@ fn apply_network_settings(
}
fn apply_gui_managed_settings(content: &str, config: &GuiConfigFile) -> Result<String, String> {
let mut document = yaml_serde_edit::YamlValue::parse(content)
.map_err(|err| format!("解析合并后的内核配置失败: {err}"))?;
let host = if config.allow_lan {
"0.0.0.0"
} else {
"127.0.0.1"
};
let mut updated = document.get().clone();
set_core_yaml_top_level_value(
&mut updated,
"host",
serde_norway::Value::String(host.to_string()),
)?;
set_core_yaml_top_level_value(
&mut updated,
"port",
serde_norway::to_value(config.port).map_err(|err| format!("序列化内核端口失败: {err}"))?,
)?;
set_core_yaml_top_level_value(
&mut updated,
"auth-dir",
serde_norway::Value::String(config.auth_dir.clone()),
)?;
set_core_yaml_top_level_value(
&mut updated,
"usage-statistics-enabled",
serde_norway::Value::Bool(true),
)?;
set_core_yaml_nested_value(
&mut updated,
"remote-management",
"secret-key",
serde_norway::Value::String(DEFAULT_MANAGEMENT_SECRET_KEY.to_string()),
)?;
set_core_yaml_nested_value(
&mut updated,
"plugins",
"enabled",
serde_norway::Value::Bool(config.plugins_enabled),
)?;
set_core_yaml_nested_value(
&mut updated,
"routing",
"strategy",
serde_norway::Value::String(config.routing_strategy.clone()),
)?;
document.set(updated);
let updated = patch_core_yaml_document(content, |document| {
let mut changed = false;
changed |= set_core_yaml_top_level_value(
document,
"host",
serde_norway::Value::String(host.to_string()),
)?;
changed |= set_core_yaml_top_level_value(
document,
"port",
serde_norway::to_value(config.port)
.map_err(|err| format!("序列化内核端口失败: {err}"))?,
)?;
changed |= set_core_yaml_top_level_value(
document,
"auth-dir",
serde_norway::Value::String(config.auth_dir.clone()),
)?;
changed |= set_core_yaml_top_level_value(
document,
"usage-statistics-enabled",
serde_norway::Value::Bool(true),
)?;
changed |= set_core_yaml_nested_value(
document,
"remote-management",
"secret-key",
serde_norway::Value::String(DEFAULT_MANAGEMENT_SECRET_KEY.to_string()),
)?;
changed |= set_core_yaml_nested_value(
document,
"plugins",
"enabled",
serde_norway::Value::Bool(config.plugins_enabled),
)?;
changed |= set_core_yaml_nested_value(
document,
"routing",
"strategy",
serde_norway::Value::String(config.routing_strategy.clone()),
)?;
Ok(changed)
})?
.unwrap_or_else(|| content.to_string());
let updated = patch_core_api_keys_yaml(
&document.get_string(),
&gui_api_key_values(&config.api_keys),
)?;
let updated = patch_core_api_keys_yaml(&updated, &gui_api_key_values(&config.api_keys))?;
serde_norway::from_str::<serde_norway::Value>(&updated)
.map_err(|err| format!("验证启动内核配置失败: {err}"))?;
Ok(updated)
@@ -7154,7 +7383,7 @@ fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> {
return Err("GUI 配置路由策略不能为空".to_string());
}
let expected_auth_dir = fixed_oauth_dir()?;
if PathBuf::from(&config.auth_dir) != expected_auth_dir {
if Path::new(&config.auth_dir) != expected_auth_dir.as_path() {
return Err(format!(
"凭证目录固定为 {},不允许自定义",
path_to_string(&expected_auth_dir)
@@ -7657,8 +7886,6 @@ fn close_windows_handle(handle: isize) {
}
fn terminate_child(child: &mut Child) -> Result<(), String> {
let process_id = child.id();
#[cfg(windows)]
{
child
@@ -7672,6 +7899,7 @@ fn terminate_child(child: &mut Child) -> Result<(), String> {
#[cfg(not(windows))]
{
let process_id = child.id();
send_process_signal(process_id, "TERM")?;
for _ in 0..20 {
@@ -8537,7 +8765,7 @@ mod tests {
.unwrap();
assert_eq!(enabled.outcome, "enabled");
let backup = agent_backup_path(&path).unwrap();
let state = agent_state_path(&[path.clone()]).unwrap();
let state = agent_state_path(std::slice::from_ref(&path)).unwrap();
let catalog_path = codex_model_catalog_path(&home);
assert_eq!(fs::read(&backup).unwrap(), original);
assert!(state.is_file());
@@ -8617,7 +8845,7 @@ mod tests {
let path = home.join(".codex/config.toml");
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, b"approval_policy = \"never\"\n").unwrap();
let state_path = agent_state_path(&[path.clone()]).unwrap();
let state_path = agent_state_path(std::slice::from_ref(&path)).unwrap();
fs::create_dir(&state_path).unwrap();
let available_models = test_agent_models(&["gpt-test"]);
@@ -8645,7 +8873,11 @@ mod tests {
fs::create_dir_all(&directory).unwrap();
fs::write(&settings, b"{}\n").unwrap();
fs::write(&legacy, b"{}\n").unwrap();
fs::write(agent_state_path(&[legacy.clone()]).unwrap(), b"{}\n").unwrap();
fs::write(
agent_state_path(std::slice::from_ref(&legacy)).unwrap(),
b"{}\n",
)
.unwrap();
assert_eq!(
agent_config_paths(AgentClient::ClaudeCode, &home),
@@ -8673,7 +8905,7 @@ mod tests {
)
.unwrap();
assert!(agent_has_managed_marker(AgentClient::Codex, &[path.clone()]).unwrap());
assert!(agent_has_managed_marker(AgentClient::Codex, std::slice::from_ref(&path)).unwrap());
let result = disable_agent_modification(AgentClient::Codex, &home, 8317, false).unwrap();
assert_eq!(result.outcome, "disabled");
assert_eq!(fs::read(&path).unwrap(), original);
@@ -8723,7 +8955,11 @@ mod tests {
.unwrap()
.unwrap();
assert_eq!(record.files.len(), 1);
write_agent_state(&agent_state_path(&[path.clone()]).unwrap(), &record).unwrap();
write_agent_state(
&agent_state_path(std::slice::from_ref(&path)).unwrap(),
&record,
)
.unwrap();
let available_models = test_agent_models(&["gpt-new"]);
let codex_models = test_codex_models(&["gpt-new"]);
@@ -8736,7 +8972,7 @@ mod tests {
Some(&codex_models),
)
.unwrap();
let upgraded = load_agent_record(AgentClient::Codex, &[path.clone()])
let upgraded = load_agent_record(AgentClient::Codex, std::slice::from_ref(&path))
.unwrap()
.unwrap();
assert_eq!(upgraded.files.len(), 2);
@@ -8773,7 +9009,7 @@ mod tests {
)
.unwrap();
let state_path = agent_state_path(&[path.clone()]).unwrap();
let state_path = agent_state_path(std::slice::from_ref(&path)).unwrap();
let mut record = load_agent_record(AgentClient::Codex, &[path])
.unwrap()
.unwrap();
@@ -9477,10 +9713,12 @@ mod tests {
validate_agent_launch_modification(AgentClient::Codex, false, AGENT_PHASE_ACTIVE)
.is_err()
);
assert!(
validate_agent_launch_modification(AgentClient::Codex, true, AGENT_PHASE_CONFLICT)
.is_err()
);
assert!(validate_agent_launch_modification(
AgentClient::Codex,
true,
AGENT_MODIFICATION_STATE_CONFLICT,
)
.is_err());
}
#[test]
+1 -1
View File
@@ -1401,7 +1401,7 @@ mod tests {
let record = sample_record("request-1", "2026-07-17T20:30:00+08:00", "gpt-test");
assert_eq!(
insert_usage_records(&mut connection, &[record.clone()]).unwrap(),
insert_usage_records(&mut connection, std::slice::from_ref(&record)).unwrap(),
1
);
assert_eq!(insert_usage_records(&mut connection, &[record]).unwrap(), 0);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "CPA GUI",
"version": "0.1.0",
"version": "0.1.0-pre.4",
"identifier": "com.cpa.gui",
"build": {
"beforeDevCommand": "bun run dev",