diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b97c82a..dcaf936 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,12 +84,12 @@ jobs: os: darwin display_os: Darwin arch: amd64 - suffix: tar.gz + suffix: dmg - runner: macos-15 os: darwin display_os: Darwin arch: aarch64 - suffix: tar.gz + suffix: dmg - runner: windows-2025 os: windows display_os: Windows @@ -164,59 +164,6 @@ jobs: bun install --frozen-lockfile bun tauri build --no-bundle shell: bash - - name: Sign macOS binary - if: matrix.os == 'darwin' - shell: bash - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - run: | - set -euo pipefail - - certificate_path="$RUNNER_TEMP/apple-signing-certificate.p12" - keychain_path="$RUNNER_TEMP/apple-signing.keychain-db" - keychain_password="$(uuidgen)" - binary="src-tauri/target/release/cpa-gui" - keychain_list="$(security list-keychains -d user)" - existing_keychains=() - while IFS= read -r existing_keychain; do - existing_keychain="${existing_keychain#*\"}" - existing_keychain="${existing_keychain%\"*}" - if [ -n "$existing_keychain" ]; then - existing_keychains+=("$existing_keychain") - fi - done <<< "$keychain_list" - - cleanup() { - rm -f "$certificate_path" - security delete-keychain "$keychain_path" || true - } - trap cleanup EXIT - - test -n "$APPLE_CERTIFICATE" - test -n "$APPLE_CERTIFICATE_PASSWORD" - test -n "$APPLE_SIGNING_IDENTITY" - test -f "$binary" - - printf '%s' "$APPLE_CERTIFICATE" | base64 -D > "$certificate_path" - security create-keychain -p "$keychain_password" "$keychain_path" - if [ "${#existing_keychains[@]}" -gt 0 ]; then - security list-keychains -d user -s "$keychain_path" "${existing_keychains[@]}" - else - security list-keychains -d user -s "$keychain_path" - fi - security set-keychain-settings -lut 21600 "$keychain_path" - security unlock-keychain -p "$keychain_password" "$keychain_path" - security import "$certificate_path" -k "$keychain_path" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path" - if ! security find-identity -v -p codesigning "$keychain_path" | grep -F -- "$APPLE_SIGNING_IDENTITY"; then - echo "Signing identity was not imported into the temporary keychain" >&2 - exit 1 - fi - - codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --keychain "$keychain_path" "$binary" - codesign --verify --strict --verbose=2 "$binary" - name: Prepare portable directory run: >- node scripts/prepare-portable.mjs @@ -241,8 +188,74 @@ jobs: throw new Error(`cpa-core must contain exactly one archive; found: ${entries.map((entry) => entry.name).join(', ')}`); } NODE - - name: Archive Unix release - if: matrix.os != 'windows' + - name: Build signed macOS DMG + if: matrix.os == 'darwin' + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + set -euo pipefail + + test -n "$APPLE_CERTIFICATE" + test -n "$APPLE_CERTIFICATE_PASSWORD" + test -n "$APPLE_ID" + test -n "$APPLE_PASSWORD" + test -n "$APPLE_SIGNING_IDENTITY" + test -n "$APPLE_TEAM_ID" + + bun tauri bundle --bundles dmg --ci --config src-tauri/tauri.dmg.conf.json + + bundle_dir="src-tauri/target/release/bundle" + dmg_path="$(find "$bundle_dir/dmg" -maxdepth 1 -type f -name '*.dmg' -print -quit)" + output_dmg="${{ steps.package.outputs.stem }}.dmg" + mount_dir="$RUNNER_TEMP/easycli-dmg" + mounted=false + + cleanup() { + if [ "$mounted" = true ]; then + hdiutil detach "$mount_dir" || true + fi + rm -rf "$mount_dir" + } + trap cleanup EXIT + + test -n "$dmg_path" + mv "$dmg_path" "$output_dmg" + test -s "$output_dmg" + codesign --verify --strict --verbose=2 "$output_dmg" + xcrun notarytool submit "$output_dmg" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + xcrun stapler staple "$output_dmg" + xcrun stapler validate "$output_dmg" + + mkdir -p "$mount_dir" + hdiutil attach "$output_dmg" -nobrowse -readonly -mountpoint "$mount_dir" + mounted=true + + app_path="$mount_dir/EasyCLIProxyAPI.app" + resources_dir="$app_path/Contents/Resources" + test -d "$app_path" + test -s "$resources_dir/core-version.txt" + core_count="$(find "$resources_dir/cpa-core" -maxdepth 1 -type f -name 'CLIProxyAPI_*.tar.gz' | wc -l | tr -d '[:space:]')" + if [ "$core_count" -ne 1 ]; then + echo "macOS app must contain exactly one bundled core archive" >&2 + exit 1 + fi + + codesign --verify --deep --strict --verbose=2 "$app_path" + xcrun stapler validate "$app_path" + hdiutil detach "$mount_dir" + mounted=false + - name: Archive Linux release + if: matrix.os == 'linux' run: tar -czf "${{ steps.package.outputs.stem }}.tar.gz" -C release "${{ steps.package.outputs.stem }}" - name: Archive Windows release if: matrix.os == 'windows' diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 0f6e8e7..c6cb279 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1862,7 +1862,7 @@ fn launch_codex_app_via_cli(executable: &Path, home: &Path) -> Result<(), String fn find_codex_app_target(home: &Path) -> Option { #[cfg(target_os = "macos")] { - return [PathBuf::from("/Applications"), home.join("Applications")] + [PathBuf::from("/Applications"), home.join("Applications")] .into_iter() .flat_map(|directory| { [ @@ -1875,7 +1875,7 @@ fn find_codex_app_target(home: &Path) -> Option { .map(move |name| directory.join(name)) }) .find(|path| path.is_dir()) - .map(CodexAppTarget::Application); + .map(CodexAppTarget::Application) } #[cfg(target_os = "windows")] @@ -2271,12 +2271,12 @@ fn agent_executable_directories(home: &Path) -> Vec { fn find_claude_desktop_executable(home: &Path) -> Option { #[cfg(target_os = "macos")] { - return [ + [ PathBuf::from("/Applications/Claude.app/Contents/MacOS/Claude"), home.join("Applications/Claude.app/Contents/MacOS/Claude"), ] .into_iter() - .find(|path| path.is_file()); + .find(|path| path.is_file()) } #[cfg(target_os = "windows")] { @@ -7978,7 +7978,7 @@ fn core_install_dir() -> Result { Ok(core_base_dir()?.join("cpa-core")) } -fn core_base_dir() -> Result { +fn executable_dir() -> Result { let exe_path = env::current_exe().map_err(|err| format!("读取当前程序路径失败: {err}"))?; exe_path .parent() @@ -7986,11 +7986,45 @@ fn core_base_dir() -> Result { .ok_or_else(|| format!("当前程序路径没有父目录: {}", path_to_string(&exe_path))) } -fn bundled_core_archive() -> Result, String> { - let platform = current_core_platform()?; - let base_dir = core_base_dir()?; +fn macos_app_resources_dir(executable_dir: &Path) -> Option { + if executable_dir.file_name().and_then(|name| name.to_str()) != Some("MacOS") { + return None; + } + let contents_dir = executable_dir.parent()?; + if contents_dir.file_name().and_then(|name| name.to_str()) != Some("Contents") { + return None; + } + let app_dir = contents_dir.parent()?; + if app_dir.extension().and_then(|extension| extension.to_str()) != Some("app") { + return None; + } + Some(contents_dir.join("Resources")) +} + +fn core_base_dir() -> Result { + let executable_dir = executable_dir()?; + #[cfg(target_os = "macos")] + if macos_app_resources_dir(&executable_dir).is_some() { + let home_dir = env::var_os("HOME") + .map(PathBuf::from) + .ok_or_else(|| "无法确定 macOS 用户目录".to_string())?; + return Ok(home_dir + .join("Library") + .join("Application Support") + .join("com.cpa.gui")); + } + Ok(executable_dir) +} + +fn bundled_core_locations(base_dir: &Path, executable_dir: &Path) -> Vec<(PathBuf, PathBuf)> { let mut locations = vec![(base_dir.join(CORE_VERSION_FILE), base_dir.join("cpa-core"))]; - if let Some(project_root) = source_project_root(&base_dir) { + if let Some(resources_dir) = macos_app_resources_dir(executable_dir) { + locations.push(( + resources_dir.join(CORE_VERSION_FILE), + resources_dir.join("cpa-core"), + )); + } + if let Some(project_root) = source_project_root(executable_dir) { if project_root != base_dir { locations.push(( project_root.join(CORE_VERSION_FILE), @@ -7998,6 +8032,14 @@ fn bundled_core_archive() -> Result, String> )); } } + locations +} + +fn bundled_core_archive() -> Result, String> { + let platform = current_core_platform()?; + let base_dir = core_base_dir()?; + let executable_dir = executable_dir()?; + let locations = bundled_core_locations(&base_dir, &executable_dir); let configured_version = locations.iter().find_map(|(version_path, _)| { fs::read_to_string(version_path) @@ -10717,6 +10759,25 @@ mod tests { fs::remove_dir_all(root).unwrap(); } + #[test] + fn bundled_core_locations_include_macos_app_resources() { + let contents_dir = agent_test_home("bundled-macos-resources") + .join("EasyCLIProxyAPI.app") + .join("Contents"); + let executable_dir = contents_dir.join("MacOS"); + let base_dir = agent_test_home("bundled-macos-data"); + let resource_location = ( + contents_dir.join("Resources").join(CORE_VERSION_FILE), + contents_dir.join("Resources").join("cpa-core"), + ); + + assert_eq!( + macos_app_resources_dir(&executable_dir), + Some(contents_dir.join("Resources")) + ); + assert!(bundled_core_locations(&base_dir, &executable_dir).contains(&resource_location)); + } + #[test] fn source_project_root_is_detected_from_the_portable_development_directory() { let root = agent_test_home("bundled-source-root"); diff --git a/src-tauri/tauri.dmg.conf.json b/src-tauri/tauri.dmg.conf.json new file mode 100644 index 0000000..e92119b --- /dev/null +++ b/src-tauri/tauri.dmg.conf.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "resources": { + "../core-version.txt": "core-version.txt", + "../cpa-core/CLIProxyAPI_*": "cpa-core/" + } + } +}