diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c1d5658..8d78e26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/package.json b/package.json index 2b4dbba..c08516a 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/prepare-portable.mjs b/scripts/prepare-portable.mjs index a71876b..661b66f 100644 --- a/scripts/prepare-portable.mjs +++ b/scripts/prepare-portable.mjs @@ -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({ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 09cd986..3647958 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -429,7 +429,7 @@ checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" [[package]] name = "cpa-gui" -version = "0.1.0" +version = "0.1.0-pre.4" dependencies = [ "chrono", "flate2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a05c182..4ecd20d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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", ] } diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..746f3ee Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..bcf1381 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..9904a8b Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..0743ce6 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..375a622 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c249b9c..8b1d894 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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>); +type AgentRecordExtension = (AgentModificationRecord, Vec); + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct GuiNetworkSettings { @@ -1413,17 +1421,15 @@ fn agent_config_paths(client: AgentClient, home: &Path) -> Vec { 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 String { match target { CodexAppTarget::Application(path) => path_to_string(path), @@ -1971,7 +1978,7 @@ fn find_claude_desktop_executable(home: &Path) -> Option { 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>)>), String> { +) -> Result { 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>)]) -> 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, state: Option, - #[allow(dead_code)] - status: Option, error: Option, #[serde(rename = "error_message")] error_message: Option, @@ -5291,17 +5296,248 @@ fn patch_core_yaml_document(content: &str, update: F) -> Result Result, { - let mut document = yaml_serde_edit::YamlValue::parse(content) + let original = serde_norway::from_str::(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::(&updated) + render_yaml_value_changes(content, &original, &updated).map(Some) +} + +struct YamlValueChange { + path: Vec, + value: serde_norway::Value, +} + +fn render_yaml_value_changes( + content: &str, + original: &serde_norway::Value, + updated: &serde_norway::Value, +) -> Result { + if original == updated { + return Ok(content.to_string()); + } + let editable_content = normalize_nested_yaml_comment_indentation(content); + let file = editable_content + .parse::() + .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::(&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::>(); + 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, + changes: &mut Vec, +) -> 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 { + 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::() + .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 { - let mut document = yaml_serde_edit::YamlValue::parse(template) + let template_value = serde_norway::from_str::(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::(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, 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 { - 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::(&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] diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index a51f72b..e991b58 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -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); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e8fd2a7..059bcb3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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",