From edf6a8ede6d9dc02261885eb706a8c5f99cb93a0 Mon Sep 17 00:00:00 2001 From: King Star Date: Wed, 22 Jul 2026 15:29:56 +0800 Subject: [PATCH 1/3] ci: add managed fork release workflow --- .github/workflows/build-matrix.yml | 205 ++----- .github/workflows/release.yml | 945 ++--------------------------- src/locales/ar.json | 7 + src/locales/cs.json | 7 + src/locales/de.json | 7 + src/locales/en-US.json | 7 + src/locales/es.json | 7 + src/locales/fr.json | 7 + src/locales/id.json | 7 + src/locales/it.json | 7 + src/locales/ja.json | 7 + src/locales/ko.json | 7 + src/locales/pl.json | 7 + src/locales/pt-br.json | 7 + src/locales/ru.json | 7 + src/locales/tr.json | 7 + src/locales/vi.json | 7 + src/locales/zh-tw.json | 7 + 18 files changed, 218 insertions(+), 1044 deletions(-) diff --git a/.github/workflows/build-matrix.yml b/.github/workflows/build-matrix.yml index 2fb9cc7f6..37ac74f88 100644 --- a/.github/workflows/build-matrix.yml +++ b/.github/workflows/build-matrix.yml @@ -1,213 +1,86 @@ -name: Build Matrix +name: Fork CI on: workflow_dispatch: pull_request: branches: - - "main" - push: - branches: - - "feature/build-matrix*" - - "feature/build-matrix/**" + - main + +permissions: + contents: read concurrency: - group: build-matrix-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: fork-ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: preflight: name: Preflight runs-on: ubuntu-latest - permissions: - contents: read steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Node.js setup + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version + cache: npm - - name: Check locales - run: node scripts/check_locales.cjs + - name: Install dependencies + run: npm ci - - name: Typecheck - run: npm run typecheck + - name: Validate frontend + run: | + npm run sync-version + node scripts/check_locales.cjs + npm run typecheck - build: - name: Build (${{ matrix.label }}) + macos-arm64: + name: Build macOS arm64 needs: preflight - runs-on: ${{ matrix.platform }} - permissions: - contents: read - strategy: - fail-fast: false - matrix: - include: - - label: "macos-aarch64" - platform: "macos-latest" - args: "--target aarch64-apple-darwin" - release_dir: "target/aarch64-apple-darwin/release" - - label: "macos-x86_64" - platform: "macos-latest" - args: "--target x86_64-apple-darwin" - release_dir: "target/x86_64-apple-darwin/release" - - label: "macos-universal" - platform: "macos-latest" - args: "--target universal-apple-darwin" - release_dir: "target/universal-apple-darwin/release" - - label: "ubuntu-22.04" - platform: "ubuntu-22.04" - args: "" - release_dir: "target/release" - - label: "ubuntu-24.04-arm" - platform: "ubuntu-24.04-arm" - args: "" - release_dir: "target/release" - - label: "windows-latest" - platform: "windows-latest" - args: "" - release_dir: "target/release" - + runs-on: macos-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Install dependencies (Linux) - if: startsWith(matrix.platform, 'ubuntu') - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf pkg-config libsoup-3.0-dev javascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev - sudo apt-get install -y libnm-dev xdg-utils - - - name: Rust setup + - name: Set up Rust uses: dtolnay/rust-toolchain@stable with: - targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} + targets: aarch64-apple-darwin - - name: Go setup + - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: sidecars/cockpit-cliproxy/go.mod cache-dependency-path: sidecars/cockpit-cliproxy/go.sum - - name: Node.js setup + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version - - - name: Cache Tauri binary dependencies - uses: actions/cache@v4 - with: - path: | - ${{ matrix.platform == 'windows-latest' && 'C:\Users\runneradmin\AppData\Local\tauri' || '' }} - ${{ startsWith(matrix.platform, 'ubuntu') && '~/.cache/tauri' || '' }} - ${{ matrix.platform == 'macos-latest' && '~/Library/Caches/tauri' || '' }} - key: tauri-deps-${{ matrix.platform }}-${{ hashFiles('**/Cargo.toml') }} - restore-keys: | - tauri-deps-${{ matrix.platform }}- - - - name: Build app (pull request) - if: github.event_name == 'pull_request' - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - npx tauri build --ci --config src-tauri/tauri.ci.conf.json ${{ matrix.args }} 2>&1 | tee "build-${{ matrix.label }}.log" + cache: npm - - name: Print build logs on failure (PR) - if: failure() && github.event_name == 'pull_request' - shell: bash - run: | - echo "=== BUILD LOG (PR) ===" - if [ -f "build-${{ matrix.label }}.log" ]; then - tail -n 200 "build-${{ matrix.label }}.log" - else - echo "Log file not found" - fi + - name: Install dependencies + run: npm ci - - name: Build app (signed) - if: github.event_name != 'pull_request' - shell: bash + - name: Build app env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - npx tauri build --ci ${{ matrix.args }} 2>&1 | tee "build-${{ matrix.label }}.log" - - - name: Print build logs on failure (Signed) - if: failure() && github.event_name != 'pull_request' - shell: bash - run: | - echo "=== BUILD LOG (SIGNED) ===" - if [ -f "build-${{ matrix.label }}.log" ]; then - tail -n 200 "build-${{ matrix.label }}.log" - else - echo "Log file not found" - fi - - - name: Summarize warnings - if: always() - shell: bash run: | set -euo pipefail - log_file="build-${{ matrix.label }}.log" - warnings_file="warnings-${{ matrix.label }}.txt" - if grep -n "^warning:" "$log_file" > "$warnings_file"; then - warning_count="$(wc -l < "$warnings_file" | tr -d ' ')" - else - : > "$warnings_file" - warning_count="0" - fi - - { - echo "## ${{ matrix.label }}" - echo - echo "- warnings: ${warning_count}" - echo "- log: build-${{ matrix.label }}.log" - if [ -s "$warnings_file" ]; then - echo - echo '```text' - sed -n '1,200p' "$warnings_file" - echo '```' - fi - } >> "$GITHUB_STEP_SUMMARY" - - - name: Upload build logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: logs-${{ matrix.label }} - retention-days: 7 - if-no-files-found: error - path: | - build-${{ matrix.label }}.log - warnings-${{ matrix.label }}.txt - - - name: Upload bundles - if: success() + npm run sync-version + ./node_modules/.bin/tauri build \ + --ci \ + --config src-tauri/tauri.ci.conf.json \ + --target aarch64-apple-darwin \ + --bundles app 2>&1 | tee build-macos-arm64.log + + - name: Upload failure log + if: failure() uses: actions/upload-artifact@v4 with: - name: bundles-${{ matrix.label }} - retention-days: 7 - if-no-files-found: error - path: | - ${{ matrix.release_dir }}/bundle + name: fork-ci-macos-arm64-failure-log + retention-days: 3 + if-no-files-found: ignore + path: build-macos-arm64.log diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e24d3f2a..440cc1c9d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,931 +1,120 @@ -name: Release +name: Fork Release on: + workflow_dispatch: push: tags: - - "v*" - workflow_dispatch: - -jobs: - prepare-release: - name: Prepare staged release - if: startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest - permissions: - contents: write - outputs: - version: ${{ steps.app_version.outputs.VERSION }} - env: - GH_REPO: ${{ github.repository }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Node.js setup - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Read app version - id: app_version - shell: bash - run: | - set -euo pipefail - VERSION="$(node -e "console.log(require(require('path').join(process.env.GITHUB_WORKSPACE, 'package.json')).version)")" - echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Validate tag matches version - shell: bash - run: | - set -euo pipefail - EXPECTED_TAG="v${{ steps.app_version.outputs.VERSION }}" - if [ "${GITHUB_REF_NAME}" != "${EXPECTED_TAG}" ]; then - echo "Tag (${GITHUB_REF_NAME}) does not match package.json version (${EXPECTED_TAG})." - exit 1 - fi - - - name: Build bilingual release notes from changelog - shell: bash - run: | - set -euo pipefail - VERSION="${{ steps.app_version.outputs.VERSION }}" - - extract_section() { - local file="$1" - local version="$2" - - awk -v version="$version" ' - BEGIN { - capture = 0 - found = 0 - } - - /^## / { - if (capture) { - exit - } - if ($0 ~ ("^## \\[" version "\\][[:space:]]*-.*$") || $0 ~ ("^## \\[v" version "\\][[:space:]]*-.*$")) { - capture = 1 - found = 1 - } - } + - "v*-jstar.*" - capture { - if ($0 ~ /^---[[:space:]]*$/) { - exit - } - print - } - - END { - if (!found) { - exit 2 - } - } - ' "$file" - } - - if ! zh_section="$(extract_section CHANGELOG.zh-CN.md "$VERSION")"; then - echo "Missing changelog section for version $VERSION in CHANGELOG.zh-CN.md" - exit 1 - fi - - if ! en_section="$(extract_section CHANGELOG.md "$VERSION")"; then - echo "Missing changelog section for version $VERSION in CHANGELOG.md" - exit 1 - fi +permissions: + contents: write - { - echo "## 更新日志(中文)" - echo - printf '%s\n' "$zh_section" - echo - echo "## Changelog (English)" - echo - printf '%s\n' "$en_section" - } > release-notes.md +concurrency: + group: fork-release-${{ github.ref }} + cancel-in-progress: false - date -u +"%Y-%m-%dT%H:%M:%SZ" > published-at.txt - - - name: Preserve previous legacy latest.json - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - PREVIOUS_TAG="$( - gh release list \ - --limit 100 \ - --json tagName,isDraft,isPrerelease \ - --jq "[.[] | select(.isDraft == false and .isPrerelease == false and .tagName != \"${GITHUB_REF_NAME}\")][0].tagName // \"\"" - )" - if [ -z "${PREVIOUS_TAG}" ]; then - echo "No previous published release was found; refusing to stage without a legacy updater manifest." - exit 1 - fi - - mkdir -p previous-release - gh release download "${PREVIOUS_TAG}" \ - --pattern "latest.json" \ - --dir previous-release \ - --clobber - test -s previous-release/latest.json - cp previous-release/latest.json legacy-latest.json - echo "Preserved legacy latest.json from ${PREVIOUS_TAG}" - - # Publish the release early (with previous complete latest.json) so parallel - # platform jobs can verify assets via public tag download URLs without - # waiting for Windows, and without reading the previous release's - # same-named target manifests through /releases/latest/. - - name: Create or update staged release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${{ steps.app_version.outputs.VERSION }}" - TITLE="Cockpit Tools v${{ steps.app_version.outputs.VERSION }}" - if gh release view "${TAG}" > /dev/null 2>&1; then - gh release edit "${TAG}" \ - --title "${TITLE}" \ - --notes-file release-notes.md - else - gh release create "${TAG}" \ - --draft \ - --title "${TITLE}" \ - --notes-file release-notes.md - fi - - IS_DRAFT="$(gh release view "${TAG}" --json isDraft --jq '.isDraft')" - if [ "${IS_DRAFT}" = "true" ]; then - cp legacy-latest.json latest.json - gh release upload "${TAG}" latest.json --clobber - gh release edit "${TAG}" --draft=false --prerelease=false --latest - echo "Published staged release ${TAG} with legacy latest.json" - else - # Re-run after a prior publish: do not clobber a finalized latest.json. - echo "Release ${TAG} is already published; leaving latest.json as-is" - gh release edit "${TAG}" --prerelease=false --latest - fi - - - name: Upload release metadata artifact - uses: actions/upload-artifact@v4 - with: - name: release-metadata - retention-days: 7 - if-no-files-found: error - path: | - release-notes.md - published-at.txt - legacy-latest.json - - build-windows: - name: Build Windows - needs: prepare-release - runs-on: windows-latest - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Rust setup - uses: dtolnay/rust-toolchain@stable - - - name: Go setup - uses: actions/setup-go@v5 - with: - go-version-file: sidecars/cockpit-cliproxy/go.mod - cache-dependency-path: sidecars/cockpit-cliproxy/go.sum - - - name: Node.js setup - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version - - - name: Build Windows app - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - npx tauri build --ci - - - name: Download release metadata - uses: actions/download-artifact@v4 - with: - name: release-metadata - path: release-metadata - - - name: Stage Windows release assets - shell: bash - run: | - set -euo pipefail - node scripts/release/stage_release_assets.cjs \ - --platform windows \ - --assets-dir "target/release/bundle" \ - --output-dir "release-assets" - - - name: Upload Windows release assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${VERSION}" - shopt -s nullglob - ASSETS=(release-assets/*) - test "${#ASSETS[@]}" -gt 0 - gh release upload "${TAG}" "${ASSETS[@]}" --clobber - echo "Uploaded ${#ASSETS[@]} Windows assets" - - - name: Build and upload Windows updater manifests - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - node scripts/release/build_target_latest_json.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --assets-dir "release-assets" \ - --notes-file "release-metadata/release-notes.md" \ - --published-at "$(cat release-metadata/published-at.txt)" \ - --targets "windows-x86_64-msi,windows-x86_64-nsis" \ - --output-dir "target-manifests" - gh release upload "v${VERSION}" target-manifests/*.json --clobber - - # Fallback only: prepare should already have published with legacy latest.json. - - name: Ensure staged release is published - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${VERSION}" - IS_DRAFT="$(gh release view "${TAG}" --json isDraft --jq '.isDraft')" - if [ "${IS_DRAFT}" = "true" ]; then - cp release-metadata/legacy-latest.json latest.json - gh release upload "${TAG}" latest.json --clobber - gh release edit "${TAG}" --draft=false --prerelease=false --latest - echo "Published staged release from Windows fallback with legacy latest.json" - else - echo "Release is already published; keeping its current latest.json" - fi - - # Mid-stage verify uses the tag download URL so parallel jobs do not race - # on /releases/latest/ before this release is marked latest (or before - # other platforms finish). Public /latest/ is checked in finalize. - - name: Verify published Windows updater manifests - shell: bash - run: | - set -euo pipefail - node scripts/release/verify_published_updater_manifests.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --targets "windows-x86_64-msi,windows-x86_64-nsis" \ - --latest-base-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" - - build-macos-aarch64: - name: Build macOS Apple Silicon - # Parallel with Windows / macOS Intel after prepare (no hard platform deps). - needs: - - prepare-release +jobs: + macos-arm64: + name: Build macOS arm64 release runs-on: macos-latest - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Rust setup + - name: Set up Rust uses: dtolnay/rust-toolchain@stable with: targets: aarch64-apple-darwin - - name: Go setup - uses: actions/setup-go@v5 - with: - go-version-file: sidecars/cockpit-cliproxy/go.mod - cache-dependency-path: sidecars/cockpit-cliproxy/go.sum - - - name: Node.js setup - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version - - - name: Build macOS Apple Silicon app - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - npx tauri build --ci --target aarch64-apple-darwin - - - name: Download release metadata - uses: actions/download-artifact@v4 - with: - name: release-metadata - path: release-metadata - - - name: Stage macOS Apple Silicon assets - shell: bash - run: | - set -euo pipefail - node scripts/release/stage_release_assets.cjs \ - --platform macos \ - --mac-arch aarch64 \ - --assets-dir "target/aarch64-apple-darwin/release/bundle" \ - --output-dir "release-assets" - - - name: Upload macOS Apple Silicon assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${VERSION}" - shopt -s nullglob - ASSETS=(release-assets/*) - test "${#ASSETS[@]}" -gt 0 - gh release upload "${TAG}" "${ASSETS[@]}" --clobber - echo "Uploaded ${#ASSETS[@]} macOS Apple Silicon assets" - - - name: Build and upload macOS Apple Silicon updater manifest - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - node scripts/release/build_target_latest_json.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --assets-dir "release-assets" \ - --notes-file "release-metadata/release-notes.md" \ - --published-at "$(cat release-metadata/published-at.txt)" \ - --targets "darwin-aarch64-app" \ - --output-dir "target-manifests" - gh release upload "v${VERSION}" target-manifests/*.json --clobber - - # Tag URL (not /releases/latest/) — see Windows verify comment. - - name: Verify published macOS Apple Silicon updater manifest - shell: bash - run: | - set -euo pipefail - node scripts/release/verify_published_updater_manifests.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --targets "darwin-aarch64-app" \ - --latest-base-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" - - build-macos-x86_64: - name: Build macOS Intel - # Parallel with Windows / macOS Apple Silicon after prepare. - needs: - - prepare-release - runs-on: macos-latest - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Rust setup - uses: dtolnay/rust-toolchain@stable - with: - targets: x86_64-apple-darwin - - - name: Go setup - uses: actions/setup-go@v5 - with: - go-version-file: sidecars/cockpit-cliproxy/go.mod - cache-dependency-path: sidecars/cockpit-cliproxy/go.sum - - - name: Node.js setup - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version - - - name: Build macOS Intel app - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - npx tauri build --ci --target x86_64-apple-darwin - - - name: Download release metadata - uses: actions/download-artifact@v4 - with: - name: release-metadata - path: release-metadata - - - name: Stage macOS Intel assets - shell: bash - run: | - set -euo pipefail - node scripts/release/stage_release_assets.cjs \ - --platform macos \ - --mac-arch x64 \ - --assets-dir "target/x86_64-apple-darwin/release/bundle" \ - --output-dir "release-assets" - - - name: Upload macOS Intel assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${VERSION}" - shopt -s nullglob - ASSETS=(release-assets/*) - test "${#ASSETS[@]}" -gt 0 - gh release upload "${TAG}" "${ASSETS[@]}" --clobber - echo "Uploaded ${#ASSETS[@]} macOS Intel assets" - - - name: Build and upload macOS Intel updater manifest - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - node scripts/release/build_target_latest_json.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --assets-dir "release-assets" \ - --notes-file "release-metadata/release-notes.md" \ - --published-at "$(cat release-metadata/published-at.txt)" \ - --targets "darwin-x86_64-app" \ - --output-dir "target-manifests" - gh release upload "v${VERSION}" target-manifests/*.json --clobber - - # Tag URL (not /releases/latest/) — see Windows verify comment. - - name: Verify published macOS Intel updater manifest - shell: bash - run: | - set -euo pipefail - node scripts/release/verify_published_updater_manifests.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --targets "darwin-x86_64-app" \ - --latest-base-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" - - build-macos-universal: - name: Build macOS Universal - # Still sequential after both arch builds (needs arm + x64 artifacts). - needs: - - prepare-release - - build-macos-aarch64 - - build-macos-x86_64 - runs-on: macos-latest - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Rust setup - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin,x86_64-apple-darwin - - - name: Go setup + - name: Set up Go uses: actions/setup-go@v5 with: go-version-file: sidecars/cockpit-cliproxy/go.mod cache-dependency-path: sidecars/cockpit-cliproxy/go.sum - - name: Node.js setup + - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version - - - name: Build macOS Universal app - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - npx tauri build --ci --target universal-apple-darwin - - - name: Stage macOS Universal assets - shell: bash - run: | - set -euo pipefail - node scripts/release/stage_release_assets.cjs \ - --platform macos \ - --mac-arch universal \ - --assets-dir "target/universal-apple-darwin/release/bundle" \ - --output-dir "release-assets" - - - name: Upload macOS Universal assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${VERSION}" - shopt -s nullglob - ASSETS=(release-assets/*) - test "${#ASSETS[@]}" -gt 0 - gh release upload "${TAG}" "${ASSETS[@]}" --clobber - echo "Uploaded ${#ASSETS[@]} macOS Universal assets" - - build-linux: - name: Build Linux (${{ matrix.label }}) - # Parallel with Windows / macOS after prepare; matrix arches run in parallel. - needs: - - prepare-release - runs-on: ${{ matrix.platform }} - permissions: - contents: write - strategy: - fail-fast: false - matrix: - include: - - label: "x86_64" - platform: "ubuntu-22.04" - targets: "linux-x86_64-appimage,linux-x86_64-deb,linux-x86_64-rpm" - - label: "aarch64" - platform: "ubuntu-24.04-arm" - targets: "linux-aarch64-appimage,linux-aarch64-deb,linux-aarch64-rpm" - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 + cache: npm - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev patchelf pkg-config libsoup-3.0-dev javascriptcoregtk-4.1 libjavascriptcoregtk-4.1-dev - sudo apt-get install -y libnm-dev xdg-utils - - - name: Rust setup - uses: dtolnay/rust-toolchain@stable - - - name: Go setup - uses: actions/setup-go@v5 - with: - go-version-file: sidecars/cockpit-cliproxy/go.mod - cache-dependency-path: sidecars/cockpit-cliproxy/go.sum - - - name: Node.js setup - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "npm" - - - name: Install frontend dependencies - run: npm install - - - name: Sync versions - run: npm run sync-version - - - name: Build Linux app - shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - set -euo pipefail - npx tauri build --ci - - - name: Download release metadata - uses: actions/download-artifact@v4 - with: - name: release-metadata - path: release-metadata + run: npm ci - - name: Stage Linux assets + - name: Validate release identity + id: identity shell: bash run: | set -euo pipefail - node scripts/release/stage_release_assets.cjs \ - --platform linux \ - --assets-dir "target/release/bundle" \ - --output-dir "release-assets" - - - name: Upload Linux assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - TAG="v${VERSION}" - shopt -s nullglob - ASSETS=(release-assets/*) - test "${#ASSETS[@]}" -gt 0 - gh release upload "${TAG}" "${ASSETS[@]}" --clobber - echo "Uploaded ${#ASSETS[@]} Linux assets" - - - name: Build and upload Linux updater manifests - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - node scripts/release/build_target_latest_json.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --assets-dir "release-assets" \ - --notes-file "release-metadata/release-notes.md" \ - --published-at "$(cat release-metadata/published-at.txt)" \ - --targets "${{ matrix.targets }}" \ - --output-dir "target-manifests" - gh release upload "v${VERSION}" target-manifests/*.json --clobber - - # Tag URL (not /releases/latest/) — see Windows verify comment. - - name: Verify published Linux updater manifests - shell: bash - run: | - set -euo pipefail - node scripts/release/verify_published_updater_manifests.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --targets "${{ matrix.targets }}" \ - --latest-base-url "https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}" - - finalize-legacy-latest: - name: Finalize legacy latest.json - # Wait for every platform build so latest.json is complete. - needs: - - prepare-release - - build-windows - - build-macos-universal - - build-linux - runs-on: ubuntu-latest - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Node.js setup - uses: actions/setup-node@v4 - with: - node-version: 20 - - - name: Download release metadata - uses: actions/download-artifact@v4 - with: - name: release-metadata - path: release-metadata - - - name: Download all release assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - mkdir -p release-assets - TAG="v${VERSION}" - downloaded="false" - for i in $(seq 1 30); do - if gh release download "${TAG}" --dir release-assets --pattern "*" --clobber; then - downloaded="true" - break + VERSION="$(node -p "require('./package.json').version")" + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + TAG_PREFIX="v${VERSION}-jstar." + REVISION="${GITHUB_REF_NAME#"${TAG_PREFIX}"}" + if [[ "${GITHUB_REF_NAME}" != "${TAG_PREFIX}"* ]] || [[ ! "${REVISION}" =~ ^[0-9]+$ ]]; then + echo "Tag ${GITHUB_REF_NAME} must match v${VERSION}-jstar.." + exit 1 fi - echo "Release assets not ready yet (attempt $i/30). Sleeping 10s..." - sleep 10 - done - if [ "${downloaded}" != "true" ]; then - echo "Failed to download release assets from ${TAG} after retries." - exit 1 fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" - - name: Build complete legacy latest.json - shell: bash - run: | - set -euo pipefail - node scripts/release/build_merged_latest_json.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --assets-dir "release-assets" \ - --notes-file "release-metadata/release-notes.md" \ - --published-at "$(cat release-metadata/published-at.txt)" \ - --output "latest.json" - - - name: Validate complete legacy latest.json - shell: bash + - name: Validate frontend run: | - set -euo pipefail - jq -e ' - .platforms["darwin-aarch64"] and - .platforms["darwin-x86_64"] and - .platforms["windows-x86_64"] and - .platforms["windows-x86_64-nsis"] and - .platforms["linux-x86_64-appimage"] and - .platforms["linux-x86_64-deb"] and - .platforms["linux-x86_64-rpm"] and - .platforms["linux-aarch64-appimage"] and - .platforms["linux-aarch64-deb"] and - .platforms["linux-aarch64-rpm"] - ' latest.json > /dev/null - jq -r '.platforms | keys[]' latest.json + npm run sync-version + node scripts/check_locales.cjs + npm run typecheck - - name: Publish complete legacy latest.json + - name: Build app env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - gh release upload "v${VERSION}" latest.json --clobber - gh release edit "v${VERSION}" --draft=false --prerelease=false --latest + ./node_modules/.bin/tauri build \ + --ci \ + --config src-tauri/tauri.ci.conf.json \ + --target aarch64-apple-darwin \ + --bundles app 2>&1 | tee build-macos-arm64.log - # End-to-end: user-facing /releases/latest/download (do not use tag URL here). - - name: Verify complete published updater state + - name: Package release asset shell: bash - run: | - set -euo pipefail - node scripts/release/verify_published_updater_manifests.cjs \ - --version "${VERSION}" \ - --repo "${GITHUB_REPOSITORY}" \ - --targets "darwin-aarch64-app,darwin-x86_64-app,windows-x86_64-msi,windows-x86_64-nsis,linux-x86_64-appimage,linux-x86_64-deb,linux-x86_64-rpm,linux-aarch64-appimage,linux-aarch64-deb,linux-aarch64-rpm" \ - --legacy - - upload-checksums: - name: Upload SHA256SUMS - needs: - - prepare-release - - finalize-legacy-latest - runs-on: ubuntu-latest - permissions: - contents: write - env: - GH_REPO: ${{ github.repository }} - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Download release assets env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash + VERSION: ${{ steps.identity.outputs.version }} run: | set -euo pipefail + APP_PATH="target/aarch64-apple-darwin/release/bundle/macos/Cockpit Tools.app" + ASSET="Cockpit-Tools_${VERSION}_macos-arm64.app.zip" + test -d "${APP_PATH}" + codesign --force --deep --sign - "${APP_PATH}" + codesign --verify --deep --strict --verbose=2 "${APP_PATH}" mkdir -p release-assets - gh release download "v${VERSION}" --dir release-assets --pattern "*" --clobber - - - name: Generate SHA256SUMS.txt from release assets - shell: bash - run: | - set -euo pipefail - cd release-assets - rm -f SHA256SUMS.txt - find . -type f -print0 \ - | sort -z \ - | while IFS= read -r -d '' file; do - shasum -a 256 "$file" - done \ - | sed 's# \./# #' \ - > ../SHA256SUMS.txt - cd .. - test -s SHA256SUMS.txt - cat SHA256SUMS.txt - - - name: Upload SHA256SUMS.txt to release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - gh release upload "v${VERSION}" SHA256SUMS.txt --clobber - - update-homebrew-cask: - name: Update Homebrew Cask - needs: - - prepare-release - - finalize-legacy-latest - - upload-checksums - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - env: - VERSION: ${{ needs.prepare-release.outputs.version }} - steps: - - name: Checkout main - uses: actions/checkout@v4 - with: - ref: main + ditto -c -k --sequesterRsrc --keepParent "${APP_PATH}" "release-assets/${ASSET}" + ( + cd release-assets + shasum -a 256 "${ASSET}" > "${ASSET}.sha256" + ) - - name: Download universal DMG from GitHub Releases - shell: bash - run: | - set -euo pipefail - URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/v${VERSION}/Cockpit.Tools_${VERSION}_universal.dmg" - echo "Downloading: ${URL}" - for i in $(seq 1 30); do - if curl -fL --retry 3 --retry-delay 1 -o cockpit-tools.dmg "${URL}"; then - exit 0 - fi - echo "Asset not ready yet (attempt $i/30). Sleeping 10s..." - sleep 10 - done - echo "Failed to download release asset after retries." - exit 1 - - - name: Compute sha256 - shell: bash - run: | - set -euo pipefail - SHA256="$(sha256sum cockpit-tools.dmg | awk '{print $1}')" - echo "SHA256=${SHA256}" >> "$GITHUB_ENV" - - - name: Update Cask file - shell: bash - run: | - set -euo pipefail - FILE="Casks/cockpit-tools.rb" - if [ ! -f "${FILE}" ]; then - echo "Missing ${FILE}. Create it first." - exit 1 - fi - - perl -0777 -i -pe \ - 's/version \"[^\"]+\"/version \"'"${VERSION}"'\"/; s/sha256 \"[0-9a-f]{64}\"/sha256 \"'"${SHA256}"'\"/' \ - "${FILE}" - - git diff -- "${FILE}" - - - name: Create pull request - id: create_cask_pr - uses: peter-evans/create-pull-request@v6 + - name: Upload short-lived workflow artifact + uses: actions/upload-artifact@v4 with: - commit-message: "chore(homebrew): update cask for v${{ env.VERSION }}" - title: "chore(homebrew): update cask for v${{ env.VERSION }}" - body: | - Auto-generated by the release workflow. - - - version: ${{ env.VERSION }} - - sha256 (universal.dmg): ${{ env.SHA256 }} - branch: "automation/update-cask-v${{ env.VERSION }}" - base: main - delete-branch: true + name: cockpit-tools-${{ steps.identity.outputs.version }}-macos-arm64 + retention-days: 3 + if-no-files-found: error + path: release-assets/* - - name: Enable auto-merge for cask PR - if: ${{ steps.create_cask_pr.outputs.pull-request-number != '' }} + - name: Publish Fork release + if: startsWith(github.ref, 'refs/tags/') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.identity.outputs.version }} shell: bash run: | set -euo pipefail - PR_NUMBER="${{ steps.create_cask_pr.outputs.pull-request-number }}" - PR_BRANCH="$(gh pr view "${PR_NUMBER}" --json headRefName --jq '.headRefName')" - if [[ "${PR_BRANCH}" != automation/update-cask-v* ]]; then - echo "Unexpected PR branch: ${PR_BRANCH}" - exit 1 + if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + gh release upload "${GITHUB_REF_NAME}" release-assets/* --clobber + gh release edit "${GITHUB_REF_NAME}" \ + --title "Cockpit Tools ${VERSION}" + else + gh release create "${GITHUB_REF_NAME}" release-assets/* \ + --title "Cockpit Tools ${VERSION}" \ + --generate-notes \ + --latest fi - gh pr merge "${PR_NUMBER}" --auto --squash --delete-branch diff --git a/src/locales/ar.json b/src/locales/ar.json index 080459af2..763ebcbfb 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -1819,6 +1819,13 @@ "commandMac": "ماك: pkill -9 -f \"CODEX_HOME=\"", "commandWin": "Windows: taskkill /F /PID (مطابقة CODEX_HOME)" } + }, + "launchSafety": { + "section": "أمان التشغيل", + "configProtection": "حماية إعدادات Codex", + "configProtectionDesc": "عند التفعيل، لا تعيد عمليات تشغيل Cockpit والإيقاظ وapp-server كتابة config.toml. يؤدي التعطيل إلى استعادة تنظيف التوافق القديم، وقد يزيل إعدادات غير معروفة.", + "sessionRepair": "إصلاح ظهور الجلسات قبل التشغيل", + "sessionRepairDesc": "عند التعطيل، يكون الإصلاح يدويا فقط. عند التفعيل، يشغل كل بدء لمثيل Codex إصلاحا سريعا وقد يعدل بيانات الجلسة وSQLite." } }, "refreshSuccess": "تم تحديث الحصة", diff --git a/src/locales/cs.json b/src/locales/cs.json index ddf8239b0..1d04d4d82 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -1772,6 +1772,13 @@ "commandMac": "macOS: pkill -9 -f \"CODEX_HOME=\"​", "commandWin": "Windows: taskkill /F /PID (shoda s CODEX_HOME)" } + }, + "launchSafety": { + "section": "Bezpečné spuštění", + "configProtection": "Chránit konfiguraci Codex", + "configProtectionDesc": "Je-li zapnuto, spuštění Cockpit, probuzení a app-server nepřepisují config.toml. Vypnutí obnoví starší čištění kompatibility, které může odstranit neznámou konfiguraci.", + "sessionRepair": "Opravit viditelnost relací před spuštěním", + "sessionRepairDesc": "Je-li vypnuto, oprava je pouze ruční. Je-li zapnuto, každé spuštění instance Codex provede rychlou opravu a může změnit metadata relací a SQLite." } }, "refreshSuccess": "Kvóta obnovena", diff --git a/src/locales/de.json b/src/locales/de.json index e1e30a542..81ec60907 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -1772,6 +1772,13 @@ "commandMac": "macOS: pkill -9 -f \"CODEX_HOME=\"​", "commandWin": "Windows: taskkill /F /PID (Übereinstimmung mit CODEX_HOME)" } + }, + "launchSafety": { + "section": "Startsicherheit", + "configProtection": "Codex-Konfiguration schützen", + "configProtectionDesc": "Wenn aktiviert, überschreiben Cockpit-Start, Aufwecken und app-server die config.toml nicht. Beim Deaktivieren wird die alte Kompatibilitätsbereinigung verwendet, die unbekannte Konfiguration entfernen kann.", + "sessionRepair": "Sitzungssichtbarkeit vor dem Start reparieren", + "sessionRepairDesc": "Wenn deaktiviert, ist nur eine manuelle Reparatur möglich. Wenn aktiviert, führt jeder Start einer Codex-Instanz eine Schnellreparatur aus und kann Sitzungsmetadaten und SQLite ändern." } }, "refreshSuccess": "Kontingent aktualisiert", diff --git a/src/locales/en-US.json b/src/locales/en-US.json index 56386661e..e0108ead0 100644 --- a/src/locales/en-US.json +++ b/src/locales/en-US.json @@ -1971,6 +1971,13 @@ "commandMac": "macOS: pkill -9 -f \"CODEX_HOME=\"", "commandWin": "Windows: taskkill /F /PID (match CODEX_HOME)" } + }, + "launchSafety": { + "section": "Launch Safety", + "configProtection": "Protect Codex configuration", + "configProtectionDesc": "When enabled, Cockpit launch, wakeup, and app-server flows do not rewrite config.toml. Disabling it restores legacy compatibility cleanup, which can remove unknown configuration.", + "sessionRepair": "Repair session visibility before launch", + "sessionRepairDesc": "When disabled, repair is manual only. When enabled, every Codex instance launch runs quick repair and may modify session metadata and SQLite." } }, "refreshSuccess": "Quota refreshed", diff --git a/src/locales/es.json b/src/locales/es.json index e2a8d2c5c..7d1ea244d 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1772,6 +1772,13 @@ "commandMac": "MacOS: pkill -9 -f \"CODEX_HOME=\"", "commandWin": "Windows: taskkill /F /PID (coincidencia con CODEX_HOME)" } + }, + "launchSafety": { + "section": "Seguridad de inicio", + "configProtection": "Proteger la configuración de Codex", + "configProtectionDesc": "Al activarlo, el inicio, la reactivación y app-server de Cockpit no reescriben config.toml. Al desactivarlo, se restaura la limpieza de compatibilidad anterior, que puede eliminar configuración desconocida.", + "sessionRepair": "Reparar la visibilidad de sesiones antes de iniciar", + "sessionRepairDesc": "Al desactivarlo, la reparación solo es manual. Al activarlo, cada inicio de una instancia de Codex ejecuta una reparación rápida y puede modificar metadatos de sesión y SQLite." } }, "refreshSuccess": "Cuota actualizada", diff --git a/src/locales/fr.json b/src/locales/fr.json index 3bc983f5f..6dfe5c3a3 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -1772,6 +1772,13 @@ "commandMac": "macOS : pkill -9 -f \"CODEX_HOME=\"", "commandWin": "Windows : taskkill /F /PID (correspondance CODEX_HOME)" } + }, + "launchSafety": { + "section": "Sécurité du démarrage", + "configProtection": "Protéger la configuration Codex", + "configProtectionDesc": "Lorsque cette option est activée, le lancement, le réveil et app-server de Cockpit ne réécrivent pas config.toml. La désactivation rétablit l’ancien nettoyage de compatibilité, susceptible de supprimer une configuration inconnue.", + "sessionRepair": "Réparer la visibilité des sessions avant le lancement", + "sessionRepairDesc": "Lorsque cette option est désactivée, la réparation est uniquement manuelle. Lorsqu’elle est activée, chaque lancement d’une instance Codex exécute une réparation rapide et peut modifier les métadonnées de session et SQLite." } }, "refreshSuccess": "Quota actualisé", diff --git a/src/locales/id.json b/src/locales/id.json index 534f72bcf..061ee542e 100644 --- a/src/locales/id.json +++ b/src/locales/id.json @@ -1819,6 +1819,13 @@ "commandMac": "macOS: pkill -9 -f \"CODEX_HOME=\"​", "commandWin": "Windows: taskkill /F /PID (match CODEX_HOME)​" } + }, + "launchSafety": { + "section": "Keamanan Peluncuran", + "configProtection": "Lindungi konfigurasi Codex", + "configProtectionDesc": "Saat diaktifkan, alur peluncuran, bangun, dan app-server Cockpit tidak menulis ulang config.toml. Menonaktifkannya mengaktifkan kembali pembersihan kompatibilitas lama yang dapat menghapus konfigurasi yang tidak dikenal.", + "sessionRepair": "Perbaiki visibilitas sesi sebelum peluncuran", + "sessionRepairDesc": "Saat dinonaktifkan, perbaikan hanya dapat dilakukan secara manual. Saat diaktifkan, setiap peluncuran instans Codex menjalankan perbaikan cepat dan dapat mengubah metadata sesi serta SQLite." } }, "refreshSuccess": "Kuota disegarkan", diff --git a/src/locales/it.json b/src/locales/it.json index d42a54067..eb9fecae8 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -1772,6 +1772,13 @@ "commandMac": "macOS: pkill -9 -f \"CODEX_HOME=\"​", "commandWin": "Windows: taskkill /F /PID (corrispondenza CODEX_HOME)" } + }, + "launchSafety": { + "section": "Sicurezza di avvio", + "configProtection": "Proteggi la configurazione Codex", + "configProtectionDesc": "Quando è attivo, l’avvio, la riattivazione e app-server di Cockpit non riscrivono config.toml. Disattivandolo viene ripristinata la pulizia di compatibilità precedente, che può rimuovere configurazioni sconosciute.", + "sessionRepair": "Ripara la visibilità delle sessioni prima dell’avvio", + "sessionRepairDesc": "Quando è disattivato, la riparazione è solo manuale. Quando è attivo, ogni avvio di un’istanza Codex esegue una riparazione rapida e può modificare i metadati delle sessioni e SQLite." } }, "refreshSuccess": "Quota aggiornata", diff --git a/src/locales/ja.json b/src/locales/ja.json index 97829edd6..b3686f2bd 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -1772,6 +1772,13 @@ "commandMac": "macOS: pkill -9 -f \"CODEX_HOME=\"​", "commandWin": "Windows: taskkill /F /PID (CODEX_HOME に一致するもの)" } + }, + "launchSafety": { + "section": "起動時の安全性", + "configProtection": "Codex 設定を保護", + "configProtectionDesc": "有効にすると、Cockpit の起動、復帰、app-server は config.toml を書き換えません。無効にすると従来の互換性クリーンアップに戻り、未知の設定が削除される場合があります。", + "sessionRepair": "起動前にセッション表示を修復", + "sessionRepairDesc": "無効の場合、修復は手動のみです。有効にすると、Codex インスタンスの起動ごとにクイック修復が実行され、セッションメタデータと SQLite が変更される場合があります。" } }, "refreshSuccess": "クォータを更新しました", diff --git a/src/locales/ko.json b/src/locales/ko.json index 5a59e4ba6..90101dde9 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -1772,6 +1772,13 @@ "commandMac": "macOS: pkill -9 -f \"CODEX_HOME=\"​", "commandWin": "Windows: taskkill /F /PID (CODEX_HOME 일치 항목)" } + }, + "launchSafety": { + "section": "시작 안전성", + "configProtection": "Codex 구성 보호", + "configProtectionDesc": "활성화하면 Cockpit 시작, 깨우기 및 app-server 흐름에서 config.toml을 다시 쓰지 않습니다. 비활성화하면 이전 호환성 정리가 복원되어 알 수 없는 구성이 제거될 수 있습니다.", + "sessionRepair": "시작 전 세션 표시 복구", + "sessionRepairDesc": "비활성화하면 수동으로만 복구할 수 있습니다. 활성화하면 각 Codex 인스턴스를 시작할 때 빠른 복구가 실행되며 세션 메타데이터와 SQLite가 수정될 수 있습니다." } }, "refreshSuccess": "할당량이 새로고침되었습니다", diff --git a/src/locales/pl.json b/src/locales/pl.json index df5b02a88..132db116a 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -1772,6 +1772,13 @@ "commandMac": "macOS: pkill -9 -f \"CODEX_HOME=__MASKA_0__\"", "commandWin": "Windows: taskkill /F /PID (dopasowanie do CODEX_HOME)" } + }, + "launchSafety": { + "section": "Bezpieczeństwo uruchamiania", + "configProtection": "Chroń konfigurację Codex", + "configProtectionDesc": "Po włączeniu uruchamianie, wybudzanie i app-server Cockpit nie nadpisują pliku config.toml. Wyłączenie przywraca starsze czyszczenie zgodności, które może usunąć nieznaną konfigurację.", + "sessionRepair": "Napraw widoczność sesji przed uruchomieniem", + "sessionRepairDesc": "Po wyłączeniu naprawa jest tylko ręczna. Po włączeniu każde uruchomienie instancji Codex wykonuje szybką naprawę i może zmienić metadane sesji oraz SQLite." } }, "refreshSuccess": "Limit odświeżony", diff --git a/src/locales/pt-br.json b/src/locales/pt-br.json index 798e78bdc..499ca1c86 100644 --- a/src/locales/pt-br.json +++ b/src/locales/pt-br.json @@ -1772,6 +1772,13 @@ "commandMac": "macOS: pkill -9 -f \"CODEX_HOME=\"​", "commandWin": "Windows: taskkill /F /PID (correspondência com CODEX_HOME)" } + }, + "launchSafety": { + "section": "Segurança de inicialização", + "configProtection": "Proteger a configuração do Codex", + "configProtectionDesc": "Quando ativado, a inicialização, a reativação e o app-server do Cockpit não reescrevem o config.toml. Desativar restaura a limpeza de compatibilidade anterior, que pode remover configurações desconhecidas.", + "sessionRepair": "Reparar a visibilidade das sessões antes de iniciar", + "sessionRepairDesc": "Quando desativado, o reparo é apenas manual. Quando ativado, cada inicialização de uma instância do Codex executa um reparo rápido e pode modificar metadados de sessão e o SQLite." } }, "refreshSuccess": "Cota atualizada", diff --git a/src/locales/ru.json b/src/locales/ru.json index 87e3fe649..f0dcb7ce9 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1772,6 +1772,13 @@ "commandMac": "macOS: pkill -9 -f \"CODEX_HOME=\"​", "commandWin": "Windows: taskkill /F /PID (совпадение по CODEX_HOME)" } + }, + "launchSafety": { + "section": "Безопасность запуска", + "configProtection": "Защитить конфигурацию Codex", + "configProtectionDesc": "Если включено, запуск, пробуждение и app-server Cockpit не перезаписывают config.toml. Отключение возвращает прежнюю очистку совместимости, которая может удалить неизвестную конфигурацию.", + "sessionRepair": "Восстанавливать видимость сеансов перед запуском", + "sessionRepairDesc": "Если отключено, восстановление выполняется только вручную. Если включено, при каждом запуске экземпляра Codex выполняется быстрое восстановление, которое может изменить метаданные сеансов и SQLite." } }, "refreshSuccess": "Квота обновлена", diff --git a/src/locales/tr.json b/src/locales/tr.json index c26c4b5da..bd9219a72 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -1772,6 +1772,13 @@ "commandMac": "macOS: pkill -9 -f \"CODEX_HOME=\"​", "commandWin": "Windows: taskkill /F /PID (CODEX_HOME ile eşleşen)" } + }, + "launchSafety": { + "section": "Başlatma Güvenliği", + "configProtection": "Codex yapılandırmasını koru", + "configProtectionDesc": "Etkinleştirildiğinde Cockpit başlatma, uyandırma ve app-server akışları config.toml dosyasını yeniden yazmaz. Devre dışı bırakmak, bilinmeyen yapılandırmayı kaldırabilen eski uyumluluk temizliğini geri getirir.", + "sessionRepair": "Başlatmadan önce oturum görünürlüğünü onar", + "sessionRepairDesc": "Devre dışıyken onarım yalnızca elle yapılır. Etkinleştirildiğinde her Codex örneği başlatılırken hızlı onarım çalışır ve oturum meta verileri ile SQLite değiştirilebilir." } }, "refreshSuccess": "Kota yenilendi", diff --git a/src/locales/vi.json b/src/locales/vi.json index 844e13d13..deb57dfd3 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -1772,6 +1772,13 @@ "commandMac": "macOS: pkill -9 -f \"CODEX_HOME=\"​", "commandWin": "Windows: taskkill /F /PID (khớp với CODEX_HOME)" } + }, + "launchSafety": { + "section": "An toàn khi khởi chạy", + "configProtection": "Bảo vệ cấu hình Codex", + "configProtectionDesc": "Khi bật, các luồng khởi chạy, đánh thức và app-server của Cockpit không ghi lại config.toml. Khi tắt, cơ chế dọn dẹp tương thích cũ được khôi phục và có thể xóa cấu hình không xác định.", + "sessionRepair": "Sửa khả năng hiển thị phiên trước khi khởi chạy", + "sessionRepairDesc": "Khi tắt, chỉ có thể sửa thủ công. Khi bật, mỗi lần khởi chạy phiên bản Codex sẽ chạy sửa nhanh và có thể thay đổi siêu dữ liệu phiên cùng SQLite." } }, "refreshSuccess": "Đã làm mới quota", diff --git a/src/locales/zh-tw.json b/src/locales/zh-tw.json index acaeda0a7..379e04f49 100644 --- a/src/locales/zh-tw.json +++ b/src/locales/zh-tw.json @@ -1772,6 +1772,13 @@ "commandMac": "macOS:pkill -9 -f“CODEX_HOME=”", "commandWin": "Windows: taskkill /F /PID (匹配 CODEX_HOME)" } + }, + "launchSafety": { + "section": "啟動安全", + "configProtection": "保護 Codex 設定", + "configProtectionDesc": "開啟時,Cockpit 啟動、喚醒和 app-server 不會自動改寫 config.toml。關閉後會啟用舊版相容性清理,可能刪除未知設定。", + "sessionRepair": "啟動前修復工作階段可見性", + "sessionRepairDesc": "關閉時只能手動修復。開啟後,每次啟動 Codex 執行個體前都會執行快速修復,並可能修改工作階段中繼資料和 SQLite。" } }, "refreshSuccess": "配額已重新整理", From bcb098070c8a769db487210273f131b66e0faf61 Mon Sep 17 00:00:00 2001 From: King Star Date: Wed, 22 Jul 2026 15:47:40 +0800 Subject: [PATCH 2/3] fix(ci): support Swift output layouts --- src-tauri/build.rs | 60 +++++++++++++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 66928e4c0..f1a2ddacb 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -9,15 +9,29 @@ fn link_macos_swift_runtime_rpaths() { println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift"); } +#[cfg(target_os = "macos")] +fn find_named_files(root: &Path, name: &str, matches: &mut Vec) { + let Ok(entries) = std::fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + find_named_files(&path, name, matches); + } else if path.file_name().and_then(|value| value.to_str()) == Some(name) { + matches.push(path); + } + } +} + #[cfg(target_os = "macos")] fn bridge_swift_rs_xcode_output_layout() { let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR is required")); - let (configuration, product_configuration) = - if std::env::var("DEBUG").ok().as_deref() == Some("true") { - ("debug", "Debug") - } else { - ("release", "Release") - }; + let configuration = if std::env::var("DEBUG").ok().as_deref() == Some("true") { + "debug" + } else { + "release" + }; let target_arch = std::env::var("CARGO_CFG_TARGET_ARCH").expect("CARGO_CFG_TARGET_ARCH is required"); let arch = match target_arch.as_str() { @@ -25,19 +39,26 @@ fn bridge_swift_rs_xcode_output_layout() { value => value, }; let package_dir = out_dir.join("swift-rs/MacosNativeMenuSwift"); - let actual = package_dir.join(format!("out/Products/{product_configuration}")); let expected = package_dir.join(format!("{arch}-apple-macosx/{configuration}")); - let library = actual.join("libMacosNativeMenuSwift.a"); + let library_name = "libMacosNativeMenuSwift.a"; + if expected.join(library_name).is_file() { + return; + } - if !library.is_file() { + let mut matches = Vec::new(); + find_named_files(&package_dir, library_name, &mut matches); + if matches.len() != 1 { panic!( - "swift-rs completed without MacosNativeMenuSwift static library: {}", - library.display() + "swift-rs produced {} MacosNativeMenuSwift static libraries under {}: {:?}", + matches.len(), + package_dir.display(), + matches ); } - if expected.join("libMacosNativeMenuSwift.a").is_file() { - return; - } + + let staged = package_dir.join(format!(".{library_name}.jstarctl-stage")); + std::fs::copy(&matches[0], &staged) + .expect("failed to stage swift-rs static library for compatibility path"); if expected.exists() || expected.is_symlink() { if expected.is_dir() && !expected.is_symlink() { std::fs::remove_dir_all(&expected) @@ -46,14 +67,9 @@ fn bridge_swift_rs_xcode_output_layout() { std::fs::remove_file(&expected).expect("failed to replace stale swift-rs output link"); } } - std::fs::create_dir_all( - expected - .parent() - .expect("swift-rs compatibility path has a parent"), - ) - .expect("failed to create swift-rs compatibility parent"); - std::os::unix::fs::symlink(&actual, &expected) - .expect("failed to link swift-rs compatibility output path"); + std::fs::create_dir_all(&expected).expect("failed to create swift-rs compatibility path"); + std::fs::rename(&staged, expected.join(library_name)) + .expect("failed to install swift-rs static library compatibility copy"); } fn go_target_from_rust_target(target: &str) -> Option<(&'static str, &'static str)> { From 02233858e892a206a45ec5fbff2c98f6be472787 Mon Sep 17 00:00:00 2001 From: King Star Date: Wed, 22 Jul 2026 16:24:08 +0800 Subject: [PATCH 3/3] fix(codex): keep runtime provider identity stable --- src-tauri/build.rs | 29 ++- src-tauri/src/commands/codex.rs | 23 +++ src-tauri/src/lib.rs | 2 + src-tauri/src/modules/codex_account.rs | 190 ++++++++++++++++-- src-tauri/src/modules/config.rs | 17 ++ src/components/codex/CodexQuickConfigCard.tsx | 54 ++++- src/services/codexService.ts | 8 + 7 files changed, 298 insertions(+), 25 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index f1a2ddacb..642aebeae 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -47,17 +47,36 @@ fn bridge_swift_rs_xcode_output_layout() { let mut matches = Vec::new(); find_named_files(&package_dir, library_name, &mut matches); - if matches.len() != 1 { + if matches.is_empty() { panic!( - "swift-rs produced {} MacosNativeMenuSwift static libraries under {}: {:?}", - matches.len(), + "swift-rs did not produce {library_name} under {}", package_dir.display(), - matches ); } + let xcode_configuration = if configuration == "debug" { + "Debug" + } else { + "Release" + }; + matches.sort(); + let source = matches + .iter() + .find(|path| { + let Some(parent) = path.parent() else { + return false; + }; + parent.file_name().and_then(|value| value.to_str()) == Some(xcode_configuration) + && parent + .parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + == Some("Products") + }) + .unwrap_or(&matches[0]); + let staged = package_dir.join(format!(".{library_name}.jstarctl-stage")); - std::fs::copy(&matches[0], &staged) + std::fs::copy(source, &staged) .expect("failed to stage swift-rs static library for compatibility path"); if expected.exists() || expected.is_symlink() { if expected.is_dir() && !expected.is_symlink() { diff --git a/src-tauri/src/commands/codex.rs b/src-tauri/src/commands/codex.rs index 876d95189..9df9a6102 100644 --- a/src-tauri/src/commands/codex.rs +++ b/src-tauri/src/commands/codex.rs @@ -669,6 +669,29 @@ pub fn save_codex_quick_config( codex_account::save_current_quick_config(model_context_window, auto_compact_token_limit) } +#[tauri::command] +pub fn get_codex_runtime_provider_id() -> String { + config::get_user_config().codex_runtime_provider_id +} + +#[tauri::command] +pub fn save_codex_runtime_provider_id(provider_id: String) -> Result { + let normalized = provider_id.trim().to_ascii_lowercase(); + let mut chars = normalized.chars(); + if !matches!(chars.next(), Some(ch) if ch.is_ascii_alphabetic()) + || !chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') + { + return Err( + "Provider ID 必须以英文字母开头,且只能包含小写字母、数字、下划线和连字符".to_string(), + ); + } + config::patch_user_config(|current| { + current.codex_runtime_provider_id = normalized.clone(); + Ok(()) + })?; + Ok(normalized) +} + #[tauri::command] pub fn get_codex_app_speed_config() -> Result { codex_speed::get_app_speed_config() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c754bfd92..e843108be 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -741,6 +741,8 @@ pub fn run() { commands::codex::open_codex_config_toml, commands::codex::get_codex_quick_config, commands::codex::save_codex_quick_config, + commands::codex::get_codex_runtime_provider_id, + commands::codex::save_codex_runtime_provider_id, commands::codex::get_codex_app_speed_config, commands::codex::save_codex_app_speed, commands::codex::get_codex_api_service_app_speed_config, diff --git a/src-tauri/src/modules/codex_account.rs b/src-tauri/src/modules/codex_account.rs index a51bfe7a0..36ec539a1 100644 --- a/src-tauri/src/modules/codex_account.rs +++ b/src-tauri/src/modules/codex_account.rs @@ -1324,6 +1324,39 @@ fn set_imagegen_headers(provider_table: &mut toml_edit::Table, images_only_for_c } } +fn configured_runtime_provider_id() -> Option { + #[cfg(test)] + { + None + } + #[cfg(not(test))] + { + let id = crate::modules::config::get_user_config().codex_runtime_provider_id; + sanitize_api_provider_id(&id) + } +} + +fn resolve_api_key_runtime_provider_id( + provider_config: &ApiProviderConfig, + base_url: &str, + configured_id: Option<&str>, +) -> String { + let source_id = provider_config + .provider_id + .as_deref() + .filter(|id| !id.trim().is_empty()) + .unwrap_or(CODEX_RUNTIME_MODEL_PROVIDER_ID); + let is_local_access_loopback = + source_id == CODEX_RUNTIME_MODEL_PROVIDER_ID && is_loopback_http_base_url(Some(base_url)); + if is_local_access_loopback { + return source_id.to_string(); + } + + configured_id + .and_then(sanitize_api_provider_id) + .unwrap_or_else(|| source_id.to_string()) +} + fn write_api_key_provider_to_config_toml( base_dir: &Path, provider_config: &ApiProviderConfig, @@ -1333,6 +1366,27 @@ fn write_api_key_provider_to_config_toml( // true → Codex 使用 auth.json/Keychain OAuth 登录态(绑定 OAuth)。 // false → 纯 API Key,配合 actor 走 bearer 生图。 require_openai_auth: bool, +) -> Result<(), String> { + let configured_provider_id = configured_runtime_provider_id(); + write_api_key_provider_to_config_toml_with_runtime_provider_id( + base_dir, + provider_config, + bearer_token, + supports_websockets, + supports_image_generation, + require_openai_auth, + configured_provider_id.as_deref(), + ) +} + +fn write_api_key_provider_to_config_toml_with_runtime_provider_id( + base_dir: &Path, + provider_config: &ApiProviderConfig, + bearer_token: &str, + supports_websockets: bool, + supports_image_generation: bool, + require_openai_auth: bool, + configured_provider_id: Option<&str>, ) -> Result<(), String> { let config_path = get_config_toml_path(base_dir); let bearer_token = normalize_api_key(bearer_token) @@ -1346,11 +1400,8 @@ fn write_api_key_provider_to_config_toml( .as_deref() .filter(|name| !name.trim().is_empty()) .unwrap_or(CODEX_DEFAULT_RUNTIME_PROVIDER_NAME); - let provider_id = provider_config - .provider_id - .as_deref() - .filter(|id| !id.trim().is_empty()) - .unwrap_or(CODEX_RUNTIME_MODEL_PROVIDER_ID); + let provider_id = + resolve_api_key_runtime_provider_id(provider_config, base_url, configured_provider_id); let existing = fs::read_to_string(&config_path).unwrap_or_default(); let mut doc = if existing.trim().is_empty() { @@ -1366,7 +1417,7 @@ fn write_api_key_provider_to_config_toml( .map(str::trim) .filter(|id| !id.is_empty()) .map(str::to_string); - if previous_provider_id.as_deref() != Some(provider_id) { + if previous_provider_id.as_deref() != Some(provider_id.as_str()) { if let Some(previous_provider_id) = previous_provider_id.as_deref() { let managed_provider_ids = collect_managed_api_key_provider_ids(); if managed_provider_ids.contains(previous_provider_id) { @@ -1380,17 +1431,17 @@ fn write_api_key_provider_to_config_toml( } } - doc[CODEX_CONFIG_MODEL_PROVIDER_KEY] = value(provider_id); + doc[CODEX_CONFIG_MODEL_PROVIDER_KEY] = value(provider_id.as_str()); if doc.get(CODEX_CONFIG_MODEL_PROVIDERS_KEY).is_none() { doc[CODEX_CONFIG_MODEL_PROVIDERS_KEY] = toml_edit::table(); } let model_providers = doc[CODEX_CONFIG_MODEL_PROVIDERS_KEY] .as_table_mut() .ok_or("config.toml 中 model_providers 不是合法表结构")?; - if !model_providers.contains_key(provider_id) { - model_providers[provider_id] = toml_edit::table(); + if !model_providers.contains_key(provider_id.as_str()) { + model_providers[provider_id.as_str()] = toml_edit::table(); } - let provider_table = model_providers[provider_id] + let provider_table = model_providers[provider_id.as_str()] .as_table_mut() .ok_or("config.toml 中目标 provider 不是合法表结构")?; provider_table["name"] = value(provider_name); @@ -8585,22 +8636,24 @@ mod tests { load_account_index, looks_like_sub2api_export, now_timestamp, parse_auth_file_last_refresh, parse_codex_account_compat, parse_line_delimited_json_values, read_api_provider_from_config_toml, read_quick_config_from_config_toml, remove_accounts, - resolve_api_provider_config, save_account, save_account_index, - should_accept_authority_snapshot, sync_account_from_auth_dir, + resolve_api_key_runtime_provider_id, resolve_api_provider_config, save_account, + save_account_index, should_accept_authority_snapshot, sync_account_from_auth_dir, sync_api_key_account_from_local_state, sync_api_key_provider_accounts, sync_managed_projection_from_auth_dir, try_parse_pending_oauth_delimited_line, upsert_account, upsert_account_for_reauth, upsert_account_from_access_token, upsert_account_from_access_token_with_hints, upsert_account_from_auth_tokens, upsert_api_key_account, validate_api_key_credentials, write_account_bundle_to_dir, - write_api_key_provider_to_config_toml, write_api_provider_to_config_toml, - write_managed_projection_to_dir, write_quick_config_to_config_toml, ApiProviderConfig, - CodexAccessTokenImportHints, CodexAccountGroupRecord, CodexAccountIndex, - CodexAccountSummary, CodexAuthFile, CodexAuthTokens, CodexGroupQuotaRefreshPolicy, - CodexJsonImportCandidate, LocalCodexOAuthSnapshot, CODEX_ACCOUNT_DETAIL_SCHEMA_VERSION, + write_api_key_provider_to_config_toml, + write_api_key_provider_to_config_toml_with_runtime_provider_id, + write_api_provider_to_config_toml, write_managed_projection_to_dir, + write_quick_config_to_config_toml, ApiProviderConfig, CodexAccessTokenImportHints, + CodexAccountGroupRecord, CodexAccountIndex, CodexAccountSummary, CodexAuthFile, + CodexAuthTokens, CodexGroupQuotaRefreshPolicy, CodexJsonImportCandidate, + LocalCodexOAuthSnapshot, CODEX_ACCOUNT_DETAIL_SCHEMA_VERSION, CODEX_AUTHORIZATION_STATUS_PENDING, CODEX_AUTO_COMPACT_DEFAULT_LIMIT, CODEX_CONTEXT_WINDOW_1M_VALUE, CODEX_DISABLE_HOSTED_IMAGE_GENERATION_HEADER, CODEX_DISABLE_HOSTED_IMAGE_GENERATION_HEADER_VALUE, CODEX_IMAGEGEN_ACTOR_HEADER, - CODEX_IMAGEGEN_ACTOR_HEADER_VALUE, CODEX_IMAGE_MODEL_ID, + CODEX_IMAGEGEN_ACTOR_HEADER_VALUE, CODEX_IMAGE_MODEL_ID, CODEX_RUNTIME_MODEL_PROVIDER_ID, }; use crate::models::codex::{CodexAccount, CodexApiProviderMode, CodexTokens}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; @@ -10827,6 +10880,107 @@ multi_agent = true fs::remove_dir_all(&base_dir).expect("cleanup temp dir"); } + #[test] + fn runtime_provider_id_projects_different_upstreams_into_one_stable_slot() { + let first = resolve_api_provider_config( + Some("https://first.example.com/v1"), + Some(CodexApiProviderMode::Custom), + Some("cmp_first"), + Some("First"), + ) + .expect("resolve first provider"); + let second = resolve_api_provider_config( + Some("https://second.example.com/v1"), + Some(CodexApiProviderMode::Custom), + Some("cmp_second"), + Some("Second"), + ) + .expect("resolve second provider"); + + assert_eq!( + resolve_api_key_runtime_provider_id( + &first, + "https://first.example.com/v1", + Some("main"), + ), + "main" + ); + assert_eq!( + resolve_api_key_runtime_provider_id( + &second, + "https://second.example.com/v1", + Some("main"), + ), + "main" + ); + } + + #[test] + fn runtime_provider_id_keeps_real_config_projection_stable_across_switches() { + let base_dir = make_temp_dir("codex-runtime-provider-switch-test"); + let first = resolve_api_provider_config( + Some("https://first.example.com/v1"), + Some(CodexApiProviderMode::Custom), + Some("cmp_first"), + Some("First"), + ) + .expect("resolve first provider"); + let second = resolve_api_provider_config( + Some("https://second.example.com/v1"), + Some(CodexApiProviderMode::Custom), + Some("cmp_second"), + Some("Second"), + ) + .expect("resolve second provider"); + + write_api_key_provider_to_config_toml_with_runtime_provider_id( + &base_dir, + &first, + "sk-first", + false, + false, + true, + Some("main"), + ) + .expect("write first provider"); + write_api_key_provider_to_config_toml_with_runtime_provider_id( + &base_dir, + &second, + "sk-second", + false, + false, + true, + Some("main"), + ) + .expect("write second provider"); + + let content = fs::read_to_string(base_dir.join("config.toml")).expect("read config"); + assert!(content.contains("model_provider = \"main\"")); + assert!(content.contains("[model_providers.main]")); + assert!(content.contains("name = \"Second\"")); + assert!(content.contains("base_url = \"https://second.example.com/v1\"")); + assert!(!content.contains("model_providers.cmp_first")); + assert!(!content.contains("model_providers.cmp_second")); + + fs::remove_dir_all(&base_dir).expect("cleanup temp dir"); + } + + #[test] + fn runtime_provider_id_setting_does_not_replace_local_gateway_identity() { + let local = resolve_api_provider_config( + Some("http://127.0.0.1:1455/v1"), + Some(CodexApiProviderMode::Custom), + Some(CODEX_RUNTIME_MODEL_PROVIDER_ID), + Some("Cockpit Local Access"), + ) + .expect("resolve local provider"); + + assert_eq!( + resolve_api_key_runtime_provider_id(&local, "http://127.0.0.1:1455/v1", Some("main")), + CODEX_RUNTIME_MODEL_PROVIDER_ID + ); + } + #[test] fn api_key_provider_projection_preserves_unmanaged_config_sections() { let base_dir = make_temp_dir("codex-api-key-config-preserve-sections-test"); diff --git a/src-tauri/src/modules/config.rs b/src-tauri/src/modules/config.rs index 534f61257..6d95e6754 100644 --- a/src-tauri/src/modules/config.rs +++ b/src-tauri/src/modules/config.rs @@ -107,6 +107,9 @@ pub struct UserConfig { /// 是否启用 Codex 客户端中的 API 服务额度显示注入 #[serde(default = "default_codex_app_ui_injection_enabled")] pub codex_app_ui_injection_enabled: bool, + /// API Key 供应商切换时写入 Codex config.toml 的稳定 provider ID + #[serde(default = "default_codex_runtime_provider_id")] + pub codex_runtime_provider_id: String, /// Codex WSL 配置目录 (Windows Only) #[serde(default = "default_codex_wsl_config_dir")] pub codex_wsl_config_dir: String, @@ -668,6 +671,9 @@ fn default_codex_sync_wsl() -> bool { fn default_codex_app_ui_injection_enabled() -> bool { true } +fn default_codex_runtime_provider_id() -> String { + "main".to_string() +} fn default_codex_wsl_config_dir() -> String { String::new() } @@ -1104,6 +1110,7 @@ impl Default for UserConfig { codex_auto_refresh_minutes: default_codex_auto_refresh(), codex_sync_wsl: default_codex_sync_wsl(), codex_app_ui_injection_enabled: default_codex_app_ui_injection_enabled(), + codex_runtime_provider_id: default_codex_runtime_provider_id(), codex_wsl_config_dir: default_codex_wsl_config_dir(), zed_auto_refresh_minutes: default_zed_auto_refresh(), ghcp_auto_refresh_minutes: default_ghcp_auto_refresh(), @@ -2445,6 +2452,16 @@ mod tests { assert!(!migrated_cfg.grok_sync_official_auth_on_switch); } + #[test] + fn codex_runtime_provider_id_defaults_to_main() { + let default_cfg = UserConfig::default(); + assert_eq!(default_cfg.codex_runtime_provider_id, "main"); + + let migrated_cfg: UserConfig = + serde_json::from_value(serde_json::json!({})).expect("旧配置反序列化应成功"); + assert_eq!(migrated_cfg.codex_runtime_provider_id, "main"); + } + #[test] fn codex_api_service_quota_display_defaults_to_enabled() { let default_cfg = UserConfig::default(); diff --git a/src/components/codex/CodexQuickConfigCard.tsx b/src/components/codex/CodexQuickConfigCard.tsx index 82e7f9f14..d4a378563 100644 --- a/src/components/codex/CodexQuickConfigCard.tsx +++ b/src/components/codex/CodexQuickConfigCard.tsx @@ -4,8 +4,10 @@ import { ChevronLeft, CircleAlert, FolderOpen, Save, X } from 'lucide-react'; import { getCodexConfigTomlPath, getCodexQuickConfig, + getCodexRuntimeProviderId, openCodexConfigToml, saveCodexQuickConfig, + saveCodexRuntimeProviderId, } from '../../services/codexService'; import { useEscClose } from '../../hooks/useEscClose'; import type { CodexQuickConfig } from '../../types/codex'; @@ -77,6 +79,7 @@ export function CodexQuickConfigCard({ onClose }: { onClose?: () => void }) { const [autoCompactLimitInput, setAutoCompactLimitInput] = useState( String(DEFAULT_AUTO_COMPACT_TOKEN_LIMIT), ); + const [runtimeProviderIdInput, setRuntimeProviderIdInput] = useState('main'); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [opening, setOpening] = useState(false); @@ -104,12 +107,14 @@ export function CodexQuickConfigCard({ onClose }: { onClose?: () => void }) { setLoading(true); setError(null); try { - const [path, config] = await Promise.all([ + const [path, config, runtimeProviderId] = await Promise.all([ getCodexConfigTomlPath(), getCodexQuickConfig(), + getCodexRuntimeProviderId(), ]); setConfigPath(path); applyLoadedConfig(config); + setRuntimeProviderIdInput(runtimeProviderId); } catch (err) { setError( t('codex.modelProviders.quickConfig.loadFailed', { @@ -209,7 +214,15 @@ export function CodexQuickConfigCard({ onClose }: { onClose?: () => void }) { ); }, [isCustomPreset, parsedAutoCompactLimit, t]); - const validationError = contextWindowError ?? compactLimitError; + const runtimeProviderIdError = useMemo(() => { + if (/^[a-z][a-z0-9_-]*$/.test(runtimeProviderIdInput.trim())) return null; + return t( + 'codex.modelProviders.quickConfig.validation.runtimeProviderIdInvalid', + 'Provider ID 必须以小写英文字母开头,且只能包含小写字母、数字、下划线和连字符', + ); + }, [runtimeProviderIdInput, t]); + + const validationError = contextWindowError ?? compactLimitError ?? runtimeProviderIdError; const targetConfig = useMemo(() => { if (selectedPresetId === 'custom') { @@ -303,7 +316,11 @@ export function CodexQuickConfigCard({ onClose }: { onClose?: () => void }) { targetConfig.modelContextWindow ?? undefined, targetConfig.autoCompactTokenLimit ?? undefined, ); + const savedRuntimeProviderId = await saveCodexRuntimeProviderId( + runtimeProviderIdInput, + ); applyLoadedConfig(saved); + setRuntimeProviderIdInput(savedRuntimeProviderId); setNotice( t( 'codex.modelProviders.quickConfig.saveSuccess', @@ -382,6 +399,39 @@ export function CodexQuickConfigCard({ onClose }: { onClose?: () => void }) {
+
+ + { + setNotice(null); + setError(null); + setRuntimeProviderIdInput(event.target.value); + }} + disabled={saving} + /> +

+ {t( + 'codex.modelProviders.quickConfig.runtimeProviderIdHint', + '切换不同供应商时保持这个 ID 不变,避免 Codex 任务按供应商拆分。保存后在下次切换或启用供应商时生效。', + )} +

+ {runtimeProviderIdError && ( +
+ + {runtimeProviderIdError} +
+ )} +