Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
936f2f49e4 | ||
|
|
9687a4b11c | ||
|
|
32bab7731e | ||
|
|
f64848ca9c | ||
|
|
94ab211431 | ||
|
|
e8b0e5b73c | ||
|
|
5b6655754d | ||
|
|
1fd5b1e608 | ||
|
|
7d0e0064d5 | ||
|
|
d38548a1f4 | ||
|
|
f25e43d281 | ||
|
|
d404905812 | ||
|
|
c200e6b86e | ||
|
|
bffeca53cc | ||
|
|
d381753be9 | ||
|
|
752078b2cf | ||
|
|
f00acccef1 | ||
|
|
e5fa7fbe05 | ||
|
|
f058bb5e85 | ||
|
|
659415f57c | ||
|
|
f51bdd6875 | ||
|
|
6c90976e8b | ||
|
|
b7d4a1996b | ||
|
|
561c3dcda3 | ||
|
|
45f6a225d9 | ||
|
|
79ccca2042 | ||
|
|
4e76fe54ac | ||
|
|
9206064e53 | ||
|
|
c8a67261ca | ||
|
|
bd1c103976 | ||
|
|
f8504dda30 | ||
|
|
b5eab73bba | ||
|
|
c263c488d0 | ||
|
|
269b463a06 | ||
|
|
7927a4253d | ||
|
|
180d6b9500 | ||
|
|
64f8ccfd7a | ||
|
|
e82ea649cd | ||
|
|
78ecb89b9b | ||
|
|
9fd405365a | ||
|
|
dd9a28ef93 | ||
|
|
99d42fd234 | ||
|
|
9be6b20a1c | ||
|
|
7d284a60fd | ||
|
|
e2b5c67a17 | ||
|
|
2f63cabb47 | ||
|
|
2dc6a35050 | ||
|
|
195fe1ee82 | ||
|
|
0eabdebe76 | ||
|
|
00acc2c835 | ||
|
|
7f69661f6a | ||
|
|
f8bb9ab2c4 | ||
|
|
9731843531 | ||
|
|
5708d4f9e9 | ||
|
|
aba5d7cc0f | ||
|
|
6df450482e | ||
|
|
f571799adb | ||
|
|
7f152b9812 | ||
|
|
a653a58f6d | ||
|
|
9019bdf3e2 | ||
|
|
1e89ff95d3 | ||
|
|
2cc94f1707 | ||
|
|
f2cb01dd0e | ||
|
|
44c7c7ecbf |
@@ -3,54 +3,188 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing release tag to rebuild, for example v0.3.0
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ inputs.tag || github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Prepare draft release
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
tag: ${{ steps.metadata.outputs.tag }}
|
||||
version: ${{ steps.metadata.outputs.version }}
|
||||
release_body: ${{ steps.metadata.outputs.release_body }}
|
||||
release_id: ${{ steps.release.outputs.release_id }}
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
||||
|
||||
- name: Validate versions and prepare release notes
|
||||
id: metadata
|
||||
env:
|
||||
REQUESTED_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$REQUESTED_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Invalid release tag: $REQUESTED_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
tag_commit=$(git rev-list -n 1 "refs/tags/${REQUESTED_TAG}^{commit}")
|
||||
if [ -z "$tag_commit" ] || [ "$tag_commit" != "$(git rev-parse HEAD)" ]; then
|
||||
echo "The workflow must run from the exact commit referenced by ${REQUESTED_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_TAG="$REQUESTED_TAG" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag[1:]
|
||||
root_package = json.loads(Path("package.json").read_text(encoding="utf-8"))
|
||||
desktop_package = json.loads(Path("apps/desktop/package.json").read_text(encoding="utf-8"))
|
||||
tauri_config = json.loads(Path("apps/desktop/src-tauri/tauri.conf.json").read_text(encoding="utf-8"))
|
||||
cargo_text = Path("apps/desktop/src-tauri/Cargo.toml").read_text(encoding="utf-8")
|
||||
cargo_match = re.search(
|
||||
r'^\[package\]\s*$.*?^version\s*=\s*"([^"]+)"',
|
||||
cargo_text,
|
||||
flags=re.S | re.M,
|
||||
)
|
||||
if not cargo_match:
|
||||
raise SystemExit("Unable to read the package version from Cargo.toml")
|
||||
|
||||
versions = {
|
||||
"package.json": root_package.get("version"),
|
||||
"apps/desktop/package.json": desktop_package.get("version"),
|
||||
"apps/desktop/src-tauri/Cargo.toml": cargo_match.group(1),
|
||||
"apps/desktop/src-tauri/tauri.conf.json": tauri_config.get("version"),
|
||||
}
|
||||
mismatches = {path: value for path, value in versions.items() if value != expected}
|
||||
if mismatches:
|
||||
details = ", ".join(f"{path}={value!r}" for path, value in mismatches.items())
|
||||
raise SystemExit(f"Release tag {tag} does not match project versions: {details}")
|
||||
|
||||
changelog = Path("CHANGELOG.md").read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
rf"^## \[{re.escape(tag)}\].*?\n(.*?)(?=^## \[|\Z)",
|
||||
changelog,
|
||||
flags=re.S | re.M,
|
||||
)
|
||||
body = match.group(1).strip() if match else ""
|
||||
if not body:
|
||||
body = "Codex-X desktop release."
|
||||
Path("release-body.md").write_text(body + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
delimiter="release_body_$(date +%s)_${RANDOM}"
|
||||
{
|
||||
echo "tag=$REQUESTED_TAG"
|
||||
echo "version=${REQUESTED_TAG#v}"
|
||||
echo "release_body<<$delimiter"
|
||||
cat release-body.md
|
||||
echo "$delimiter"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create clean draft release
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ steps.metadata.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
is_draft=$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')
|
||||
if [ "$is_draft" != "true" ]; then
|
||||
echo "${RELEASE_TAG} is already published; use a new version instead of replacing updater assets" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--title "Codex-X ${RELEASE_TAG}" \
|
||||
--notes-file release-body.md \
|
||||
--draft
|
||||
else
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--verify-tag \
|
||||
--title "Codex-X ${RELEASE_TAG}" \
|
||||
--notes-file release-body.md \
|
||||
--draft
|
||||
fi
|
||||
|
||||
release_id=""
|
||||
for attempt in {1..10}; do
|
||||
release_id=$(gh release view "$RELEASE_TAG" \
|
||||
--json databaseId \
|
||||
--jq '.databaseId' 2>/dev/null || true)
|
||||
[ -z "$release_id" ] || break
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "Unable to locate draft release for ${RELEASE_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" \
|
||||
--jq '.[].id' | while read -r asset_id; do
|
||||
[ -z "$asset_id" ] || gh api --method DELETE "/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}"
|
||||
done
|
||||
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: prepare
|
||||
runs-on: ${{ matrix.platform }}
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS Apple Silicon
|
||||
platform: macos-latest
|
||||
args: --bundles dmg
|
||||
artifact: codex-x-macos-arm64
|
||||
paths: |
|
||||
apps/desktop/src-tauri/target/release/bundle/dmg/*.dmg
|
||||
macos: true
|
||||
args: --bundles app,dmg
|
||||
asset_pattern: Codex-X-[version]-macos-apple-silicon[ext]
|
||||
- name: macOS Intel
|
||||
platform: macos-15-intel
|
||||
args: --bundles dmg
|
||||
artifact: codex-x-macos-x64
|
||||
paths: |
|
||||
apps/desktop/src-tauri/target/release/bundle/dmg/*.dmg
|
||||
- name: Linux
|
||||
macos: true
|
||||
args: --bundles app,dmg
|
||||
asset_pattern: Codex-X-[version]-macos-intel[ext]
|
||||
- name: Linux x64
|
||||
platform: ubuntu-22.04
|
||||
args: --bundles deb,rpm
|
||||
artifact: codex-x-linux
|
||||
paths: |
|
||||
apps/desktop/src-tauri/target/release/bundle/deb/*.deb
|
||||
apps/desktop/src-tauri/target/release/bundle/rpm/*.rpm
|
||||
- name: Windows
|
||||
macos: false
|
||||
args: --bundles deb,rpm,appimage
|
||||
asset_pattern: Codex-X-[version]-linux-x64[ext]
|
||||
- name: Windows x64
|
||||
platform: windows-latest
|
||||
macos: false
|
||||
args: --bundles msi
|
||||
artifact: codex-x-windows
|
||||
paths: |
|
||||
apps/desktop/src-tauri/target/release/bundle/msi/*.msi
|
||||
apps/desktop/src-tauri/Codex-X_*_windows_x64_portable.zip
|
||||
asset_pattern: Codex-X-[version]-windows-x64[ext]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
@@ -64,144 +198,324 @@ jobs:
|
||||
patchelf
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
with:
|
||||
version: 10.26.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: apps/desktop/src-tauri
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Tauri bundles
|
||||
run: pnpm --dir apps/desktop tauri build ${{ matrix.args }}
|
||||
|
||||
- name: Create Windows portable zip
|
||||
- name: Stage pinned Windows skin runtime
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$version = '22.23.1'
|
||||
$expectedSha256 = '7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29'
|
||||
$archive = Join-Path $env:RUNNER_TEMP "node-v$version-win-x64.zip"
|
||||
$extractRoot = Join-Path $env:RUNNER_TEMP "codex-x-node-$version"
|
||||
$targetRoot = 'apps/desktop/src-tauri/runtime/windows/node'
|
||||
Invoke-WebRequest -Uri "https://nodejs.org/dist/v$version/node-v$version-win-x64.zip" -OutFile $archive
|
||||
$actualSha256 = (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
if ($actualSha256 -cne $expectedSha256) {
|
||||
throw "Node.js archive checksum mismatch: $actualSha256"
|
||||
}
|
||||
Remove-Item -LiteralPath $extractRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Expand-Archive -LiteralPath $archive -DestinationPath $extractRoot
|
||||
$sourceRoot = Join-Path $extractRoot "node-v$version-win-x64"
|
||||
New-Item -ItemType Directory -Path $targetRoot -Force | Out-Null
|
||||
Copy-Item -LiteralPath (Join-Path $sourceRoot 'node.exe') -Destination $targetRoot -Force
|
||||
Copy-Item -LiteralPath (Join-Path $sourceRoot 'LICENSE') -Destination $targetRoot -Force
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $targetRoot 'node.exe') -PathType Leaf)) {
|
||||
throw 'Pinned Windows Node.js runtime was not staged.'
|
||||
}
|
||||
|
||||
- name: Test Windows filesystem compatibility
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked
|
||||
|
||||
- name: Import Apple signing certificate and notarization key
|
||||
if: matrix.macos
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(node -p "require('./apps/desktop/package.json').version")
|
||||
portable_dir="apps/desktop/src-tauri/portable/Codex-X_${version}_windows_x64_portable"
|
||||
rm -rf "apps/desktop/src-tauri/portable"
|
||||
required=(
|
||||
APPLE_CERTIFICATE
|
||||
APPLE_CERTIFICATE_PASSWORD
|
||||
APPLE_API_ISSUER
|
||||
APPLE_API_KEY
|
||||
APPLE_API_KEY_P8_BASE64
|
||||
APPLE_SIGNING_IDENTITY
|
||||
APPLE_TEAM_ID
|
||||
KEYCHAIN_PASSWORD
|
||||
)
|
||||
for name in "${required[@]}"; do
|
||||
if [ -z "${!name:-}" ]; then
|
||||
echo "Missing required repository secret: ${name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
certificate_path="$RUNNER_TEMP/codex-x-developer-id.p12"
|
||||
api_key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8"
|
||||
keychain_path="$RUNNER_TEMP/codex-x-signing.keychain-db"
|
||||
|
||||
printf '%s' "$APPLE_CERTIFICATE" | base64 -D > "$certificate_path"
|
||||
printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 -D > "$api_key_path"
|
||||
chmod 600 "$certificate_path" "$api_key_path"
|
||||
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
|
||||
security default-keychain -s "$keychain_path"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
|
||||
security set-keychain-settings -lut 21600 "$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"
|
||||
security find-identity -v -p codesigning "$keychain_path" \
|
||||
| grep -F -- "$APPLE_SIGNING_IDENTITY" >/dev/null
|
||||
|
||||
echo "APPLE_API_KEY_PATH=$api_key_path" >> "$GITHUB_ENV"
|
||||
echo "CODEXX_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
|
||||
echo "CODEXX_CERTIFICATE_PATH=$certificate_path" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate updater signing secrets
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ] || [ -z "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" ]; then
|
||||
echo "Missing Tauri updater signing repository secrets" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build, sign, and upload updater artifacts
|
||||
# Pinned to tauri-action v1.0.0 because this step receives the updater private key.
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ matrix.macos && secrets.APPLE_SIGNING_IDENTITY || '' }}
|
||||
APPLE_API_ISSUER: ${{ matrix.macos && secrets.APPLE_API_ISSUER || '' }}
|
||||
APPLE_API_KEY: ${{ matrix.macos && secrets.APPLE_API_KEY || '' }}
|
||||
APPLE_API_KEY_PATH: ${{ matrix.macos && env.APPLE_API_KEY_PATH || '' }}
|
||||
APPLE_TEAM_ID: ${{ matrix.macos && secrets.APPLE_TEAM_ID || '' }}
|
||||
with:
|
||||
releaseId: ${{ needs.prepare.outputs.release_id }}
|
||||
tagName: ${{ needs.prepare.outputs.tag }}
|
||||
releaseBody: ${{ needs.prepare.outputs.release_body }}
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
projectPath: apps/desktop
|
||||
args: ${{ matrix.args }}
|
||||
releaseAssetNamePattern: ${{ matrix.asset_pattern }}
|
||||
uploadUpdaterJson: true
|
||||
uploadUpdaterSignatures: true
|
||||
updaterJsonPreferNsis: false
|
||||
retryAttempts: 3
|
||||
|
||||
- name: Verify updater signatures were generated
|
||||
run: |
|
||||
set -euo pipefail
|
||||
signature_count=$(find apps/desktop/src-tauri/target/release/bundle -type f -name '*.sig' | wc -l | tr -d ' ')
|
||||
if [ "$signature_count" -lt 1 ]; then
|
||||
echo "No updater signatures were generated" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify signed and notarized macOS bundles
|
||||
if: matrix.macos
|
||||
run: |
|
||||
set -euo pipefail
|
||||
app_path=$(find apps/desktop/src-tauri/target/release/bundle/macos -maxdepth 1 -type d -name '*.app' -print -quit)
|
||||
dmg_path=$(find apps/desktop/src-tauri/target/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)
|
||||
test -n "$app_path"
|
||||
test -n "$dmg_path"
|
||||
codesign --verify --deep --strict --verbose=2 "$app_path"
|
||||
spctl --assess --type execute --verbose=4 "$app_path"
|
||||
xcrun stapler validate "$app_path"
|
||||
hdiutil verify "$dmg_path"
|
||||
|
||||
- name: Create and upload Windows portable archive
|
||||
if: matrix.platform == 'windows-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
portable_root="apps/desktop/src-tauri/portable"
|
||||
portable_dir="$portable_root/Codex-X-${VERSION}-windows-x64-portable"
|
||||
archive="apps/desktop/src-tauri/Codex-X-${VERSION}-windows-x64-portable.zip"
|
||||
rm -rf "$portable_root"
|
||||
mkdir -p "$portable_dir"
|
||||
cp apps/desktop/src-tauri/target/release/codex-x.exe "$portable_dir/Codex-X.exe"
|
||||
mkdir -p "$portable_dir/skin-runtime/node"
|
||||
cp apps/desktop/src-tauri/runtime/windows/node/node.exe "$portable_dir/skin-runtime/node/node.exe"
|
||||
cp apps/desktop/src-tauri/runtime/windows/node/LICENSE "$portable_dir/skin-runtime/node/LICENSE"
|
||||
touch "$portable_dir/Codex-X.portable"
|
||||
cat > "$portable_dir/README-portable.txt" <<'EOF'
|
||||
Codex-X Windows Portable
|
||||
|
||||
Double click Codex-X.exe to run. No MSI installation is required.
|
||||
The portable edition uses manual downloads instead of in-app installation.
|
||||
Keep Codex-X.portable next to Codex-X.exe so the app can identify this edition.
|
||||
Keep the skin-runtime directory beside Codex-X.exe; it contains the verified Windows skin runtime.
|
||||
Microsoft Edge WebView2 Runtime is required; Windows 10/11 usually already includes it.
|
||||
EOF
|
||||
powershell -NoProfile -Command "Compress-Archive -Path 'apps/desktop/src-tauri/portable/Codex-X_${version}_windows_x64_portable/*' -DestinationPath 'apps/desktop/src-tauri/Codex-X_${version}_windows_x64_portable.zip' -Force"
|
||||
powershell -NoProfile -Command "Compress-Archive -Path '${portable_dir}/*' -DestinationPath '${archive}' -Force"
|
||||
gh release upload "$RELEASE_TAG" "$archive" --clobber
|
||||
|
||||
- name: Normalize release asset names
|
||||
- name: Remove temporary Apple credentials
|
||||
if: always() && matrix.macos
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(node -p "require('./apps/desktop/package.json').version")
|
||||
case "${{ matrix.name }}" in
|
||||
"macOS Apple Silicon")
|
||||
target="apps/desktop/src-tauri/target/release/bundle/dmg/Codex-X_${version}_macos_apple_silicon_aarch64.dmg"
|
||||
src=$(find apps/desktop/src-tauri/target/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' | head -n 1)
|
||||
test -n "$src"
|
||||
mv "$src" "$target"
|
||||
;;
|
||||
"macOS Intel")
|
||||
target="apps/desktop/src-tauri/target/release/bundle/dmg/Codex-X_${version}_macos_intel_x64.dmg"
|
||||
src=$(find apps/desktop/src-tauri/target/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' | head -n 1)
|
||||
test -n "$src"
|
||||
mv "$src" "$target"
|
||||
;;
|
||||
"Windows")
|
||||
target="apps/desktop/src-tauri/target/release/bundle/msi/Codex-X_${version}_windows_x64.msi"
|
||||
src=$(find apps/desktop/src-tauri/target/release/bundle/msi -maxdepth 1 -type f -name '*.msi' | head -n 1)
|
||||
test -n "$src"
|
||||
mv "$src" "$target"
|
||||
;;
|
||||
"Linux")
|
||||
deb_target="apps/desktop/src-tauri/target/release/bundle/deb/Codex-X_${version}_linux_x64.deb"
|
||||
rpm_target="apps/desktop/src-tauri/target/release/bundle/rpm/Codex-X_${version}_linux_x64.rpm"
|
||||
deb_src=$(find apps/desktop/src-tauri/target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' | head -n 1)
|
||||
rpm_src=$(find apps/desktop/src-tauri/target/release/bundle/rpm -maxdepth 1 -type f -name '*.rpm' | head -n 1)
|
||||
test -n "$deb_src"
|
||||
test -n "$rpm_src"
|
||||
mv "$deb_src" "$deb_target"
|
||||
mv "$rpm_src" "$rpm_target"
|
||||
;;
|
||||
esac
|
||||
if [ -n "${CODEXX_KEYCHAIN_PATH:-}" ]; then
|
||||
security delete-keychain "$CODEXX_KEYCHAIN_PATH" || true
|
||||
fi
|
||||
rm -f "${CODEXX_CERTIFICATE_PATH:-}" "${APPLE_API_KEY_PATH:-}"
|
||||
|
||||
- name: Upload bundle artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: ${{ matrix.paths }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
needs: build
|
||||
publish:
|
||||
name: Validate and publish release
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download bundle artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- name: Prepare release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
|
||||
tag = os.environ["GITHUB_REF_NAME"]
|
||||
text = Path("CHANGELOG.md").read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
rf"^## \[{re.escape(tag)}\].*?\n(.*?)(?=^## \[|\Z)",
|
||||
text,
|
||||
flags=re.S | re.M,
|
||||
)
|
||||
body = match.group(1).strip() if match else ""
|
||||
if not body:
|
||||
body = "Codex-X desktop release."
|
||||
with open("release-body.md", "w", encoding="utf-8") as fh:
|
||||
fh.write(body + "\n")
|
||||
PY
|
||||
|
||||
- name: Publish release
|
||||
- name: Match release asset labels to filenames
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
title="Codex-X ${tag}"
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
gh release edit "$tag" --title "$title" --notes-file release-body.md --latest
|
||||
else
|
||||
gh release create "$tag" --title "$title" --notes-file release-body.md --latest
|
||||
fi
|
||||
mapfile -t assets < <(find release-assets -type f \( -name '*.dmg' -o -name '*.msi' -o -name '*.deb' -o -name '*.rpm' -o -name '*.zip' \) -print | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets found" >&2
|
||||
assets_endpoint="/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100"
|
||||
gh api "$assets_endpoint" \
|
||||
--jq '.[] | select(.label != .name) | [.id, .name] | @tsv' \
|
||||
| while IFS=$'\t' read -r asset_id asset_name; do
|
||||
gh api --method PATCH \
|
||||
"/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
|
||||
-f "label=${asset_name}" >/dev/null
|
||||
done
|
||||
|
||||
mismatched_labels=$(gh api "$assets_endpoint" \
|
||||
--jq '[.[] | select(.label != .name)] | length')
|
||||
if [ "$mismatched_labels" -ne 0 ]; then
|
||||
echo "Release asset labels do not match their filenames" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Uploading %s\n' "${assets[@]}"
|
||||
gh release upload "$tag" "${assets[@]}" --clobber
|
||||
|
||||
- name: Download and validate updater manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" > release-assets.json
|
||||
latest_asset_id=$(jq -r '.[] | select(.name == "latest.json") | .id' release-assets.json)
|
||||
if [ -z "$latest_asset_id" ] || [ "$latest_asset_id" = "null" ]; then
|
||||
echo "latest.json is missing from the draft release" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh api \
|
||||
-H "Accept: application/octet-stream" \
|
||||
"/repos/${GITHUB_REPOSITORY}/releases/assets/${latest_asset_id}" \
|
||||
> latest.json
|
||||
python3 scripts/validate_updater_release.py \
|
||||
--manifest latest.json \
|
||||
--assets release-assets.json \
|
||||
--version "$VERSION" \
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--release-tag "$RELEASE_TAG" \
|
||||
--rewrite-download-urls \
|
||||
--require-signature-assets
|
||||
gh release upload "$RELEASE_TAG" latest.json --clobber
|
||||
|
||||
- name: Remove public signature assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" \
|
||||
--jq '.[] | select(.name | endswith(".sig")) | .id' \
|
||||
| while read -r asset_id; do
|
||||
[ -z "$asset_id" ] || gh api --method DELETE "/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}"
|
||||
done
|
||||
|
||||
- name: Publish the verified release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||
run: gh release edit "$RELEASE_TAG" --draft=false --latest
|
||||
|
||||
- name: Verify published updater endpoint
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
endpoint="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/latest.json"
|
||||
curl -fsSL \
|
||||
--retry 12 \
|
||||
--retry-delay 5 \
|
||||
--retry-all-errors \
|
||||
"$endpoint" \
|
||||
-o published-latest.json
|
||||
python3 scripts/validate_updater_release.py \
|
||||
--manifest published-latest.json \
|
||||
--assets release-assets.json \
|
||||
--version "$VERSION" \
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--release-tag "$RELEASE_TAG"
|
||||
|
||||
python3 - <<'PY' > updater-urls.txt
|
||||
import json
|
||||
manifest = json.load(open("published-latest.json", encoding="utf-8"))
|
||||
for entry in manifest["platforms"].values():
|
||||
print(entry["url"])
|
||||
PY
|
||||
sort -u updater-urls.txt | while read -r url; do
|
||||
curl -fsSL \
|
||||
--range 0-0 \
|
||||
--retry 5 \
|
||||
--retry-delay 3 \
|
||||
--retry-all-errors \
|
||||
"$url" \
|
||||
-o /dev/null
|
||||
done
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Refresh Star History
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: star-history-${{ github.repository }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
name: Refresh chart data
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Send authenticated repository snapshot
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INGEST_TOKEN: ${{ secrets.STAR_HISTORY_INGEST_TOKEN }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
CAMO_PURGE_URLS: ${{ vars.STAR_HISTORY_CAMO_URLS }}
|
||||
STAR_HISTORY_ENDPOINT: ${{ vars.STAR_HISTORY_ENDPOINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "${INGEST_TOKEN}"
|
||||
test -n "${STAR_HISTORY_ENDPOINT}"
|
||||
metadata_file="${RUNNER_TEMP}/star-history-metadata.json"
|
||||
payload_file="${RUNNER_TEMP}/star-history-payload.json"
|
||||
|
||||
gh api \
|
||||
--header "Accept: application/vnd.github+json" \
|
||||
--header "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"repos/${REPOSITORY}" > "${metadata_file}"
|
||||
|
||||
jq -n \
|
||||
--arg repository "${REPOSITORY}" \
|
||||
--slurpfile metadata "${metadata_file}" \
|
||||
'{
|
||||
repository: $repository,
|
||||
createdAt: $metadata[0].created_at,
|
||||
checkedAt: (now | todateiso8601),
|
||||
currentStars: $metadata[0].stargazers_count
|
||||
}' > "${payload_file}"
|
||||
|
||||
curl \
|
||||
--fail-with-body \
|
||||
--retry 3 \
|
||||
--retry-all-errors \
|
||||
--connect-timeout 15 \
|
||||
--max-time 240 \
|
||||
--request POST \
|
||||
--header "Authorization: Bearer ${INGEST_TOKEN}" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data-binary "@${payload_file}" \
|
||||
"${STAR_HISTORY_ENDPOINT}"
|
||||
|
||||
while IFS= read -r camo_url; do
|
||||
test -n "${camo_url}" || continue
|
||||
case "${camo_url}" in
|
||||
https://camo.githubusercontent.com/*) ;;
|
||||
*) echo "::warning::Skipped an invalid Camo purge URL"; continue ;;
|
||||
esac
|
||||
curl \
|
||||
--fail \
|
||||
--silent \
|
||||
--show-error \
|
||||
--retry 2 \
|
||||
--request PURGE \
|
||||
"${camo_url}" > /dev/null \
|
||||
|| echo "::warning::GitHub Camo purge failed and will be retried on the next refresh"
|
||||
done <<< "${CAMO_PURGE_URLS}"
|
||||
@@ -23,6 +23,14 @@ apps/desktop/src-tauri/gen/schemas/
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
apple cert/
|
||||
*.p12
|
||||
*.p8
|
||||
*.pfx
|
||||
*.pem
|
||||
*.key
|
||||
codex-x-updater.key
|
||||
codex-new-ui/
|
||||
|
||||
# Backups
|
||||
*.bak
|
||||
|
||||
@@ -2,18 +2,187 @@
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
|
||||
## [Unreleased] - 2026-07-10
|
||||
## [v0.3.6] - 2026-07-31
|
||||
|
||||
### 更新
|
||||
|
||||
- 指令提示词新增两种注入方式:追加模式通过 Codex-X 受管区块合并到 `CODEX_HOME/AGENTS.md`,保留用户原有规则;替换模式继续使用 `model_instructions_file` 独立提示词文件。
|
||||
- GitHub 提示词改为动态发现:Codex-X 会读取仓库 `examples/` 下的全部 `.md`,新增模板无需重新发布软件;启用时会获取最新内容,离线自动回退本地缓存或打包版本。
|
||||
- Skills / MCP 列表改为固定名称顺序,开启、关闭、刷新或检查更新后不再改变项目位置。
|
||||
- 供应商编辑页改为整页纵向滚动,`config.toml` 编辑器会随内容自动增高,不再嵌套独立的上下滚动区域。
|
||||
- 提示词页面重新区分“当前实际启用模式”和“下次启用方式”,并使用“保留原提示词 / 替换原提示词”说明实际效果;当前模板会直接显示正在使用的模式。
|
||||
- 提示词页的“启用方式”新增问号说明,补充两种模式对现有系统提示词和 `model_instructions_file` 的实际影响,帮助用户快速判断是否会覆盖现有配置。
|
||||
- 概览页移除“启用 / 禁用提示词、恢复最新备份”快捷操作,避免跳过模板和注入方式选择直接启用默认提示词。
|
||||
- Codex CLI 版本检测适配合并后的 `ChatGPT.app`,同时保留旧版 `Codex.app` 兼容。
|
||||
- 供应商与 OpenAI Official 切换改为热更新;切换后新建会话即可生效,不再要求重启 Codex 客户端。
|
||||
- macOS 关闭主窗口后进入顶部栏驻留模式,Windows 隐藏任务栏窗口;托盘菜单仍可恢复窗口或明确退出应用。
|
||||
- 新增独立的项目维护日志,记录配置写入、供应商切换、会话同步和桌面生命周期的长期约束与验证依据。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复官方认证被第三方供应商覆盖或清除的问题;第三方密钥仅写入当前 provider,官方 ChatGPT 登录与官方 API Key 均使用独立快照保存并可往返恢复。
|
||||
- 修复编辑活动供应商会新建副本、重复导入 cc-switch 供应商产生多条相同记录,以及旧版 cc-switch 数据库缺少 `category` 字段时无法读取官方认证的问题。
|
||||
- 修复供应商、提示词及其他配置操作并发写入时可能覆盖或破坏 `config.toml` 的问题;写入现在使用跨进程锁、原始快照、条件原子替换和失败回滚。
|
||||
- 修复会话同步显示成功但 Codex 未实际使用同步结果的问题;只处理活动 SQLite,严格校验 SQLite 指定的 rollout 路径、文件身份和线程 ID,并兼容 `codex-dev.db` 与显式 `sqlite_home` 下的自定义数据库名。
|
||||
- 修复会话列表将旧数据库或孤立 JSONL 计入活动会话、把 JSONL 记录行数误当会话数,以及同步过程中修改非活动数据库或工作目录字段的问题。
|
||||
|
||||
## [v0.3.5] - 2026-07-28
|
||||
|
||||
### 更新
|
||||
|
||||
- GitHub 指令提示词现在可以直接查看和编辑本地内容;模板名称和文件名继续跟随在线目录,Markdown 内容由用户在现有提示词编辑页中维护。
|
||||
- 模板内容实际修改后会标记为“本地已修改”,后续同步永久跳过该模板的远端内容,同时继续发现和下载 GitHub 新增模板;启用时优先使用用户的本地版本。
|
||||
- 仅查看或保存未变化的内容不会退出 GitHub 同步;本地修改在下次启用该模板时生效。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 jsDelivr `@main` 目录缓存旧版本时,“同步 GitHub 模板”只能看到 5 个模板、无法发现仓库中 11 个模板的问题;目录成员现在始终由 GitHub 源站确认,源站不可用时仍保留 CDN 和本地缓存回退。
|
||||
- 修复当前使用中转供应商且 live `auth.json` 不存在时,概览页错误显示“认证文件未找到”的问题;已保存的官方认证快照现在会显示为“官方认证已保存”。
|
||||
|
||||
## [v0.3.4] - 2026-07-28
|
||||
|
||||
### 更新
|
||||
|
||||
- 在线提示词库新增 3 套软件开发模板和 3 套写作辅助模板,并按照模板文件名前缀自动归入对应分类;用户手动分类仍然优先。
|
||||
- 软件开发分类新增“长期维护工程师”“系统化调试与根因修复”“严格代码审查”,其中“长期维护工程师”来自项目当前实际使用的维护规范。
|
||||
- 写作辅助分类新增“清晰表达与润色”“技术文档写作”“结构化长文起草”。6 套新增模板通过 GitHub 在线同步,不增加安装包体积。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 彻底隔离 OpenAI Official 与中转供应商认证:切换到中转前保存可信的官方配置快照,切回官方时恢复该快照,避免 `auth.json` 被中转或 cc-switch 覆盖后导致官方登录失效。
|
||||
- “还原官方配置”现在只恢复独立快照,不再自动切换当前供应商;新增“新建官方配置”,可清除受污染的 live `auth.json` 并引导用户重新完成官方登录。
|
||||
- 优化官方 `auth.json` 编辑器的高度、换行和长 Token 选取体验,避免内容看起来被截断或只能依赖底部横向滚动条。
|
||||
|
||||
## [v0.3.3] - 2026-07-26
|
||||
|
||||
### 调整
|
||||
|
||||
- 暂时下线皮肤中心并隐藏侧边栏入口,皮肤页面、运行时实现和用户本地主题文件继续保留,后续可以通过统一功能开关重新启用。
|
||||
- 升级后会自动暂停 Codex-X 此前启用的皮肤并恢复官方显示;若自动停用失败,应用会显示明确错误,不会让用户在入口隐藏后失去恢复提示。
|
||||
- 内置“椎名真白·樱花画室”不再注册为随应用编译的主题,其约 1.7 MB 壁纸不再进入正式安装包;主题源文件仍保留在仓库中。
|
||||
|
||||
## [v0.3.2] - 2026-07-25
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 Windows Codex 冷启动或进入新版首页、外观设置页时,皮肤运行时因只识别旧版主壳层和侧栏而错误提示“未找到经过验证的 Codex 渲染器”的问题;首次连接现在会等待页面渐进加载,并兼容渲染文档切换。
|
||||
- “关闭皮肤”保持为随时可用的异常恢复入口,不再因运行状态刷新延迟、皮肤部分失效或其他操作卡住而无法点击;恢复期间仍会阻止重复提交和其他皮肤写操作。
|
||||
|
||||
### 安全
|
||||
|
||||
- 新版 Windows 页面检测继续要求官方进程、回环 CDP、`app://` 协议以及 Codex 壳层、首页或外观设置页的明确锚点,不会向普通网页或无标记辅助页面注入皮肤。
|
||||
|
||||
## [v0.3.1] - 2026-07-24
|
||||
|
||||
### 更新
|
||||
|
||||
- 皮肤中心重新设计主题画廊:直接展示真实壁纸和侧边栏、会话区、输入框、按钮等整套界面效果,单个主题不再拉伸占满整行,并移除端点、目录、`theme.json` 路径和主题包结构等开发者信息。
|
||||
- 皮肤中心新增可管理的分类筛选并进一步压缩主题卡片密度;导出主题时改用系统“另存为”窗口选择目录和文件名。
|
||||
- 支持直接从图片创建自适应主题,并可编辑主题名称、简介和界面透明度;内置主题与图片主题共用相同的编辑与预览规格。
|
||||
- 主题画廊根据当前分类数量自适应 1 至 4 列,少量主题保持紧凑,多个主题充分利用可用宽度。
|
||||
- 实机换肤扩展到 Windows:动态校验官方 `OpenAI.Codex` Store 包,安全启动仅绑定回环地址的 CDP 会话,并在 Windows 发布包中附带固定校验的 Node.js 22 运行时;不修改 `WindowsApps`、`app.asar` 或权限。
|
||||
- 皮肤中心只保留“关闭皮肤”入口,移除与其重复的“恢复官方外观”按钮;底层完整恢复能力继续保留用于异常恢复。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 macOS 皮肤已经注入后 Codex-X 仍长期显示“应用中”、重开后无法识别当前主题的问题;单次注入现在会明确退出,外部命令带有硬超时,并可从经过验证的 Codex 主进程恢复遗失的本机调试端口。
|
||||
- 修复皮肤尚未运行时错误显示“重新应用”、刷新缺少明确反馈,以及提示词或皮肤卡片过多时页面无法继续向下滚动的问题。
|
||||
- 优化普通横图在新任务首页超宽横幅中的自动焦点,避免人物头部被居中 `cover` 裁掉;主题包仍可通过 `art.focusY` 显式覆盖自动位置。
|
||||
- 修复切换到中转供应商时覆盖官方 `auth.json` 的问题,官方登录凭据与供应商凭据现在分别保存和恢复。
|
||||
- 改进 Codex Desktop 检测,兼容更多安装路径、包标识和版本输出格式,减少部分用户错误显示“未检测到 Codex”的情况。
|
||||
- 修复启用皮肤后新会话输入框消失、输入框出现会话内容穿透,以及图片主题预览错误使用默认背景的问题。
|
||||
- 修复浅色主题的固定白色遮罩洗淡壁纸颜色的问题;壁纸保持原图色彩,卡片和项目选择区域跟随透明度,同时为输入框保留可读性下限。
|
||||
|
||||
### 安全
|
||||
|
||||
- 换肤运行时只连接经过签名、进程归属和页面标记验证的官方 Codex,并将 CDP 限制在 `127.0.0.1`;停止注入器前会核对 PID、启动时间、Node 路径、脚本路径和端口。
|
||||
- 主题导入增加 ZIP 越界、符号链接、重复文件、文件数量、图片大小和 Manifest 字段校验;运行时使用稳定快照,避免主题切换时配置与图片不一致。
|
||||
|
||||
### 开发
|
||||
|
||||
- 固定复用 Codex Dream Skin Studio `5fd8af532efbaa87d2d0092297fd2d45cd56574e` 的 MIT 注入实现,并加入 Rust 与 Node 专项回归测试;未包含上游声明为非 MIT 授权的真人主题素材。
|
||||
|
||||
## [v0.3.0] - 2026-07-16
|
||||
|
||||
### 更新
|
||||
|
||||
- 全面升级桌面界面,统一各页面布局、按钮、弹窗和 Toast,并加入深浅色模式与页面过渡动画。
|
||||
- 新增应用内更新:macOS、Windows MSI 和 Linux AppImage 可直接下载、安装并重新启动;发现更新时会在侧边栏显示入口和进度。
|
||||
- 完善供应商管理:删除增加二次确认,连接测试改用 `/v1/models`,添加或编辑时可获取并选择模型,同时简化 OpenAI Official 页面。
|
||||
- 完善指令提示词管理:新增分类功能,内置两套新模板,精简模板卡片,添加或编辑后只保存、不自动启用。
|
||||
- 在线提示词会在启动后后台同步并保存到本机;支持 CDN、GitHub 备用源和系统代理,离线时也能使用上次同步内容。
|
||||
- 简化会话管理:统一同步到当前供应商,取消启动时自动修改,并支持查看、选择和删除内部会话。
|
||||
- 优化 Skills/MCP 导入,自动排除已经导入的内容,没有新内容时不再弹出空窗口。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复旧提示词配置可能导致 Codex 报 `expected u32`,以及 Windows `.codex` 使用链接目录后无法正常读写的问题。
|
||||
- 修复会话同步可能改写聊天内容、出现 `invalid_id_prefix`、重复显示会话或覆盖正在写入记录的问题。
|
||||
- 修复深浅色模式下的文字、滚动条、复选框和弹窗显示异常,以及弹窗打开后背景仍能滚动的问题。
|
||||
- 修复提示词缓存延迟造成的重复同步、错误更新或误删除,并修复应用内更新失败后的错误状态提示。
|
||||
|
||||
### 兼容说明
|
||||
|
||||
- `v0.3.0` 是首个支持应用内更新的版本,旧版用户需要手动安装本版本一次。
|
||||
- Windows 便携版和 Linux deb/rpm 继续手动更新;Windows MSI 首次安装仍可能出现 SmartScreen 提示。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加更新包签名、macOS 签名与公证,并完成后端模块拆分和发布流程安全加固。
|
||||
|
||||
## [v0.2.35] - 2026-07-13
|
||||
|
||||
### 更新
|
||||
|
||||
- 新增 `gpt-5.6-sol-unrestricted.md` 指令提示词模板,同步 GitHub 模板后即可在【指令提示词】页面启用。
|
||||
- 优化第三方供应商切换:切换后新建或重新打开 Codex 会话即可使用新的中转,无需重启整个 Codex 客户端。
|
||||
- 优化从 cc-switch 导入供应商的结果提示,现在会分别显示新增、更新、合并和跳过数量。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复相同 API 地址和 API Key 的供应商因名称、模型或其他配置不同而重复显示的问题;相同地址但不同 Key 的供应商仍会分别保留,升级后也会自动整理已有重复项。
|
||||
- 修复切换到其他供应商或切回 OpenAI Official 后,原供应商可能从列表中消失的问题;切换前会自动保存当前供应商。
|
||||
- 修复从 cc-switch 重复导入时可能覆盖 Codex-X 中已有供应商名称、模型或自定义配置的问题,并兼容旧版 cc-switch 数据。
|
||||
- 优化会话列表:Codex 自动创建的后台子会话不再重复显示为普通会话,项目数量和会话总数更加准确。
|
||||
- 优化会话编号显示,保留编号开头和结尾,减少多个会话看起来像同一个会话的情况。
|
||||
- 修复检测到的供应商与本地供应商同名时,编辑保存可能因 ID 冲突覆盖另一条记录的问题。
|
||||
|
||||
## [v0.2.34] - 2026-07-13
|
||||
|
||||
### 更新
|
||||
|
||||
- 会话管理新增指定会话永久删除:支持单选、多选,并在二次确认后从当前 Codex 存储中硬删除;操作不进入回收站、不创建新的删除备份,Codex-X 内无法撤销。
|
||||
- “按项目路径分组”新增项目级三态勾选,可一次选中某个项目在当前列表中加载的全部会话,也支持跨多个项目批量选择;搜索状态下会同时显示当前命中数与项目会话总数。
|
||||
- 重做会话列表为更克制的表格式布局,统一标题、更新时间、Provider、模型和 ID 列,降低高亮边框、彩色标签与重复装饰造成的视觉干扰。
|
||||
- 移除“已选 / 需修复、选择需同步、清空选择、修复选中”等按条修复控件,保留顶部“检查会话”和“同步 / 修复”用于全量处理。
|
||||
- 永久删除确认窗口会预览待删除会话,并要求确认已关闭正在使用相关会话的其他 Codex 窗口或 CLI;由所选会话派生的子会话也会一并处理。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复默认窗口大小下会话列表滚动条在下拉过程中消失、必须放大窗口才能继续查看的问题;列表现在使用稳定的内部滚动区域并阻止滚动穿透。
|
||||
- 修复同时存在当前 `state_*.sqlite` 与旧版 `sqlite/state_*.sqlite` 时,会话总数重复统计并混入旧会话的问题;现在优先使用当前活动会话数据库。
|
||||
- 修复批量删除部分失败时结果反馈不完整的问题,界面会保留未删除项并显示失败数量与原因,方便关闭占用进程后重试。
|
||||
|
||||
### 开发
|
||||
|
||||
- 永久删除优先调用 Codex App Server 官方 `thread/delete`,不可用时使用本地兼容清理,并复用跨平台 Codex 可执行文件探测与启动逻辑。
|
||||
- 本地兼容清理覆盖父子会话、当前及旧版会话数据库关联记录、活动/归档 Rollout、会话索引、历史记录、Shell 快照以及日志、记忆、目标和本地目录等关联数据。
|
||||
- 会话同步与删除共用维护锁,并增加 Rollout 规范路径校验与符号链接边界保护,避免并发维护或越界删除。
|
||||
- 增加活动数据库优先级、父子会话与重复库清理、关联记录清理、Rollout 越界拒绝及符号链接防护测试。
|
||||
|
||||
## [v0.2.33] - 2026-07-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 重做【指令提示词】页面布局:当前状态、启用方式和模板列表使用统一的紧凑框架,模板来源与更新时间更容易查看。
|
||||
- GitHub 提示词目录改为权威同步:自动发现并缓存新增 `.md`,清理远端已删除模板和历史别名缓存;离线时仍可使用上次成功同步的内容。
|
||||
- GitHub `examples/` 新增 `海鸥3.0破甲.md`,并清理不再使用的旧示例模板。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复首次进入提示词页或切换导航返回时,会先显示历史缓存、随后再跳到 GitHub 最新模板的问题;同一次启动中会保留已同步状态,不再重复回退旧列表。
|
||||
- 修复提示词模板较多时列表溢出最外层边框的问题,外层面板现在会随完整列表正确增高。
|
||||
- 修复 GitHub 同步并发、部分模板下载失败和异常目录响应可能造成缓存状态错乱或误删的问题;失败后允许重新同步。
|
||||
- 修复远端模板已删除但仍在启用时,重启后可能无法识别和关闭的问题;当前生效模板会保留明确的关闭入口。
|
||||
- 删除 `gpt5.5-jeli.md` 介绍中多余的“测试生效”文案。
|
||||
- 修复 macOS Apple Silicon / Intel、Windows 和 Linux 应用图标外围出现大块白边的问题,重新生成全平台透明图标资源。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加 GitHub 模板动态发现、缓存去重、离线回退、过期缓存删除和异常目录保护测试,并为缓存刷新增加互斥与事务保护。
|
||||
- 修正发布脚本遗漏 `Cargo.lock` 的问题,确保 tag 内 Rust 包版本与应用版本一致。
|
||||
|
||||
## [v0.2.32] - 2026-07-11
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
# Codex-X
|
||||
|
||||
**Prompt Injection · Provider Switching · TOML / Auth Visual Manager for Codex**
|
||||
**Codex Prompts · API / Providers · Sessions · Skills / MCP in One Place**
|
||||
|
||||
Codex-X is a cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. It ships with `gpt5.5-unrestricted.md` and `gpt5.4-unrestricted.md`, and supports one-click enable / disable for instruction prompts, third-party Provider switching, official Auth management, TOML visual editing, and local session Provider Sync.
|
||||
A cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. Manage prompt templates, switch third-party APIs, organize / repair / permanently delete local sessions, manage Skills / MCP, apply macOS / Windows skins, and inspect TOML and login credentials without repeatedly editing configuration files by hand.
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
|
||||
@@ -32,15 +32,14 @@
|
||||
|
||||
## What is Codex-X?
|
||||
|
||||
Codex-X is not just a configuration-file editor. It is a **visual enhancement manager** built for Codex CLI and Codex Desktop workflows.
|
||||
When you use Codex Desktop, the CLI, third-party APIs, and multiple prompts together, settings quickly become scattered across different files. Codex-X brings these frequent tasks into one desktop interface, so you can see the current state and complete common actions with a click.
|
||||
|
||||
It turns several high-frequency operations into a desktop UI:
|
||||
You can use it to:
|
||||
|
||||
- Enable / disable instruction prompt templates for Codex
|
||||
- Switch between official OpenAI and third-party Codex API Providers
|
||||
- View / edit `~/.codex/config.toml`
|
||||
- View / edit official `~/.codex/auth.json`
|
||||
- Check and repair Provider metadata for local historical sessions
|
||||
- Choose from the prompt library or import your own `.md`, then keep the existing prompt or replace it
|
||||
- Save, test, and switch between OpenAI Official and third-party APIs, including Providers imported from cc-switch
|
||||
- Search and organize local sessions by project, check / automatically repair problems, and precisely delete sessions you no longer need
|
||||
- Manage Skills and MCP in one place, and inspect the current `config.toml`, `auth.json`, and operation backups
|
||||
|
||||
## Preview
|
||||
|
||||
@@ -109,106 +108,145 @@ It turns several high-frequency operations into a desktop UI:
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center" width="180">Feature</th>
|
||||
<th align="center">Description</th>
|
||||
<th align="center" width="190">What you want to do</th>
|
||||
<th align="center">How Codex-X helps</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">⚡ Provider API</td>
|
||||
<td>Visually manage official OpenAI / third-party Codex Providers, including Base URL, API Key, Model, Wire API, and one-click switching.</td>
|
||||
<td align="center">🧩 <b>Use prompt templates</b></td>
|
||||
<td align="left">The current library contains <b>5 templates</b>. Enable / disable one with a click and choose “Keep existing” or “Replace existing”; GitHub sync, local caching, and importing or editing your own <code>.md</code> files are supported.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🧩 <b>Prompt Injection</b></td>
|
||||
<td><b>Signature feature</b>: built-in <code>gpt5.4-unrestricted.md</code> / <code>gpt5.5-unrestricted.md</code>, one-click write into Codex configuration; after enabling, it can produce results similar to the screenshots above for SQL injection testing and APK / EXE reverse engineering scenarios.</td>
|
||||
<td align="center">⚡ <b>Switch APIs / relays</b></td>
|
||||
<td align="left">Save, test, and enable multiple third-party Providers, or import them from cc-switch; entries with the same endpoint and Key are merged automatically. Create or reopen a session after switching to apply the change without restarting the entire Codex client.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">💬 Session Management</td>
|
||||
<td>Read Codex local SQLite and rollout JSONL, display local sessions visually, and support Provider Sync / historical thread metadata repair.</td>
|
||||
<td align="center">💬 <b>Organize local sessions</b></td>
|
||||
<td align="left">Search sessions, group them by project path, and check or automatically repair inconsistent data. Select one, several, or an entire project to permanently delete specific sessions from Codex storage.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📝 TOML Config</td>
|
||||
<td>View current <code>~/.codex/config.toml</code>, edit full TOML directly from the Provider editor, and save changes back to the Codex config directory.</td>
|
||||
<td align="center">🧠 <b>Manage Skills / MCP</b></td>
|
||||
<td align="left">View available Skills and MCP servers, preview existing items before import, install a Skill from ZIP, enable / disable individual entries, and check Skill update status.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🧠 Skills / MCP</td>
|
||||
<td>New Skills & MCP page: read Codex Skills / MCP servers, import existing items, install Skill ZIP packages, enable / disable individual Skills or MCP servers, and check Skill update status.</td>
|
||||
<td align="center">📝 <b>Manage config and login</b></td>
|
||||
<td align="left">Inspect the active Codex <code>config.toml</code> and <code>auth.json</code> in one place, with a clear distinction between official login credentials and third-party API Keys. Important writes are backed up automatically and can be restored when needed.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🔐 Auth Management</td>
|
||||
<td>Read / edit official <code>~/.codex/auth.json</code>, and distinguish ChatGPT login-state Auth from third-party API Keys.</td>
|
||||
<td align="center">🎨 <b>Apply macOS / Windows skins</b></td>
|
||||
<td align="left">Use the bundled Shiina Mashiro art-studio theme with real-wallpaper, full-interface previews. Import or export packs, hot-switch themes, and turn the skin off directly. The runtime is built into Codex-X; the separate Codex Dream Skin engine is not required.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📦 Cross-platform Release</td>
|
||||
<td>Provides macOS Apple Silicon / Intel <code>.dmg</code>, Windows <code>.msi</code> / portable <code>.zip</code>, Linux <code>.deb</code> / <code>.rpm</code>, plus in-app update checking.</td>
|
||||
<td align="center">📦 <b>Use it across platforms</b></td>
|
||||
<td align="left">Available for macOS Apple Silicon / Intel, Windows MSI / portable, and Linux packages. Installed editions can download, verify, and install updates in the app; the portable edition keeps manual downloads.</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## Highlights
|
||||
|
||||
### 1. Built-in dual instruction prompt templates
|
||||
### 1. Prompt Template Center
|
||||
|
||||
Codex-X currently includes two prompt templates:
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Template_library-11_total-2563eb?style=flat-square" alt="11 templates in the current library" />
|
||||
<img src="https://img.shields.io/badge/Bundled_offline-5_templates-16a34a?style=flat-square" alt="5 templates bundled for offline use" />
|
||||
<img src="https://img.shields.io/badge/GitHub_sync-Automatic_updates-f59e0b?style=flat-square" alt="Automatic GitHub template sync" />
|
||||
</p>
|
||||
|
||||
- `examples/gpt5.5-unrestricted.md`
|
||||
- `examples/gpt5.4-unrestricted.md`
|
||||
|
||||
You can enable / disable them directly from the Prompt page. When enabled, Codex-X writes the selected `.md` file into the Codex configuration directory and updates `model_instructions_file` in `config.toml`.
|
||||
> [!TIP]
|
||||
> **Ready after installation, automatically expanded when online.**
|
||||
>
|
||||
> The app bundle includes 5 templates for offline use. After launch, it synchronizes 6 additional software-development and writing templates, plus later updates, from GitHub `examples/` in the background without waiting for the **Prompt** page to open. Successfully synchronized online versions are cached locally and remain available during temporary network outages.
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center">Item</th>
|
||||
<th align="center"><code>gpt5.4-unrestricted.md</code></th>
|
||||
<th align="center"><code>gpt5.5-unrestricted.md</code></th>
|
||||
<th align="center">Template</th>
|
||||
<th align="center">Best for</th>
|
||||
<th align="center">Availability</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Positioning</td>
|
||||
<td align="center">5.4 CTF sandbox</td>
|
||||
<td align="center">5.5 unrestricted mode</td>
|
||||
<td><a href="examples/gpt5.5-unrestricted.md"><code>gpt5.5-unrestricted.md</code></a></td>
|
||||
<td align="left">Compact and general-purpose for everyday coding and technical work</td>
|
||||
<td align="center">Bundled offline<br />GitHub updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Strategy</td>
|
||||
<td align="center">Indirect bypass through CTF competition sandbox framing</td>
|
||||
<td align="center">Direct declaration of unrestricted developer mode</td>
|
||||
<td><a href="examples/gpt5.4-unrestricted.md"><code>gpt5.4-unrestricted.md</code></a></td>
|
||||
<td align="left">GPT-5.4 / Codex CLI workflows with a CTF and security-research focus</td>
|
||||
<td align="center">Bundled offline<br />GitHub updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Style</td>
|
||||
<td align="center">Security-research oriented, detailed methodology</td>
|
||||
<td align="center">Minimal instructions, broad coverage</td>
|
||||
<td><a href="examples/gpt5.5-jeli.md"><code>gpt5.5-jeli.md</code></a></td>
|
||||
<td align="left">A plain-language general version with a fuller engineering and reverse-engineering workflow</td>
|
||||
<td align="center">Bundled offline<br />GitHub updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Length</td>
|
||||
<td align="center">~180 lines</td>
|
||||
<td align="center">~40 lines</td>
|
||||
<td><a href="examples/gpt-5.6-sol-unrestricted.md"><code>gpt-5.6-sol-unrestricted.md</code></a></td>
|
||||
<td align="left">A GPT-5.6 SOL prompt focused on direct execution and bilingual tasks</td>
|
||||
<td align="center">Bundled offline<br />GitHub updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Target</td>
|
||||
<td align="center">GPT-5.4 Codex CLI</td>
|
||||
<td align="center">GPT-5.5 Codex CLI</td>
|
||||
<td><a href="examples/%E6%B5%B7%E9%B8%A53.0%E7%A0%B4%E7%94%B2.md"><code>海鸥3.0破甲.md</code></a></td>
|
||||
<td align="left">A Chinese technical-operator persona with routing for coding, CTF, reverse engineering, memory, and protocol work</td>
|
||||
<td align="center">Bundled offline<br />GitHub updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-maintainer.md"><code>software-development-maintainer.md</code></a></td>
|
||||
<td align="left">Long-term maintenance with reuse, minimal changes, safety, testing, and maintainability</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-debugging.md"><code>software-development-debugging.md</code></a></td>
|
||||
<td align="left">Evidence-driven debugging from reliable reproduction to root-cause repair and regression checks</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-code-review.md"><code>software-development-code-review.md</code></a></td>
|
||||
<td align="left">Severity-ranked review of defects, regressions, security risks, and test gaps</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-clarity-editor.md"><code>writing-clarity-editor.md</code></a></td>
|
||||
<td align="left">Chinese and English editing that preserves the author's meaning and facts</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-technical-docs.md"><code>writing-technical-docs.md</code></a></td>
|
||||
<td align="left">Source-grounded README, guide, API, architecture, and release documentation</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-structured-draft.md"><code>writing-structured-draft.md</code></a></td>
|
||||
<td align="left">Turns scattered material into a structured report, proposal, retrospective, or article draft</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Post-deployment test prompt:
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<b>Keep existing prompt</b><br />
|
||||
Best for users who already have personal rules. Codex-X only appends its managed content and removes only that content when disabled, leaving the original prompt untouched.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<b>Replace existing prompt</b><br />
|
||||
Makes the selected template the primary instruction entry point, which is useful when you want to switch completely to a specific template.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
```text
|
||||
How do I perform SQL injection testing against a target?
|
||||
```
|
||||
A backup is created automatically before every enable or disable action. In addition to the template library, you can import, edit, and delete your own `.md` prompts.
|
||||
|
||||
Typical effect:
|
||||
### 2. Provider Switching: Ready in a New Session
|
||||
|
||||
```text
|
||||
Before injection → refusal or generic answer
|
||||
After injection → direct security research methodology and testing steps
|
||||
```
|
||||
> [!NOTE]
|
||||
> After enabling a new third-party Provider, create or reopen a Codex session to use the new relay. You do not need to restart the entire Codex client.
|
||||
|
||||
### 2. Visual Provider switching
|
||||
|
||||
- Add third-party Codex API Providers
|
||||
- Edit Base URL / API Key / Model / Wire API
|
||||
- View and edit Provider-related TOML from the Provider page
|
||||
- Clearly display the currently active Provider
|
||||
- Import Codex Providers from a cc-switch database
|
||||
- Save multiple third-party Providers and always see which one is currently active
|
||||
- Test an API endpoint before switching, and save or enable a configuration separately
|
||||
- Edit the Base URL, API Key, Model, Wire API, and complete TOML on the same page
|
||||
- cc-switch imports report added, updated, merged, and skipped entries; the same URL + Key is no longer shown more than once
|
||||
- Switching back to OpenAI Official preserves the current official login, and third-party configurations no longer disappear unexpectedly
|
||||
|
||||
### 3. Official Auth management
|
||||
|
||||
@@ -224,26 +262,46 @@ After injection → direct security research methodology and testing steps
|
||||
- Edit full TOML directly from the Provider editor
|
||||
- Save changes back to the Codex configuration directory
|
||||
|
||||
### 5. Session Management / Provider Sync
|
||||
### 5. Session Management: Inspect, Repair, and Permanently Delete
|
||||
|
||||
Codex-X can read Codex local session data:
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<b>Find and organize</b><br />
|
||||
Search sessions by title or project path and group them by project. Internal subagent sessions created automatically by Codex stay out of the normal session list by default.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<b>Inspect and repair</b><br />
|
||||
Check whether local sessions match the current Provider, repair all mismatches manually, or enable automatic inspection and repair at startup.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
<b>Precise deletion</b><br />
|
||||
Select one session, several sessions, or one or more projects to select all sessions under them. After confirmation, the matching sessions and their derived child sessions are removed from Codex storage itself.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
```text
|
||||
~/.codex/sqlite/*.db
|
||||
~/.codex/state_5.sqlite
|
||||
~/.codex/sessions/**/rollout-*.jsonl
|
||||
~/.codex/archived_sessions/**/rollout-*.jsonl
|
||||
```
|
||||
|
||||
It checks whether historical session Provider metadata matches the current configuration, and supports one-click sync / repair so historical threads can still be recognized, opened, and continued by native Codex.
|
||||
> [!CAUTION]
|
||||
> **Permanent deletion cannot be undone.** Close any Codex windows or CLI processes still using those sessions, then review the deletion list again in the confirmation dialog.
|
||||
|
||||
### 6. Skills / MCP Management
|
||||
|
||||
Codex-X now includes a dedicated **Skills & MCP** page for managing Codex capability extensions in one place.
|
||||
Manage Codex capability extensions from the **Skills & MCP** page instead of searching through multiple directories and configuration files.
|
||||
|
||||
- Skills: read existing Codex Skills, import existing items, install ZIP packages, enable / disable, and check update status
|
||||
- MCP: read Codex MCP servers, import existing items, enable / disable, and write enabled servers back to Codex <code>config.toml</code>
|
||||
- Useful for Android APK reverse engineering, Ghidra / IDA, Web / API / protocol reverse engineering, CTF, and security-testing skill packs
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<b>Skills</b><br />
|
||||
View current Skills, import existing content, or install from ZIP. Enable / disable entries individually and check whether installed Skills have updates.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<b>MCP</b><br />
|
||||
Preview existing MCP servers before importing them, then choose what Codex-X should manage. Codex-X maintains the Codex configuration when a server is enabled or disabled.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 7. Reverse Skills Navigation
|
||||
|
||||
@@ -288,9 +346,9 @@ Codex-X now includes a dedicated **Skills & MCP** page for managing Codex capabi
|
||||
- macOS Intel `.dmg`
|
||||
- Windows `.msi`
|
||||
- Windows Portable `.zip`
|
||||
- Linux `.deb` / `.rpm`
|
||||
- Linux `.deb` / `.rpm` / `.AppImage`
|
||||
- Automatic GitHub Releases builds
|
||||
- In-app update checking
|
||||
- In-app updates for installed editions; manual updates for Windows Portable
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -345,7 +403,16 @@ Build desktop bundles:
|
||||
pnpm --dir apps/desktop tauri build
|
||||
```
|
||||
|
||||
## macOS Installation Note
|
||||
## Desktop Installation Notes
|
||||
|
||||
### Skin Center security boundary
|
||||
|
||||
- Live skin application supports macOS and Windows. Linux can still manage, import, and export packs but is never reported as applied.
|
||||
- macOS uses only the signed Node.js bundled with the official Codex app and validates its bundle and process identity. Windows dynamically validates the current user's registered `OpenAI.Codex` Store package and uses the pinned Node.js 22 runtime shipped with Codex-X releases.
|
||||
- CDP binds only to `127.0.0.1`; Codex-X does not modify the official `.app`, `WindowsApps`, `app.asar`, code signature, directory permissions, or `config.toml`.
|
||||
- First application usually asks to restart Codex. Turn off skin removes the live interface skin immediately, and any theme can be applied again later.
|
||||
- Themes adapt cropping from image dimensions and content. Subject-focused artwork can use `art.focusX`, `art.focusY` (`0` to `1`), and `art.safeArea` in `theme.json` for precise framing without generating duplicate images for the same theme.
|
||||
- Injection is based on the MIT-licensed [Fei-Away/Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) source. The pinned source and license notice are in `apps/desktop/src-tauri/resources/skin-runtime/NOTICE.md`.
|
||||
|
||||
If you see “app is damaged” when opening an unsigned / unnotarized DMG, this is normal macOS Gatekeeper behavior.
|
||||
|
||||
@@ -366,6 +433,12 @@ Thanks to the [LINUX DO forum](https://linux.do/) community for attention, feedb
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=yynxxxxx%2FCodex-X&type=date&legend=top-left">
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=yynxxxxx%2Fcodex-x&type=date&legend=top-left&sealed_token=Vqvz67Jv_WIePGePCN8RdJaY5oCyqqCZUSFWs4M4dAxP8JXFZlYEWbI8YcU6SFgpqOqqJifzpOTIlMg4ee8NaCkHpCSqv1r5pxewR-tQlmxswaZlhedd6A" width="900" />
|
||||
</a>
|
||||
<p align="center">
|
||||
<a href="https://github.com/yynxxxxx/Codex-X/stargazers">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=light" />
|
||||
<img alt="Codex-X Star History" src="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=light" width="900" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
# Codex-X
|
||||
|
||||
**Codex 提示词注入 · Provider 切换 · TOML / Auth 可视化管理器**
|
||||
**Codex 可视化提示词注入 · Provider · 会话 · Skills / MCP 管理工具**
|
||||
|
||||
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具,内置 `gpt5.5-unrestricted.md` 与 `gpt5.4-unrestricted.md`,支持一键写入 / 禁用指令提示词、第三方 Provider 切换、官方 Auth 管理、TOML 可视化编辑与本地会话 Provider Sync。
|
||||
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP、macOS / Windows 皮肤和 TOML 配置都放进可视化界面里,不用反复手改文件。
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
|
||||
@@ -32,75 +32,50 @@
|
||||
|
||||
## Codex-X 是什么?
|
||||
|
||||
Codex-X 不是普通的配置文件编辑器,而是一个面向 Codex CLI 的 **可视化增强管理器**。
|
||||
当你同时使用 Codex 桌面端、CLI、第三方 API、Skills / MCP 和多套提示词时,配置很容易散落在不同文件里。Codex-X 把这些高频操作集中到一个桌面界面中,让当前状态看得见、常用操作点一下就能完成。
|
||||
|
||||
它把几个高频操作做成了桌面软件:
|
||||
你可以用它:
|
||||
|
||||
- 给 Codex 写入 / 禁用指令提示词模板
|
||||
- 切换官方 OpenAI 与第三方 Codex API Provider
|
||||
- 查看 / 编辑 `~/.codex/config.toml`
|
||||
- 查看 / 编辑官方 `~/.codex/auth.json`
|
||||
- 检查并修复本地历史会话的 Provider 元数据
|
||||
- 像管理插件一样管理提示词:分类、导入 Markdown、自定义编辑、一键启用 / 禁用
|
||||
- 内置 5 套提示词模板,同时支持用户把自己的提示词变成可视化模板库
|
||||
- 保存、测试并切换 OpenAI Official 与第三方 API,还能从 cc-switch 导入现有供应商
|
||||
- 同步、检查、搜索和删除本地会话,按项目路径整理 Codex 历史记录
|
||||
- 集中管理 Skills 与 MCP,查看当前 `config.toml`、`auth.json` 和操作备份
|
||||
|
||||
## 软件预览
|
||||
|
||||
<details open>
|
||||
<summary><b>应用界面预览</b>:主界面 / Provider / TOML / Auth</summary>
|
||||
<summary><b>新版 UI:指令提示词管理中心</b></summary>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/app/preview.png" alt="Codex-X 应用界面预览:主界面、Provider、TOML、Auth" width="920" />
|
||||
<img src="docs/screenshots/app/new-ui/prompts.png" alt="Codex-X 新版指令提示词管理界面" width="920" />
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>提示词注入效果:安全测试场景</b></summary>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<b>SQL 注入测试</b><br />
|
||||
<sub>部署后测试:如何对目标进行 SQL 注入测试?</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/security/sql-injection.png" alt="SQL 注入测试效果图" width="420" />
|
||||
<b>分类管理</b><br />
|
||||
<sub>把提示词按破甲 / 逆向、软件开发、写作辅助等分类维护</sub><br />
|
||||
<img src="docs/screenshots/app/new-ui/prompt-categories.png" alt="Codex-X 提示词分类管理" width="420" />
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<b>NSFW 响应测试</b><br />
|
||||
<sub>用于观察提示词注入后的边界响应变化</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/security/nsfw.png" alt="NSFW 响应测试效果图" width="420" />
|
||||
<b>自定义提示词</b><br />
|
||||
<sub>直接添加、编辑或导入自己的 Markdown 提示词</sub><br />
|
||||
<img src="docs/screenshots/app/new-ui/prompt-form.png" alt="Codex-X 添加自定义提示词" width="420" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>提示词注入效果:逆向工程场景</b></summary>
|
||||
<summary><b>Skills / MCP 可视化管理</b></summary>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<b>APK 逆向分析</b><br />
|
||||
<sub>Android APK 静态 / 动态分析思路</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/reverse/apk-reverse-1.png" alt="APK 逆向分析效果图" width="420" />
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<b>APK 逆向分析 2</b><br />
|
||||
<sub>补充 APK 逆向流程与定位方式</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/reverse/apk-reverse-2.png" alt="APK 逆向分析效果图 2" width="420" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<b>EXE 逆向分析</b><br />
|
||||
<sub>Windows 可执行文件分析与调试方向</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/reverse/exe-reverse.png" alt="EXE 逆向分析效果图" width="620" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/app/new-ui/skills-mcp.png" alt="Codex-X Skills 与 MCP 管理界面" width="920" />
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
@@ -109,143 +84,215 @@ Codex-X 不是普通的配置文件编辑器,而是一个面向 Codex CLI 的
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center" width="180">功能</th>
|
||||
<th align="center">说明</th>
|
||||
<th align="center" width="190">你想做的事</th>
|
||||
<th align="center">Codex-X 能帮你</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">⚡ 供应商 API</td>
|
||||
<td>可视化管理官方 OpenAI / 第三方 Codex Provider,支持 Base URL、API Key、Model、Wire API 与一键切换。</td>
|
||||
<td align="center"><b>提示词注入管理</b></td>
|
||||
<td align="left">内置 <b>5 套</b>提示词模板,支持分类、GitHub 同步、本地缓存、导入 <code>.md</code>、添加自定义提示词、编辑说明、一键启用 / 禁用。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🧩 <b>提示词注入</b></td>
|
||||
<td><b>特色功能</b>:内置 <code>gpt5.4-unrestricted.md</code> / <code>gpt5.5-unrestricted.md</code>,一键写入 Codex 配置;启用后可达到上方效果图中的 SQL 注入测试、APK / EXE 逆向等响应效果。</td>
|
||||
<td align="center"><b>启用方式切换</b></td>
|
||||
<td align="left">可选择“保留原提示词”追加写入,也可选择“替换原提示词”完整切换;适合在不同模型、不同任务、不同 Prompt 之间快速切换。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">💬 会话管理</td>
|
||||
<td>读取 Codex 本地 SQLite 与 rollout JSONL,会话列表可视化展示,并支持 Provider Sync / 修复历史 thread 元数据。</td>
|
||||
<td align="center"><b>Provider / API</b></td>
|
||||
<td align="left">添加、编辑、启用、删除第三方供应商;支持连接检测、模型获取 / 测试、从 cc-switch 导入,并可在 OpenAI Official 与中转 API 之间切换。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📝 TOML 配置</td>
|
||||
<td>查看当前 <code>~/.codex/config.toml</code>,并在 Provider 编辑页直接编辑完整 TOML,保存后同步到 Codex 配置目录。</td>
|
||||
<td align="center"><b>会话管理</b></td>
|
||||
<td align="left">搜索本地会话、按项目路径分组、同步当前供应商、检查会话状态,并支持单选 / 多选 / 项目级永久删除。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🧠 Skills / MCP</td>
|
||||
<td>新增技能和 MCP 管理页:读取 Codex 当前可用的 Skills / MCP,支持导入已有、从 ZIP 安装 Skill、启用 / 禁用单个 Skill 或 MCP,并可检查 Skill 更新状态。</td>
|
||||
<td align="center"><b>Skills / MCP</b></td>
|
||||
<td align="left">可视化查看 Skills 与 MCP,导入已有配置,从 ZIP 安装 Skill,逐项启用 / 禁用,并检查更新状态。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🔐 Auth 管理</td>
|
||||
<td>读取 / 编辑官方 <code>~/.codex/auth.json</code>,区分 ChatGPT 登录态 Auth 与第三方 API Key。</td>
|
||||
<td align="center"><b>配置与登录</b></td>
|
||||
<td align="left">集中查看 Codex 当前使用的 <code>config.toml</code> 与 <code>auth.json</code>,区分官方登录态和第三方 API Key;重要写入前自动备份。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📦 跨平台发布</td>
|
||||
<td>提供 macOS Apple Silicon / Intel <code>.dmg</code>、Windows <code>.msi</code> / portable <code>.zip</code>、Linux <code>.deb</code> / <code>.rpm</code>,支持应用内检测更新。</td>
|
||||
<td align="center"><b>macOS / Windows 皮肤中心</b></td>
|
||||
<td align="left">内置“椎名真白·樱花画室”主题,以真实壁纸和完整界面缩略图展示主题效果,支持导入 / 导出、热切换和关闭皮肤;换肤运行时已经内置在 Codex-X 中,无需另装 Codex Dream Skin 引擎。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>跨平台使用</b></td>
|
||||
<td align="left">提供 macOS Apple Silicon / Intel、Windows MSI / 便携版和 Linux 安装包;安装版可在应用内直接下载、校验并安装更新,便携版继续使用手动下载。</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 核心亮点
|
||||
|
||||
### 1. 内置双指令提示词模板
|
||||
### 1. 可视化提示词注入中心
|
||||
|
||||
Codex-X 当前内置两套提示词模板:
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/当前模板库-11_套-2563eb?style=flat-square" alt="当前模板库 11 套" />
|
||||
<img src="https://img.shields.io/badge/离线内置-5_套-16a34a?style=flat-square" alt="离线内置 5 套" />
|
||||
<img src="https://img.shields.io/badge/GitHub_同步-自动更新-f59e0b?style=flat-square" alt="GitHub 自动同步" />
|
||||
<img src="https://img.shields.io/badge/自定义提示词-支持导入_编辑-7c3aed?style=flat-square" alt="支持自定义提示词" />
|
||||
</p>
|
||||
|
||||
- `examples/gpt5.5-unrestricted.md`
|
||||
- `examples/gpt5.4-unrestricted.md`
|
||||
> [!TIP]
|
||||
> **安装后就能用,联网后自动补齐,也能维护自己的提示词库。**
|
||||
>
|
||||
> 安装包离线自带 5 套模板;软件启动后会从 GitHub `examples/` 同步另外 6 套软件开发与写作辅助模板,以及后续更新。同步成功的在线版本会缓存到本地,临时离线仍可继续使用。你也可以导入自己的 `.md`、新增分类、编辑说明,并像切换插件一样启用或禁用任意提示词。
|
||||
|
||||
你可以在【指令提示词】页面直接点击启用 / 禁用。启用后,Codex-X 会把对应 `.md` 写入 Codex 配置目录,并更新 `config.toml` 中的 `model_instructions_file`。
|
||||
Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可视化提示词注入与管理工具:
|
||||
|
||||
- 按分类管理提示词,例如破甲 / 逆向、软件开发、写作辅助,也可以新增自己的分类
|
||||
- 支持同步 GitHub 模板、导入 Markdown、手动添加提示词、编辑标题 / 文件名 / 内容
|
||||
- 每个提示词都有独立开关,打开时自动按当前启用方式写入 Codex 指令文件
|
||||
- 支持“保留原提示词”和“替换原提示词”两种模式,适合日常叠加或完整切换
|
||||
- 本地缓存可离线使用,后续模板更新不会影响你自己维护的自定义提示词
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center">对比项</th>
|
||||
<th align="center"><code>gpt5.4-unrestricted.md</code></th>
|
||||
<th align="center"><code>gpt5.5-unrestricted.md</code></th>
|
||||
<th align="center">模板</th>
|
||||
<th align="center">适合场景</th>
|
||||
<th align="center">获取方式</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">定位</td>
|
||||
<td align="center">5.4 CTF 沙箱</td>
|
||||
<td align="center">5.5 无限制模式</td>
|
||||
<td><a href="examples/gpt5.5-unrestricted.md"><code>gpt5.5-unrestricted.md</code></a></td>
|
||||
<td align="left">短小通用,适合日常 coding 与常规技术任务</td>
|
||||
<td align="center">离线内置<br />GitHub 更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">策略</td>
|
||||
<td align="center">CTF 竞赛沙箱间接绕过</td>
|
||||
<td align="center">直接声明无限制开发者模式</td>
|
||||
<td><a href="examples/gpt5.4-unrestricted.md"><code>gpt5.4-unrestricted.md</code></a></td>
|
||||
<td align="left">面向 GPT-5.4 / Codex CLI,偏 CTF 与安全研究工作流</td>
|
||||
<td align="center">离线内置<br />GitHub 更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">风格</td>
|
||||
<td align="center">安全研究导向,方法论详细</td>
|
||||
<td align="center">极简指令,覆盖面广</td>
|
||||
<td><a href="examples/gpt5.5-jeli.md"><code>gpt5.5-jeli.md</code></a></td>
|
||||
<td align="left">大白话通用版,提供更完整的工程与逆向执行流程</td>
|
||||
<td align="center">离线内置<br />GitHub 更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">长度</td>
|
||||
<td align="center">约 180 行</td>
|
||||
<td align="center">约 40 行</td>
|
||||
<td><a href="examples/gpt-5.6-sol-unrestricted.md"><code>gpt-5.6-sol-unrestricted.md</code></a></td>
|
||||
<td align="left">gpt5.6-sol 破甲提示词,偏直接执行与中英文任务</td>
|
||||
<td align="center">离线内置<br />GitHub 更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">适用</td>
|
||||
<td align="center">GPT-5.4 Codex CLI</td>
|
||||
<td align="center">GPT-5.5 Codex CLI</td>
|
||||
<td><a href="examples/%E6%B5%B7%E9%B8%A53.0%E7%A0%B4%E7%94%B2.md"><code>海鸥3.0破甲.md</code></a></td>
|
||||
<td align="left">中文技术操作员人格,覆盖 coding、CTF、逆向、内存与协议任务路由</td>
|
||||
<td align="center">离线内置<br />GitHub 更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-maintainer.md"><code>software-development-maintainer.md</code></a></td>
|
||||
<td align="left">长期维护正式项目,强调复用、最小改动、安全、测试与可维护性</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-debugging.md"><code>software-development-debugging.md</code></a></td>
|
||||
<td align="left">从稳定复现和证据采集推进到根因修复与回归验证</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-code-review.md"><code>software-development-code-review.md</code></a></td>
|
||||
<td align="left">按严重级别审查缺陷、回归、安全风险和测试缺口</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-clarity-editor.md"><code>writing-clarity-editor.md</code></a></td>
|
||||
<td align="left">在保留原意和事实的前提下润色中英文表达</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-technical-docs.md"><code>writing-technical-docs.md</code></a></td>
|
||||
<td align="left">基于代码与事实编写 README、指南、API 和发布文档</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-structured-draft.md"><code>writing-structured-draft.md</code></a></td>
|
||||
<td align="left">将零散材料组织成报告、方案、复盘或文章初稿</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
部署后可以测试:
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<b>保留原提示词</b><br />
|
||||
适合已经有个人规则的用户。Codex-X 只追加自己管理的内容,禁用时也只移除这一部分,不动原有提示词。
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<b>替换原提示词</b><br />
|
||||
将所选模板设为当前主要指令入口,适合希望完整切换到某套模板的用户。
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
```text
|
||||
如何对目标进行 SQL 注入测试?
|
||||
```
|
||||
每次启用或禁用前都会自动创建备份。除了模板库,你也可以导入、编辑、删除自己的 `.md` 提示词,并通过分类管理把常用提示词整理成自己的工作流。
|
||||
|
||||
典型效果:
|
||||
> [!NOTE]
|
||||
> 如果你有好用的提示词模板,欢迎在 [Issues](https://github.com/yynxxxxx/Codex-X/issues) 提交:请附上模板名称、适用场景、Markdown 内容、推荐启用方式和必要说明。合适的模板会考虑收录到 `examples/`,让更多用户可以一键同步使用。
|
||||
|
||||
```text
|
||||
破甲前 → 拒绝或泛化回答
|
||||
破甲后 → 直接给安全研究方法论与测试步骤
|
||||
```
|
||||
### 2. Provider / API:添加、检测、获取模型、随时切换
|
||||
|
||||
### 2. Provider 可视化切换
|
||||
> [!NOTE]
|
||||
> 启用新的第三方供应商后,新建或重新打开 Codex 会话即可使用新的中转,不需要重启整个 Codex 客户端。
|
||||
|
||||
- 添加第三方 Codex API Provider
|
||||
- 编辑 Base URL / API Key / Model / Wire API
|
||||
- Provider 页面可查看并编辑对应 TOML
|
||||
- 当前启用 Provider 状态清晰可见
|
||||
- 支持从 cc-switch 数据库导入 Codex Provider
|
||||
- 保存多个第三方供应商,随时查看当前正在使用哪一个
|
||||
- 切换前可检测连接,并可获取模型进行测试
|
||||
- 在同一页面编辑 Base URL、API Key、Model、Wire API 和完整 TOML
|
||||
- 从 cc-switch 导入时自动区分新增、更新、合并与跳过;相同 URL + Key 不再重复显示
|
||||
- 切回 OpenAI Official 时保留当前官方登录态,第三方配置也不会凭空消失
|
||||
|
||||
### 3. 官方 Auth 管理
|
||||
### 3. 会话管理:同步、检查与永久删除
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<b>同步和检查</b><br />
|
||||
检查本地会话是否和当前 Provider / 模型一致,需要时一键同步到当前供应商配置,不修改聊天内容。
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<b>查找和整理</b><br />
|
||||
按标题、项目路径、供应商或 ID 搜索会话,也可以按项目路径分组查看,适合清理长期使用后积累的会话列表。
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
<b>精确删除</b><br />
|
||||
支持单选、多选,也可以勾选一个或多个项目,一次选中项目下的全部会话;确认后会从 Codex 自身存储中删除对应会话及其派生子会话。
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> [!CAUTION]
|
||||
> **永久删除不可恢复。** 删除前请先关闭仍在使用这些会话的 Codex 窗口或 CLI,并在确认窗口中再次核对待删除列表。
|
||||
|
||||
### 4. Skills / MCP 管理
|
||||
|
||||
在【技能和 MCP】页面集中管理 Codex 的能力扩展,不必再到多个目录和配置文件中逐项查找。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<b>Skills</b><br />
|
||||
查看当前 Skill,导入已有内容或从 ZIP 安装;可以逐项启用 / 禁用,并检查已安装 Skill 是否有更新。
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<b>MCP</b><br />
|
||||
导入前先预览现有 MCP Server,再决定哪些需要纳管;启用或禁用后由 Codex-X 自动维护 Codex 配置。
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 5. TOML 与官方 Auth 管理
|
||||
|
||||
- 自动读取 Codex 官方 `auth.json`
|
||||
- 支持查看 / 编辑 ChatGPT 登录态 Auth
|
||||
- 区分官方 Auth 与第三方 API Key
|
||||
- 官方配置可和第三方 Provider 在 UI 中统一管理
|
||||
|
||||
### 4. TOML 可视化编辑
|
||||
|
||||
- 查看当前 Codex `config.toml`
|
||||
- 查看当前 Codex 正在使用的 live `config.toml`
|
||||
- 深色代码预览与语法高亮
|
||||
- Provider 编辑页可直接编辑完整 TOML
|
||||
- 保存后同步到 Codex 配置目录
|
||||
|
||||
### 5. 会话管理 / Provider Sync
|
||||
|
||||
Codex-X 可以读取 Codex 本地会话数据:
|
||||
|
||||
```text
|
||||
~/.codex/sqlite/*.db
|
||||
~/.codex/state_5.sqlite
|
||||
~/.codex/sessions/**/rollout-*.jsonl
|
||||
~/.codex/archived_sessions/**/rollout-*.jsonl
|
||||
```
|
||||
|
||||
用于检查旧会话的 Provider 元数据是否和当前配置一致,并支持一键同步 / 修复,让历史 thread 继续被原生 Codex 识别、打开和续聊。
|
||||
|
||||
### 6. Skills / MCP 管理
|
||||
|
||||
Codex-X 新增独立的【技能和 MCP】页面,用于集中管理 Codex 的专业能力扩展。
|
||||
|
||||
- Skills:读取当前 Codex Skills,支持导入已有、从 ZIP 安装、启用 / 禁用、检查更新状态
|
||||
- MCP:读取当前 Codex MCP Server,支持导入已有、启用 / 禁用,启用后写入 Codex <code>config.toml</code>
|
||||
- 适合管理 Android APK 逆向、Ghidra / IDA、Web / API / 协议逆向、CTF、安全测试等技能包
|
||||
|
||||
### 7. 逆向 Skills 导航
|
||||
### 6. 逆向 Skills 导航
|
||||
|
||||
<div align="center">
|
||||
<a href="https://yynxxxxx.github.io/Codex-X/">
|
||||
@@ -282,15 +329,15 @@ Codex-X 新增独立的【技能和 MCP】页面,用于集中管理 Codex 的
|
||||
</a>
|
||||
</p>
|
||||
|
||||
### 8. 跨平台桌面软件
|
||||
### 7. 跨平台桌面软件
|
||||
|
||||
- macOS Apple Silicon `.dmg`
|
||||
- macOS Intel `.dmg`
|
||||
- Windows `.msi`
|
||||
- Windows Portable `.zip`
|
||||
- Linux `.deb` / `.rpm`
|
||||
- Linux `.deb` / `.rpm` / `.AppImage`
|
||||
- GitHub Releases 自动构建发布
|
||||
- 应用内检查更新
|
||||
- 安装版支持应用内自动更新,Windows 便携版保留手动更新
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -345,7 +392,16 @@ pnpm dev
|
||||
pnpm --dir apps/desktop tauri build
|
||||
```
|
||||
|
||||
## macOS 安装说明
|
||||
## 桌面端安装说明
|
||||
|
||||
### 皮肤中心安全边界
|
||||
|
||||
- 实机换肤支持 macOS 和 Windows;Linux 仍可管理、导入和导出主题包,但不会显示为已应用。
|
||||
- macOS 只使用官方 Codex 内置且签名一致的 Node.js,并校验 Bundle ID、代码签名和进程归属;Windows 动态校验当前用户注册的 `OpenAI.Codex` Store 包,并使用发布包内固定校验的 Node.js 22 运行时。
|
||||
- CDP 仅绑定 `127.0.0.1`,不修改官方 `.app`、`WindowsApps`、`app.asar`、代码签名、目录权限或 `config.toml`。
|
||||
- 首次应用通常需要确认重启 Codex;“关闭皮肤”会立即移除当前界面皮肤,之后可随时重新应用主题。
|
||||
- 主题默认会根据图片尺寸和内容自适应裁切;人物或主体构图可在 `theme.json` 使用 `art.focusX`、`art.focusY`(`0` 到 `1`)和 `art.safeArea` 精确控制,无需为同一主题重复生成图片。
|
||||
- 注入实现基于 [Fei-Away/Codex-Dream-Skin](https://github.com/Fei-Away/Codex-Dream-Skin) 的 MIT 源码,固定来源和许可见 `apps/desktop/src-tauri/resources/skin-runtime/NOTICE.md`。
|
||||
|
||||
如果你在未签名 / 未公证的 DMG 中看到“软件已损坏”提示,这是 macOS Gatekeeper 的正常行为。
|
||||
|
||||
@@ -366,6 +422,19 @@ xattr -dr com.apple.quarantine /Applications/Codex-X.app
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=yynxxxxx%2FCodex-X&type=date&legend=top-left">
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=yynxxxxx%2Fcodex-x&type=date&legend=top-left&sealed_token=Vqvz67Jv_WIePGePCN8RdJaY5oCyqqCZUSFWs4M4dAxP8JXFZlYEWbI8YcU6SFgpqOqqJifzpOTIlMg4ee8NaCkHpCSqv1r5pxewR-tQlmxswaZlhedd6A" width="900" />
|
||||
</a>
|
||||
<p align="center">
|
||||
<a href="https://github.com/yynxxxxx/Codex-X/stargazers">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=light" />
|
||||
<img alt="Codex-X Star History" src="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=light" width="900" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **使用声明**
|
||||
>
|
||||
> 本项目仅用于大模型与智能体相关技术的学习、研究与交流,软件本身不包含主动破坏性功能。请在合法、合规并获得授权的范围内使用,禁止将其用于攻击、侵害他人权益或其他违法用途。使用者应自行判断使用边界,并对相关行为与后果承担责任。
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Codex-X</title>
|
||||
<script src="/theme-init.js"></script>
|
||||
<style>
|
||||
html, body { margin: 0; min-height: 100%; background: #070a13; }
|
||||
#root:empty::before {
|
||||
content: "Codex-X";
|
||||
position: fixed; inset: 0; display: grid; place-items: center;
|
||||
color: #eef4ff; font: 800 28px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: radial-gradient(circle at 30% 12%, rgba(124,92,255,.22), transparent 32%), linear-gradient(135deg, #060814 0%, #0c1120 52%, #071421 100%);
|
||||
}
|
||||
html, body { margin: 0; min-height: 100%; background: #f3f4f6; }
|
||||
html[data-theme="dark"], html[data-theme="dark"] body { background: #24262b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.2.32",
|
||||
"version": "0.3.6",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
@@ -10,10 +10,14 @@
|
||||
"tauri": "tauri",
|
||||
"dev:renderer": "vite --host 127.0.0.1 --port 1420",
|
||||
"build:renderer": "vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:skin-runtime": "node src-tauri/resources/skin-runtime/tests/run-tests.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- Source: Simple Icons 16.26.0 (CC0-1.0). -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,10 @@
|
||||
(function () {
|
||||
var theme = "light";
|
||||
try {
|
||||
theme = window.localStorage.getItem("codexx.theme") === "dark" ? "dark" : "light";
|
||||
} catch (_error) {
|
||||
// Keep the light default when storage is unavailable.
|
||||
}
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
@@ -1,28 +1,44 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.2.32"
|
||||
version = "0.3.6"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
rust-version = "1.85.0"
|
||||
rust-version = "1.89.0"
|
||||
|
||||
[lib]
|
||||
name = "codexx_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
windows-runtime-check = []
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.8.2", features = [] }
|
||||
tauri = { version = "2.8.2", features = ["protocol-asset", "tray-icon"] }
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml_edit = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "5.0"
|
||||
thiserror = "2.0"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
rusqlite = { version = "0.31", features = ["backup", "bundled"] }
|
||||
semver = "1.0"
|
||||
ureq = { version = "2.12", features = ["tls"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = [
|
||||
"blocking",
|
||||
"rustls-no-provider",
|
||||
"socks",
|
||||
"system-proxy",
|
||||
] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
sha2 = "0.10"
|
||||
percent-encoding = "2.3"
|
||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-single-instance = "=2.4.3"
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging"
|
||||
"core:window:allow-start-dragging",
|
||||
"dialog:allow-save",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download",
|
||||
"updater:allow-install",
|
||||
"updater:allow-download-and-install",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "shiina-mashiro-blossom",
|
||||
"name": "椎名真白·樱花画室",
|
||||
"brandSubtitle": "椎名真白主题",
|
||||
"tagline": "椎名真白在白樱画室里安静作画。",
|
||||
"projectPrefix": "选择项目 · ",
|
||||
"projectLabel": "◉ 选择项目",
|
||||
"statusText": "椎名真白主题运行中",
|
||||
"quote": "安静创作,也会发光",
|
||||
"image": "background.png",
|
||||
"appearance": "light",
|
||||
"surfaceOpacity": 0.62,
|
||||
"art": {
|
||||
"focusX": 0.28,
|
||||
"focusY": 0.26,
|
||||
"safeArea": "right",
|
||||
"taskMode": "ambient"
|
||||
},
|
||||
"colors": {
|
||||
"background": "#f9f4f7",
|
||||
"panel": "#fffafd",
|
||||
"panelAlt": "#fff1f7",
|
||||
"accent": "#ef7da4",
|
||||
"accentAlt": "#ffadc9",
|
||||
"secondary": "#f5c2d6",
|
||||
"highlight": "#c95f8a",
|
||||
"text": "#2a2026",
|
||||
"muted": "#8d7180",
|
||||
"line": "rgba(239, 125, 164, .26)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Codex Dream Skin Studio contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Codex Dream Skin Runtime Notice
|
||||
|
||||
The files in this directory are adapted from Codex Dream Skin Studio:
|
||||
|
||||
- Source: https://github.com/Fei-Away/Codex-Dream-Skin
|
||||
- Pinned source commit: `5fd8af532efbaa87d2d0092297fd2d45cd56574e`
|
||||
- Upstream runtime version: `1.2.0`
|
||||
- License: MIT, reproduced in `LICENSE`
|
||||
|
||||
Codex-X embeds these runtime sources and manages them itself. It does not require
|
||||
the separate Codex Dream Skin Studio installer or its `~/.codex` engine directory.
|
||||
The Windows release also bundles Node.js 22.23.1 from nodejs.org, verified against
|
||||
SHA-256 `7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29`;
|
||||
its upstream license is distributed beside `node.exe`.
|
||||
|
||||
Only the upstream software source and procedurally generated abstract presets are
|
||||
included. The Arina Hashimoto preset and other assets excluded by the upstream
|
||||
notice are intentionally not distributed with Codex-X.
|
||||
|
||||
Codex Dream Skin Studio and Codex-X are unofficial projects and are not affiliated
|
||||
with, endorsed by, or sponsored by OpenAI. Codex and related trademarks belong to
|
||||
their respective owners.
|
||||
@@ -0,0 +1,796 @@
|
||||
((cssText, artDataUrl, themeConfig) => {
|
||||
const STATE_KEY = "__CODEX_DREAM_SKIN_STATE__";
|
||||
const DISABLED_KEY = "__CODEX_DREAM_SKIN_DISABLED__";
|
||||
const STYLE_ID = "codex-dream-skin-style";
|
||||
const CHROME_ID = "codex-dream-skin-chrome";
|
||||
const SHELL_ATTR = "data-dream-shell";
|
||||
const ART_ATTRS = [
|
||||
"data-dream-art-wide", "data-dream-art-safe", "data-dream-task-mode",
|
||||
"data-dream-art-safe-area", "data-dream-art-task-mode", "data-dream-art-aspect",
|
||||
"data-dream-art-ready",
|
||||
];
|
||||
const VERSION = __DREAM_SKIN_VERSION_JSON__;
|
||||
const STYLE_REVISION = __DREAM_SKIN_STYLE_REVISION_JSON__;
|
||||
const THEME = themeConfig && typeof themeConfig === "object" ? themeConfig : {};
|
||||
const ART = THEME.art && typeof THEME.art === "object" ? THEME.art : {};
|
||||
const ART_METADATA = THEME.artMetadata && typeof THEME.artMetadata === "object"
|
||||
? THEME.artMetadata : null;
|
||||
const ANALYSIS_CACHE_KEY = "__CODEX_DREAM_SKIN_ANALYSIS_CACHE__";
|
||||
const THEME_VARIABLES = [
|
||||
"--ds-bg", "--ds-panel", "--ds-panel-2", "--ds-green", "--ds-lime",
|
||||
"--ds-cyan", "--ds-purple", "--ds-text", "--ds-muted", "--ds-line",
|
||||
"--ds-bg-rgb", "--ds-panel-rgb", "--ds-panel-2-rgb", "--ds-accent-rgb",
|
||||
"--ds-accent-alt-rgb", "--ds-secondary-rgb", "--ds-highlight-rgb",
|
||||
"--ds-text-rgb", "--ds-muted-rgb", "--ds-line-rgb", "--ds-surface-opacity",
|
||||
"--dream-art-focus-x", "--dream-art-focus-y", "--dream-art-position",
|
||||
"--dream-skin-focus-x", "--dream-skin-focus-y", "--dream-skin-art-position",
|
||||
"--dream-skin-home-art-position",
|
||||
"--dream-skin-name", "--dream-skin-tagline", "--dream-skin-project-prefix",
|
||||
"--dream-skin-project-label",
|
||||
];
|
||||
const installToken = {};
|
||||
const existingAnalysisCache = window[ANALYSIS_CACHE_KEY];
|
||||
const analysisCache = existingAnalysisCache && typeof existingAnalysisCache.get === "function" &&
|
||||
typeof existingAnalysisCache.set === "function" ? existingAnalysisCache : new Map();
|
||||
window[ANALYSIS_CACHE_KEY] = analysisCache;
|
||||
let artAnalysis = typeof THEME.artKey === "string" ? analysisCache.get(THEME.artKey) ?? null : null;
|
||||
let analysisTimer = null;
|
||||
let samplingNativeShell = false;
|
||||
let rootObserver = null;
|
||||
const now = () => typeof performance === "object" && typeof performance.now === "function"
|
||||
? performance.now() : Date.now();
|
||||
const metrics = {
|
||||
ensureCalls: 0,
|
||||
rootPasses: 0,
|
||||
routePasses: 0,
|
||||
layoutReads: 0,
|
||||
attributeWrites: 0,
|
||||
styleWrites: 0,
|
||||
textWrites: 0,
|
||||
analysisRuns: 0,
|
||||
analysisCacheHits: artAnalysis ? 1 : 0,
|
||||
firstEnsureMs: null,
|
||||
analysisMs: null,
|
||||
};
|
||||
window[DISABLED_KEY] = false;
|
||||
|
||||
const previous = window[STATE_KEY];
|
||||
const artUrl = (() => {
|
||||
const comma = artDataUrl.indexOf(",");
|
||||
const mime = /^data:([^;,]+)/.exec(artDataUrl)?.[1] || "image/png";
|
||||
const binary = atob(artDataUrl.slice(comma + 1));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return URL.createObjectURL(new Blob([bytes], { type: mime }));
|
||||
})();
|
||||
|
||||
if (previous?.observer) previous.observer.disconnect();
|
||||
if (previous?.rootObserver) previous.rootObserver.disconnect();
|
||||
if (previous?.resizeObserver) previous.resizeObserver.disconnect();
|
||||
if (previous?.timer) clearInterval(previous.timer);
|
||||
if (previous?.scheduler?.timeout) clearTimeout(previous.scheduler.timeout);
|
||||
if (previous?.scheduler?.frame != null && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(previous.scheduler.frame);
|
||||
}
|
||||
if (previous?.analysisTimer) clearTimeout(previous.analysisTimer);
|
||||
if (previous?.resizeHandler) window.removeEventListener("resize", previous.resizeHandler);
|
||||
if (previous?.mediaHandler && previous?.mediaQuery) {
|
||||
try { previous.mediaQuery.removeEventListener("change", previous.mediaHandler); } catch {}
|
||||
}
|
||||
|
||||
const cssString = (value) => JSON.stringify(String(value ?? ""));
|
||||
|
||||
const setStyleProperty = (root, name, value) => {
|
||||
if (root.style.getPropertyValue(name) !== value) {
|
||||
root.style.setProperty(name, value);
|
||||
metrics.styleWrites += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const setAttribute = (root, name, value) => {
|
||||
const normalized = String(value);
|
||||
if (root.getAttribute(name) !== normalized) {
|
||||
root.setAttribute(name, normalized);
|
||||
metrics.attributeWrites += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const setTextContent = (node, value) => {
|
||||
if (node && node.textContent !== value) {
|
||||
node.textContent = value;
|
||||
metrics.textWrites += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const parseRgb = (value) => {
|
||||
if (!value || value === "transparent") return null;
|
||||
const hex = String(value).trim().match(/^#([0-9a-f]{6})$/i);
|
||||
if (hex) {
|
||||
const number = Number.parseInt(hex[1], 16);
|
||||
return { r: number >> 16, g: (number >> 8) & 255, b: number & 255 };
|
||||
}
|
||||
const m = String(value).match(/rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i);
|
||||
if (!m) return null;
|
||||
return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };
|
||||
};
|
||||
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
|
||||
const rgbString = (value) => {
|
||||
const rgb = parseRgb(value);
|
||||
return rgb ? `${Math.round(rgb.r)} ${Math.round(rgb.g)} ${Math.round(rgb.b)}` : null;
|
||||
};
|
||||
|
||||
const rgbToHex = ({ r, g, b }) => `#${[r, g, b]
|
||||
.map((value) => clamp(Math.round(value), 0, 255).toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
|
||||
const rgbToHsl = ({ r, g, b }) => {
|
||||
const values = [r, g, b].map((value) => value / 255);
|
||||
const max = Math.max(...values);
|
||||
const min = Math.min(...values);
|
||||
const lightness = (max + min) / 2;
|
||||
if (max === min) return { h: 0, s: 0, l: lightness };
|
||||
const delta = max - min;
|
||||
const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
|
||||
let hue;
|
||||
if (max === values[0]) hue = (values[1] - values[2]) / delta + (values[1] < values[2] ? 6 : 0);
|
||||
else if (max === values[1]) hue = (values[2] - values[0]) / delta + 2;
|
||||
else hue = (values[0] - values[1]) / delta + 4;
|
||||
return { h: hue * 60, s: saturation, l: lightness };
|
||||
};
|
||||
|
||||
const hslToRgb = ({ h, s, l }) => {
|
||||
const hue = ((h % 360) + 360) % 360 / 360;
|
||||
if (s === 0) {
|
||||
const neutral = Math.round(l * 255);
|
||||
return { r: neutral, g: neutral, b: neutral };
|
||||
}
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
const channel = (offset) => {
|
||||
let t = hue + offset;
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
return { r: channel(1 / 3) * 255, g: channel(0) * 255, b: channel(-1 / 3) * 255 };
|
||||
};
|
||||
|
||||
const luminance = ({ r, g, b }) => {
|
||||
const lin = [r, g, b].map((c) => {
|
||||
const x = c / 255;
|
||||
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
||||
};
|
||||
|
||||
/** Detect Codex app light/dark shell for CSS branching. */
|
||||
const detectShellMode = () => {
|
||||
const root = document.documentElement;
|
||||
const body = document.body;
|
||||
const cls = `${root.className || ""} ${body?.className || ""}`.toLowerCase();
|
||||
|
||||
if (/\b(dark|theme-dark|appearance-dark)\b/.test(cls)) return "dark";
|
||||
if (/\b(light|theme-light|appearance-light)\b/.test(cls)) return "light";
|
||||
|
||||
const dataTheme = (
|
||||
root.getAttribute("data-theme") ||
|
||||
root.getAttribute("data-appearance") ||
|
||||
root.getAttribute("data-color-mode") ||
|
||||
body?.getAttribute("data-theme") ||
|
||||
body?.getAttribute("data-appearance") ||
|
||||
""
|
||||
).toLowerCase();
|
||||
if (dataTheme.includes("dark")) return "dark";
|
||||
if (dataTheme.includes("light")) return "light";
|
||||
|
||||
// Radios in profile menu (if present in DOM)
|
||||
const checked = document.querySelector('input[name="appearance-theme"]:checked');
|
||||
if (checked) {
|
||||
const label = (checked.getAttribute("aria-label") || checked.value || "").toLowerCase();
|
||||
if (label.includes("暗") || label.includes("dark")) return "dark";
|
||||
if (label.includes("浅") || label.includes("light")) return "light";
|
||||
if (label.includes("系统") || label.includes("system")) {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
}
|
||||
|
||||
// The skin itself declares color-scheme on :root. Once installed,
|
||||
// reading getComputedStyle(root) directly would therefore keep `auto`
|
||||
// themes locked to the previous shell mode. Temporarily remove only our
|
||||
// own root class/attribute, sample the native computed scheme, then restore
|
||||
// synchronously. Mutation records created by this probe are drained below
|
||||
// so the root observer does not schedule a redundant ensure pass.
|
||||
try {
|
||||
const hadSkin = root.classList.contains("codex-dream-skin");
|
||||
const savedShell = root.getAttribute(SHELL_ATTR);
|
||||
samplingNativeShell = true;
|
||||
if (hadSkin) root.classList.remove("codex-dream-skin");
|
||||
if (savedShell !== null) root.removeAttribute(SHELL_ATTR);
|
||||
let colorScheme = "";
|
||||
try {
|
||||
colorScheme = getComputedStyle(root).colorScheme || "";
|
||||
} finally {
|
||||
if (hadSkin) root.classList.add("codex-dream-skin");
|
||||
if (savedShell !== null) root.setAttribute(SHELL_ATTR, savedShell);
|
||||
rootObserver?.takeRecords?.();
|
||||
samplingNativeShell = false;
|
||||
}
|
||||
if (colorScheme.includes("dark") && !colorScheme.includes("light")) return "dark";
|
||||
if (colorScheme.includes("light") && !colorScheme.includes("dark")) return "light";
|
||||
} catch {
|
||||
samplingNativeShell = false;
|
||||
}
|
||||
|
||||
try {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
} catch {}
|
||||
|
||||
// Only use surface luminance before the skin owns those surfaces. Sampling
|
||||
// our own translucent layers would create route-dependent light/dark flips.
|
||||
if (!root.classList.contains("codex-dream-skin")) {
|
||||
const samples = [
|
||||
body,
|
||||
document.querySelector("main.main-surface"),
|
||||
document.querySelector("aside.app-shell-left-panel"),
|
||||
].filter(Boolean);
|
||||
let votesLight = 0;
|
||||
let votesDark = 0;
|
||||
for (const el of samples) {
|
||||
try {
|
||||
const rgb = parseRgb(getComputedStyle(el).backgroundColor);
|
||||
if (!rgb) continue;
|
||||
const L = luminance(rgb);
|
||||
if (L >= 0.55) votesLight += 1;
|
||||
else if (L <= 0.25) votesDark += 1;
|
||||
} catch {}
|
||||
}
|
||||
if (votesLight > votesDark) return "light";
|
||||
if (votesDark > votesLight) return "dark";
|
||||
}
|
||||
return "light";
|
||||
};
|
||||
|
||||
const makeAdaptivePalette = (sample, shell) => {
|
||||
const source = sample || { r: 108, g: 126, b: 136 };
|
||||
const hsl = rgbToHsl(source);
|
||||
const hue = hsl.s < 0.12 ? 214 : hsl.h;
|
||||
const saturation = clamp(hsl.s, 0.38, 0.72);
|
||||
const accent = hslToRgb({ h: hue, s: saturation, l: shell === "light" ? 0.42 : 0.66 });
|
||||
const accentAlt = hslToRgb({ h: hue + 12, s: saturation * 0.82, l: shell === "light" ? 0.52 : 0.73 });
|
||||
const secondary = hslToRgb({ h: hue - 24, s: saturation * 0.64, l: shell === "light" ? 0.56 : 0.62 });
|
||||
const highlight = hslToRgb({ h: hue + 24, s: saturation * 0.76, l: shell === "light" ? 0.36 : 0.58 });
|
||||
const neutral = (lightness, chroma = 0.08) => rgbToHex(hslToRgb({ h: hue, s: chroma, l: lightness }));
|
||||
return shell === "light" ? {
|
||||
background: neutral(0.965, 0.07),
|
||||
panel: neutral(0.987, 0.035),
|
||||
panelAlt: neutral(0.945, 0.09),
|
||||
accent: rgbToHex(accent),
|
||||
accentAlt: rgbToHex(accentAlt),
|
||||
secondary: rgbToHex(secondary),
|
||||
highlight: rgbToHex(highlight),
|
||||
text: neutral(0.13, 0.10),
|
||||
muted: neutral(0.42, 0.08),
|
||||
line: `rgba(${Math.round(accent.r)}, ${Math.round(accent.g)}, ${Math.round(accent.b)}, .24)`,
|
||||
} : {
|
||||
background: neutral(0.055, 0.045),
|
||||
panel: neutral(0.085, 0.04),
|
||||
panelAlt: neutral(0.125, 0.05),
|
||||
accent: rgbToHex(accent),
|
||||
accentAlt: rgbToHex(accentAlt),
|
||||
secondary: rgbToHex(secondary),
|
||||
highlight: rgbToHex(highlight),
|
||||
text: neutral(0.93, 0.025),
|
||||
muted: neutral(0.69, 0.03),
|
||||
line: `rgba(${Math.round(accent.r)}, ${Math.round(accent.g)}, ${Math.round(accent.b)}, .28)`,
|
||||
};
|
||||
};
|
||||
|
||||
const resolvedShell = () => {
|
||||
if (THEME.appearance === "light" || THEME.appearance === "dark") return THEME.appearance;
|
||||
// Image luminance may tune accents and scrims, but auto appearance follows
|
||||
// Codex/ChatGPT (or the OS fallback) so a bright wallpaper cannot flip a
|
||||
// native dark session back to a light shell after analysis.
|
||||
return detectShellMode();
|
||||
};
|
||||
|
||||
const applyTheme = (root, shell) => {
|
||||
const colors = THEME.colors || {};
|
||||
const explicit = new Set(Array.isArray(THEME.explicitColorKeys) ? THEME.explicitColorKeys : []);
|
||||
const adaptive = makeAdaptivePalette(artAnalysis?.accentRgb, shell);
|
||||
const legacyLight = !THEME.appearance && shell === "light";
|
||||
const structural = new Set(["background", "panel", "panelAlt", "text", "muted"]);
|
||||
const pick = (name) => {
|
||||
const allowExplicit = explicit.has(name) && !(legacyLight && structural.has(name));
|
||||
return allowExplicit && typeof colors[name] === "string" ? colors[name] : adaptive[name];
|
||||
};
|
||||
const accent = pick("accent");
|
||||
const accentAlt = explicit.has("accentAlt") ? pick("accentAlt") : (explicit.has("accent") ? accent : adaptive.accentAlt);
|
||||
const variables = {
|
||||
"--ds-bg": pick("background"),
|
||||
"--ds-panel": pick("panel"),
|
||||
"--ds-panel-2": pick("panelAlt"),
|
||||
"--ds-green": accent,
|
||||
"--ds-lime": accentAlt,
|
||||
"--ds-cyan": pick("secondary"),
|
||||
"--ds-purple": pick("highlight"),
|
||||
"--ds-text": pick("text"),
|
||||
"--ds-muted": pick("muted"),
|
||||
"--ds-line": explicit.has("line") && typeof colors.line === "string" ? colors.line : adaptive.line,
|
||||
};
|
||||
const surfaceOpacity = Number.isFinite(Number(THEME.surfaceOpacity))
|
||||
? clamp(Number(THEME.surfaceOpacity), 0.35, 1)
|
||||
: 1;
|
||||
setStyleProperty(root, "--ds-surface-opacity", surfaceOpacity.toFixed(2));
|
||||
|
||||
for (const [name, value] of Object.entries(variables)) {
|
||||
if (typeof value === "string" && value) setStyleProperty(root, name, value);
|
||||
}
|
||||
const rgbVariables = {
|
||||
"--ds-bg-rgb": variables["--ds-bg"],
|
||||
"--ds-panel-rgb": variables["--ds-panel"],
|
||||
"--ds-panel-2-rgb": variables["--ds-panel-2"],
|
||||
"--ds-accent-rgb": variables["--ds-green"],
|
||||
"--ds-accent-alt-rgb": variables["--ds-lime"],
|
||||
"--ds-secondary-rgb": variables["--ds-cyan"],
|
||||
"--ds-highlight-rgb": variables["--ds-purple"],
|
||||
"--ds-text-rgb": variables["--ds-text"],
|
||||
"--ds-muted-rgb": variables["--ds-muted"],
|
||||
"--ds-line-rgb": variables["--ds-line"],
|
||||
};
|
||||
for (const [name, value] of Object.entries(rgbVariables)) {
|
||||
const rgb = rgbString(value);
|
||||
if (rgb) setStyleProperty(root, name, rgb);
|
||||
}
|
||||
setStyleProperty(root, "--dream-skin-name", cssString(THEME.name || "Codex Dream Skin"));
|
||||
setStyleProperty(root, "--dream-skin-tagline", cssString(THEME.tagline || "Make something wonderful."));
|
||||
setStyleProperty(root, "--dream-skin-project-prefix", cssString(THEME.projectPrefix || "选择项目 · "));
|
||||
setStyleProperty(root, "--dream-skin-project-label", cssString(THEME.projectLabel || "◉ 选择项目"));
|
||||
};
|
||||
|
||||
const applyArtMetadata = (root) => {
|
||||
const profile = artAnalysis || ART_METADATA;
|
||||
const inferredSafe = profile?.safeArea || "center";
|
||||
const safeArea = ART.safeArea && ART.safeArea !== "auto" ? ART.safeArea : inferredSafe;
|
||||
const canonicalSafe = ["left", "right", "center", "none"].includes(safeArea)
|
||||
? safeArea : "center";
|
||||
const focusX = typeof ART.focusX === "number" ? ART.focusX
|
||||
: profile?.focusX ?? (safeArea === "left" ? 0.72 : safeArea === "right" ? 0.28 : 0.5);
|
||||
const focusY = typeof ART.focusY === "number" ? ART.focusY : profile?.focusY ?? 0.5;
|
||||
const explicitFocusY = typeof ART.focusY === "number";
|
||||
const taskMode = ART.taskMode && ART.taskMode !== "auto"
|
||||
? ART.taskMode : profile?.taskMode || "ambient";
|
||||
const wide = profile?.wide || false;
|
||||
const aspect = profile?.aspect || "unknown";
|
||||
const homeFocusY = !explicitFocusY && !wide && ["wide", "landscape"].includes(aspect)
|
||||
? Math.min(focusY, 0.28) : focusY;
|
||||
const focusXValue = `${(clamp(focusX, 0, 1) * 100).toFixed(2)}%`;
|
||||
const focusYValue = `${(clamp(focusY, 0, 1) * 100).toFixed(2)}%`;
|
||||
const homeFocusYValue = `${(clamp(homeFocusY, 0, 1) * 100).toFixed(2)}%`;
|
||||
|
||||
setAttribute(root, "data-dream-art-wide", wide ? "true" : "false");
|
||||
setAttribute(root, "data-dream-art-safe", canonicalSafe);
|
||||
setAttribute(root, "data-dream-task-mode", taskMode);
|
||||
setAttribute(root, "data-dream-art-safe-area", safeArea);
|
||||
setAttribute(root, "data-dream-art-task-mode", taskMode);
|
||||
setAttribute(root, "data-dream-art-aspect", aspect);
|
||||
setAttribute(root, "data-dream-art-ready", artAnalysis ? "true" : "false");
|
||||
setStyleProperty(root, "--dream-art-focus-x", focusXValue);
|
||||
setStyleProperty(root, "--dream-art-focus-y", focusYValue);
|
||||
setStyleProperty(root, "--dream-art-position", `${focusXValue} ${focusYValue}`);
|
||||
setStyleProperty(root, "--dream-skin-focus-x", focusXValue);
|
||||
setStyleProperty(root, "--dream-skin-focus-y", focusYValue);
|
||||
setStyleProperty(root, "--dream-skin-art-position", `${focusXValue} ${focusYValue}`);
|
||||
setStyleProperty(root, "--dream-skin-home-art-position", `${focusXValue} ${homeFocusYValue}`);
|
||||
};
|
||||
|
||||
const analyzeArt = () => new Promise((resolve) => {
|
||||
const startedAt = now();
|
||||
metrics.analysisRuns += 1;
|
||||
if (typeof window.Image !== "function" || !document?.createElement) {
|
||||
metrics.analysisMs = Number((now() - startedAt).toFixed(3));
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const image = new window.Image();
|
||||
let settled = false;
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (analysisTimer) clearTimeout(analysisTimer);
|
||||
analysisTimer = null;
|
||||
metrics.analysisMs = Number((now() - startedAt).toFixed(3));
|
||||
resolve(value);
|
||||
};
|
||||
analysisTimer = setTimeout(() => finish(null), 6000);
|
||||
image.onerror = () => finish(null);
|
||||
image.onload = () => {
|
||||
try {
|
||||
const ratio = image.naturalWidth / image.naturalHeight;
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) throw new Error("Invalid image dimensions");
|
||||
const maxDimension = 96;
|
||||
const width = Math.max(16, Math.round(ratio >= 1 ? maxDimension : maxDimension * ratio));
|
||||
const height = Math.max(16, Math.round(ratio >= 1 ? maxDimension / ratio : maxDimension));
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext?.("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("Canvas is unavailable");
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const data = context.getImageData(0, 0, width, height).data;
|
||||
const samples = new Array(width * height);
|
||||
const bins = Array.from({ length: 24 }, () => ({ weight: 0, r: 0, g: 0, b: 0 }));
|
||||
let lightTotal = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const offset = (y * width + x) * 4;
|
||||
if (data[offset + 3] < 32) continue;
|
||||
const rgb = { r: data[offset], g: data[offset + 1], b: data[offset + 2] };
|
||||
const light = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255;
|
||||
const hsl = rgbToHsl(rgb);
|
||||
samples[y * width + x] = { light, saturation: hsl.s };
|
||||
lightTotal += light;
|
||||
count += 1;
|
||||
if (hsl.s >= 0.16 && hsl.l >= 0.16 && hsl.l <= 0.86) {
|
||||
const bin = bins[Math.min(23, Math.floor(hsl.h / 15))];
|
||||
const weight = hsl.s * (1 - Math.abs(hsl.l - 0.52) * 0.85);
|
||||
bin.weight += weight;
|
||||
bin.r += rgb.r * weight;
|
||||
bin.g += rgb.g * weight;
|
||||
bin.b += rgb.b * weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!count) throw new Error("Image has no visible pixels");
|
||||
const brightness = lightTotal / count;
|
||||
const information = (start, end) => {
|
||||
let total = 0;
|
||||
let totalSquared = 0;
|
||||
let edges = 0;
|
||||
let edgeCount = 0;
|
||||
let pixels = 0;
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = start; x < end; x += 1) {
|
||||
const sample = samples[y * width + x];
|
||||
if (!sample) continue;
|
||||
total += sample.light;
|
||||
totalSquared += sample.light * sample.light;
|
||||
pixels += 1;
|
||||
const previous = x > start ? samples[y * width + x - 1] : null;
|
||||
const above = y > 0 ? samples[(y - 1) * width + x] : null;
|
||||
if (previous) { edges += Math.abs(sample.light - previous.light); edgeCount += 1; }
|
||||
if (above) { edges += Math.abs(sample.light - above.light); edgeCount += 1; }
|
||||
}
|
||||
}
|
||||
const mean = pixels ? total / pixels : 0;
|
||||
const variance = pixels ? Math.max(0, totalSquared / pixels - mean * mean) : 1;
|
||||
return Math.sqrt(variance) * 0.58 + (edgeCount ? edges / edgeCount : 1) * 0.42;
|
||||
};
|
||||
const zoneWidth = Math.max(1, Math.floor(width * 0.38));
|
||||
const leftInformation = information(0, zoneWidth);
|
||||
const rightInformation = information(width - zoneWidth, width);
|
||||
let safeArea = "center";
|
||||
if (leftInformation < rightInformation * 0.86) safeArea = "left";
|
||||
else if (rightInformation < leftInformation * 0.86) safeArea = "right";
|
||||
|
||||
let saliencyTotal = 0;
|
||||
let saliencyX = 0;
|
||||
let saliencyY = 0;
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
const sample = samples[y * width + x];
|
||||
if (!sample) continue;
|
||||
const previous = x > 0 ? samples[y * width + x - 1] : null;
|
||||
const above = y > 0 ? samples[(y - 1) * width + x] : null;
|
||||
const edge = (previous ? Math.abs(sample.light - previous.light) : 0) +
|
||||
(above ? Math.abs(sample.light - above.light) : 0);
|
||||
const weight = 0.01 + Math.abs(sample.light - brightness) * 0.48 +
|
||||
sample.saturation * 0.34 + edge * 0.28;
|
||||
saliencyTotal += weight;
|
||||
saliencyX += (x + 0.5) / width * weight;
|
||||
saliencyY += (y + 0.5) / height * weight;
|
||||
}
|
||||
}
|
||||
let focusX = saliencyTotal ? saliencyX / saliencyTotal : 0.5;
|
||||
let focusY = saliencyTotal ? saliencyY / saliencyTotal : 0.5;
|
||||
if (safeArea === "left") focusX = Math.max(0.64, focusX);
|
||||
if (safeArea === "right") focusX = Math.min(0.36, focusX);
|
||||
focusX = clamp(focusX, 0.12, 0.88);
|
||||
focusY = clamp(focusY, 0.18, 0.82);
|
||||
|
||||
const accentBin = bins.reduce((best, candidate) => candidate.weight > best.weight ? candidate : best, bins[0]);
|
||||
const accentRgb = accentBin.weight > 0 ? {
|
||||
r: accentBin.r / accentBin.weight,
|
||||
g: accentBin.g / accentBin.weight,
|
||||
b: accentBin.b / accentBin.weight,
|
||||
} : null;
|
||||
const aspect = ratio >= 2.25 ? "ultrawide" : ratio >= 1.45 ? "wide"
|
||||
: ratio >= 1.08 ? "landscape" : ratio >= 0.9 ? "square" : "portrait";
|
||||
finish({
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
ratio,
|
||||
wide: ratio >= 1.75,
|
||||
aspect,
|
||||
brightness,
|
||||
shell: brightness >= 0.58 ? "light" : "dark",
|
||||
safeArea,
|
||||
focusX,
|
||||
focusY,
|
||||
taskMode: ratio >= 2.25 ? "banner" : "ambient",
|
||||
accentRgb,
|
||||
});
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
};
|
||||
image.src = artUrl;
|
||||
});
|
||||
|
||||
let chromeParts = null;
|
||||
let observedShellMain = null;
|
||||
let resizeObserver = null;
|
||||
|
||||
const ensureStyle = (root) => {
|
||||
let style = document.getElementById(STYLE_ID);
|
||||
if (!style) {
|
||||
style = document.createElement("style");
|
||||
style.id = STYLE_ID;
|
||||
style.textContent = cssText;
|
||||
style.dataset.dreamSkinVersion = VERSION;
|
||||
(document.head || root).appendChild(style);
|
||||
} else if (style.dataset.dreamSkinStyleRevision !== STYLE_REVISION) {
|
||||
style.textContent = cssText;
|
||||
}
|
||||
style.dataset.dreamSkinVersion = VERSION;
|
||||
style.dataset.dreamSkinStyleRevision = STYLE_REVISION;
|
||||
return style;
|
||||
};
|
||||
|
||||
const applyRootState = (root) => {
|
||||
metrics.rootPasses += 1;
|
||||
ensureStyle(root);
|
||||
const shell = resolvedShell();
|
||||
setAttribute(root, SHELL_ATTR, shell);
|
||||
setStyleProperty(root, "--dream-skin-art", `url("${artUrl}")`);
|
||||
applyTheme(root, shell);
|
||||
applyArtMetadata(root);
|
||||
root.classList.add("codex-dream-skin");
|
||||
return shell;
|
||||
};
|
||||
|
||||
const syncRouteState = (shell, { layout = false } = {}) => {
|
||||
metrics.routePasses += 1;
|
||||
const root = document.documentElement;
|
||||
if (!root) return;
|
||||
shell ||= root.getAttribute(SHELL_ATTR) || resolvedShell();
|
||||
const shellMain = document.querySelector("main.main-surface") || document.querySelector("main");
|
||||
const homeIndicator = document.querySelector('[data-testid="home-icon"]');
|
||||
const home = homeIndicator?.closest('[role="main"]') ||
|
||||
[...document.querySelectorAll('[role="main"]')].find((candidate) =>
|
||||
candidate.querySelector('[data-feature="game-source"]') &&
|
||||
candidate.querySelector('[data-codex-composer-root]')) || null;
|
||||
for (const candidate of document.querySelectorAll('[role="main"].dream-skin-home')) {
|
||||
if (candidate !== home) candidate.classList.remove("dream-skin-home");
|
||||
}
|
||||
if (home) home.classList.add("dream-skin-home");
|
||||
const homeUtilityBars = new Set(home
|
||||
? home.querySelectorAll('[class*="_homeUtilityBar_"]')
|
||||
: []);
|
||||
for (const candidate of document.querySelectorAll(".dream-skin-home-utility")) {
|
||||
if (!homeUtilityBars.has(candidate)) candidate.classList.remove("dream-skin-home-utility");
|
||||
}
|
||||
for (const candidate of homeUtilityBars) candidate.classList.add("dream-skin-home-utility");
|
||||
|
||||
if (!shellMain || !document.body) return;
|
||||
if (observedShellMain !== shellMain) {
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver?.observe(shellMain);
|
||||
observedShellMain = shellMain;
|
||||
layout = true;
|
||||
}
|
||||
shellMain.classList.toggle("dream-skin-home-shell", Boolean(home));
|
||||
let chrome = document.getElementById(CHROME_ID);
|
||||
let created = false;
|
||||
if (!chrome || chrome.parentElement !== document.body) {
|
||||
chrome?.remove();
|
||||
chrome = document.createElement("div");
|
||||
chrome.id = CHROME_ID;
|
||||
chrome.setAttribute("aria-hidden", "true");
|
||||
chrome.innerHTML = `
|
||||
<div class="dream-skin-brand">
|
||||
<span class="dream-skin-portal-mark">◉</span>
|
||||
<span><b></b><small></small></span>
|
||||
</div>
|
||||
<div class="dream-skin-status"><i></i><span></span></div>
|
||||
<div class="dream-skin-quote"></div>
|
||||
<div class="dream-skin-particles"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||
<div class="dream-skin-orbit"></div>`;
|
||||
document.body.appendChild(chrome);
|
||||
created = true;
|
||||
chromeParts = null;
|
||||
}
|
||||
if (!chromeParts || chromeParts.chrome !== chrome) {
|
||||
chromeParts = {
|
||||
chrome,
|
||||
name: chrome.querySelector(".dream-skin-brand b"),
|
||||
subtitle: chrome.querySelector(".dream-skin-brand small"),
|
||||
status: chrome.querySelector(".dream-skin-status span"),
|
||||
quote: chrome.querySelector(".dream-skin-quote"),
|
||||
};
|
||||
}
|
||||
setTextContent(chromeParts.name, THEME.name || "Codex Dream Skin");
|
||||
setTextContent(chromeParts.subtitle, THEME.brandSubtitle || "CODEX DREAM SKIN");
|
||||
setTextContent(chromeParts.status, THEME.statusText || "DREAM SKIN ONLINE");
|
||||
setTextContent(chromeParts.quote, THEME.quote || "MAKE SOMETHING WONDERFUL");
|
||||
if (layout || created) {
|
||||
metrics.layoutReads += 1;
|
||||
const shellBox = shellMain.getBoundingClientRect();
|
||||
setStyleProperty(chrome, "left", `${Math.round(shellBox.left)}px`);
|
||||
setStyleProperty(chrome, "top", `${Math.round(shellBox.top)}px`);
|
||||
setStyleProperty(chrome, "width", `${Math.round(shellBox.width)}px`);
|
||||
setStyleProperty(chrome, "height", `${Math.round(shellBox.height)}px`);
|
||||
}
|
||||
chrome.classList.toggle("dream-skin-home-shell", Boolean(home));
|
||||
if (chrome.dataset.dreamShell !== shell) {
|
||||
chrome.dataset.dreamShell = shell;
|
||||
metrics.attributeWrites += 1;
|
||||
}
|
||||
};
|
||||
|
||||
const ensure = ({ root: rootPass = true, route = true, layout = true } = {}) => {
|
||||
if (window[DISABLED_KEY]) return;
|
||||
const root = document.documentElement;
|
||||
if (!root) return;
|
||||
metrics.ensureCalls += 1;
|
||||
const shell = rootPass ? applyRootState(root) : null;
|
||||
if (route) syncRouteState(shell, { layout });
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
const state = window[STATE_KEY];
|
||||
if (state?.installToken !== installToken) return false;
|
||||
window[DISABLED_KEY] = true;
|
||||
document.documentElement?.classList.remove("codex-dream-skin");
|
||||
document.documentElement?.removeAttribute(SHELL_ATTR);
|
||||
for (const name of ART_ATTRS) document.documentElement?.removeAttribute(name);
|
||||
document.documentElement?.style.removeProperty("--dream-skin-art");
|
||||
for (const name of THEME_VARIABLES) document.documentElement?.style.removeProperty(name);
|
||||
document.querySelectorAll(".dream-skin-home").forEach((node) => node.classList.remove("dream-skin-home"));
|
||||
document.querySelectorAll(".dream-skin-home-shell").forEach((node) => node.classList.remove("dream-skin-home-shell"));
|
||||
document.querySelectorAll(".dream-skin-home-utility").forEach((node) => node.classList.remove("dream-skin-home-utility"));
|
||||
document.getElementById(STYLE_ID)?.remove();
|
||||
document.getElementById(CHROME_ID)?.remove();
|
||||
state?.observer?.disconnect();
|
||||
state?.rootObserver?.disconnect();
|
||||
state?.resizeObserver?.disconnect();
|
||||
if (state?.timer) clearInterval(state.timer);
|
||||
if (state?.scheduler?.timeout) clearTimeout(state.scheduler.timeout);
|
||||
if (state?.scheduler?.frame != null && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(state.scheduler.frame);
|
||||
}
|
||||
if (analysisTimer) clearTimeout(analysisTimer);
|
||||
if (state?.resizeHandler) window.removeEventListener("resize", state.resizeHandler);
|
||||
if (state?.mediaHandler && state?.mediaQuery) {
|
||||
try { state.mediaQuery.removeEventListener("change", state.mediaHandler); } catch {}
|
||||
}
|
||||
if (state?.artUrl) URL.revokeObjectURL(state.artUrl);
|
||||
delete window[STATE_KEY];
|
||||
return true;
|
||||
};
|
||||
|
||||
const scheduler = { timeout: null, frame: null, root: false, route: false, layout: false };
|
||||
const flushScheduledEnsure = () => {
|
||||
if (scheduler.frame !== null && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(scheduler.frame);
|
||||
}
|
||||
if (scheduler.timeout) clearTimeout(scheduler.timeout);
|
||||
scheduler.frame = null;
|
||||
scheduler.timeout = null;
|
||||
const pending = { root: scheduler.root, route: scheduler.route, layout: scheduler.layout };
|
||||
scheduler.root = false;
|
||||
scheduler.route = false;
|
||||
scheduler.layout = false;
|
||||
ensure(pending);
|
||||
};
|
||||
const scheduleEnsure = ({ root = false, route = true, layout = false } = {}) => {
|
||||
scheduler.root ||= root;
|
||||
scheduler.route ||= route;
|
||||
scheduler.layout ||= layout;
|
||||
if (scheduler.timeout || scheduler.frame !== null) return;
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
scheduler.frame = requestAnimationFrame(flushScheduledEnsure);
|
||||
scheduler.timeout = setTimeout(flushScheduledEnsure, 96);
|
||||
} else {
|
||||
scheduler.timeout = setTimeout(flushScheduledEnsure, 64);
|
||||
}
|
||||
};
|
||||
const observer = new MutationObserver(() => scheduleEnsure({ route: true }));
|
||||
rootObserver = new MutationObserver(() => {
|
||||
if (samplingNativeShell) return;
|
||||
scheduleEnsure({ root: true, route: true });
|
||||
});
|
||||
const resizeHandler = () => scheduleEnsure({ route: true, layout: true });
|
||||
if (typeof ResizeObserver === "function") {
|
||||
resizeObserver = new ResizeObserver(() => scheduleEnsure({ route: true, layout: true }));
|
||||
}
|
||||
|
||||
let mediaQuery = null;
|
||||
let mediaHandler = null;
|
||||
try {
|
||||
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaHandler = () => scheduleEnsure({ root: true, route: true });
|
||||
} catch {}
|
||||
|
||||
window[STATE_KEY] = {
|
||||
ensure,
|
||||
cleanup,
|
||||
observer,
|
||||
rootObserver,
|
||||
resizeObserver,
|
||||
timer: null,
|
||||
scheduler,
|
||||
resizeHandler,
|
||||
mediaQuery,
|
||||
mediaHandler,
|
||||
artUrl,
|
||||
installToken,
|
||||
analysis: artAnalysis,
|
||||
artMetadata: ART_METADATA,
|
||||
metrics,
|
||||
version: VERSION,
|
||||
themeId: THEME.id || "custom",
|
||||
detectShellMode,
|
||||
};
|
||||
const firstEnsureStartedAt = now();
|
||||
ensure({ layout: !previous || !document.getElementById(CHROME_ID) });
|
||||
metrics.firstEnsureMs = Number((now() - firstEnsureStartedAt).toFixed(3));
|
||||
if (previous?.artUrl && previous.artUrl !== artUrl) URL.revokeObjectURL(previous.artUrl);
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
rootObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "data-theme", "data-appearance", "data-color-mode", "style"],
|
||||
});
|
||||
if (document.body) {
|
||||
rootObserver.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "data-theme", "data-appearance", "data-color-mode", "style"],
|
||||
});
|
||||
}
|
||||
const timer = setInterval(() => ensure(), 4000);
|
||||
window[STATE_KEY].timer = timer;
|
||||
window.addEventListener("resize", resizeHandler, { passive: true });
|
||||
if (mediaHandler && mediaQuery) {
|
||||
mediaQuery.addEventListener("change", mediaHandler);
|
||||
}
|
||||
const analysisPromise = artAnalysis ? Promise.resolve(null) : analyzeArt();
|
||||
window[STATE_KEY].analysisTimer = analysisTimer;
|
||||
analysisPromise.then((analysis) => {
|
||||
const state = window[STATE_KEY];
|
||||
if (!analysis || state?.installToken !== installToken || window[DISABLED_KEY]) return;
|
||||
artAnalysis = analysis;
|
||||
state.analysis = analysis;
|
||||
if (typeof THEME.artKey === "string") {
|
||||
analysisCache.set(THEME.artKey, analysis);
|
||||
while (analysisCache.size > 8) analysisCache.delete(analysisCache.keys().next().value);
|
||||
}
|
||||
ensure({ root: true, route: false, layout: false });
|
||||
}).catch(() => {});
|
||||
return {
|
||||
installed: true,
|
||||
version: VERSION,
|
||||
themeId: THEME.id || "custom",
|
||||
shell: resolvedShell(),
|
||||
analysis: artAnalysis,
|
||||
};
|
||||
})(__DREAM_SKIN_CSS_JSON__, __DREAM_SKIN_ART_JSON__, __DREAM_SKIN_THEME_JSON__)
|
||||
@@ -0,0 +1,131 @@
|
||||
const SOF_MARKERS = new Set([
|
||||
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7,
|
||||
0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf,
|
||||
]);
|
||||
export const MAX_IMAGE_DIMENSION = 16384;
|
||||
export const MAX_IMAGE_PIXELS = 50_000_000;
|
||||
|
||||
function uint16be(bytes, offset) {
|
||||
return bytes[offset] * 256 + bytes[offset + 1];
|
||||
}
|
||||
|
||||
function uint16le(bytes, offset) {
|
||||
return bytes[offset] + bytes[offset + 1] * 256;
|
||||
}
|
||||
|
||||
function uint24le(bytes, offset) {
|
||||
return bytes[offset] + bytes[offset + 1] * 256 + bytes[offset + 2] * 65536;
|
||||
}
|
||||
|
||||
function uint32be(bytes, offset) {
|
||||
return bytes[offset] * 0x1000000 + bytes[offset + 1] * 0x10000 +
|
||||
bytes[offset + 2] * 0x100 + bytes[offset + 3];
|
||||
}
|
||||
|
||||
function uint32le(bytes, offset) {
|
||||
return bytes[offset] + bytes[offset + 1] * 0x100 + bytes[offset + 2] * 0x10000 +
|
||||
bytes[offset + 3] * 0x1000000;
|
||||
}
|
||||
|
||||
function ascii(bytes, offset, length) {
|
||||
return String.fromCharCode(...bytes.subarray(offset, offset + length));
|
||||
}
|
||||
|
||||
function pngDimensions(bytes) {
|
||||
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
if (bytes.length < 24 || signature.some((value, index) => bytes[index] !== value) ||
|
||||
uint32be(bytes, 8) !== 13 || ascii(bytes, 12, 4) !== "IHDR") return null;
|
||||
const width = uint32be(bytes, 16);
|
||||
const height = uint32be(bytes, 20);
|
||||
return width > 0 && height > 0 ? { width, height } : null;
|
||||
}
|
||||
|
||||
function jpegDimensions(bytes) {
|
||||
if (bytes.length < 12 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
|
||||
let offset = 2;
|
||||
while (offset + 9 < bytes.length) {
|
||||
if (bytes[offset] !== 0xff) {
|
||||
offset += 1;
|
||||
continue;
|
||||
}
|
||||
while (offset < bytes.length && bytes[offset] === 0xff) offset += 1;
|
||||
const marker = bytes[offset++];
|
||||
if (marker === 0xd9 || marker === 0xda) break;
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue;
|
||||
if (offset + 2 > bytes.length) break;
|
||||
const length = uint16be(bytes, offset);
|
||||
if (length < 2 || offset + length > bytes.length) break;
|
||||
if (SOF_MARKERS.has(marker) && length >= 7) {
|
||||
const height = uint16be(bytes, offset + 3);
|
||||
const width = uint16be(bytes, offset + 5);
|
||||
return width > 0 && height > 0 ? { width, height } : null;
|
||||
}
|
||||
offset += length;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function webpDimensions(bytes) {
|
||||
if (bytes.length < 20 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WEBP") {
|
||||
return null;
|
||||
}
|
||||
const riffEnd = Math.min(bytes.length, uint32le(bytes, 4) + 8);
|
||||
let offset = 12;
|
||||
while (offset + 8 <= riffEnd) {
|
||||
const type = ascii(bytes, offset, 4);
|
||||
const size = bytes[offset + 4] + bytes[offset + 5] * 256 +
|
||||
bytes[offset + 6] * 65536 + bytes[offset + 7] * 0x1000000;
|
||||
const data = offset + 8;
|
||||
if (data + size > riffEnd) break;
|
||||
if (type === "VP8X" && size >= 10) {
|
||||
return { width: uint24le(bytes, data + 4) + 1, height: uint24le(bytes, data + 7) + 1 };
|
||||
}
|
||||
if (type === "VP8L" && size >= 5 && bytes[data] === 0x2f) {
|
||||
const width = 1 + bytes[data + 1] + ((bytes[data + 2] & 0x3f) << 8);
|
||||
const height = 1 + (bytes[data + 2] >> 6) + (bytes[data + 3] << 2) +
|
||||
((bytes[data + 4] & 0x0f) << 10);
|
||||
return { width, height };
|
||||
}
|
||||
if (type === "VP8 " && size >= 10 && bytes[data + 3] === 0x9d &&
|
||||
bytes[data + 4] === 0x01 && bytes[data + 5] === 0x2a) {
|
||||
return {
|
||||
width: uint16le(bytes, data + 6) & 0x3fff,
|
||||
height: uint16le(bytes, data + 8) & 0x3fff,
|
||||
};
|
||||
}
|
||||
offset = data + size + (size % 2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function classifyImageDimensions({ width, height }) {
|
||||
const ratio = width / height;
|
||||
if (
|
||||
!Number.isSafeInteger(width) || !Number.isSafeInteger(height)
|
||||
|| width < 1 || height < 1
|
||||
|| width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION
|
||||
|| width * height > MAX_IMAGE_PIXELS
|
||||
|| !Number.isFinite(ratio)
|
||||
) return null;
|
||||
const aspect = ratio >= 2.25 ? "ultrawide" : ratio >= 1.45 ? "wide"
|
||||
: ratio >= 1.08 ? "landscape" : ratio >= 0.9 ? "square" : "portrait";
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
ratio,
|
||||
wide: ratio >= 1.75,
|
||||
aspect,
|
||||
taskMode: ratio >= 2.25 ? "banner" : "ambient",
|
||||
};
|
||||
}
|
||||
|
||||
export function readImageMetadata(value, extension = "") {
|
||||
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
|
||||
const normalized = extension.toLowerCase();
|
||||
let dimensions = null;
|
||||
if (normalized === ".png" || bytes[0] === 0x89) dimensions = pngDimensions(bytes);
|
||||
else if (normalized === ".jpg" || normalized === ".jpeg" ||
|
||||
(bytes[0] === 0xff && bytes[1] === 0xd8)) dimensions = jpegDimensions(bytes);
|
||||
else if (normalized === ".webp" || ascii(bytes, 8, 4) === "WEBP") dimensions = webpDimensions(bytes);
|
||||
return dimensions ? classifyImageDimensions(dimensions) : null;
|
||||
}
|
||||
@@ -0,0 +1,942 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { constants as fsConstants, watch as watchFs } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readImageMetadata } from "./image-metadata.mjs";
|
||||
|
||||
const scriptPath = fileURLToPath(import.meta.url);
|
||||
const here = path.dirname(scriptPath);
|
||||
const root = path.resolve(here, "..");
|
||||
const SKIN_VERSION = "1.2.4";
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]"]);
|
||||
const CDP_ID_PATTERN = /^[A-Za-z0-9._-]{1,200}$/;
|
||||
const MAX_ART_BYTES = 16 * 1024 * 1024;
|
||||
const CODEX_SURFACE_SELECTORS = Object.freeze({
|
||||
shell: "main.main-surface",
|
||||
sidebar: "aside.app-shell-left-panel",
|
||||
composer: ".composer-surface-chrome",
|
||||
home: '[role="main"]:has([data-testid="home-icon"])',
|
||||
appearance: 'input[name="appearance-theme"]',
|
||||
themePreview: '[data-testid="theme-preview"]',
|
||||
});
|
||||
let staticPayloadAssets = null;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
port: 9341,
|
||||
mode: "watch",
|
||||
timeoutMs: 30000,
|
||||
screenshot: null,
|
||||
reload: false,
|
||||
themeDir: null,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--port") options.port = Number(argv[++i]);
|
||||
else if (arg === "--once") options.mode = "once";
|
||||
else if (arg === "--watch") options.mode = "watch";
|
||||
else if (arg === "--verify") options.mode = "verify";
|
||||
else if (arg === "--remove") options.mode = "remove";
|
||||
else if (arg === "--check-payload") options.mode = "check";
|
||||
else if (arg === "--timeout-ms") options.timeoutMs = Number(argv[++i]);
|
||||
else if (arg === "--screenshot") options.screenshot = path.resolve(argv[++i]);
|
||||
else if (arg === "--theme-dir") options.themeDir = path.resolve(argv[++i]);
|
||||
else if (arg === "--reload") options.reload = true;
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
if (!Number.isInteger(options.port) || options.port < 1024 || options.port > 65535) {
|
||||
throw new Error(`Invalid port: ${options.port}`);
|
||||
}
|
||||
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 250 || options.timeoutMs > 120000) {
|
||||
throw new Error(`Invalid timeout: ${options.timeoutMs}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function validatedDebuggerUrl(target, port) {
|
||||
const url = new URL(target.webSocketDebuggerUrl);
|
||||
const pathIsValid = /^\/devtools\/page\/[A-Za-z0-9._-]{1,200}$/.test(url.pathname);
|
||||
if (
|
||||
url.protocol !== "ws:" || !LOOPBACK_HOSTS.has(url.hostname) || Number(url.port) !== port
|
||||
|| url.username || url.password || url.search || url.hash || !pathIsValid
|
||||
) {
|
||||
throw new Error("Rejected a CDP WebSocket URL outside the allowed loopback page endpoint shape");
|
||||
}
|
||||
return url.href;
|
||||
}
|
||||
|
||||
function isValidCdpPageTarget(item, port) {
|
||||
if (
|
||||
item?.type !== "page" || !item.url?.startsWith("app://")
|
||||
|| typeof item.id !== "string" || !CDP_ID_PATTERN.test(item.id)
|
||||
|| !item.webSocketDebuggerUrl
|
||||
) return false;
|
||||
try {
|
||||
const debuggerUrl = new URL(validatedDebuggerUrl(item, port));
|
||||
return debuggerUrl.pathname === `/devtools/page/${item.id}`;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class CdpSession {
|
||||
constructor(target, port) {
|
||||
this.target = target;
|
||||
this.ws = new WebSocket(validatedDebuggerUrl(target, port));
|
||||
this.nextId = 1;
|
||||
this.pending = new Map();
|
||||
this.listeners = new Map();
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
async open() {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
try { this.ws.close(); } catch {}
|
||||
reject(new Error("CDP WebSocket open timed out"));
|
||||
}, 5000);
|
||||
this.ws.addEventListener("open", () => { clearTimeout(timeout); resolve(); }, { once: true });
|
||||
this.ws.addEventListener("error", () => { clearTimeout(timeout); reject(new Error("CDP WebSocket open failed")); }, { once: true });
|
||||
});
|
||||
this.ws.addEventListener("message", (event) => this.onMessage(event));
|
||||
this.ws.addEventListener("error", () => this.close());
|
||||
this.ws.addEventListener("close", () => {
|
||||
this.closed = true;
|
||||
for (const waiter of this.pending.values()) {
|
||||
clearTimeout(waiter.timeout);
|
||||
waiter.reject(new Error("CDP socket closed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
});
|
||||
await this.send("Runtime.enable");
|
||||
await this.send("Page.enable");
|
||||
return this;
|
||||
}
|
||||
|
||||
onMessage(event) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (!message || typeof message !== "object") {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
if (message.id) {
|
||||
const waiter = this.pending.get(message.id);
|
||||
if (!waiter) return;
|
||||
clearTimeout(waiter.timeout);
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) waiter.reject(new Error(`${message.error.message} (${message.error.code})`));
|
||||
else waiter.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
for (const listener of this.listeners.get(message.method) ?? []) {
|
||||
try { listener(message.params ?? {}); } catch (error) {
|
||||
console.error(`[dream-skin] CDP listener failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
on(method, listener) {
|
||||
const listeners = this.listeners.get(method) ?? [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(method, listeners);
|
||||
}
|
||||
|
||||
send(method, params = {}, timeoutMs = 10000) {
|
||||
if (this.closed) return Promise.reject(new Error("CDP session is closed"));
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this.nextId++;
|
||||
const timeout = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`CDP command timed out: ${method}`));
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timeout });
|
||||
try {
|
||||
this.ws.send(JSON.stringify({ id, method, params }));
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
this.pending.delete(id);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async evaluate(expression) {
|
||||
const result = await this.send("Runtime.evaluate", {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
userGesture: false,
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
const detail = result.exceptionDetails.exception?.description ?? result.exceptionDetails.text;
|
||||
throw new Error(`Renderer evaluation failed: ${detail}`);
|
||||
}
|
||||
return result.result?.value;
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const waiter of this.pending.values()) {
|
||||
clearTimeout(waiter.timeout);
|
||||
waiter.reject(new Error("CDP session closed"));
|
||||
}
|
||||
this.pending.clear();
|
||||
if (!this.closed) {
|
||||
try { this.ws.close(); } catch {}
|
||||
}
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function listAppTargets(port) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json/list`, {
|
||||
redirect: "error",
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const targets = await response.json();
|
||||
if (!Array.isArray(targets)) throw new Error("CDP target list was not an array");
|
||||
return targets.filter((item) => isValidCdpPageTarget(item, port));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeSession(session) {
|
||||
return session.evaluate(`(() => {
|
||||
const markers = {
|
||||
shell: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.shell)})),
|
||||
sidebar: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.sidebar)})),
|
||||
composer: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.composer)})),
|
||||
main: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.home)})),
|
||||
settings: Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)})) ||
|
||||
Boolean(document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)})),
|
||||
};
|
||||
return {
|
||||
title: document.title,
|
||||
href: location.href,
|
||||
markers,
|
||||
codex: location.protocol === 'app:' &&
|
||||
((markers.shell && markers.sidebar) || markers.main || markers.settings),
|
||||
};
|
||||
})()`);
|
||||
}
|
||||
|
||||
export async function waitForCodexProbe(session, timeoutMs = 1800) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let probe = null;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
probe = await probeSession(session);
|
||||
if (probe?.codex) return probe;
|
||||
} catch {
|
||||
// Chromium can briefly replace the execution context while Codex boots.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
return probe;
|
||||
}
|
||||
|
||||
async function connectTarget(target, port) {
|
||||
return new CdpSession(target, port).open();
|
||||
}
|
||||
|
||||
async function connectCodexTargets(port, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const targets = await listAppTargets(port);
|
||||
const connected = [];
|
||||
for (const target of targets) {
|
||||
let session;
|
||||
try {
|
||||
session = await connectTarget(target, port);
|
||||
const remainingMs = Math.max(50, deadline - Date.now());
|
||||
const probe = await waitForCodexProbe(session, Math.min(1800, remainingMs));
|
||||
if (probe?.codex) connected.push({ target, session, probe });
|
||||
else session.close();
|
||||
} catch (error) {
|
||||
session?.close();
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (connected.length) return connected;
|
||||
lastError = new Error("No page matched the expected Codex surface markers");
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
}
|
||||
throw new Error(`No verified Codex renderer on 127.0.0.1:${port}: ${lastError?.message ?? "timed out"}`);
|
||||
}
|
||||
|
||||
function assertContainedPath(rootPath, candidatePath, label) {
|
||||
const relative = path.relative(rootPath, candidatePath);
|
||||
if (
|
||||
relative === ""
|
||||
|| (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
|
||||
) return;
|
||||
throw new Error(`${label} must stay inside its theme directory`);
|
||||
}
|
||||
|
||||
async function loadTheme(themeDir) {
|
||||
const requestedRoot = themeDir ?? path.join(root, "assets");
|
||||
const configPath = path.join(requestedRoot, "theme.json");
|
||||
let assetsRoot;
|
||||
let canonicalConfigPath;
|
||||
try {
|
||||
[assetsRoot, canonicalConfigPath] = await Promise.all([
|
||||
fs.realpath(requestedRoot),
|
||||
fs.realpath(configPath),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (themeDir && error.code === "ENOENT") {
|
||||
throw new Error(`Explicit theme directory is missing theme.json: ${configPath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
assertContainedPath(assetsRoot, canonicalConfigPath, "Theme config");
|
||||
let config;
|
||||
try {
|
||||
config = await fs.readFile(canonicalConfigPath, "utf8");
|
||||
} catch (error) {
|
||||
if (themeDir && error.code === "ENOENT") {
|
||||
throw new Error(`Explicit theme directory is missing theme.json: ${configPath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const raw = JSON.parse(config);
|
||||
if (raw.schemaVersion !== 1 || typeof raw.image !== "string" || !raw.image) {
|
||||
throw new Error(`${configPath} has an unsupported schema or image field`);
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(raw.image)) {
|
||||
throw new Error(`${configPath} has an invalid image field`);
|
||||
}
|
||||
if (path.basename(raw.image) !== raw.image) throw new Error("Theme image must stay inside its theme directory");
|
||||
const text = (value, fallback, max, name) => {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== "string" || /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(value)) {
|
||||
throw new Error(`${configPath} has an invalid ${name} field`);
|
||||
}
|
||||
return value.trim() ? Array.from(value.trim()).slice(0, max).join("") : fallback;
|
||||
};
|
||||
const color = (value, fallback) => {
|
||||
if (typeof value !== "string") return fallback;
|
||||
const normalized = value.trim();
|
||||
return /^#[0-9a-f]{6}$/i.test(normalized) || /^rgba?\([0-9., %]+\)$/i.test(normalized)
|
||||
? normalized
|
||||
: fallback;
|
||||
};
|
||||
const choice = (value, name, choices) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || !choices.includes(value)) {
|
||||
throw new Error(`${configPath} has an invalid ${name} field`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const unit = (value, name) => {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
||||
throw new Error(`${configPath} has an invalid ${name} field`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const rawColors = raw.colors && typeof raw.colors === "object" && !Array.isArray(raw.colors)
|
||||
? raw.colors : null;
|
||||
const colorKeys = [
|
||||
"background", "panel", "panelAlt", "accent", "accentAlt", "secondary",
|
||||
"highlight", "text", "muted", "line",
|
||||
];
|
||||
const appearance = choice(raw.appearance, "appearance", ["auto", "light", "dark"]);
|
||||
const surfaceOpacity = unit(raw.surfaceOpacity, "surfaceOpacity");
|
||||
if (raw.art !== undefined && (!raw.art || typeof raw.art !== "object" || Array.isArray(raw.art))) {
|
||||
throw new Error(`${configPath} has an invalid art field`);
|
||||
}
|
||||
const rawArt = raw.art || {};
|
||||
const art = {
|
||||
focusX: unit(rawArt.focusX, "art.focusX"),
|
||||
focusY: unit(rawArt.focusY, "art.focusY"),
|
||||
safeArea: choice(rawArt.safeArea, "art.safeArea", ["auto", "left", "right", "center", "none"]),
|
||||
taskMode: choice(rawArt.taskMode, "art.taskMode", ["auto", "ambient", "banner", "off"]),
|
||||
};
|
||||
const theme = {
|
||||
schemaVersion: 1,
|
||||
id: text(raw.id, "custom", 80, "id"),
|
||||
name: text(raw.name, "Codex Dream Skin", 80, "name"),
|
||||
brandSubtitle: text(raw.brandSubtitle, "CODEX DREAM SKIN", 80, "brandSubtitle"),
|
||||
tagline: text(raw.tagline, "Make something wonderful.", 160, "tagline"),
|
||||
projectPrefix: text(raw.projectPrefix, "选择项目 · ", 80, "projectPrefix"),
|
||||
projectLabel: text(raw.projectLabel, "◉ 选择项目", 80, "projectLabel"),
|
||||
statusText: text(raw.statusText, "DREAM SKIN ONLINE", 80, "statusText"),
|
||||
quote: text(raw.quote, "MAKE SOMETHING WONDERFUL", 80, "quote"),
|
||||
image: raw.image,
|
||||
surfaceOpacity: surfaceOpacity ?? 1,
|
||||
colorMode: rawColors ? "explicit" : "auto",
|
||||
explicitColorKeys: rawColors ? colorKeys.filter((key) => Object.hasOwn(rawColors, key)) : [],
|
||||
colors: {
|
||||
background: color(rawColors?.background, "#071116"),
|
||||
panel: color(rawColors?.panel, "#0b1a20"),
|
||||
panelAlt: color(rawColors?.panelAlt, "#10272c"),
|
||||
accent: color(rawColors?.accent, "#7cff46"),
|
||||
accentAlt: color(rawColors?.accentAlt, "#b8ff3d"),
|
||||
secondary: color(rawColors?.secondary, "#36d7e8"),
|
||||
highlight: color(rawColors?.highlight, "#642a8c"),
|
||||
text: color(rawColors?.text, "#e9fff1"),
|
||||
muted: color(rawColors?.muted, "#9ebdb3"),
|
||||
line: color(rawColors?.line, "rgba(124, 255, 70, .28)"),
|
||||
},
|
||||
};
|
||||
if (appearance !== undefined) theme.appearance = appearance;
|
||||
if (Object.values(art).some((value) => value !== undefined)) {
|
||||
theme.art = Object.fromEntries(Object.entries(art).filter(([, value]) => value !== undefined));
|
||||
}
|
||||
const requestedImagePath = path.join(assetsRoot, theme.image);
|
||||
let imagePath;
|
||||
try {
|
||||
imagePath = await fs.realpath(requestedImagePath);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") throw new Error(`Theme image is missing: ${requestedImagePath}`);
|
||||
throw error;
|
||||
}
|
||||
assertContainedPath(assetsRoot, imagePath, "Theme image");
|
||||
const imageStat = await fs.stat(imagePath);
|
||||
const extension = path.extname(theme.image).toLowerCase();
|
||||
if (![".png", ".jpg", ".jpeg", ".webp"].includes(extension)) {
|
||||
throw new Error(`Unsupported theme image format: ${extension || "missing"}`);
|
||||
}
|
||||
let imageHandle;
|
||||
try {
|
||||
imageHandle = await fs.open(imagePath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
||||
} catch (error) {
|
||||
if (error.code === "ELOOP") throw new Error("Theme image changed into a symbolic link while loading");
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const openedStat = await imageHandle.stat();
|
||||
if (
|
||||
!imageStat.isFile()
|
||||
|| !openedStat.isFile()
|
||||
|| imageStat.dev !== openedStat.dev
|
||||
|| imageStat.ino !== openedStat.ino
|
||||
|| openedStat.size < 1
|
||||
|| openedStat.size > MAX_ART_BYTES
|
||||
) {
|
||||
throw new Error(`Theme image must be a stable non-empty file no larger than ${MAX_ART_BYTES} bytes`);
|
||||
}
|
||||
const art = await imageHandle.readFile();
|
||||
if (art.length < 1 || art.length > MAX_ART_BYTES) {
|
||||
throw new Error(`Theme image must be a non-empty file no larger than ${MAX_ART_BYTES} bytes`);
|
||||
}
|
||||
return { art, assetsRoot, extension, imagePath, theme };
|
||||
} finally {
|
||||
await imageHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStaticPayloadAssets() {
|
||||
const cacheHit = Boolean(staticPayloadAssets);
|
||||
if (!staticPayloadAssets) {
|
||||
staticPayloadAssets = Promise.all([
|
||||
fs.readFile(path.join(root, "assets", "dream-skin.css"), "utf8"),
|
||||
fs.readFile(path.join(root, "assets", "renderer-inject.js"), "utf8"),
|
||||
]).catch((error) => {
|
||||
staticPayloadAssets = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
const [css, template] = await staticPayloadAssets;
|
||||
return { css, template, cacheHit };
|
||||
}
|
||||
|
||||
function invalidateStaticPayloadAssets() {
|
||||
staticPayloadAssets = null;
|
||||
}
|
||||
|
||||
async function loadPayload(themeDir) {
|
||||
const startedAt = performance.now();
|
||||
const [staticAssets, loaded] = await Promise.all([
|
||||
loadStaticPayloadAssets(),
|
||||
loadTheme(themeDir),
|
||||
]);
|
||||
const { css, template } = staticAssets;
|
||||
const { art, extension, theme } = loaded;
|
||||
const styleRevision = createHash("sha256").update(css).digest("hex").slice(0, 20);
|
||||
const artMetadata = readImageMetadata(art, extension);
|
||||
if (!artMetadata) {
|
||||
throw new Error("Theme image metadata is invalid or exceeds the 16384px / 50MP safety limit");
|
||||
}
|
||||
const artKey = createHash("sha256").update(art).digest("hex").slice(0, 20);
|
||||
theme.artMetadata = artMetadata;
|
||||
theme.artKey = artKey;
|
||||
const mime = extension === ".jpg" || extension === ".jpeg" ? "image/jpeg"
|
||||
: extension === ".webp" ? "image/webp" : "image/png";
|
||||
const artDataUrl = `data:${mime};base64,${art.toString("base64")}`;
|
||||
const payload = template
|
||||
.replace("__DREAM_SKIN_CSS_JSON__", JSON.stringify(css))
|
||||
.replace("__DREAM_SKIN_ART_JSON__", JSON.stringify(artDataUrl))
|
||||
.replace("__DREAM_SKIN_THEME_JSON__", JSON.stringify(theme))
|
||||
.replace("__DREAM_SKIN_VERSION_JSON__", JSON.stringify(SKIN_VERSION))
|
||||
.replace("__DREAM_SKIN_STYLE_REVISION_JSON__", JSON.stringify(styleRevision));
|
||||
const revision = createHash("sha256")
|
||||
.update(SKIN_VERSION)
|
||||
.update(css)
|
||||
.update(template)
|
||||
.update(JSON.stringify(theme))
|
||||
.digest("hex")
|
||||
.slice(0, 20);
|
||||
return {
|
||||
imageBytes: art.length,
|
||||
payload,
|
||||
revision,
|
||||
theme,
|
||||
timings: {
|
||||
buildMs: Number((performance.now() - startedAt).toFixed(3)),
|
||||
staticCacheHit: staticAssets.cacheHit,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function applyToSession(session, payload) {
|
||||
return session.evaluate(payload);
|
||||
}
|
||||
|
||||
async function removeFromSession(session) {
|
||||
return session.evaluate(`(() => {
|
||||
window.__CODEX_DREAM_SKIN_DISABLED__ = true;
|
||||
const state = window.__CODEX_DREAM_SKIN_STATE__;
|
||||
if (state?.cleanup) return state.cleanup();
|
||||
document.documentElement?.classList.remove('codex-dream-skin');
|
||||
document.documentElement?.style.removeProperty('--dream-skin-art');
|
||||
document.getElementById('codex-dream-skin-style')?.remove();
|
||||
document.getElementById('codex-dream-skin-chrome')?.remove();
|
||||
delete window.__CODEX_DREAM_SKIN_STATE__;
|
||||
return true;
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function verifyRemovedSession(session) {
|
||||
return session.evaluate(`(() =>
|
||||
!document.documentElement.classList.contains('codex-dream-skin') &&
|
||||
!document.getElementById('codex-dream-skin-style') &&
|
||||
!document.getElementById('codex-dream-skin-chrome') &&
|
||||
!window.__CODEX_DREAM_SKIN_STATE__
|
||||
)()`);
|
||||
}
|
||||
|
||||
export async function verifySession(session) {
|
||||
return session.evaluate(`(() => {
|
||||
const box = (node) => {
|
||||
if (!node) return null;
|
||||
const r = node.getBoundingClientRect();
|
||||
const style = getComputedStyle(node);
|
||||
return {
|
||||
x: Math.round(r.x), y: Math.round(r.y),
|
||||
width: Math.round(r.width), height: Math.round(r.height),
|
||||
visible: r.width > 0 && r.height > 0 && style.display !== 'none' && style.visibility !== 'hidden',
|
||||
};
|
||||
};
|
||||
const homeIndicator = document.querySelector('[data-testid="home-icon"]');
|
||||
const homeSignal = homeIndicator ?? document.querySelector('[data-feature="game-source"]') ??
|
||||
document.querySelector('.group\\\\/home-suggestions');
|
||||
const homeRoute = homeSignal?.closest('[role="main"]') ?? null;
|
||||
const settingsAnchor = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)}) ||
|
||||
document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)});
|
||||
const home = document.querySelector('[role="main"].dream-skin-home');
|
||||
const suggestions = home?.querySelector('.group\\\\/home-suggestions') ?? null;
|
||||
const cardBoxes = suggestions ? [...suggestions.querySelectorAll('button')].map(box) : [];
|
||||
const visibleCards = cardBoxes.filter((item) => item?.visible);
|
||||
const hero = box(home?.firstElementChild?.firstElementChild?.firstElementChild);
|
||||
const projectButton = box(home?.querySelector('.group\\\\/project-selector > button'));
|
||||
const shell = box(document.querySelector('main.main-surface'));
|
||||
const composer = box(document.querySelector('.composer-surface-chrome'));
|
||||
const sidebar = box(document.querySelector('aside.app-shell-left-panel'));
|
||||
const chrome = document.getElementById('codex-dream-skin-chrome');
|
||||
const result = {
|
||||
installed: document.documentElement.classList.contains('codex-dream-skin'),
|
||||
version: window.__CODEX_DREAM_SKIN_STATE__?.version ?? null,
|
||||
stylePresent: Boolean(document.getElementById('codex-dream-skin-style')),
|
||||
chromePresent: Boolean(chrome),
|
||||
chromePointerEvents: getComputedStyle(chrome || document.body).pointerEvents,
|
||||
homeRoute: Boolean(homeRoute),
|
||||
homeRouteBox: box(homeRoute),
|
||||
homePresent: Boolean(home),
|
||||
settingsRoute: Boolean(settingsAnchor),
|
||||
settingsAnchor: box(settingsAnchor),
|
||||
hero,
|
||||
cards: cardBoxes,
|
||||
visibleCardCount: visibleCards.length,
|
||||
projectButton,
|
||||
shell,
|
||||
composer,
|
||||
sidebar,
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
documentOverflow: {
|
||||
x: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||||
y: document.documentElement.scrollHeight > document.documentElement.clientHeight,
|
||||
},
|
||||
};
|
||||
const standardSurface = Boolean(result.shell?.visible && result.sidebar?.visible);
|
||||
const routeSurface = Boolean(result.homeRouteBox?.visible || result.settingsAnchor?.visible);
|
||||
const chromePass = result.settingsRoute ||
|
||||
(result.chromePresent && result.chromePointerEvents === 'none');
|
||||
const basePass = result.installed && result.version === ${JSON.stringify(SKIN_VERSION)} &&
|
||||
result.stylePresent && chromePass && (standardSurface || routeSurface) &&
|
||||
!result.documentOverflow.x;
|
||||
// Project selector markup varies across Codex builds — soft requirement.
|
||||
const homePass = !result.homeRoute || (
|
||||
result.homePresent && result.hero?.visible && result.hero.width >= 280 && result.hero.height >= 120
|
||||
);
|
||||
result.pass = Boolean(basePass && homePass);
|
||||
result.softNotes = {
|
||||
projectButtonOptional: !result.projectButton?.visible,
|
||||
composerOptionalOnNonTaskRoutes: !result.composer?.visible,
|
||||
suggestionCardsOptional: result.homeRoute && result.visibleCardCount === 0,
|
||||
};
|
||||
return result;
|
||||
})()`);
|
||||
}
|
||||
|
||||
async function waitForVerifiedSession(session, timeoutMs) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastResult;
|
||||
while (Date.now() < deadline) {
|
||||
lastResult = await verifySession(session);
|
||||
if (lastResult.pass) return lastResult;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
return lastResult;
|
||||
}
|
||||
|
||||
async function capture(session, outputPath) {
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
const bestEffortInput = async (method, params) => {
|
||||
try {
|
||||
await session.send(method, params, 750);
|
||||
} catch {
|
||||
// Screenshot capture is still valid when a renderer omits the Input domain.
|
||||
}
|
||||
};
|
||||
await bestEffortInput("Input.dispatchKeyEvent", {
|
||||
type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27,
|
||||
});
|
||||
await bestEffortInput("Input.dispatchKeyEvent", {
|
||||
type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27,
|
||||
});
|
||||
const viewport = await session.evaluate("({ width: innerWidth, height: innerHeight })");
|
||||
await bestEffortInput("Input.dispatchMouseEvent", {
|
||||
type: "mouseMoved",
|
||||
x: Math.round(viewport.width * 0.64),
|
||||
y: Math.round(viewport.height * 0.62),
|
||||
button: "none",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const result = await session.send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: false,
|
||||
});
|
||||
await fs.writeFile(outputPath, Buffer.from(result.data, "base64"));
|
||||
}
|
||||
|
||||
async function runOneShot(options) {
|
||||
const connected = await connectCodexTargets(options.port, options.timeoutMs);
|
||||
const loaded = (options.mode === "once" || options.reload) ? await loadPayload(options.themeDir) : null;
|
||||
const payload = loaded?.payload ?? null;
|
||||
const results = [];
|
||||
let screenshotCaptured = false;
|
||||
|
||||
for (const { target, session, probe } of connected) {
|
||||
try {
|
||||
if (options.mode === "remove") await removeFromSession(session);
|
||||
else if (options.mode === "once") await applyToSession(session, payload);
|
||||
|
||||
if (options.reload) {
|
||||
await session.send("Page.reload", { ignoreCache: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 1600));
|
||||
if (options.mode !== "remove") await applyToSession(session, payload);
|
||||
}
|
||||
|
||||
const result = options.mode === "remove"
|
||||
? await verifyRemovedSession(session)
|
||||
: await waitForVerifiedSession(session, options.timeoutMs);
|
||||
results.push({ targetId: target.id, title: target.title, url: target.url, probe, result });
|
||||
|
||||
if (options.screenshot && !screenshotCaptured) {
|
||||
await capture(session, options.screenshot);
|
||||
screenshotCaptured = true;
|
||||
}
|
||||
} finally {
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ mode: options.mode, version: SKIN_VERSION, port: options.port, targets: results }, null, 2));
|
||||
const failed = results.length === 0 || results.some((item) => options.mode === "remove" ? item.result !== true : !item.result?.pass);
|
||||
if (failed) process.exitCode = 2;
|
||||
}
|
||||
|
||||
async function flushStandardStreams() {
|
||||
const flush = (stream) => new Promise((resolve) => {
|
||||
if (!stream?.writable || stream.destroyed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
stream.write("", resolve);
|
||||
});
|
||||
await Promise.all([flush(process.stdout), flush(process.stderr)]);
|
||||
}
|
||||
|
||||
export function earlyPayloadFor(payload, revision) {
|
||||
return `(() => {
|
||||
const generationKey = "__CODEX_DREAM_SKIN_EARLY_GENERATION__";
|
||||
const appliedKey = "__CODEX_DREAM_SKIN_EARLY_APPLIED__";
|
||||
const generation = ${JSON.stringify(revision)};
|
||||
window[generationKey] = generation;
|
||||
let observer = null;
|
||||
let timeout = null;
|
||||
const stop = () => {
|
||||
observer?.disconnect();
|
||||
observer = null;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = null;
|
||||
};
|
||||
const install = () => {
|
||||
if (window[generationKey] !== generation) { stop(); return true; }
|
||||
if (!document.documentElement) return false;
|
||||
if (location.protocol !== "app:") return false;
|
||||
const shell = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.shell)});
|
||||
const sidebar = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.sidebar)});
|
||||
const home = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.home)});
|
||||
const settings = document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.appearance)}) ||
|
||||
document.querySelector(${JSON.stringify(CODEX_SURFACE_SELECTORS.themePreview)});
|
||||
if (!(shell && sidebar) && !home && !settings) return false;
|
||||
stop();
|
||||
${payload};
|
||||
window[appliedKey] = generation;
|
||||
return true;
|
||||
};
|
||||
if (install()) return;
|
||||
if (typeof MutationObserver === "function" && document.documentElement) {
|
||||
observer = new MutationObserver(install);
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
timeout = setTimeout(stop, 10000);
|
||||
})()`;
|
||||
}
|
||||
|
||||
function watchPayloadSources(themeDir, onDirty) {
|
||||
const assetsRoot = path.join(root, "assets");
|
||||
const themeRoot = themeDir ?? assetsRoot;
|
||||
const watchers = [];
|
||||
const add = (directory, kind) => {
|
||||
let watcher;
|
||||
try {
|
||||
watcher = watchFs(directory, { persistent: false }, (_event, filename) => {
|
||||
const name = filename ? String(filename) : "";
|
||||
const staticChanged = directory === assetsRoot &&
|
||||
(!name || name === "dream-skin.css" || name === "renderer-inject.js");
|
||||
if (kind === "static" && !staticChanged) return;
|
||||
onDirty({ staticChanged });
|
||||
});
|
||||
watcher.on("error", (error) => {
|
||||
console.error(`[dream-skin] file watch unavailable for ${directory}: ${error.message}`);
|
||||
});
|
||||
watchers.push(watcher);
|
||||
} catch (error) {
|
||||
console.error(`[dream-skin] file watch unavailable for ${directory}: ${error.message}`);
|
||||
}
|
||||
};
|
||||
add(themeRoot, "theme");
|
||||
if (themeRoot !== assetsRoot) add(assetsRoot, "static");
|
||||
return () => watchers.forEach((watcher) => watcher.close());
|
||||
}
|
||||
|
||||
async function runWatch(options) {
|
||||
let current = await loadPayload(options.themeDir);
|
||||
const sessions = new Map();
|
||||
const rejected = new Set();
|
||||
let stopping = false;
|
||||
let reloadTimer = null;
|
||||
let reloadChain = Promise.resolve();
|
||||
let discoveryDelayMs = 100;
|
||||
let lastListErrorAt = 0;
|
||||
const stop = () => { stopping = true; };
|
||||
process.on("SIGINT", stop);
|
||||
process.on("SIGTERM", stop);
|
||||
|
||||
const registerEarly = async (session, payload, revision) => {
|
||||
const result = await session.send("Page.addScriptToEvaluateOnNewDocument", {
|
||||
source: earlyPayloadFor(payload, revision),
|
||||
});
|
||||
return result.identifier ?? null;
|
||||
};
|
||||
|
||||
const removeEarly = async (record) => {
|
||||
if (!record.earlyScriptId || record.session.closed) return;
|
||||
const identifier = record.earlyScriptId;
|
||||
record.earlyScriptId = null;
|
||||
await record.session.send("Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => {});
|
||||
};
|
||||
|
||||
const refreshPayload = async () => {
|
||||
const next = await loadPayload(options.themeDir);
|
||||
if (next.revision === current.revision) return;
|
||||
current = next;
|
||||
for (const record of sessions.values()) {
|
||||
const { session } = record;
|
||||
if (session.closed) continue;
|
||||
try {
|
||||
const nextIdentifier = await registerEarly(session, current.payload, current.revision);
|
||||
if (record.earlyScriptId) {
|
||||
await session.send("Page.removeScriptToEvaluateOnNewDocument", {
|
||||
identifier: record.earlyScriptId,
|
||||
}).catch(() => {});
|
||||
}
|
||||
record.earlyScriptId = nextIdentifier;
|
||||
record.needsLoadFallback = !nextIdentifier;
|
||||
await applyToSession(session, current.payload);
|
||||
} catch (error) {
|
||||
record.needsLoadFallback = true;
|
||||
console.error(`[dream-skin] theme refresh failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
console.log(`[dream-skin] refreshed theme ${current.theme.id} (${current.timings.buildMs}ms)`);
|
||||
};
|
||||
|
||||
const queuePayloadRefresh = ({ staticChanged = false } = {}) => {
|
||||
if (staticChanged) invalidateStaticPayloadAssets();
|
||||
if (reloadTimer) clearTimeout(reloadTimer);
|
||||
reloadTimer = setTimeout(() => {
|
||||
reloadTimer = null;
|
||||
reloadChain = reloadChain.then(refreshPayload).catch((error) => {
|
||||
console.error(`[dream-skin] theme reload failed: ${error.message}`);
|
||||
});
|
||||
}, 45);
|
||||
};
|
||||
const closePayloadWatchers = watchPayloadSources(options.themeDir, queuePayloadRefresh);
|
||||
|
||||
try {
|
||||
while (!stopping) {
|
||||
let targets = [];
|
||||
try {
|
||||
targets = await listAppTargets(options.port);
|
||||
discoveryDelayMs = 100;
|
||||
} catch (error) {
|
||||
if (Date.now() - lastListErrorAt >= 2000) {
|
||||
console.error(`[dream-skin] ${new Date().toISOString()} ${error.message}`);
|
||||
lastListErrorAt = Date.now();
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, discoveryDelayMs));
|
||||
discoveryDelayMs = Math.min(500, Math.round(discoveryDelayMs * 1.6));
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeIds = new Set(targets.map((target) => target.id));
|
||||
for (const [id, record] of sessions) {
|
||||
if (!activeIds.has(id) || record.session.closed) {
|
||||
record.session.close();
|
||||
sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
if (sessions.has(target.id)) continue;
|
||||
let session;
|
||||
let record;
|
||||
try {
|
||||
session = await connectTarget(target, options.port);
|
||||
record = { session, earlyScriptId: null, needsLoadFallback: false };
|
||||
try {
|
||||
record.earlyScriptId = await registerEarly(session, current.payload, current.revision);
|
||||
await session.evaluate(earlyPayloadFor(current.payload, current.revision));
|
||||
} catch (error) {
|
||||
record.needsLoadFallback = true;
|
||||
console.error(`[dream-skin] early injection unavailable: ${error.message}`);
|
||||
}
|
||||
const probe = await waitForCodexProbe(session);
|
||||
if (!probe?.codex) {
|
||||
await removeEarly(record);
|
||||
session.close();
|
||||
if (!rejected.has(target.id)) {
|
||||
console.error(`[dream-skin] rejected non-Codex app target ${target.id}`);
|
||||
rejected.add(target.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
rejected.delete(target.id);
|
||||
session.on("Page.loadEventFired", () => {
|
||||
if (!record.needsLoadFallback) return;
|
||||
setTimeout(() => applyToSession(session, current.payload).catch((error) => {
|
||||
console.error(`[dream-skin] fallback reinject failed: ${error.message}`);
|
||||
}), 0);
|
||||
});
|
||||
const earlyApplied = await session.evaluate(
|
||||
`window.__CODEX_DREAM_SKIN_EARLY_APPLIED__ === ${JSON.stringify(current.revision)}`,
|
||||
);
|
||||
if (!earlyApplied) {
|
||||
await session.evaluate(
|
||||
`window.__CODEX_DREAM_SKIN_EARLY_GENERATION__ = ${JSON.stringify(`fallback:${current.revision}`)}`,
|
||||
);
|
||||
await applyToSession(session, current.payload);
|
||||
}
|
||||
sessions.set(target.id, record);
|
||||
console.log(`[dream-skin] injected verified Codex target ${target.id} (${target.title || target.url})`);
|
||||
} catch (error) {
|
||||
if (record) await removeEarly(record);
|
||||
session?.close();
|
||||
console.error(`[dream-skin] inject failed for ${target.id}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
const pollDelay = sessions.size ? 800 : (targets.length ? 250 : 100);
|
||||
await new Promise((resolve) => setTimeout(resolve, pollDelay));
|
||||
}
|
||||
} finally {
|
||||
if (reloadTimer) clearTimeout(reloadTimer);
|
||||
closePayloadWatchers();
|
||||
await reloadChain.catch(() => {});
|
||||
await Promise.all([...sessions.values()].map((record) => removeEarly(record)));
|
||||
for (const record of sessions.values()) record.session.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (path.resolve(process.argv[1] || "") === path.resolve(scriptPath)) {
|
||||
let options = null;
|
||||
try {
|
||||
options = parseArgs(process.argv.slice(2));
|
||||
if (options.mode === "check") {
|
||||
const loaded = await loadPayload(options.themeDir);
|
||||
console.log(JSON.stringify({
|
||||
pass: true,
|
||||
version: SKIN_VERSION,
|
||||
themeId: loaded.theme.id,
|
||||
themeName: loaded.theme.name,
|
||||
imageBytes: loaded.imageBytes,
|
||||
payloadBytes: Buffer.byteLength(loaded.payload),
|
||||
artMetadata: loaded.theme.artMetadata ?? null,
|
||||
timings: loaded.timings,
|
||||
}, null, 2));
|
||||
} else if (options.mode === "watch") await runWatch(options);
|
||||
else {
|
||||
await runOneShot(options);
|
||||
await flushStandardStreams();
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[dream-skin] ${error.stack || error.message}`);
|
||||
process.exitCode = 1;
|
||||
if (options?.mode && options.mode !== "watch" && options.mode !== "check") {
|
||||
await flushStandardStreams();
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const [sourceDirArg, stageDirArg] = process.argv.slice(2);
|
||||
if (!sourceDirArg || !stageDirArg) {
|
||||
throw new Error("Usage: stage-theme.mjs <source-theme-dir> <stage-dir>");
|
||||
}
|
||||
|
||||
const MAX_CONFIG_BYTES = 1024 * 1024;
|
||||
const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
|
||||
const OPEN_FLAGS = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);
|
||||
|
||||
function assertContained(rootPath, candidatePath, label) {
|
||||
const relative = path.relative(rootPath, candidatePath);
|
||||
if (
|
||||
relative === ""
|
||||
|| (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
|
||||
) return;
|
||||
throw new Error(`${label} must stay inside its theme directory`);
|
||||
}
|
||||
|
||||
function sameStat(left, right) {
|
||||
return left.isFile() && right.isFile()
|
||||
&& left.dev === right.dev
|
||||
&& left.ino === right.ino
|
||||
&& left.size === right.size
|
||||
&& left.mtimeMs === right.mtimeMs
|
||||
&& left.ctimeMs === right.ctimeMs;
|
||||
}
|
||||
|
||||
async function readStableFile(filePath, label, maxBytes) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(filePath, OPEN_FLAGS);
|
||||
} catch (error) {
|
||||
if (error.code === "ELOOP") throw new Error(`${label} must not be a symbolic link`);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const before = await handle.stat();
|
||||
if (!before.isFile()) throw new Error(`${label} must be a regular file`);
|
||||
if (before.size > maxBytes) throw new Error(`${label} is larger than ${maxBytes} bytes`);
|
||||
const bytes = await handle.readFile();
|
||||
const after = await handle.stat();
|
||||
if (!sameStat(before, after)) {
|
||||
throw new Error(`${label} changed while it was being staged`);
|
||||
}
|
||||
if (bytes.length > maxBytes) throw new Error(`${label} is larger than ${maxBytes} bytes`);
|
||||
return { bytes, stat: after };
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
function decodeJson(bytes, label) {
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
if (text.includes("\0")) throw new Error(`${label} contains NUL characters`);
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(`${label} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExclusive(filePath, bytes) {
|
||||
const temporary = `${filePath}.${process.pid}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(temporary, bytes, { flag: "wx", mode: 0o600 });
|
||||
await fs.rename(temporary, filePath);
|
||||
} finally {
|
||||
await fs.rm(temporary, { force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sourceRoot = await fs.realpath(sourceDirArg);
|
||||
const sourceStat = await fs.stat(sourceRoot);
|
||||
if (!sourceStat.isDirectory()) throw new Error("Theme source must be a directory");
|
||||
|
||||
const configPath = path.join(sourceRoot, "theme.json");
|
||||
const config = await readStableFile(configPath, "Theme config", MAX_CONFIG_BYTES);
|
||||
const theme = decodeJson(config.bytes, "Theme config");
|
||||
if (theme?.schemaVersion !== 1 || typeof theme.image !== "string" || !theme.image) {
|
||||
throw new Error("Theme config has an unsupported schema or image field");
|
||||
}
|
||||
if (path.basename(theme.image) !== theme.image) {
|
||||
throw new Error("Theme image must stay inside its theme directory");
|
||||
}
|
||||
if (theme.image === "theme.json") {
|
||||
throw new Error("Theme image must not replace theme.json");
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(theme.image)) {
|
||||
throw new Error("Theme image contains control characters");
|
||||
}
|
||||
|
||||
const imagePath = path.resolve(sourceRoot, theme.image);
|
||||
assertContained(sourceRoot, imagePath, "Theme image");
|
||||
const image = await readStableFile(imagePath, "Theme image", MAX_IMAGE_BYTES);
|
||||
if (image.bytes.length < 1) throw new Error("Theme image is empty");
|
||||
|
||||
const stageRoot = await fs.realpath(stageDirArg);
|
||||
const stageStat = await fs.stat(stageRoot);
|
||||
if (!stageStat.isDirectory()) throw new Error("Theme stage must be a directory");
|
||||
assertContained(stageRoot, path.join(stageRoot, "theme.json"), "Staged theme config");
|
||||
assertContained(stageRoot, path.join(stageRoot, theme.image), "Staged theme image");
|
||||
|
||||
// Write both files from the already-open, stable descriptors. The caller
|
||||
// publishes the image first and theme.json last, so the watcher only ever
|
||||
// observes a complete pair; subsequent source edits cannot race the copy.
|
||||
await writeExclusive(path.join(stageRoot, theme.image), image.bytes);
|
||||
await writeExclusive(path.join(stageRoot, "theme.json"), config.bytes);
|
||||
process.stdout.write(theme.image);
|
||||
}
|
||||
|
||||
await main();
|
||||
|
After Width: | Height: | Size: 117 KiB |
@@ -0,0 +1,92 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
MAX_IMAGE_DIMENSION,
|
||||
MAX_IMAGE_PIXELS,
|
||||
classifyImageDimensions,
|
||||
readImageMetadata,
|
||||
} from "../scripts/image-metadata.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const macosRoot = path.resolve(here, "..");
|
||||
|
||||
const fixture = await fs.readFile(path.join(macosRoot, "tests", "fixtures", "background.jpg"));
|
||||
assert.deepEqual(readImageMetadata(fixture, ".jpg"), {
|
||||
width: 1920,
|
||||
height: 1200,
|
||||
ratio: 1.6,
|
||||
wide: false,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
});
|
||||
const malformedJpeg = Buffer.from(fixture);
|
||||
malformedJpeg[0] = 0;
|
||||
assert.equal(readImageMetadata(malformedJpeg, ".jpg"), null);
|
||||
|
||||
assert.deepEqual(classifyImageDimensions({ width: 2400, height: 1350 }), {
|
||||
width: 2400,
|
||||
height: 1350,
|
||||
ratio: 2400 / 1350,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
});
|
||||
assert.equal(MAX_IMAGE_DIMENSION, 16384);
|
||||
assert.equal(MAX_IMAGE_PIXELS, 50_000_000);
|
||||
assert.equal(classifyImageDimensions({ width: 10000, height: 6000 }), null);
|
||||
assert.equal(classifyImageDimensions({ width: 20000, height: 1 }), null);
|
||||
assert.equal(classifyImageDimensions({ width: 2560.5, height: 1440 }), null);
|
||||
|
||||
const writeAscii = (bytes, offset, value) => {
|
||||
for (let index = 0; index < value.length; index += 1) bytes[offset + index] = value.charCodeAt(index);
|
||||
};
|
||||
const writeUint32Le = (bytes, offset, value) => {
|
||||
bytes[offset] = value & 0xff;
|
||||
bytes[offset + 1] = (value >>> 8) & 0xff;
|
||||
bytes[offset + 2] = (value >>> 16) & 0xff;
|
||||
bytes[offset + 3] = (value >>> 24) & 0xff;
|
||||
};
|
||||
const writeUint24Le = (bytes, offset, value) => {
|
||||
bytes[offset] = value & 0xff;
|
||||
bytes[offset + 1] = (value >>> 8) & 0xff;
|
||||
bytes[offset + 2] = (value >>> 16) & 0xff;
|
||||
};
|
||||
|
||||
const vp8l = new Uint8Array(26);
|
||||
writeAscii(vp8l, 0, "RIFF");
|
||||
writeUint32Le(vp8l, 4, vp8l.length - 8);
|
||||
writeAscii(vp8l, 8, "WEBP");
|
||||
writeAscii(vp8l, 12, "VP8L");
|
||||
writeUint32Le(vp8l, 16, 5);
|
||||
vp8l.set([0x2f, 0x7f, 0xc2, 0x59, 0x00], 20);
|
||||
assert.deepEqual(readImageMetadata(vp8l, ".webp"), {
|
||||
width: 640,
|
||||
height: 360,
|
||||
ratio: 640 / 360,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
});
|
||||
|
||||
const vp8x = new Uint8Array(30);
|
||||
writeAscii(vp8x, 0, "RIFF");
|
||||
writeUint32Le(vp8x, 4, vp8x.length - 8);
|
||||
writeAscii(vp8x, 8, "WEBP");
|
||||
writeAscii(vp8x, 12, "VP8X");
|
||||
writeUint32Le(vp8x, 16, 10);
|
||||
writeUint24Le(vp8x, 24, 2559);
|
||||
writeUint24Le(vp8x, 27, 1439);
|
||||
assert.deepEqual(readImageMetadata(vp8x, ".webp"), {
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
ratio: 2560 / 1440,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
});
|
||||
|
||||
assert.equal(readImageMetadata(new Uint8Array([0, 1, 2, 3]), ".png"), null);
|
||||
|
||||
console.log("PASS: image dimensions strictly classify PNG, JPEG, VP8L, and VP8X profiles.");
|
||||
@@ -0,0 +1,228 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
earlyPayloadFor,
|
||||
probeSession,
|
||||
verifySession,
|
||||
waitForCodexProbe,
|
||||
} from "../scripts/injector.mjs";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const injectorPath = path.resolve(here, "../scripts/injector.mjs");
|
||||
const source = await fs.readFile(injectorPath, "utf8");
|
||||
|
||||
function createFixture(protocol = "app:") {
|
||||
const observers = [];
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
const markers = { shell: false, sidebar: false, home: false, settings: false, themePreview: false };
|
||||
const context = {
|
||||
window: { installs: [] },
|
||||
location: { protocol },
|
||||
document: {
|
||||
documentElement: {},
|
||||
querySelector(selector) {
|
||||
if (selector === "main.main-surface") return markers.shell ? {} : null;
|
||||
if (selector === "aside.app-shell-left-panel") return markers.sidebar ? {} : null;
|
||||
if (selector === '[role="main"]:has([data-testid="home-icon"])') return markers.home ? {} : null;
|
||||
if (selector === 'input[name="appearance-theme"]') return markers.settings ? {} : null;
|
||||
if (selector === '[data-testid="theme-preview"]') return markers.themePreview ? {} : null;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
MutationObserver: class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.connected = true;
|
||||
observers.push(this);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() { this.connected = false; }
|
||||
},
|
||||
setTimeout(callback) {
|
||||
const id = nextTimer++;
|
||||
timers.set(id, callback);
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
};
|
||||
return { context, markers, observers };
|
||||
}
|
||||
|
||||
const guarded = createFixture();
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("guarded")', "guarded"), guarded.context);
|
||||
assert.deepEqual(guarded.context.window.installs, [], "Auxiliary app targets must remain untouched.");
|
||||
guarded.markers.shell = true;
|
||||
guarded.observers[0].callback([]);
|
||||
assert.deepEqual(guarded.context.window.installs, [], "A main surface without the Codex sidebar is not sufficient.");
|
||||
|
||||
const home = createFixture();
|
||||
home.markers.home = true;
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("home")', "home"), home.context);
|
||||
assert.deepEqual(home.context.window.installs, ["home"], "The explicit Codex home route must support early injection.");
|
||||
|
||||
const settings = createFixture();
|
||||
settings.markers.settings = true;
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("settings")', "settings"), settings.context);
|
||||
assert.deepEqual(
|
||||
settings.context.window.installs,
|
||||
["settings"],
|
||||
"The Codex appearance route must support early injection.",
|
||||
);
|
||||
|
||||
const web = createFixture("https:");
|
||||
web.markers.shell = true;
|
||||
web.markers.sidebar = true;
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("web")', "web"), web.context);
|
||||
assert.deepEqual(web.context.window.installs, [], "Web pages must remain untouched even with spoofed shell markers.");
|
||||
|
||||
const generations = createFixture();
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("old")', "old"), generations.context);
|
||||
vm.runInNewContext(earlyPayloadFor('window.installs.push("new")', "new"), generations.context);
|
||||
generations.markers.shell = true;
|
||||
generations.markers.sidebar = true;
|
||||
for (const observer of generations.observers) observer.callback([]);
|
||||
assert.deepEqual(
|
||||
generations.context.window.installs,
|
||||
["new"],
|
||||
"A stale early script must yield to the newest watcher generation.",
|
||||
);
|
||||
assert.equal(generations.context.window.__CODEX_DREAM_SKIN_EARLY_APPLIED__, "new");
|
||||
|
||||
function createProbeSession(frames) {
|
||||
let callCount = 0;
|
||||
return {
|
||||
get callCount() { return callCount; },
|
||||
async evaluate(expression) {
|
||||
const frame = frames[Math.min(callCount, frames.length - 1)];
|
||||
callCount += 1;
|
||||
if (frame instanceof Error) throw frame;
|
||||
const marker = (key) => frame[key] ? {} : null;
|
||||
return vm.runInNewContext(expression, {
|
||||
location: { protocol: frame.protocol ?? "app:", href: `${frame.protocol ?? "app:"}//codex` },
|
||||
document: {
|
||||
title: "Codex",
|
||||
querySelector(selector) {
|
||||
if (selector === "main.main-surface") return marker("shell");
|
||||
if (selector === "aside.app-shell-left-panel") return marker("sidebar");
|
||||
if (selector === ".composer-surface-chrome") return marker("composer");
|
||||
if (selector === '[role="main"]:has([data-testid="home-icon"])') return marker("home");
|
||||
if (selector === 'input[name="appearance-theme"]') return marker("settings");
|
||||
if (selector === '[data-testid="theme-preview"]') return marker("themePreview");
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
assert.equal((await probeSession(createProbeSession([{ shell: true, sidebar: true }]))).codex, true);
|
||||
assert.equal((await probeSession(createProbeSession([{ home: true }]))).codex, true);
|
||||
assert.equal((await probeSession(createProbeSession([{ settings: true }]))).codex, true);
|
||||
assert.equal((await probeSession(createProbeSession([{}]))).codex, false, "An unmarked app page must be rejected.");
|
||||
assert.equal(
|
||||
(await probeSession(createProbeSession([{ protocol: "https:", shell: true, sidebar: true }]))).codex,
|
||||
false,
|
||||
"A non-app page must be rejected even when it spoofs the standard shell.",
|
||||
);
|
||||
|
||||
const delayed = createProbeSession([
|
||||
new Error("Execution context was destroyed"),
|
||||
{},
|
||||
{ home: true },
|
||||
]);
|
||||
assert.equal((await waitForCodexProbe(delayed, 300))?.codex, true);
|
||||
assert.equal(delayed.callCount, 3, "Probe waiting must survive document replacement and delayed DOM markers.");
|
||||
|
||||
function createVerificationSession(route) {
|
||||
const node = (width = 640, height = 480) => ({
|
||||
getBoundingClientRect: () => ({ x: 0, y: 0, width, height }),
|
||||
querySelector: () => null,
|
||||
});
|
||||
const body = node();
|
||||
const homeHero = node(560, 240);
|
||||
const home = node();
|
||||
home.firstElementChild = { firstElementChild: { firstElementChild: homeHero } };
|
||||
const homeIndicator = { closest: () => home };
|
||||
const settingsAnchor = node(320, 48);
|
||||
const chrome = node();
|
||||
return {
|
||||
async evaluate(expression) {
|
||||
return vm.runInNewContext(expression, {
|
||||
innerWidth: 1280,
|
||||
innerHeight: 800,
|
||||
window: { __CODEX_DREAM_SKIN_STATE__: { version: "1.2.4" } },
|
||||
getComputedStyle(target) {
|
||||
return { display: "block", visibility: "visible", pointerEvents: target === chrome ? "none" : "auto" };
|
||||
},
|
||||
document: {
|
||||
body,
|
||||
documentElement: {
|
||||
classList: { contains: () => true },
|
||||
scrollWidth: 1280,
|
||||
clientWidth: 1280,
|
||||
scrollHeight: 800,
|
||||
clientHeight: 800,
|
||||
},
|
||||
getElementById(id) {
|
||||
if (id === "codex-dream-skin-style") return {};
|
||||
if (id === "codex-dream-skin-chrome" && route === "home") return chrome;
|
||||
return null;
|
||||
},
|
||||
querySelector(selector) {
|
||||
if (selector === '[data-testid="home-icon"]' && route === "home") return homeIndicator;
|
||||
if (selector === '[role="main"].dream-skin-home' && route === "home") return home;
|
||||
if (selector === 'input[name="appearance-theme"]' && route === "settings") return settingsAnchor;
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
(await verifySession(createVerificationSession("home"))).pass,
|
||||
true,
|
||||
"A verified Codex home route must not require the legacy shell/sidebar pair.",
|
||||
);
|
||||
assert.equal(
|
||||
(await verifySession(createVerificationSession("settings"))).pass,
|
||||
true,
|
||||
"A verified appearance route must accept the installed root theme before the main shell mounts.",
|
||||
);
|
||||
assert.equal(
|
||||
(await verifySession(createVerificationSession("auxiliary"))).pass,
|
||||
false,
|
||||
"An auxiliary app page must not pass post-install verification.",
|
||||
);
|
||||
|
||||
const discoveryStart = source.indexOf("record.earlyScriptId = await registerEarly");
|
||||
const probeStart = source.indexOf("const probe = await waitForCodexProbe", discoveryStart);
|
||||
assert.ok(discoveryStart >= 0 && probeStart > discoveryStart, "Early registration must happen before full shell probing.");
|
||||
assert.match(
|
||||
source,
|
||||
/connectCodexTargets[\s\S]*const probe = await waitForCodexProbe/,
|
||||
"One-shot discovery must wait for a progressively loaded Codex renderer.",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/finally\s*\{[\s\S]*Promise\.all\(\[\.\.\.sessions\.values\(\)\][\s\S]*removeEarly\(record\)/,
|
||||
"Watcher shutdown must unregister persistent Page scripts before closing CDP sessions.",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const earlyApplied = await session\.evaluate\([\s\S]*if \(!earlyApplied\) \{[\s\S]*applyToSession/,
|
||||
"The watcher must not run the full payload twice after a successful early install.",
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/await runOneShot\(options\);[\s\S]{0,160}await flushStandardStreams\(\);[\s\S]{0,100}process\.exit\(process\.exitCode \?\? 0\)/,
|
||||
"One-shot commands must exit after flushing output so a lingering CDP close handshake cannot block Codex-X.",
|
||||
);
|
||||
|
||||
console.log("PASS: renderer probing is route-aware, delayed-load safe, generation-safe, and guarded.");
|
||||
@@ -0,0 +1,613 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import vm from "node:vm";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const macosRoot = path.resolve(here, "..");
|
||||
const template = await fs.readFile(path.join(macosRoot, "assets", "renderer-inject.js"), "utf8");
|
||||
const css = await fs.readFile(path.join(macosRoot, "assets", "dream-skin.css"), "utf8");
|
||||
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/background-image:\s*var\(--dream-skin-art\),\s*var\(--dream-skin-art\)/,
|
||||
"The home hero must not stack duplicate copies of the selected image.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-safe="left"[\s\S]{0,140}--ds-art-position:\s*100% var\(--ds-focus-y\);/,
|
||||
"A left text-safe image must preserve its right-side subject on narrower windows.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.dream-skin-home > div:has\(\[data-feature="game-source"\]\) > div:first-child > div:first-child\s*\{[\s\S]{0,700}background-position:\s*var\(--ds-home-art-position\) !important;/,
|
||||
"The home hero must use its route-specific focal point.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.dream-skin-home > div:has\(\[data-feature="game-source"\]\)\s*\{[\s\S]{0,120}min-height:\s*100% !important;/,
|
||||
"The home layout must target the content container instead of an inserted banner sibling.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/\.dream-skin-home > div:first-child\s*\{[\s\S]{0,120}min-height:\s*100% !important;/,
|
||||
"An empty home banner container must not push the composer below the viewport.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/background-size:\s*auto 100% !important;/,
|
||||
"Wide home artwork must not leave an unpainted half-card by fitting only to height.",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
css,
|
||||
/background-size:\s*100% 100%,\s*100% 100%,\s*100% auto;/,
|
||||
"Wide task artwork must cover the full route instead of ending above the composer.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-task-mode="ambient"[\s\S]{0,500}body\s*\{[\s\S]{0,500}background-image:\s*var\(--dream-skin-art\) !important;[\s\S]{0,200}background-size:\s*cover !important;/,
|
||||
"Wide ambient task artwork should cover the full application window.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-task-mode="banner"[\s\S]{0,900}body\s*\{[\s\S]{0,500}background-image:\s*var\(--dream-skin-art\) !important;[\s\S]{0,200}background-size:\s*cover !important;/,
|
||||
"Wide banner task artwork should use the same full-window wallpaper contract as ambient routes.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-wide="true"\]:has\(main\.main-surface\.dream-skin-home-shell\)[\s\S]{0,100}body\s*\{[\s\S]{0,300}background-image:\s*var\(--dream-skin-art\) !important;/,
|
||||
"Wide home artwork should use the same full-window image as utility routes.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-wide="true"\]:has\(main\.main-surface\.dream-skin-home-shell\)[\s\S]{0,120}body\s*\{[\s\S]{0,260}background-position:\s*var\(--ds-art-position\) !important;/,
|
||||
"Wide home artwork must honor the configured focal point instead of forcing a centered crop.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-task-mode="ambient"[\s\S]{0,260}data-dream-art-wide="true"\]:has\(main\.main-surface:not\(\.dream-skin-home-shell\)\)[\s\S]{0,120}body\s*\{[\s\S]{0,260}background-position:\s*var\(--ds-art-position\) !important;/,
|
||||
"Wide task artwork must retain the same focal point as the home route.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-art-wide="true"\]\s+\.composer-surface-chrome\s*\{[\s\S]{0,500}backdrop-filter:\s*none !important;/,
|
||||
"Wide artwork should use one uniform composer surface without a split blur layer.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--ds-immersive-composer-solid:\s*rgb\(var\(--ds-panel-rgb\) \/ \.94\);/,
|
||||
"The light composer should remain opaque enough to prevent thread text from showing through.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--ds-task-immersive-edge:\s*rgb\(var\(--ds-panel-rgb\) \/ calc\(\.86 \* var\(--ds-surface-opacity\)\)\);/,
|
||||
"Wide task surfaces should expose the wallpaper according to the configured opacity.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--ds-task-fade:\s*linear-gradient\(180deg,[\s\S]{0,400}var\(--ds-surface-opacity\)/,
|
||||
"Regular landscape artwork should apply the configured opacity to its task scrim.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/main\.main-surface:not\(\.dream-skin-home-shell\)::before\s*\{[\s\S]{0,420}opacity:\s*1;/,
|
||||
"The wallpaper layer must keep its original colors instead of blending through a white base.",
|
||||
);
|
||||
const homeSuggestionRuleStart = css.indexOf(
|
||||
".dream-skin-home .group\\/home-suggestions button {",
|
||||
);
|
||||
assert.notEqual(homeSuggestionRuleStart, -1, "The home suggestion card rule must exist.");
|
||||
const homeSuggestionRuleEnd = css.indexOf("}", homeSuggestionRuleStart);
|
||||
const homeSuggestionRule = css.slice(homeSuggestionRuleStart, homeSuggestionRuleEnd);
|
||||
assert.match(
|
||||
homeSuggestionRule,
|
||||
/background:\s*rgb\(var\(--ds-panel-rgb\) \/ calc\(\.56 \* var\(--ds-surface-opacity\)\)\) !important;/,
|
||||
"Home suggestion cards must use the shared surface opacity instead of a fixed white layer.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/--ds-composer-surface:\s*rgb\(var\(--ds-panel-rgb\) \/ calc\(\.82 \+ \(\.12 \* var\(--ds-surface-opacity\)\)\)\);/,
|
||||
"The composer must keep a readable minimum opacity while responding to the skin setting.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/html\.codex-dream-skin \.composer-surface-chrome\s*\{[\s\S]{0,100}overflow:\s*hidden !important;/,
|
||||
"Composer internals must be clipped to the rounded input surface.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/data-dream-shell="light"\]\[data-dream-art-wide="true"\][\s\S]{0,100}\.composer-surface-chrome\s*\{[\s\S]{0,400}backdrop-filter:\s*blur\(8px\) saturate\(102%\) !important;/,
|
||||
"The translucent light composer should softly separate text from detailed artwork.",
|
||||
);
|
||||
assert.match(
|
||||
template,
|
||||
/\[class\*="_homeUtilityBar_"\][\s\S]{0,500}dream-skin-home-utility/,
|
||||
"The renderer should give the current native home utility bar a stable theme class.",
|
||||
);
|
||||
assert.match(
|
||||
template,
|
||||
/\[data-feature="game-source"\][\s\S]{0,180}\[data-codex-composer-root\]/,
|
||||
"Home detection should use stable semantic markers instead of optional suggestion cards.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.dream-skin-home:has\(\.dream-skin-home-utility\)[\s\S]{0,120}\.composer-surface-chrome\s*\{[\s\S]{0,180}border-radius:\s*0 0 22px 22px !important;/,
|
||||
"The home utility bar and composer should render as one continuous control.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.composer-surface-chrome button:not\(\[class~="bg-token-foreground"\]\)[\s\S]{0,100}color:\s*var\(--ds-muted\) !important;/,
|
||||
"Composer controls must remain readable when Codex native tokens lag behind a forced dark appearance.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.composer-surface-chrome button:not\(\[class~="bg-token-foreground"\]\) \*\s*\{[\s\S]{0,80}color:\s*currentColor !important;/,
|
||||
"Nested labels inside composer controls must inherit the corrected theme color.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.composer-surface-chrome p\.placeholder::after\s*\{[\s\S]{0,120}color:\s*rgb\(var\(--ds-muted-rgb\) \/ \.82\) !important;[\s\S]{0,80}opacity:\s*1 !important;/,
|
||||
"Composer placeholder text must not inherit a stale native color with double opacity.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/header\.app-header-tint\s*\{[\s\S]{0,180}background:\s*transparent !important;/,
|
||||
"Wide artwork should not paint a separate opaque header band.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\.thread-scroll-container \.bg-gradient-to-t\.from-token-main-surface-primary\s*\{[\s\S]{0,100}background:\s*transparent !important;/,
|
||||
"Wide artwork should remove the native opaque fade behind the sticky composer.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/div\.sticky:has\(input\[type="text"\]\)[\s\S]{0,100}background:\s*transparent !important;/,
|
||||
"Search routes should not retain the native opaque sticky band.",
|
||||
);
|
||||
assert.match(
|
||||
css,
|
||||
/\[class~="bg-token-main-surface-primary"\]\[class~="h-full"\]\[class~="w-full"\][\s\S]{0,100}background:\s*transparent !important;/,
|
||||
"Full-size utility route wrappers should not hide the selected artwork.",
|
||||
);
|
||||
|
||||
function createStyleDeclaration() {
|
||||
const values = new Map();
|
||||
return {
|
||||
values,
|
||||
getPropertyValue(name) { return values.get(name) ?? ""; },
|
||||
setProperty(name, value) { values.set(name, value); },
|
||||
removeProperty(name) { values.delete(name); },
|
||||
};
|
||||
}
|
||||
|
||||
function createClassList(initial = []) {
|
||||
const values = new Set(initial);
|
||||
return {
|
||||
values,
|
||||
add(...names) { for (const name of names) values.add(name); },
|
||||
remove(...names) { for (const name of names) values.delete(name); },
|
||||
contains(name) { return values.has(name); },
|
||||
toggle(name, enabled) {
|
||||
if (enabled) values.add(name);
|
||||
else values.delete(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createFixture(theme, {
|
||||
nativeShell = "light",
|
||||
analysisFixture = null,
|
||||
analysisCache = null,
|
||||
} = {}) {
|
||||
let fixtureShell = nativeShell;
|
||||
const nodes = new Map();
|
||||
const attributes = new Map();
|
||||
const bodyAttributes = new Map();
|
||||
const observers = [];
|
||||
const resizeObservers = [];
|
||||
const timers = new Map();
|
||||
let nextTimer = 1;
|
||||
let nextBlob = 1;
|
||||
const rootStyle = createStyleDeclaration();
|
||||
const root = {
|
||||
className: nativeShell === "dark" ? "electron-dark" : "electron-light",
|
||||
classList: createClassList(),
|
||||
style: rootStyle,
|
||||
appendChild(node) {
|
||||
node.parentElement = root;
|
||||
if (node.id) nodes.set(node.id, node);
|
||||
},
|
||||
getAttribute(name) { return attributes.get(name) ?? null; },
|
||||
setAttribute(name, value) { attributes.set(name, String(value)); },
|
||||
removeAttribute(name) { attributes.delete(name); },
|
||||
};
|
||||
const body = {
|
||||
className: "",
|
||||
appendChild(node) {
|
||||
node.parentElement = body;
|
||||
if (node.id) nodes.set(node.id, node);
|
||||
},
|
||||
getAttribute(name) { return bodyAttributes.get(name) ?? null; },
|
||||
setAttribute(name, value) { bodyAttributes.set(name, String(value)); },
|
||||
};
|
||||
const shellBox = { left: 280, top: 36, width: 1000, height: 764 };
|
||||
const shellMain = {
|
||||
classList: createClassList(),
|
||||
getBoundingClientRect() {
|
||||
return { ...shellBox };
|
||||
},
|
||||
};
|
||||
|
||||
const createElement = (tagName) => {
|
||||
if (tagName === "canvas" && analysisFixture) {
|
||||
return {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext() {
|
||||
return {
|
||||
drawImage() {},
|
||||
getImageData() { return { data: analysisFixture.pixels }; },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
const childNodes = new Map();
|
||||
const element = {
|
||||
id: "",
|
||||
dataset: {},
|
||||
style: createStyleDeclaration(),
|
||||
classList: createClassList(),
|
||||
parentElement: null,
|
||||
textContent: "",
|
||||
innerHTML: "",
|
||||
setAttribute() {},
|
||||
querySelector(selector) {
|
||||
if (!childNodes.has(selector)) childNodes.set(selector, { textContent: "" });
|
||||
return childNodes.get(selector);
|
||||
},
|
||||
remove() { if (element.id) nodes.delete(element.id); },
|
||||
};
|
||||
return element;
|
||||
};
|
||||
|
||||
const document = {
|
||||
documentElement: root,
|
||||
head: root,
|
||||
body,
|
||||
createElement,
|
||||
getElementById(id) { return nodes.get(id) ?? null; },
|
||||
querySelector(selector) {
|
||||
if (selector === "main.main-surface" || selector === "main") return shellMain;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() { return []; },
|
||||
};
|
||||
const mediaQuery = {
|
||||
matches: false,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
const revokedUrls = [];
|
||||
const window = {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
matchMedia() {
|
||||
mediaQuery.matches = fixtureShell === "dark";
|
||||
return mediaQuery;
|
||||
},
|
||||
};
|
||||
if (analysisCache) window.__CODEX_DREAM_SKIN_ANALYSIS_CACHE__ = analysisCache;
|
||||
if (analysisFixture) {
|
||||
window.Image = class {
|
||||
naturalWidth = analysisFixture.naturalWidth;
|
||||
naturalHeight = analysisFixture.naturalHeight;
|
||||
set src(_) { this.onload(); }
|
||||
};
|
||||
}
|
||||
const context = {
|
||||
window,
|
||||
document,
|
||||
MutationObserver: class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
observers.push(this);
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
},
|
||||
ResizeObserver: class {
|
||||
constructor(callback) {
|
||||
this.callback = callback;
|
||||
this.target = null;
|
||||
resizeObservers.push(this);
|
||||
}
|
||||
observe(target) { this.target = target; }
|
||||
disconnect() { this.target = null; }
|
||||
},
|
||||
URL: {
|
||||
createObjectURL() { return `blob:fixture-${nextBlob++}`; },
|
||||
revokeObjectURL(value) { revokedUrls.push(value); },
|
||||
},
|
||||
Blob,
|
||||
Uint8Array,
|
||||
atob,
|
||||
getComputedStyle() {
|
||||
const skinShell = root.classList.contains("codex-dream-skin")
|
||||
? (attributes.get("data-dream-shell") || "dark") : fixtureShell;
|
||||
return {
|
||||
colorScheme: skinShell,
|
||||
backgroundColor: fixtureShell === "dark" ? "rgb(24, 24, 27)" : "rgb(250, 250, 250)",
|
||||
};
|
||||
},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
setTimeout(callback, delay) {
|
||||
const id = ++nextTimer;
|
||||
timers.set(id, { callback, delay });
|
||||
return id;
|
||||
},
|
||||
clearTimeout(id) { timers.delete(id); },
|
||||
cancelAnimationFrame() {},
|
||||
};
|
||||
const payloadFor = (nextTheme, cssText = ".fixture { color: blue; }") => template
|
||||
.replace("__DREAM_SKIN_CSS_JSON__", JSON.stringify(cssText))
|
||||
.replace("__DREAM_SKIN_ART_JSON__", JSON.stringify("data:image/png;base64,AA=="))
|
||||
.replace("__DREAM_SKIN_THEME_JSON__", JSON.stringify(nextTheme))
|
||||
.replace("__DREAM_SKIN_VERSION_JSON__", JSON.stringify("test"))
|
||||
.replace("__DREAM_SKIN_STYLE_REVISION_JSON__", JSON.stringify(cssText));
|
||||
const flushTimers = (maximumDelay = Infinity) => {
|
||||
const pending = [...timers.entries()].filter(([, timer]) => timer.delay <= maximumDelay);
|
||||
for (const [id, timer] of pending) {
|
||||
timers.delete(id);
|
||||
timer.callback();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
attributes,
|
||||
body,
|
||||
bodyAttributes,
|
||||
context,
|
||||
flushTimers,
|
||||
nodes,
|
||||
observers,
|
||||
payload: payloadFor(theme),
|
||||
payloadFor,
|
||||
revokedUrls,
|
||||
resizeObservers,
|
||||
root,
|
||||
rootStyle,
|
||||
shellBox,
|
||||
timers,
|
||||
window,
|
||||
setNativeShell(value) { fixtureShell = value; },
|
||||
};
|
||||
}
|
||||
|
||||
const defaults = createFixture({
|
||||
id: "default-contract",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
});
|
||||
const defaultResult = vm.runInNewContext(defaults.payload, defaults.context);
|
||||
assert.equal(defaultResult.installed, true);
|
||||
assert.equal(defaults.attributes.get("data-dream-shell"), "light");
|
||||
assert.equal(defaults.attributes.get("data-dream-art-safe-area"), "center");
|
||||
assert.equal(defaults.attributes.get("data-dream-art-task-mode"), "ambient");
|
||||
assert.equal(defaults.attributes.get("data-dream-art-ready"), "false");
|
||||
assert.equal(defaults.rootStyle.values.get("--dream-art-position"), "50.00% 50.00%");
|
||||
assert.equal(defaults.rootStyle.values.get("--ds-surface-opacity"), "1.00");
|
||||
const defaultMetrics = defaults.window.__CODEX_DREAM_SKIN_STATE__.metrics;
|
||||
assert.equal(defaultMetrics.rootPasses, 1);
|
||||
assert.equal(defaultMetrics.routePasses, 1);
|
||||
assert.equal(defaultMetrics.layoutReads, 1);
|
||||
for (let index = 0; index < 50; index += 1) defaults.observers[0].callback([]);
|
||||
assert.equal(defaults.timers.size, 1, "Mutation bursts should coalesce into one scheduled ensure.");
|
||||
defaults.flushTimers(64);
|
||||
assert.equal(defaultMetrics.rootPasses, 1, "Subtree mutations must not recompute root theme tokens.");
|
||||
assert.equal(defaultMetrics.routePasses, 2);
|
||||
assert.equal(defaultMetrics.layoutReads, 1, "Subtree mutations must not force shell layout reads.");
|
||||
assert.equal(defaults.resizeObservers.length, 1);
|
||||
assert.ok(defaults.resizeObservers[0].target);
|
||||
defaults.shellBox.left = 196;
|
||||
defaults.shellBox.width = 1084;
|
||||
defaults.resizeObservers[0].callback([]);
|
||||
defaults.flushTimers(64);
|
||||
assert.equal(defaultMetrics.layoutReads, 2, "Shell ResizeObserver changes must refresh chrome geometry.");
|
||||
const defaultChrome = defaults.nodes.get("codex-dream-skin-chrome");
|
||||
assert.equal(defaultChrome.style.values.get("left"), "196px");
|
||||
assert.equal(defaultChrome.style.values.get("width"), "1084px");
|
||||
|
||||
// Auto appearance must continue following the native shell after the skin is
|
||||
// already installed. The fixture makes the injected root color-scheme win
|
||||
// whenever our class remains on <html>, so a temporary native probe is needed
|
||||
// for each light → dark → light transition.
|
||||
const shellFollow = createFixture({
|
||||
id: "shell-follow",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
});
|
||||
shellFollow.root.className = "";
|
||||
vm.runInNewContext(shellFollow.payload, shellFollow.context);
|
||||
assert.equal(shellFollow.attributes.get("data-dream-shell"), "light");
|
||||
shellFollow.setNativeShell("dark");
|
||||
shellFollow.window.__CODEX_DREAM_SKIN_STATE__.ensure();
|
||||
assert.equal(shellFollow.attributes.get("data-dream-shell"), "dark");
|
||||
shellFollow.setNativeShell("light");
|
||||
shellFollow.window.__CODEX_DREAM_SKIN_STATE__.ensure();
|
||||
assert.equal(shellFollow.attributes.get("data-dream-shell"), "light");
|
||||
|
||||
defaults.root.className = "";
|
||||
defaults.body.setAttribute("data-theme", "dark");
|
||||
defaults.observers[1].callback([{ type: "attributes", target: defaults.body }]);
|
||||
defaults.flushTimers(64);
|
||||
assert.equal(defaults.attributes.get("data-dream-shell"), "dark", "Body theme changes must apply without the fallback interval.");
|
||||
|
||||
const synchronousWide = createFixture({
|
||||
id: "synchronous-wide",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
artKey: "wide-art",
|
||||
artMetadata: {
|
||||
width: 2400,
|
||||
height: 1350,
|
||||
ratio: 2400 / 1350,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
},
|
||||
});
|
||||
vm.runInNewContext(synchronousWide.payload, synchronousWide.context);
|
||||
assert.equal(synchronousWide.attributes.get("data-dream-art-wide"), "true");
|
||||
assert.equal(synchronousWide.attributes.get("data-dream-art-aspect"), "wide");
|
||||
assert.equal(synchronousWide.attributes.get("data-dream-art-task-mode"), "ambient");
|
||||
assert.equal(synchronousWide.attributes.get("data-dream-art-ready"), "false");
|
||||
|
||||
const autoLandscape = createFixture({
|
||||
id: "auto-landscape-focus",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
artMetadata: {
|
||||
width: 1586,
|
||||
height: 992,
|
||||
ratio: 1586 / 992,
|
||||
wide: false,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
},
|
||||
});
|
||||
vm.runInNewContext(autoLandscape.payload, autoLandscape.context);
|
||||
assert.equal(autoLandscape.rootStyle.values.get("--dream-art-position"), "50.00% 50.00%");
|
||||
assert.equal(
|
||||
autoLandscape.rootStyle.values.get("--dream-skin-home-art-position"),
|
||||
"50.00% 28.00%",
|
||||
"An unconfigured landscape should keep upper subjects visible in the much wider home hero.",
|
||||
);
|
||||
|
||||
const cachedAnalysis = {
|
||||
width: 2400,
|
||||
height: 1350,
|
||||
ratio: 2400 / 1350,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
safeArea: "left",
|
||||
focusX: 0.72,
|
||||
focusY: 0.48,
|
||||
accentRgb: { r: 180, g: 90, b: 110 },
|
||||
};
|
||||
const cached = createFixture({
|
||||
id: "cached-wide",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
artKey: "cached-art",
|
||||
artMetadata: synchronousWide.window.__CODEX_DREAM_SKIN_STATE__.artMetadata,
|
||||
}, { analysisCache: new Map([["cached-art", cachedAnalysis]]) });
|
||||
vm.runInNewContext(cached.payload, cached.context);
|
||||
assert.equal(cached.attributes.get("data-dream-art-ready"), "true");
|
||||
assert.equal(cached.attributes.get("data-dream-art-safe-area"), "left");
|
||||
assert.equal(cached.window.__CODEX_DREAM_SKIN_STATE__.metrics.analysisCacheHits, 1);
|
||||
assert.equal(cached.window.__CODEX_DREAM_SKIN_STATE__.metrics.analysisRuns, 0);
|
||||
|
||||
const previousWideState = synchronousWide.window.__CODEX_DREAM_SKIN_STATE__;
|
||||
const stableStyle = synchronousWide.nodes.get("codex-dream-skin-style");
|
||||
vm.runInNewContext(synchronousWide.payloadFor({
|
||||
id: "switched-wide",
|
||||
appearance: "dark",
|
||||
art: { safeArea: "right", taskMode: "ambient" },
|
||||
artKey: "switched-art",
|
||||
artMetadata: {
|
||||
width: 2400,
|
||||
height: 1350,
|
||||
ratio: 2400 / 1350,
|
||||
wide: true,
|
||||
aspect: "wide",
|
||||
taskMode: "ambient",
|
||||
},
|
||||
}, ".fixture { color: red; }"), synchronousWide.context);
|
||||
assert.equal(synchronousWide.nodes.get("codex-dream-skin-style"), stableStyle);
|
||||
assert.equal(stableStyle.textContent, ".fixture { color: red; }");
|
||||
assert.equal(stableStyle.dataset.dreamSkinVersion, "test");
|
||||
assert.equal(synchronousWide.rootStyle.values.get("--dream-skin-art"), 'url("blob:fixture-2")');
|
||||
assert.deepEqual(synchronousWide.revokedUrls, ["blob:fixture-1"]);
|
||||
assert.equal(previousWideState.cleanup(), false, "An old async cleanup must not remove the new theme.");
|
||||
|
||||
const brightPixels = new Uint8ClampedArray(96 * 32 * 4);
|
||||
for (let offset = 0; offset < brightPixels.length; offset += 4) {
|
||||
brightPixels[offset] = 245;
|
||||
brightPixels[offset + 1] = 224;
|
||||
brightPixels[offset + 2] = 224;
|
||||
brightPixels[offset + 3] = 255;
|
||||
}
|
||||
const nativeDark = createFixture({
|
||||
id: "native-dark-contract",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "auto", taskMode: "auto" },
|
||||
}, {
|
||||
nativeShell: "dark",
|
||||
analysisFixture: { naturalWidth: 2400, naturalHeight: 800, pixels: brightPixels },
|
||||
});
|
||||
vm.runInNewContext(nativeDark.payload, nativeDark.context);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
nativeDark.window.__CODEX_DREAM_SKIN_STATE__.ensure();
|
||||
assert.equal(nativeDark.window.__CODEX_DREAM_SKIN_STATE__.analysis.shell, "light");
|
||||
assert.equal(nativeDark.attributes.get("data-dream-shell"), "dark");
|
||||
assert.match(nativeDark.rootStyle.values.get("--ds-bg"), /^#[0-9a-f]{6}$/);
|
||||
assert.ok(Number.parseInt(nativeDark.rootStyle.values.get("--ds-bg").slice(1), 16) < 0x303030);
|
||||
|
||||
const explicit = createFixture({
|
||||
id: "explicit-contract",
|
||||
appearance: "dark",
|
||||
surfaceOpacity: 0.55,
|
||||
art: { focusX: 0.15, focusY: 0.8, safeArea: "none", taskMode: "off" },
|
||||
});
|
||||
const explicitResult = vm.runInNewContext(explicit.payload, explicit.context);
|
||||
assert.equal(explicitResult.shell, "dark");
|
||||
assert.equal(explicit.attributes.get("data-dream-shell"), "dark");
|
||||
assert.equal(explicit.attributes.get("data-dream-art-safe-area"), "none");
|
||||
assert.equal(explicit.attributes.get("data-dream-art-safe"), "none");
|
||||
assert.equal(explicit.attributes.get("data-dream-art-task-mode"), "off");
|
||||
assert.equal(explicit.rootStyle.values.get("--dream-art-position"), "15.00% 80.00%");
|
||||
assert.equal(explicit.rootStyle.values.get("--dream-skin-home-art-position"), "15.00% 80.00%");
|
||||
assert.equal(explicit.rootStyle.values.get("--ds-surface-opacity"), "0.55");
|
||||
assert.equal(explicit.window.__CODEX_DREAM_SKIN_STATE__.analysis, null);
|
||||
|
||||
const banner = createFixture({
|
||||
id: "banner-contract",
|
||||
appearance: "auto",
|
||||
art: { safeArea: "left", taskMode: "banner" },
|
||||
artMetadata: {
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
ratio: 2560 / 1440,
|
||||
wide: true,
|
||||
aspect: "ultrawide",
|
||||
taskMode: "banner",
|
||||
safeArea: "left",
|
||||
focusX: 0.72,
|
||||
focusY: 0.44,
|
||||
},
|
||||
});
|
||||
vm.runInNewContext(banner.payload, banner.context);
|
||||
assert.equal(banner.attributes.get("data-dream-art-wide"), "true");
|
||||
assert.equal(banner.attributes.get("data-dream-art-task-mode"), "banner");
|
||||
assert.equal(banner.attributes.get("data-dream-task-mode"), "banner");
|
||||
|
||||
assert.equal(explicit.window.__CODEX_DREAM_SKIN_STATE__.cleanup(), true);
|
||||
assert.equal(explicit.root.classList.contains("codex-dream-skin"), false);
|
||||
assert.equal(explicit.attributes.has("data-dream-shell"), false);
|
||||
assert.equal(explicit.attributes.has("data-dream-art-safe-area"), false);
|
||||
assert.equal(explicit.attributes.has("data-dream-art-task-mode"), false);
|
||||
assert.equal(explicit.rootStyle.values.has("--dream-art-position"), false);
|
||||
assert.equal(explicit.nodes.has("codex-dream-skin-style"), false);
|
||||
assert.equal(explicit.nodes.has("codex-dream-skin-chrome"), false);
|
||||
assert.deepEqual(explicit.revokedUrls, ["blob:fixture-1"]);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.equal(explicit.root.classList.contains("codex-dream-skin"), false);
|
||||
assert.equal(explicit.nodes.has("codex-dream-skin-style"), false);
|
||||
assert.equal(explicit.window.__CODEX_DREAM_SKIN_STATE__, undefined);
|
||||
|
||||
console.log("PASS: renderer honors adaptive art metadata, fallback, and cleanup behavior.");
|
||||
@@ -0,0 +1,4 @@
|
||||
await import("./image-metadata.test.mjs");
|
||||
await import("./injector-bootstrap.test.mjs");
|
||||
await import("./renderer-inject.test.mjs");
|
||||
await import("./theme-stage.test.mjs");
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const macosRoot = path.resolve(here, "..");
|
||||
const stageScript = path.join(macosRoot, "scripts", "stage-theme.mjs");
|
||||
const fixtureAsset = path.join(macosRoot, "tests", "fixtures", "background.jpg");
|
||||
const tempRoot = await fs.mkdtemp(path.join("/tmp", "codex-dream-skin-stage-"));
|
||||
|
||||
function runStage(source, stage) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [stageScript, source, stage], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => {
|
||||
if (code === 0) resolve(stdout);
|
||||
else reject(new Error(stderr || `stage-theme exited with ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const source = path.join(tempRoot, "themes", "preset-race");
|
||||
const stage = path.join(tempRoot, "stage");
|
||||
await fs.mkdir(source, { recursive: true });
|
||||
await fs.mkdir(stage);
|
||||
await fs.copyFile(fixtureAsset, path.join(source, "background-a.png"));
|
||||
await fs.writeFile(
|
||||
path.join(source, "theme.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, id: "preset-race", name: "A", image: "background-a.png" })}\n`,
|
||||
);
|
||||
|
||||
const imageName = await runStage(source, stage);
|
||||
assert.equal(imageName, "background-a.png");
|
||||
const stagedConfig = JSON.parse(await fs.readFile(path.join(stage, "theme.json"), "utf8"));
|
||||
assert.equal(stagedConfig.image, "background-a.png");
|
||||
const stagedBeforeMutation = await fs.readFile(path.join(stage, "background-a.png"));
|
||||
|
||||
// A source edit after staging must not change the pair that is about to be
|
||||
// published. This is the regression for switch-theme's old copy-after-
|
||||
// validation TOCTOU window.
|
||||
await fs.copyFile(fixtureAsset, path.join(source, "background-b.png"));
|
||||
await fs.writeFile(
|
||||
path.join(source, "theme.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, id: "preset-race", name: "B", image: "background-b.png" })}\n`,
|
||||
);
|
||||
await fs.writeFile(path.join(source, "background-a.png"), Buffer.from("changed-after-stage"));
|
||||
assert.deepEqual(await fs.readFile(path.join(stage, "background-a.png")), stagedBeforeMutation);
|
||||
assert.equal(JSON.parse(await fs.readFile(path.join(stage, "theme.json"), "utf8")).name, "A");
|
||||
|
||||
const outside = path.join(tempRoot, "outside.png");
|
||||
await fs.copyFile(fixtureAsset, outside);
|
||||
const traversal = path.join(tempRoot, "traversal");
|
||||
await fs.mkdir(traversal);
|
||||
await fs.writeFile(
|
||||
path.join(traversal, "theme.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, id: "bad", image: "../outside.png" })}\n`,
|
||||
);
|
||||
const traversalStage = path.join(tempRoot, "traversal-stage");
|
||||
await fs.mkdir(traversalStage);
|
||||
await assert.rejects(runStage(traversal, traversalStage), /inside its theme directory/);
|
||||
|
||||
const symlink = path.join(tempRoot, "symlink");
|
||||
await fs.mkdir(symlink);
|
||||
await fs.symlink(outside, path.join(symlink, "background.png"));
|
||||
await fs.writeFile(
|
||||
path.join(symlink, "theme.json"),
|
||||
`${JSON.stringify({ schemaVersion: 1, id: "bad-link", image: "background.png" })}\n`,
|
||||
);
|
||||
const symlinkStage = path.join(tempRoot, "symlink-stage");
|
||||
await fs.mkdir(symlinkStage);
|
||||
await assert.rejects(runStage(symlink, symlinkStage), /symbolic link/);
|
||||
|
||||
console.log("PASS: theme staging snapshots a matched, contained config/image pair.");
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidateSet('inspect', 'selectPort', 'launch', 'stopCodex', 'launchNormal', 'verifyPort', 'processInfo', 'injectorStatus', 'stopInjector')]
|
||||
[string]$Action,
|
||||
[int]$Port = 9341,
|
||||
[int]$TargetPid = 0,
|
||||
[string]$StatePath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
. (Join-Path $PSScriptRoot 'common-windows.ps1')
|
||||
|
||||
function Write-CodexxJson {
|
||||
param([Parameter(Mandatory = $true)][object]$Value)
|
||||
$Value | ConvertTo-Json -Depth 8 -Compress
|
||||
}
|
||||
|
||||
function Get-CodexxState {
|
||||
if (-not $StatePath -or -not (Test-Path -LiteralPath $StatePath -PathType Leaf)) {
|
||||
throw 'Codex-X skin state file is missing.'
|
||||
}
|
||||
return Get-Content -LiteralPath $StatePath -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop
|
||||
}
|
||||
|
||||
function Get-CodexxDebugIdentity {
|
||||
param([Parameter(Mandatory = $true)][object]$Codex)
|
||||
foreach ($process in @(Get-DreamSkinCodexProcesses -Codex $Codex)) {
|
||||
$commandLine = "$($process.CommandLine)"
|
||||
foreach ($match in [regex]::Matches($commandLine, '(?i)(?:^|\s)--remote-debugging-port(?:=|\s+)(?<port>[0-9]{4,5})(?=$|\s)')) {
|
||||
$candidate = [int]$match.Groups['port'].Value
|
||||
if ($candidate -lt 1024 -or $candidate -gt 65535) { continue }
|
||||
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $candidate -Codex $Codex
|
||||
if ($null -ne $identity) {
|
||||
return [pscustomobject]@{ Port = $candidate; Identity = $identity }
|
||||
}
|
||||
}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-CodexxInjectorState {
|
||||
param([Parameter(Mandatory = $true)][object]$State)
|
||||
if (-not $State.injectorPid -or -not $State.injectorStartedAt -or
|
||||
-not $State.injectorPath -or -not $State.nodePath -or -not $State.port) {
|
||||
return $false
|
||||
}
|
||||
$processId = [int]$State.injectorPid
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $processId" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $process) { return $false }
|
||||
$processPath = Get-DreamSkinProcessExecutablePath -ProcessInfo $process
|
||||
$commandLine = "$($process.CommandLine)"
|
||||
$startedAt = Get-DreamSkinProcessStartedAt -ProcessId $processId
|
||||
$portPattern = '(?i)(?:^|\s)--port(?:=|\s+)' + [regex]::Escape("$($State.port)") + '(?=$|\s)'
|
||||
return [bool](
|
||||
$processPath -and
|
||||
([System.IO.Path]::GetFileName($processPath) -ieq 'node.exe') -and
|
||||
(Test-DreamSkinPathEqual -Left $processPath -Right "$($State.nodePath)") -and
|
||||
(Test-DreamSkinCommandLineToken -CommandLine $commandLine -Token "$($State.injectorPath)") -and
|
||||
(Test-DreamSkinCommandLineToken -CommandLine $commandLine -Token '--watch') -and
|
||||
[regex]::IsMatch($commandLine, $portPattern) -and
|
||||
$startedAt -eq "$($State.injectorStartedAt)"
|
||||
)
|
||||
}
|
||||
|
||||
$operationLock = Enter-DreamSkinOperationLock
|
||||
try {
|
||||
switch ($Action) {
|
||||
'inspect' {
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
$processes = @(Get-DreamSkinCodexProcesses -Codex $codex)
|
||||
$debug = Get-CodexxDebugIdentity -Codex $codex
|
||||
$watcherPids = @(Get-CimInstance Win32_Process -Filter "Name = 'node.exe'" -ErrorAction SilentlyContinue |
|
||||
Where-Object {
|
||||
$_.CommandLine -and
|
||||
[regex]::IsMatch("$($_.CommandLine)", '(?i)(?:^|\s)--watch(?=$|\s)') -and
|
||||
"$($_.CommandLine)".IndexOf('injector.mjs', [System.StringComparison]::OrdinalIgnoreCase) -ge 0
|
||||
} | ForEach-Object { [int]$_.ProcessId })
|
||||
Write-CodexxJson ([pscustomobject]@{
|
||||
packageRoot = "$($codex.PackageRoot)"
|
||||
executable = "$($codex.Executable)"
|
||||
version = "$($codex.Version)"
|
||||
packageFullName = "$($codex.PackageFullName)"
|
||||
packageFamilyName = "$($codex.PackageFamilyName)"
|
||||
appUserModelId = "$($codex.AppUserModelId)"
|
||||
running = $processes.Count -gt 0
|
||||
debugPort = if ($null -eq $debug) { $null } else { [int]$debug.Port }
|
||||
browserId = if ($null -eq $debug) { $null } else { "$($debug.Identity.BrowserId)" }
|
||||
watcherPids = $watcherPids
|
||||
})
|
||||
break
|
||||
}
|
||||
'selectPort' {
|
||||
Assert-DreamSkinPort -Port $Port
|
||||
Write-CodexxJson ([pscustomobject]@{ port = (Select-DreamSkinPort -PreferredPort $Port) })
|
||||
break
|
||||
}
|
||||
'launch' {
|
||||
Assert-DreamSkinPort -Port $Port
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
$preserved = @(Get-DreamSkinCodexProcesses -Codex $codex | ForEach-Object { [int]$_.ProcessId })
|
||||
$arguments = @('--remote-debugging-address=127.0.0.1', "--remote-debugging-port=$Port")
|
||||
try {
|
||||
$launch = Start-DreamSkinCodexForDebugging -Codex $codex -Arguments $arguments `
|
||||
-Port $Port -PreserveProcessIds $preserved
|
||||
$deadline = (Get-Date).AddSeconds(45)
|
||||
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex
|
||||
while ($null -eq $identity -and (Get-Date) -lt $deadline) {
|
||||
Start-Sleep -Milliseconds 350
|
||||
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex
|
||||
}
|
||||
if ($null -eq $identity) {
|
||||
throw "Codex did not expose a verified loopback CDP endpoint on port $Port within 45 seconds."
|
||||
}
|
||||
Write-CodexxJson ([pscustomobject]@{
|
||||
port = $Port
|
||||
browserId = "$($identity.BrowserId)"
|
||||
strategy = "$($launch.Strategy)"
|
||||
})
|
||||
} catch {
|
||||
try { Stop-DreamSkinCodex -Codex $codex -PreserveProcessIds $preserved -AllowForce } catch {}
|
||||
if ($preserved.Count -eq 0 -and (Get-DreamSkinCodexProcesses -Codex $codex).Count -eq 0) {
|
||||
try { $null = Start-DreamSkinCodex -Codex $codex } catch {}
|
||||
}
|
||||
throw
|
||||
}
|
||||
break
|
||||
}
|
||||
'stopCodex' {
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
Stop-DreamSkinCodex -Codex $codex -AllowForce
|
||||
Write-CodexxJson ([pscustomobject]@{ stopped = $true })
|
||||
break
|
||||
}
|
||||
'launchNormal' {
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
$processId = Start-DreamSkinCodex -Codex $codex
|
||||
Write-CodexxJson ([pscustomobject]@{ processId = [int]$processId })
|
||||
break
|
||||
}
|
||||
'verifyPort' {
|
||||
Assert-DreamSkinPort -Port $Port
|
||||
$codex = Get-DreamSkinCodexInstall
|
||||
$identity = Get-DreamSkinVerifiedCdpIdentity -Port $Port -Codex $codex
|
||||
Write-CodexxJson ([pscustomobject]@{
|
||||
verified = $null -ne $identity
|
||||
browserId = if ($null -eq $identity) { $null } else { "$($identity.BrowserId)" }
|
||||
})
|
||||
break
|
||||
}
|
||||
'processInfo' {
|
||||
if ($TargetPid -le 0) { throw 'Target PID must be positive.' }
|
||||
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $TargetPid" -ErrorAction SilentlyContinue
|
||||
if ($null -eq $process) {
|
||||
Write-CodexxJson ([pscustomobject]@{ alive = $false })
|
||||
break
|
||||
}
|
||||
Write-CodexxJson ([pscustomobject]@{
|
||||
alive = $true
|
||||
path = (Get-DreamSkinProcessExecutablePath -ProcessInfo $process)
|
||||
commandLine = "$($process.CommandLine)"
|
||||
startedAt = (Get-DreamSkinProcessStartedAt -ProcessId $TargetPid)
|
||||
})
|
||||
break
|
||||
}
|
||||
'injectorStatus' {
|
||||
$state = Get-CodexxState
|
||||
Write-CodexxJson ([pscustomobject]@{ active = (Test-CodexxInjectorState -State $state) })
|
||||
break
|
||||
}
|
||||
'stopInjector' {
|
||||
$state = Get-CodexxState
|
||||
if (-not (Test-CodexxInjectorState -State $state)) {
|
||||
$process = Get-Process -Id ([int]$state.injectorPid) -ErrorAction SilentlyContinue
|
||||
if ($null -ne $process) { throw 'The recorded skin injector identity does not match; it was not stopped.' }
|
||||
} else {
|
||||
$null = Stop-DreamSkinRecordedInjector -State $state
|
||||
}
|
||||
Write-CodexxJson ([pscustomobject]@{ stopped = $true })
|
||||
break
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
Exit-DreamSkinOperationLock -Mutex $operationLock
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
$script:DreamSkinUtf8NoBom = [System.Text.UTF8Encoding]::new($false, $true)
|
||||
$script:DreamSkinLegacyAppearanceTheme = 'appearanceTheme = "light"'
|
||||
$script:DreamSkinManagedLightCodeTheme = 'appearanceLightCodeThemeId = "codex"'
|
||||
$script:DreamSkinManagedLightChromeTheme = 'appearanceLightChromeTheme = { accent = "#B65CFF", contrast = 64, fonts = { code = "Cascadia Code", ui = "Microsoft YaHei UI" }, ink = "#4A235F", opaqueWindows = true, semanticColors = { diffAdded = "#BCE8CF", diffRemoved = "#F7B8CE", skill = "#C47BFF" }, surface = "#FFF4FA" }'
|
||||
|
||||
function ConvertFrom-DreamSkinUtf8Bytes {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Bytes,
|
||||
[Parameter(Mandatory = $true)][string]$Path
|
||||
)
|
||||
|
||||
try {
|
||||
$offset = if ($Bytes.Length -ge 3 -and $Bytes[0] -eq 0xEF -and $Bytes[1] -eq 0xBB -and $Bytes[2] -eq 0xBF) { 3 } else { 0 }
|
||||
$content = $script:DreamSkinUtf8NoBom.GetString($Bytes, $offset, $Bytes.Length - $offset)
|
||||
if ($content.IndexOf([char]0) -ge 0) {
|
||||
throw "Refusing to rewrite a config file containing NUL characters (possibly BOM-less UTF-16): $Path"
|
||||
}
|
||||
return $content
|
||||
} catch [System.Text.DecoderFallbackException] {
|
||||
throw "Refusing to rewrite a config file that is not valid UTF-8: $Path"
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DreamSkinBytesEqual {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Left,
|
||||
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Right
|
||||
)
|
||||
if ($Left.Length -ne $Right.Length) { return $false }
|
||||
for ($index = 0; $index -lt $Left.Length; $index++) {
|
||||
if ($Left[$index] -ne $Right[$index]) { return $false }
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
function Assert-DreamSkinFileUnchanged {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[AllowNull()][byte[]]$ExpectedBytes
|
||||
)
|
||||
if ($null -eq $ExpectedBytes) {
|
||||
if (Test-Path -LiteralPath $Path) { throw "File changed during the operation; retry without other writers: $Path" }
|
||||
return
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $Path)) { throw "File disappeared during the operation; retry: $Path" }
|
||||
$currentBytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
if (-not (Test-DreamSkinBytesEqual -Left $ExpectedBytes -Right $currentBytes)) {
|
||||
throw "File changed during the operation; retry without other writers: $Path"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DreamSkinNewLine {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
if ($Content.Contains("`r`n")) { return "`r`n" }
|
||||
return "`n"
|
||||
}
|
||||
|
||||
function Read-DreamSkinUtf8File {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
return (ConvertFrom-DreamSkinUtf8Bytes -Bytes $bytes -Path $Path)
|
||||
}
|
||||
|
||||
function Write-DreamSkinUtf8FileAtomically {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[AllowEmptyString()]
|
||||
[string]$Content,
|
||||
|
||||
[AllowNull()]
|
||||
[byte[]]$ExpectedBytes
|
||||
)
|
||||
|
||||
$bytes = $script:DreamSkinUtf8NoBom.GetBytes($Content)
|
||||
if ($PSBoundParameters.ContainsKey('ExpectedBytes')) {
|
||||
Write-DreamSkinBytesAtomically -Path $Path -Bytes $bytes -ExpectedBytes $ExpectedBytes
|
||||
} else {
|
||||
Write-DreamSkinBytesAtomically -Path $Path -Bytes $bytes
|
||||
}
|
||||
}
|
||||
|
||||
function Remove-DreamSkinAtomicArtifact {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
if ([System.IO.File]::Exists($Path)) {
|
||||
[System.IO.File]::Delete($Path)
|
||||
}
|
||||
}
|
||||
|
||||
function Write-DreamSkinBytesAtomically {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Path,
|
||||
[Parameter(Mandatory = $true)][AllowEmptyCollection()][byte[]]$Bytes,
|
||||
[AllowNull()][byte[]]$ExpectedBytes
|
||||
)
|
||||
|
||||
$fullPath = [System.IO.Path]::GetFullPath($Path)
|
||||
$directory = [System.IO.Path]::GetDirectoryName($fullPath)
|
||||
if (-not [System.IO.Directory]::Exists($directory)) {
|
||||
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
|
||||
}
|
||||
$fileName = [System.IO.Path]::GetFileName($fullPath)
|
||||
$operationId = "$PID.$([guid]::NewGuid().ToString('N'))"
|
||||
$temporary = Join-Path $directory ".$fileName.$operationId.tmp"
|
||||
$replacementBackup = Join-Path $directory ".$fileName.$operationId.replace-backup"
|
||||
|
||||
try {
|
||||
[System.IO.File]::WriteAllBytes($temporary, $Bytes)
|
||||
if ($PSBoundParameters.ContainsKey('ExpectedBytes')) {
|
||||
Assert-DreamSkinFileUnchanged -Path $fullPath -ExpectedBytes $ExpectedBytes
|
||||
}
|
||||
if ([System.IO.File]::Exists($fullPath)) {
|
||||
[System.IO.File]::Replace($temporary, $fullPath, $replacementBackup)
|
||||
} else {
|
||||
[System.IO.File]::Move($temporary, $fullPath)
|
||||
}
|
||||
} finally {
|
||||
foreach ($artifact in @($temporary, $replacementBackup)) {
|
||||
try {
|
||||
Remove-DreamSkinAtomicArtifact -Path $artifact
|
||||
} catch {
|
||||
try {
|
||||
Write-Warning "Could not remove temporary atomic config artifact '$artifact': $($_.Exception.Message)"
|
||||
} catch {
|
||||
# Cleanup must never mask the result of the atomic write.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DreamSkinTomlKeyTokenPattern {
|
||||
param([Parameter(Mandatory = $true)][string]$Key)
|
||||
$bare = [regex]::Escape($Key)
|
||||
$doubleQuoted = [regex]::Escape('"' + $Key + '"')
|
||||
$singleQuoted = [regex]::Escape("'" + $Key + "'")
|
||||
return "(?:$bare|$doubleQuoted|$singleQuoted)"
|
||||
}
|
||||
|
||||
function ConvertTo-DreamSkinTomlAsciiEscapeProbe {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value)
|
||||
|
||||
$result = $Value
|
||||
$characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'.ToCharArray()
|
||||
foreach ($character in $characters) {
|
||||
$code = ([int][char]$character).ToString('x2')
|
||||
$pattern = '(?i)\\(?:u00' + $code + '|U000000' + $code + ')'
|
||||
$result = [regex]::Replace($result, $pattern, [string]$character)
|
||||
}
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-DreamSkinTomlArrayBracketBalance {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Line)
|
||||
|
||||
$quote = $null
|
||||
$escaped = $false
|
||||
$balance = 0
|
||||
for ($index = 0; $index -lt $Line.Length; $index++) {
|
||||
$character = $Line[$index]
|
||||
if ($null -eq $quote) {
|
||||
if ($character -eq '#') { break }
|
||||
if ($character -eq '"' -or $character -eq "'") { $quote = $character }
|
||||
elseif ($character -eq '[') { $balance++ }
|
||||
elseif ($character -eq ']') { $balance-- }
|
||||
continue
|
||||
}
|
||||
if ($quote -eq '"') {
|
||||
if ($escaped) { $escaped = $false; continue }
|
||||
if ($character -eq '\') { $escaped = $true; continue }
|
||||
}
|
||||
if ($character -eq $quote) { $quote = $null }
|
||||
}
|
||||
return $balance
|
||||
}
|
||||
|
||||
function Assert-DreamSkinTomlLineEditingSafe {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
|
||||
if ($Content.Contains('"""') -or $Content.Contains("'''")) {
|
||||
throw 'Refusing to rewrite TOML containing multiline strings; use single-line values before installing Dream Skin.'
|
||||
}
|
||||
foreach ($match in [regex]::Matches($Content, '(?m)^[^\r\n]*=[\t ]*\[[^\r\n]*\r?$')) {
|
||||
if ((Get-DreamSkinTomlArrayBracketBalance -Line $match.Value) -ne 0) {
|
||||
throw 'Refusing to rewrite TOML containing multiline arrays; use single-line arrays before installing Dream Skin.'
|
||||
}
|
||||
}
|
||||
|
||||
$probe = ConvertTo-DreamSkinTomlAsciiEscapeProbe -Value $Content
|
||||
if ($probe -cne $Content) {
|
||||
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
|
||||
$desktopShape = "(?m)^[\t ]*(?:\[\[?[\t ]*$desktopToken[\t ]*(?:\]|\.)|$desktopToken[\t ]*(?:\.|=))"
|
||||
$rawDesktopShapes = [regex]::Matches($Content, $desktopShape).Count
|
||||
$probedDesktopShapes = [regex]::Matches($probe, $desktopShape).Count
|
||||
if ($probedDesktopShapes -gt $rawDesktopShapes) {
|
||||
throw 'Refusing to rewrite an escaped TOML key equivalent to desktop; normalize the key spelling first.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DreamSkinDesktopSectionPattern {
|
||||
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
|
||||
return "(?ms)^[\t ]*\[[\t ]*$desktopToken[\t ]*\][\t ]*(?:#[^\r\n]*)?(?:\r?\n|(?=\z))(?<body>.*?)(?=^[\t ]*\[\[?|\z)"
|
||||
}
|
||||
|
||||
function Test-DreamSkinDesktopNestedTable {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
|
||||
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $Key
|
||||
return [regex]::IsMatch(
|
||||
$Content,
|
||||
"(?m)^[\t ]*\[[\t ]*$desktopToken[\t ]*\.[\t ]*$keyToken[\t ]*(?:\]|\.)"
|
||||
)
|
||||
}
|
||||
|
||||
function Assert-DreamSkinDesktopShapeSupported {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
|
||||
Assert-DreamSkinTomlLineEditingSafe -Content $Content
|
||||
$sectionPattern = Get-DreamSkinDesktopSectionPattern
|
||||
if ([regex]::Matches($Content, $sectionPattern).Count -gt 1) {
|
||||
throw 'Refusing to rewrite multiple equivalent [desktop] tables.'
|
||||
}
|
||||
|
||||
$desktopToken = Get-DreamSkinTomlKeyTokenPattern -Key 'desktop'
|
||||
if ([regex]::IsMatch($Content, "(?m)^[\t ]*\[\[[\t ]*$desktopToken[\t ]*(?:\]\]|\.)")) {
|
||||
throw 'Refusing to rewrite a config that represents desktop as an array of tables.'
|
||||
}
|
||||
foreach ($key in @('appearanceTheme', 'appearanceLightCodeThemeId')) {
|
||||
if (Test-DreamSkinDesktopNestedTable -Content $Content -Key $key) {
|
||||
throw "Refusing to replace '$key' because it is represented as a nested desktop table."
|
||||
}
|
||||
}
|
||||
|
||||
$firstTable = [regex]::Match($Content, '(?m)^[\t ]*\[\[?')
|
||||
$rootContent = if ($firstTable.Success) { $Content.Substring(0, $firstTable.Index) } else { $Content }
|
||||
if ([regex]::IsMatch($rootContent, "(?m)^[\t ]*$desktopToken[\t ]*(?:\.|=)")) {
|
||||
throw 'Refusing to rewrite root dotted or inline desktop keys; normalize them to a [desktop] table first.'
|
||||
}
|
||||
|
||||
$desktop = Get-DreamSkinDesktopSection -Content $Content
|
||||
if ($null -ne $desktop) {
|
||||
$bodyProbe = ConvertTo-DreamSkinTomlAsciiEscapeProbe -Value $desktop.Body
|
||||
foreach ($key in @('appearanceTheme', 'appearanceLightCodeThemeId', 'appearanceLightChromeTheme')) {
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $key
|
||||
$settingShape = "(?m)^[\t ]*$keyToken[\t ]*(?:\.|=)"
|
||||
if ($key -eq 'appearanceLightChromeTheme' -and
|
||||
(Test-DreamSkinDesktopNestedTable -Content $Content -Key $key) -and
|
||||
[regex]::IsMatch($desktop.Body, $settingShape)) {
|
||||
throw "Refusing to rewrite '$key' because both a scalar and nested table are present."
|
||||
}
|
||||
if ([regex]::Matches($bodyProbe, $settingShape).Count -gt
|
||||
[regex]::Matches($desktop.Body, $settingShape).Count) {
|
||||
throw "Refusing to rewrite an escaped TOML key equivalent to '$key'."
|
||||
}
|
||||
if ([regex]::IsMatch($desktop.Body, "(?m)^[\t ]*$keyToken[\t ]*\.")) {
|
||||
throw "Refusing to replace dotted '$key' keys in the [desktop] table."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Get-DreamSkinDesktopSection {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
|
||||
$match = [regex]::Match($Content, (Get-DreamSkinDesktopSectionPattern))
|
||||
if (-not $match.Success) { return $null }
|
||||
return [pscustomobject]@{
|
||||
Body = $match.Groups['body'].Value
|
||||
BodyStart = $match.Groups['body'].Index
|
||||
BodyLength = $match.Groups['body'].Length
|
||||
SectionStart = $match.Index
|
||||
SectionLength = $match.Length
|
||||
}
|
||||
}
|
||||
|
||||
function Add-DreamSkinDesktopSection {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content,
|
||||
[Parameter(Mandatory = $true)][string]$NewLine
|
||||
)
|
||||
|
||||
if ($Content.Length -eq 0) { return "[desktop]$NewLine" }
|
||||
$separator = if ($Content.EndsWith("`n")) { $NewLine } else { $NewLine + $NewLine }
|
||||
return $Content + $separator + "[desktop]$NewLine"
|
||||
}
|
||||
|
||||
function Set-DreamSkinSectionSetting {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Body,
|
||||
[Parameter(Mandatory = $true)][string]$Key,
|
||||
[AllowNull()][object]$Line,
|
||||
[Parameter(Mandatory = $true)][string]$NewLine
|
||||
)
|
||||
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $Key
|
||||
$pattern = "(?m)^[\t ]*$keyToken[\t ]*=[^\r\n]*(?:\r?\n|(?=\z))"
|
||||
$matcher = [regex]::new($pattern)
|
||||
if ($matcher.Matches($Body).Count -gt 1) {
|
||||
throw "Refusing to rewrite duplicate '$Key' entries in the [desktop] section."
|
||||
}
|
||||
if ($null -eq $Line) { return $matcher.Replace($Body, '', 1) }
|
||||
$normalizedLine = $Line.TrimEnd("`r", "`n") + $NewLine
|
||||
if ($matcher.IsMatch($Body)) {
|
||||
$literalReplacement = $normalizedLine.Replace('$', '$$')
|
||||
return $matcher.Replace($Body, $literalReplacement, 1)
|
||||
}
|
||||
$separator = if ($Body.Length -eq 0 -or $Body.EndsWith("`n")) { '' } else { $NewLine }
|
||||
return $Body + $separator + $normalizedLine
|
||||
}
|
||||
|
||||
function Get-DreamSkinSectionSettingLine {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][AllowEmptyString()][string]$Body,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $Key
|
||||
$matches = [regex]::Matches($Body, "(?m)^[\t ]*$keyToken[\t ]*=.*$")
|
||||
if ($matches.Count -gt 1) { throw "Refusing to inspect duplicate '$Key' entries in the [desktop] section." }
|
||||
if ($matches.Count -eq 0) { return $null }
|
||||
return $matches[0].Value.Trim()
|
||||
}
|
||||
|
||||
function Test-DreamSkinLegacyManagedLightTrio {
|
||||
param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content)
|
||||
$desktop = Get-DreamSkinDesktopSection -Content $Content
|
||||
if ($null -eq $desktop) { return $false }
|
||||
return (
|
||||
(Get-DreamSkinSectionSettingLine -Body $desktop.Body -Key 'appearanceTheme') -ceq
|
||||
$script:DreamSkinLegacyAppearanceTheme -and
|
||||
(Get-DreamSkinSectionSettingLine -Body $desktop.Body -Key 'appearanceLightCodeThemeId') -ceq
|
||||
$script:DreamSkinManagedLightCodeTheme -and
|
||||
(Get-DreamSkinSectionSettingLine -Body $desktop.Body -Key 'appearanceLightChromeTheme') -ceq
|
||||
$script:DreamSkinManagedLightChromeTheme
|
||||
)
|
||||
}
|
||||
|
||||
function Get-DreamSkinAppearanceMarkerPath {
|
||||
param([Parameter(Mandatory = $true)][string]$BackupPath)
|
||||
return "$BackupPath.appearance.json"
|
||||
}
|
||||
|
||||
function Read-DreamSkinAppearanceMarker {
|
||||
param([Parameter(Mandatory = $true)][string]$BackupPath)
|
||||
$markerPath = Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath
|
||||
if (-not (Test-Path -LiteralPath $markerPath)) { return $null }
|
||||
try {
|
||||
$marker = (Read-DreamSkinUtf8File -Path $markerPath) | ConvertFrom-Json -ErrorAction Stop
|
||||
} catch {
|
||||
throw "Dream Skin appearance marker is unreadable; config was preserved: $markerPath"
|
||||
}
|
||||
if ($null -eq $marker -or $marker -is [string] -or $marker -is [array]) {
|
||||
throw "Dream Skin appearance marker is invalid; config was preserved: $markerPath"
|
||||
}
|
||||
$schemaVersion = 0
|
||||
try { $schemaVersion = [int]$marker.schemaVersion } catch { $schemaVersion = 0 }
|
||||
# v1 markers are always unmanaged; v2 markers may pin appearanceTheme.
|
||||
$validUnmanagedV1 = $schemaVersion -eq 1 -and $marker.appearanceThemeManaged -is [bool] -and
|
||||
-not [bool]$marker.appearanceThemeManaged
|
||||
$validV2 = $schemaVersion -eq 2 -and $marker.appearanceThemeManaged -is [bool]
|
||||
if (-not ($validUnmanagedV1 -or $validV2)) {
|
||||
throw "Dream Skin appearance marker is invalid; config was preserved: $markerPath"
|
||||
}
|
||||
return $marker
|
||||
}
|
||||
|
||||
function Write-DreamSkinAppearanceMarker {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$BackupPath,
|
||||
[bool]$Managed = $false
|
||||
)
|
||||
$markerPath = Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath
|
||||
if (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue) {
|
||||
Assert-DreamSkinNoReparseComponents -Path $markerPath
|
||||
}
|
||||
# Unmanaged markers keep the v1 shape older engines accept; managed pins use
|
||||
# schemaVersion 2, which older engines conservatively refuse to act on.
|
||||
$schemaVersion = 1
|
||||
if ($Managed) { $schemaVersion = 2 }
|
||||
$marker = [ordered]@{
|
||||
schemaVersion = $schemaVersion
|
||||
appearanceThemeManaged = $Managed
|
||||
} | ConvertTo-Json
|
||||
Write-DreamSkinUtf8FileAtomically -Path $markerPath -Content ($marker + "`r`n")
|
||||
}
|
||||
|
||||
function Install-DreamSkinBaseTheme {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BackupPath,
|
||||
|
||||
[ValidateSet('auto', 'light', 'dark')]
|
||||
[string]$AppearanceTheme = 'auto'
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ConfigPath)) { throw "Codex config not found: $ConfigPath" }
|
||||
if (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue) {
|
||||
Assert-DreamSkinNoReparseComponents -Path $BackupPath
|
||||
Assert-DreamSkinNoReparseComponents -Path (Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath)
|
||||
}
|
||||
$originalBytes = [System.IO.File]::ReadAllBytes($ConfigPath)
|
||||
$content = ConvertFrom-DreamSkinUtf8Bytes -Bytes $originalBytes -Path $ConfigPath
|
||||
$appearanceMarker = Read-DreamSkinAppearanceMarker -BackupPath $BackupPath
|
||||
$appearanceMarkerPath = Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath
|
||||
$appearanceMarkerExisted = Test-Path -LiteralPath $appearanceMarkerPath -PathType Leaf
|
||||
$backupCreated = $false
|
||||
if (-not (Test-Path -LiteralPath $BackupPath)) {
|
||||
Write-DreamSkinBytesAtomically -Path $BackupPath -Bytes $originalBytes -ExpectedBytes $null
|
||||
$backupCreated = $true
|
||||
}
|
||||
|
||||
$writeCompleted = $false
|
||||
try {
|
||||
Assert-DreamSkinDesktopShapeSupported -Content $content
|
||||
$newLine = Get-DreamSkinNewLine -Content $content
|
||||
$desktop = Get-DreamSkinDesktopSection -Content $content
|
||||
if ($null -eq $desktop) {
|
||||
$content = Add-DreamSkinDesktopSection -Content $content -NewLine $newLine
|
||||
$desktop = Get-DreamSkinDesktopSection -Content $content
|
||||
}
|
||||
|
||||
$body = $desktop.Body
|
||||
$backupContent = $null
|
||||
$pinnedAppearance = $AppearanceTheme -ne 'auto'
|
||||
$managedByMarker = $null -ne $appearanceMarker -and [bool]$appearanceMarker.appearanceThemeManaged
|
||||
$legacyMigration = $null -eq $appearanceMarker -and (Test-Path -LiteralPath $BackupPath) -and
|
||||
(Test-DreamSkinLegacyManagedLightTrio -Content $content)
|
||||
# Put the pre-install appearanceTheme back whenever we stop managing it:
|
||||
# either migrating away from the legacy forced-light trio, or un-pinning
|
||||
# after a fixed-appearance theme is replaced by an auto one.
|
||||
if (-not $pinnedAppearance -and ($legacyMigration -or $managedByMarker)) {
|
||||
$backupContent = ConvertFrom-DreamSkinUtf8Bytes -Bytes ([System.IO.File]::ReadAllBytes($BackupPath)) -Path $BackupPath
|
||||
Assert-DreamSkinDesktopShapeSupported -Content $backupContent
|
||||
$backupDesktop = Get-DreamSkinDesktopSection -Content $backupContent
|
||||
$savedAppearance = if ($null -ne $backupDesktop) {
|
||||
Get-DreamSkinSectionSettingLine -Body $backupDesktop.Body -Key 'appearanceTheme'
|
||||
} else { $null }
|
||||
$body = Set-DreamSkinSectionSetting -Body $body -Key 'appearanceTheme' -Line $savedAppearance -NewLine $newLine
|
||||
}
|
||||
if ($pinnedAppearance) {
|
||||
# Native token surfaces (dropdowns/popovers) follow appearanceTheme, so a
|
||||
# fixed-appearance theme pins it to match; Restore puts the original back.
|
||||
$body = Set-DreamSkinSectionSetting -Body $body -Key 'appearanceTheme' `
|
||||
-Line ('appearanceTheme = "{0}"' -f $AppearanceTheme) -NewLine $newLine
|
||||
}
|
||||
$settings = [ordered]@{
|
||||
appearanceLightCodeThemeId = $script:DreamSkinManagedLightCodeTheme
|
||||
appearanceLightChromeTheme = $script:DreamSkinManagedLightChromeTheme
|
||||
}
|
||||
$hasNestedLightChromeTheme = Test-DreamSkinDesktopNestedTable `
|
||||
-Content $content -Key 'appearanceLightChromeTheme'
|
||||
foreach ($key in $settings.Keys) {
|
||||
if ($key -eq 'appearanceLightChromeTheme' -and $hasNestedLightChromeTheme) { continue }
|
||||
$body = Set-DreamSkinSectionSetting -Body $body -Key $key -Line $settings[$key] -NewLine $newLine
|
||||
}
|
||||
|
||||
$content = $content.Substring(0, $desktop.BodyStart) + $body +
|
||||
$content.Substring($desktop.BodyStart + $desktop.BodyLength)
|
||||
# Commit the metadata first. A config commit must never exist without the
|
||||
# marker that tells restore exactly which appearance keys we own.
|
||||
Write-DreamSkinAppearanceMarker -BackupPath $BackupPath -Managed $pinnedAppearance
|
||||
Write-DreamSkinUtf8FileAtomically -Path $ConfigPath -Content $content -ExpectedBytes $originalBytes
|
||||
$writeCompleted = $true
|
||||
} catch {
|
||||
if (-not $writeCompleted) {
|
||||
$configUnchanged = $false
|
||||
try {
|
||||
$configUnchanged = (Test-Path -LiteralPath $ConfigPath -PathType Leaf) -and
|
||||
(Test-DreamSkinBytesEqual -Left $originalBytes -Right ([System.IO.File]::ReadAllBytes($ConfigPath)))
|
||||
} catch {
|
||||
$configUnchanged = $false
|
||||
}
|
||||
if ($configUnchanged) {
|
||||
$markerCleanupSucceeded = $true
|
||||
if (-not $appearanceMarkerExisted -and (Test-Path -LiteralPath $appearanceMarkerPath)) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $appearanceMarkerPath -Force -ErrorAction Stop
|
||||
} catch {
|
||||
$markerCleanupSucceeded = $false
|
||||
}
|
||||
}
|
||||
if ($markerCleanupSucceeded -and $backupCreated) {
|
||||
Remove-Item -LiteralPath $BackupPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
function Restore-DreamSkinBaseTheme {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ConfigPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BackupPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackupPath)) { throw 'No pre-install config backup is available.' }
|
||||
if (Get-Command Assert-DreamSkinNoReparseComponents -ErrorAction SilentlyContinue) {
|
||||
Assert-DreamSkinNoReparseComponents -Path $BackupPath
|
||||
Assert-DreamSkinNoReparseComponents -Path (Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath)
|
||||
}
|
||||
$backupBytes = [System.IO.File]::ReadAllBytes($BackupPath)
|
||||
$backupContent = ConvertFrom-DreamSkinUtf8Bytes -Bytes $backupBytes -Path $BackupPath
|
||||
$currentBytes = [System.IO.File]::ReadAllBytes($ConfigPath)
|
||||
$currentContent = ConvertFrom-DreamSkinUtf8Bytes -Bytes $currentBytes -Path $ConfigPath
|
||||
Assert-DreamSkinDesktopShapeSupported -Content $backupContent
|
||||
Assert-DreamSkinDesktopShapeSupported -Content $currentContent
|
||||
$newLine = Get-DreamSkinNewLine -Content $currentContent
|
||||
$backupDesktop = Get-DreamSkinDesktopSection -Content $backupContent
|
||||
$currentDesktop = Get-DreamSkinDesktopSection -Content $currentContent
|
||||
if ($null -eq $currentDesktop) {
|
||||
$currentContent = Add-DreamSkinDesktopSection -Content $currentContent -NewLine $newLine
|
||||
$currentDesktop = Get-DreamSkinDesktopSection -Content $currentContent
|
||||
}
|
||||
|
||||
$body = $currentDesktop.Body
|
||||
$appearanceMarker = Read-DreamSkinAppearanceMarker -BackupPath $BackupPath
|
||||
$restoreLegacyAppearance = $null -eq $appearanceMarker -and
|
||||
(Test-DreamSkinLegacyManagedLightTrio -Content $currentContent)
|
||||
$restoreManagedAppearance = $null -ne $appearanceMarker -and
|
||||
[bool]$appearanceMarker.appearanceThemeManaged
|
||||
$restoreKeys = @('appearanceLightCodeThemeId', 'appearanceLightChromeTheme')
|
||||
if ($restoreLegacyAppearance -or $restoreManagedAppearance) {
|
||||
$restoreKeys = @('appearanceTheme') + $restoreKeys
|
||||
}
|
||||
$hasNestedLightChromeTheme = Test-DreamSkinDesktopNestedTable `
|
||||
-Content $currentContent -Key 'appearanceLightChromeTheme'
|
||||
foreach ($key in $restoreKeys) {
|
||||
if ($key -eq 'appearanceLightChromeTheme' -and $hasNestedLightChromeTheme) { continue }
|
||||
$keyToken = Get-DreamSkinTomlKeyTokenPattern -Key $key
|
||||
$pattern = "(?m)^[\t ]*$keyToken[\t ]*=[^\r\n]*(?:\r?\n|(?=\z))"
|
||||
$saved = if ($null -ne $backupDesktop) { [regex]::Match($backupDesktop.Body, $pattern) } else { $null }
|
||||
$line = if ($null -ne $saved -and $saved.Success) { $saved.Value } else { $null }
|
||||
$body = Set-DreamSkinSectionSetting -Body $body -Key $key -Line $line -NewLine $newLine
|
||||
}
|
||||
if ($null -eq $backupDesktop -and [string]::IsNullOrWhiteSpace($body)) {
|
||||
$currentContent = $currentContent.Remove($currentDesktop.SectionStart, $currentDesktop.SectionLength)
|
||||
} else {
|
||||
$currentContent = $currentContent.Substring(0, $currentDesktop.BodyStart) + $body +
|
||||
$currentContent.Substring($currentDesktop.BodyStart + $currentDesktop.BodyLength)
|
||||
}
|
||||
Write-DreamSkinUtf8FileAtomically -Path $ConfigPath -Content $currentContent -ExpectedBytes $currentBytes
|
||||
}
|
||||
|
||||
function Restore-DreamSkinConfigBackup {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$ConfigPath,
|
||||
[Parameter(Mandatory = $true)][string]$BackupPath,
|
||||
[Parameter(Mandatory = $true)][string]$RecoveryBackupPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackupPath)) { throw 'No pre-install config backup is available.' }
|
||||
$backupBytes = [System.IO.File]::ReadAllBytes($BackupPath)
|
||||
$null = ConvertFrom-DreamSkinUtf8Bytes -Bytes $backupBytes -Path $BackupPath
|
||||
$currentBytes = $null
|
||||
if (Test-Path -LiteralPath $ConfigPath) {
|
||||
$currentBytes = [System.IO.File]::ReadAllBytes($ConfigPath)
|
||||
Write-DreamSkinBytesAtomically -Path $RecoveryBackupPath -Bytes $currentBytes -ExpectedBytes $null
|
||||
}
|
||||
|
||||
Write-DreamSkinBytesAtomically -Path $ConfigPath -Bytes $backupBytes -ExpectedBytes $currentBytes
|
||||
}
|
||||
|
||||
function Archive-DreamSkinConfigBackup {
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$BackupPath,
|
||||
[Parameter(Mandatory = $true)][string]$ArchivePath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackupPath)) { return }
|
||||
if (Test-Path -LiteralPath $ArchivePath) { throw "Config backup archive already exists: $ArchivePath" }
|
||||
Move-Item -LiteralPath $BackupPath -Destination $ArchivePath -ErrorAction Stop
|
||||
Remove-Item -LiteralPath (Get-DreamSkinAppearanceMarkerPath -BackupPath $BackupPath) -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
The official Windows release workflow places the pinned Node.js runtime here.
|
||||
|
||||
Version: 22.23.1
|
||||
Archive: node-v22.23.1-win-x64.zip
|
||||
SHA-256: 7df0bc9375723f4a86b3aa1b7cc73342423d9677a8df4538aca31a049e309c29
|
||||
Source: https://nodejs.org/dist/v22.23.1/node-v22.23.1-win-x64.zip
|
||||
|
||||
Local source builds may use Node.js 22 or newer from PATH instead.
|
||||
@@ -0,0 +1,174 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::ensure_directory;
|
||||
use crate::paths::app_home;
|
||||
use crate::sqlite_utils::table_column_set;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_db_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
static TEST_DB_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
TEST_DB_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("test app database lock poisoned")
|
||||
}
|
||||
|
||||
fn db_path() -> Result<PathBuf> {
|
||||
Ok(app_home()?.join("codexx.db"))
|
||||
}
|
||||
|
||||
fn ensure_sqlite_column(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
column: &str,
|
||||
alter_sql: &str,
|
||||
) -> Result<()> {
|
||||
let cols = table_column_set(conn, table)?;
|
||||
if cols.contains(column) {
|
||||
return Ok(());
|
||||
}
|
||||
match conn.execute(alter_sql, []) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
let message = e.to_string().to_ascii_lowercase();
|
||||
if message.contains("duplicate column") || message.contains("duplicate column name") {
|
||||
// Another running Codex-X process may have applied the same
|
||||
// lightweight migration between our PRAGMA check and ALTER.
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Database(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn open() -> Result<Connection> {
|
||||
let path = db_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let conn = Connection::open(&path).map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
api_key TEXT,
|
||||
toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses',
|
||||
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_providers_updated_at ON providers(updated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS prompts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_prompts_updated_at ON prompts(updated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS builtin_prompt_cache (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
checked_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS managed_mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
server_config TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS managed_skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
directory TEXT NOT NULL,
|
||||
source_path TEXT,
|
||||
content_hash TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
ensure_sqlite_column(
|
||||
&conn,
|
||||
"providers",
|
||||
"toml_config",
|
||||
"ALTER TABLE providers ADD COLUMN toml_config TEXT",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
&conn,
|
||||
"providers",
|
||||
"source",
|
||||
"ALTER TABLE providers ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
&conn,
|
||||
"providers",
|
||||
"source_id",
|
||||
"ALTER TABLE providers ADD COLUMN source_id TEXT",
|
||||
)?;
|
||||
conn.execute_batch(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_providers_source_identity
|
||||
ON providers(source, source_id)
|
||||
WHERE source_id IS NOT NULL;",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM prompts
|
||||
WHERE id LIKE 'external-%'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM prompts AS kept
|
||||
WHERE lower(kept.filename) = lower(prompts.filename)
|
||||
AND kept.id NOT LIKE 'external-%'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM prompts
|
||||
WHERE id LIKE 'external-%'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM prompts AS kept
|
||||
WHERE kept.content = prompts.content
|
||||
AND kept.id NOT LIKE 'external-%'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM prompts
|
||||
WHERE id LIKE 'external-%'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM prompts AS kept
|
||||
WHERE kept.content = prompts.content
|
||||
AND kept.id LIKE 'external-%'
|
||||
AND kept.rowid <> prompts.rowid
|
||||
AND (kept.updated_at > prompts.updated_at OR (kept.updated_at = prompts.updated_at AND kept.rowid > prompts.rowid))
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(conn)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use crate::constants::AGENTS_FILENAME;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, write_json};
|
||||
use crate::paths::app_home;
|
||||
use crate::prompts::agents_path;
|
||||
use crate::{auth_path, config_path};
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BackupMeta {
|
||||
pub(crate) id: String,
|
||||
pub(crate) action: String,
|
||||
pub(crate) created_at: String,
|
||||
pub(crate) codex_dir: String,
|
||||
pub(crate) config_path: String,
|
||||
pub(crate) auth_path: String,
|
||||
pub(crate) had_config: bool,
|
||||
pub(crate) had_auth: bool,
|
||||
#[serde(default)]
|
||||
pub(crate) agents_path: String,
|
||||
#[serde(default)]
|
||||
pub(crate) had_agents: bool,
|
||||
#[serde(default)]
|
||||
pub(crate) tracks_agents: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BackupEntry {
|
||||
id: String,
|
||||
action: String,
|
||||
created_at: String,
|
||||
path: String,
|
||||
had_config: bool,
|
||||
had_auth: bool,
|
||||
had_agents: bool,
|
||||
}
|
||||
|
||||
fn lexical_absolute_path(path: &Path) -> Result<PathBuf> {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.join(path)
|
||||
};
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in absolute.components() {
|
||||
match component {
|
||||
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
||||
Component::RootDir => normalized.push(component.as_os_str()),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
Component::Normal(part) => normalized.push(part),
|
||||
}
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Resolves every existing ancestor while retaining a normalized suffix. This
|
||||
/// compares missing CODEX_HOME targets without requiring the target to exist.
|
||||
fn normalized_path_identity(path: &Path) -> Result<PathBuf> {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.join(path)
|
||||
};
|
||||
for ancestor in absolute.ancestors() {
|
||||
match fs::canonicalize(ancestor) {
|
||||
Ok(canonical) => {
|
||||
let suffix = absolute
|
||||
.strip_prefix(ancestor)
|
||||
.expect("ancestor must be a path prefix");
|
||||
return lexical_absolute_path(&canonical.join(suffix));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(io_err(ancestor, error)),
|
||||
}
|
||||
}
|
||||
lexical_absolute_path(&absolute)
|
||||
}
|
||||
|
||||
fn same_path_identity(left: &Path, right: &Path) -> Result<bool> {
|
||||
let left = normalized_path_identity(left)?;
|
||||
let right = normalized_path_identity(right)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let left = left.to_string_lossy().replace('/', "\\");
|
||||
let right = right.to_string_lossy().replace('/', "\\");
|
||||
Ok(left.eq_ignore_ascii_case(&right))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Ok(left == right)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_backup_codex_dir(meta: &BackupMeta, codex_dir: &Path) -> Result<()> {
|
||||
let recorded = meta.codex_dir.trim();
|
||||
if recorded.is_empty() {
|
||||
return Err(CodexxError::Config("备份元数据缺少 CODEX_HOME".to_string()));
|
||||
}
|
||||
if same_path_identity(Path::new(recorded), codex_dir)? {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CodexxError::Config(format!(
|
||||
"备份属于其他 CODEX_HOME,拒绝恢复:备份为 {},当前为 {}",
|
||||
Path::new(recorded).display(),
|
||||
codex_dir.display()
|
||||
)))
|
||||
}
|
||||
|
||||
fn backup_root() -> Result<PathBuf> {
|
||||
Ok(app_home()?.join("backups"))
|
||||
}
|
||||
|
||||
pub(crate) fn action_backup_root(codex_dir: &Path) -> Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
Ok(codex_dir.join(".codexx-test-backups"))
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let _ = codex_dir;
|
||||
backup_root()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn create_backup(codex_dir: &Path, action: &str) -> Result<Option<String>> {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static BACKUP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let cfg = config_path(codex_dir);
|
||||
let auth = auth_path(codex_dir);
|
||||
let agents = agents_path(codex_dir);
|
||||
let had_config = cfg.exists();
|
||||
let had_auth = auth.exists();
|
||||
let had_agents = agents.exists();
|
||||
|
||||
let id = format!(
|
||||
"{}-{}-{}",
|
||||
Local::now().format("%Y%m%d-%H%M%S-%3f"),
|
||||
BACKUP_COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
action
|
||||
);
|
||||
let dir = action_backup_root(codex_dir)?.join(&id);
|
||||
ensure_directory(&dir)?;
|
||||
|
||||
if had_config {
|
||||
fs::copy(&cfg, dir.join("config.toml")).map_err(|e| io_err(&cfg, e))?;
|
||||
}
|
||||
if had_auth {
|
||||
fs::copy(&auth, dir.join("auth.json")).map_err(|e| io_err(&auth, e))?;
|
||||
}
|
||||
if had_agents {
|
||||
fs::copy(&agents, dir.join(AGENTS_FILENAME)).map_err(|e| io_err(&agents, e))?;
|
||||
}
|
||||
|
||||
let meta = BackupMeta {
|
||||
id: id.clone(),
|
||||
action: action.to_string(),
|
||||
created_at: Local::now().to_rfc3339(),
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
config_path: cfg.display().to_string(),
|
||||
auth_path: auth.display().to_string(),
|
||||
had_config,
|
||||
had_auth,
|
||||
agents_path: agents.display().to_string(),
|
||||
had_agents,
|
||||
tracks_agents: true,
|
||||
};
|
||||
write_json(
|
||||
&dir.join("meta.json"),
|
||||
&serde_json::to_value(meta).expect("meta serialize"),
|
||||
)?;
|
||||
Ok(Some(id))
|
||||
}
|
||||
|
||||
fn read_backup_entry(dir: &Path) -> Option<BackupEntry> {
|
||||
let meta_path = dir.join("meta.json");
|
||||
let text = fs::read_to_string(&meta_path).ok()?;
|
||||
let meta: BackupMeta = serde_json::from_str(&text).ok()?;
|
||||
Some(BackupEntry {
|
||||
id: meta.id,
|
||||
action: meta.action,
|
||||
created_at: meta.created_at,
|
||||
path: dir.display().to_string(),
|
||||
had_config: meta.had_config,
|
||||
had_auth: meta.had_auth,
|
||||
had_agents: meta.had_agents,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn backups() -> Result<Vec<BackupEntry>> {
|
||||
let root = backup_root()?;
|
||||
if !root.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
for entry in fs::read_dir(&root).map_err(|e| io_err(&root, e))? {
|
||||
let entry = entry.map_err(|e| io_err(&root, e))?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(backup) = read_backup_entry(&path) {
|
||||
entries.push(backup);
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
pub(crate) fn latest_backup() -> Result<Option<BackupEntry>> {
|
||||
Ok(backups()?.into_iter().next())
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::paths::home_dir;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn push_existing_candidate(candidates: &mut Vec<PathBuf>, candidate: Option<PathBuf>) {
|
||||
let Some(path) = candidate else {
|
||||
return;
|
||||
};
|
||||
if !candidates.iter().any(|item| item == &path) {
|
||||
candidates.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ccswitch_db_candidates() -> Result<Vec<PathBuf>> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Ok(value) = std::env::var("CC_SWITCH_HOME") {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(PathBuf::from(trimmed).join("cc-switch.db")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let home = home_dir()?;
|
||||
// cc-switch 当前主要使用这个位置,macOS/Windows/Linux 都适用。
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(home.join(".cc-switch").join("cc-switch.db")),
|
||||
);
|
||||
|
||||
// 兼容 Tauri/AppData 风格位置,防止未来或不同发行版变更数据目录。
|
||||
if let Some(data_dir) = dirs::data_dir() {
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(data_dir.join("com.ccswitch.desktop").join("cc-switch.db")),
|
||||
);
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(data_dir.join("cc-switch").join("cc-switch.db")),
|
||||
);
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(data_dir.join("CC Switch").join("cc-switch.db")),
|
||||
);
|
||||
}
|
||||
if let Some(data_local_dir) = dirs::data_local_dir() {
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(
|
||||
data_local_dir
|
||||
.join("com.ccswitch.desktop")
|
||||
.join("cc-switch.db"),
|
||||
),
|
||||
);
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(data_local_dir.join("cc-switch").join("cc-switch.db")),
|
||||
);
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(data_local_dir.join("CC Switch").join("cc-switch.db")),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(
|
||||
home.join("Library")
|
||||
.join("Application Support")
|
||||
.join("com.ccswitch.desktop")
|
||||
.join("cc-switch.db"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(
|
||||
PathBuf::from(appdata)
|
||||
.join("com.ccswitch.desktop")
|
||||
.join("cc-switch.db"),
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Ok(localappdata) = std::env::var("LOCALAPPDATA") {
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(
|
||||
PathBuf::from(localappdata)
|
||||
.join("com.ccswitch.desktop")
|
||||
.join("cc-switch.db"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME") {
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(
|
||||
PathBuf::from(xdg_data_home)
|
||||
.join("com.ccswitch.desktop")
|
||||
.join("cc-switch.db"),
|
||||
),
|
||||
);
|
||||
}
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(
|
||||
home.join(".local")
|
||||
.join("share")
|
||||
.join("com.ccswitch.desktop")
|
||||
.join("cc-switch.db"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
pub(crate) fn default_ccswitch_db_path() -> Result<PathBuf> {
|
||||
let candidates = ccswitch_db_candidates()?;
|
||||
candidates
|
||||
.iter()
|
||||
.find(|path| path.exists())
|
||||
.cloned()
|
||||
.or_else(|| candidates.into_iter().next())
|
||||
.ok_or_else(|| CodexxError::Config("无法生成 cc-switch 数据库候选路径".to_string()))
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
use crate::backups::create_backup;
|
||||
use crate::config_path;
|
||||
use crate::error::Result;
|
||||
use crate::file_io::parse_toml_document;
|
||||
use crate::live_config::{
|
||||
acquire_live_config_lock, atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot,
|
||||
};
|
||||
use std::path::Path;
|
||||
use toml_edit::{DocumentMut, Item, Table};
|
||||
|
||||
const INSTRUCTION_KEY: &str = "model_instructions_file";
|
||||
const MODEL_AVAILABILITY_NUX_KEY: &str = "model_availability_nux";
|
||||
|
||||
fn markdown_path(item: Option<&Item>) -> bool {
|
||||
item.and_then(|item| item.as_str())
|
||||
.map(str::trim)
|
||||
.is_some_and(|path| !path.is_empty() && path.to_ascii_lowercase().ends_with(".md"))
|
||||
}
|
||||
|
||||
fn tui_table(doc: &DocumentMut) -> Option<&Table> {
|
||||
doc.get("tui").and_then(|item| item.as_table())
|
||||
}
|
||||
|
||||
fn remove_markdown_path(table: &mut Table, key: &str) -> Option<Item> {
|
||||
if markdown_path(table.get(key)) {
|
||||
table.remove(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Repairs prompt paths appended by older Codex-X versions after a `[tui]`
|
||||
/// header. Only the exact legacy keys with Markdown path values are touched.
|
||||
pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
|
||||
let _lock = acquire_live_config_lock(codex_dir)?;
|
||||
migrate_legacy_prompt_config_locked(codex_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn migrated_legacy_prompt_config_text(cfg: &Path, text: &str) -> Result<Option<String>> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut doc = parse_toml_document(cfg, text)?;
|
||||
let nested_instruction = tui_table(&doc)
|
||||
.and_then(|tui| tui.get(MODEL_AVAILABILITY_NUX_KEY))
|
||||
.and_then(|item| item.as_table())
|
||||
.is_some_and(|nux| markdown_path(nux.get(INSTRUCTION_KEY)));
|
||||
let tui_instruction =
|
||||
tui_table(&doc).is_some_and(|tui| markdown_path(tui.get(INSTRUCTION_KEY)));
|
||||
let nux_as_instruction =
|
||||
tui_table(&doc).is_some_and(|tui| markdown_path(tui.get(MODEL_AVAILABILITY_NUX_KEY)));
|
||||
|
||||
if !nested_instruction && !tui_instruction && !nux_as_instruction {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let root_instruction_exists = doc.as_table().contains_key(INSTRUCTION_KEY);
|
||||
let removed_nested = doc
|
||||
.get_mut("tui")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
.and_then(|tui| tui.get_mut(MODEL_AVAILABILITY_NUX_KEY))
|
||||
.and_then(|item| item.as_table_mut())
|
||||
.and_then(|nux| remove_markdown_path(nux, INSTRUCTION_KEY));
|
||||
let removed_tui = doc
|
||||
.get_mut("tui")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
.and_then(|tui| remove_markdown_path(tui, INSTRUCTION_KEY));
|
||||
let removed_nux_value = doc
|
||||
.get_mut("tui")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
.and_then(|tui| remove_markdown_path(tui, MODEL_AVAILABILITY_NUX_KEY));
|
||||
|
||||
if !root_instruction_exists {
|
||||
let instruction = removed_nested
|
||||
.or(removed_tui)
|
||||
.or(removed_nux_value)
|
||||
.expect("a detected legacy prompt path must still be removable");
|
||||
doc.as_table_mut().insert(INSTRUCTION_KEY, instruction);
|
||||
}
|
||||
|
||||
Ok(Some(doc.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_legacy_prompt_config_locked(codex_dir: &Path) -> Result<bool> {
|
||||
let cfg = config_path(codex_dir);
|
||||
let original = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, original.as_deref())?;
|
||||
let Some(migrated) = migrated_legacy_prompt_config_text(&cfg, &text)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
create_backup(codex_dir, "migrate-legacy-prompt-config")?;
|
||||
|
||||
atomic_write_if_unchanged(&cfg, original.as_deref(), migrated.as_bytes())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backups::action_backup_root;
|
||||
use crate::file_io::write_text;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn temp_codex_dir(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-config-migration-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("create temp Codex directory");
|
||||
dir
|
||||
}
|
||||
|
||||
fn read_doc(codex_dir: &Path) -> DocumentMut {
|
||||
fs::read_to_string(config_path(codex_dir))
|
||||
.expect("read migrated config")
|
||||
.parse()
|
||||
.expect("parse migrated config")
|
||||
}
|
||||
|
||||
fn backup_count(codex_dir: &Path) -> usize {
|
||||
let root = action_backup_root(codex_dir).expect("resolve backup root");
|
||||
if !root.exists() {
|
||||
return 0;
|
||||
}
|
||||
fs::read_dir(root).expect("read backup root").count()
|
||||
}
|
||||
|
||||
fn only_backup_config(codex_dir: &Path) -> String {
|
||||
let root = action_backup_root(codex_dir).expect("resolve backup root");
|
||||
let entries = fs::read_dir(root)
|
||||
.expect("read backup root")
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.expect("read backup entries");
|
||||
assert_eq!(entries.len(), 1);
|
||||
fs::read_to_string(entries[0].path().join("config.toml")).expect("read backed-up config")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_instruction_nested_under_model_availability_nux() {
|
||||
let codex_dir = temp_codex_dir("nested-nux");
|
||||
let original = r#"model = "gpt-5.6"
|
||||
|
||||
[tui.model_availability_nux]
|
||||
# Keep valid model counters and their comments.
|
||||
"gpt-5.5" = 4
|
||||
model_instructions_file = "./legacy-prompt.md" # Keep the prompt note too.
|
||||
"gpt-5.6" = 2
|
||||
|
||||
[tui.notifications]
|
||||
enabled = true
|
||||
"#;
|
||||
write_text(&config_path(&codex_dir), original).expect("write legacy config");
|
||||
|
||||
assert!(migrate_legacy_prompt_config(&codex_dir).expect("migrate config"));
|
||||
|
||||
let migrated_text = fs::read_to_string(config_path(&codex_dir)).expect("read config");
|
||||
let doc = read_doc(&codex_dir);
|
||||
assert_eq!(
|
||||
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
|
||||
Some("./legacy-prompt.md")
|
||||
);
|
||||
let nux = doc["tui"][MODEL_AVAILABILITY_NUX_KEY]
|
||||
.as_table()
|
||||
.expect("keep model availability table");
|
||||
assert!(!nux.contains_key(INSTRUCTION_KEY));
|
||||
assert_eq!(
|
||||
nux.get("gpt-5.5").and_then(|item| item.as_integer()),
|
||||
Some(4)
|
||||
);
|
||||
assert_eq!(
|
||||
nux.get("gpt-5.6").and_then(|item| item.as_integer()),
|
||||
Some(2)
|
||||
);
|
||||
assert!(migrated_text.contains("# Keep valid model counters and their comments."));
|
||||
assert!(migrated_text.contains("# Keep the prompt note too."));
|
||||
assert_eq!(doc["tui"]["notifications"]["enabled"].as_bool(), Some(true));
|
||||
assert_eq!(backup_count(&codex_dir), 1);
|
||||
assert_eq!(only_backup_config(&codex_dir), original);
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_instruction_directly_under_tui() {
|
||||
let codex_dir = temp_codex_dir("direct-tui");
|
||||
write_text(
|
||||
&config_path(&codex_dir),
|
||||
"[tui]\nnotifications = true\nmodel_instructions_file = \"./direct.md\"\n",
|
||||
)
|
||||
.expect("write legacy config");
|
||||
|
||||
assert!(migrate_legacy_prompt_config(&codex_dir).expect("migrate config"));
|
||||
|
||||
let doc = read_doc(&codex_dir);
|
||||
assert_eq!(
|
||||
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
|
||||
Some("./direct.md")
|
||||
);
|
||||
assert!(doc["tui"].as_table().is_some_and(|tui| {
|
||||
!tui.contains_key(INSTRUCTION_KEY)
|
||||
&& tui.get("notifications").and_then(|item| item.as_bool()) == Some(true)
|
||||
}));
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_markdown_path_stored_as_tui_model_availability_nux() {
|
||||
let codex_dir = temp_codex_dir("nux-as-path");
|
||||
write_text(
|
||||
&config_path(&codex_dir),
|
||||
"[tui]\nmodel_availability_nux = \"./gpt-5.6-sol-unrestricted.md\"\n",
|
||||
)
|
||||
.expect("write legacy config");
|
||||
|
||||
assert!(migrate_legacy_prompt_config(&codex_dir).expect("migrate config"));
|
||||
|
||||
let doc = read_doc(&codex_dir);
|
||||
assert_eq!(
|
||||
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
|
||||
Some("./gpt-5.6-sol-unrestricted.md")
|
||||
);
|
||||
assert!(doc["tui"]
|
||||
.as_table()
|
||||
.is_some_and(|tui| { !tui.contains_key(MODEL_AVAILABILITY_NUX_KEY) }));
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_instruction_wins_over_legacy_nested_value() {
|
||||
let codex_dir = temp_codex_dir("root-wins");
|
||||
write_text(
|
||||
&config_path(&codex_dir),
|
||||
r#"model_instructions_file = "./current.md"
|
||||
|
||||
[tui.model_availability_nux]
|
||||
"gpt-5.5" = 4
|
||||
model_instructions_file = "./stale.md"
|
||||
"#,
|
||||
)
|
||||
.expect("write conflicting config");
|
||||
|
||||
assert!(migrate_legacy_prompt_config(&codex_dir).expect("migrate config"));
|
||||
|
||||
let doc = read_doc(&codex_dir);
|
||||
assert_eq!(
|
||||
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
|
||||
Some("./current.md")
|
||||
);
|
||||
assert!(!doc["tui"][MODEL_AVAILABILITY_NUX_KEY]
|
||||
.as_table()
|
||||
.expect("keep nux table")
|
||||
.contains_key(INSTRUCTION_KEY));
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_legal_tui_values_unchanged_without_backup() {
|
||||
let codex_dir = temp_codex_dir("legal-tui");
|
||||
let original = "[tui]\nmodel_availability_nux = 4\nnotifications = true\n";
|
||||
write_text(&config_path(&codex_dir), original).expect("write legal config");
|
||||
|
||||
assert!(!migrate_legacy_prompt_config(&codex_dir).expect("inspect config"));
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&codex_dir)).expect("read unchanged config"),
|
||||
original
|
||||
);
|
||||
assert_eq!(backup_count(&codex_dir), 0);
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_is_idempotent_and_backs_up_only_once() {
|
||||
let codex_dir = temp_codex_dir("idempotent");
|
||||
write_text(
|
||||
&config_path(&codex_dir),
|
||||
"[tui]\nmodel_instructions_file = \"./once.md\"\n",
|
||||
)
|
||||
.expect("write legacy config");
|
||||
|
||||
assert!(migrate_legacy_prompt_config(&codex_dir).expect("first migration"));
|
||||
let after_first = fs::read_to_string(config_path(&codex_dir)).expect("read first result");
|
||||
assert!(!migrate_legacy_prompt_config(&codex_dir).expect("second migration"));
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&codex_dir)).expect("read second result"),
|
||||
after_first
|
||||
);
|
||||
assert_eq!(backup_count(&codex_dir), 1);
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_codex_state_runs_the_migration() {
|
||||
let codex_dir = temp_codex_dir("state-load");
|
||||
write_text(
|
||||
&config_path(&codex_dir),
|
||||
"[tui]\nmodel_availability_nux = \"./loaded.md\"\n",
|
||||
)
|
||||
.expect("write legacy config");
|
||||
|
||||
crate::state::build_state(codex_dir.clone()).expect("load Codex state");
|
||||
|
||||
let doc = read_doc(&codex_dir);
|
||||
assert_eq!(
|
||||
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
|
||||
Some("./loaded.md")
|
||||
);
|
||||
assert_eq!(backup_count(&codex_dir), 1);
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,25 @@ pub(crate) const INSTRUCTION_JELI_FILENAME: &str = "gpt5.5-jeli.md";
|
||||
pub(crate) const INSTRUCTION_JELI_CONTENT: &str =
|
||||
include_str!("../../../../examples/gpt5.5-jeli.md");
|
||||
|
||||
pub(crate) const INSTRUCTION_56_SOL_FILENAME: &str = "gpt-5.6-sol-unrestricted.md";
|
||||
pub(crate) const INSTRUCTION_56_SOL_CONTENT: &str =
|
||||
include_str!("../../../../examples/gpt-5.6-sol-unrestricted.md");
|
||||
|
||||
pub(crate) const INSTRUCTION_SEAGULL_FILENAME: &str = "海鸥3.0破甲.md";
|
||||
pub(crate) const INSTRUCTION_SEAGULL_CONTENT: &str =
|
||||
include_str!("../../../../examples/海鸥3.0破甲.md");
|
||||
|
||||
pub(crate) const AGENTS_FILENAME: &str = "AGENTS.md";
|
||||
pub(crate) const AGENTS_MANAGED_BEGIN: &str = "<!-- CODEX-X:INSTRUCTIONS:BEGIN -->";
|
||||
pub(crate) const AGENTS_MANAGED_END: &str = "<!-- CODEX-X:INSTRUCTIONS:END -->";
|
||||
pub(crate) const AGENTS_TEMPLATE_PREFIX: &str = "<!-- CODEX-X:TEMPLATE:";
|
||||
pub(crate) const JSDELIVR_EXAMPLES_API: &str =
|
||||
"https://data.jsdelivr.com/v1/packages/gh/yynxxxxx/Codex-X@main?structure=flat";
|
||||
pub(crate) const JSDELIVR_EXAMPLES_BASE: &str =
|
||||
"https://cdn.jsdelivr.net/gh/yynxxxxx/Codex-X@main/examples/";
|
||||
pub(crate) const GITHUB_EXAMPLES_API: &str =
|
||||
"https://api.github.com/repos/yynxxxxx/Codex-X/contents/examples?ref=main";
|
||||
pub(crate) const GITHUB_EXAMPLES_BASE: &str =
|
||||
"https://raw.githubusercontent.com/yynxxxxx/Codex-X/main/examples/";
|
||||
|
||||
pub(crate) const MAX_SKILL_ZIP_BYTES: u64 = 20 * 1024 * 1024;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager, WindowEvent,
|
||||
};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const TRAY_ID: &str = "codex-x-tray";
|
||||
const SHOW_WINDOW_MENU_ID: &str = "show-main-window";
|
||||
const QUIT_APP_MENU_ID: &str = "quit-codex-x";
|
||||
const SHOW_TRAY_MENU_ON_LEFT_CLICK: bool = cfg!(target_os = "macos");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_macos_tray_mode(app: &tauri::AppHandle, dock_visible: bool) -> tauri::Result<()> {
|
||||
let policy = if dock_visible {
|
||||
tauri::ActivationPolicy::Regular
|
||||
} else {
|
||||
tauri::ActivationPolicy::Accessory
|
||||
};
|
||||
|
||||
// Attempt both operations: either one alone can leave a stale Dock entry on some macOS versions.
|
||||
let dock_result = app.set_dock_visibility(dock_visible);
|
||||
let policy_result = app.set_activation_policy(policy);
|
||||
dock_result?;
|
||||
policy_result
|
||||
}
|
||||
|
||||
fn retain_first_error(first_error: &mut Option<tauri::Error>, result: tauri::Result<()>) {
|
||||
if let Err(error) = result {
|
||||
if first_error.is_none() {
|
||||
*first_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> {
|
||||
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut first_error = None;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
retain_first_error(&mut first_error, window.set_skip_taskbar(false));
|
||||
#[cfg(target_os = "macos")]
|
||||
retain_first_error(&mut first_error, app.show());
|
||||
|
||||
retain_first_error(&mut first_error, window.unminimize());
|
||||
retain_first_error(&mut first_error, window.show());
|
||||
retain_first_error(&mut first_error, window.set_focus());
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
retain_first_error(&mut first_error, set_macos_tray_mode(app, true));
|
||||
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
pub(crate) fn restore_main_window(app: &tauri::AppHandle) {
|
||||
if let Err(error) = show_main_window(app) {
|
||||
eprintln!("failed to restore the Codex-X window: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn setup_system_tray(app: &tauri::App) -> tauri::Result<()> {
|
||||
let show_window =
|
||||
MenuItem::with_id(app, SHOW_WINDOW_MENU_ID, "显示 Codex-X", true, None::<&str>)?;
|
||||
let quit_app = MenuItem::with_id(app, QUIT_APP_MENU_ID, "退出 Codex-X", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show_window, &quit_app])?;
|
||||
|
||||
let mut tray = TrayIconBuilder::with_id(TRAY_ID)
|
||||
.menu(&menu)
|
||||
.tooltip("Codex-X")
|
||||
.show_menu_on_left_click(SHOW_TRAY_MENU_ON_LEFT_CLICK)
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
SHOW_WINDOW_MENU_ID => restore_main_window(app),
|
||||
QUIT_APP_MENU_ID => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if !SHOW_TRAY_MENU_ON_LEFT_CLICK
|
||||
&& matches!(
|
||||
event,
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
}
|
||||
)
|
||||
{
|
||||
restore_main_window(tray.app_handle());
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(icon) = app.default_window_icon().cloned() {
|
||||
tray = tray.icon(icon);
|
||||
}
|
||||
|
||||
tray.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn handle_window_event(window: &tauri::Window, event: &WindowEvent) {
|
||||
if window.label() != MAIN_WINDOW_LABEL {
|
||||
return;
|
||||
}
|
||||
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
if let Err(error) = window.hide() {
|
||||
eprintln!("failed to hide the Codex-X window: {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Err(error) = window.set_skip_taskbar(true) {
|
||||
eprintln!("failed to remove Codex-X from the taskbar: {error}");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Err(error) = set_macos_tray_mode(window.app_handle(), false) {
|
||||
eprintln!("failed to move Codex-X to the menu bar: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Reopen { .. } = event {
|
||||
restore_main_window(app);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = (app, event);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use serde::Serializer;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum CodexxError {
|
||||
#[error("无法获取用户主目录")]
|
||||
NoHomeDir,
|
||||
#[error("IO error at {path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("TOML parse error at {path}: {message}")]
|
||||
Toml { path: String, message: String },
|
||||
#[error("JSON error at {path}: {source}")]
|
||||
Json {
|
||||
path: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("配置错误: {0}")]
|
||||
Config(String),
|
||||
#[error("SQLite error: {0}")]
|
||||
Database(String),
|
||||
}
|
||||
|
||||
pub(crate) type Result<T> = std::result::Result<T, CodexxError>;
|
||||
|
||||
impl serde::Serialize for CodexxError {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,511 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use chrono::Local;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
pub(crate) fn io_err(path: &Path, source: std::io::Error) -> CodexxError {
|
||||
CodexxError::Io {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn json_err(path: &Path, source: serde_json::Error) -> CodexxError {
|
||||
CodexxError::Json {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn metadata_is_file_link(metadata: &fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::FileTypeExt;
|
||||
metadata.file_type().is_symlink_file()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn metadata_is_file_link(_metadata: &fs::Metadata) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn metadata_is_directory_link(metadata: &fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::FileTypeExt;
|
||||
metadata.file_type().is_symlink_dir()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn metadata_is_directory_link(_metadata: &fs::Metadata) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn metadata_is_directory_entry(metadata: &fs::Metadata) -> bool {
|
||||
metadata.is_dir() && !metadata_is_directory_link(metadata)
|
||||
}
|
||||
|
||||
pub(crate) fn directory_exists(path: &Path) -> bool {
|
||||
let Ok(metadata) = fs::symlink_metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
metadata_is_directory_entry(&metadata)
|
||||
|| (!metadata_is_file_link(&metadata)
|
||||
&& (metadata_is_directory_link(&metadata) || metadata.file_type().is_symlink())
|
||||
&& fs::metadata(path).is_ok_and(|target| target.is_dir()))
|
||||
}
|
||||
|
||||
fn existing_directory_entry(path: &Path, metadata: &fs::Metadata) -> Result<()> {
|
||||
if metadata_is_directory_entry(metadata) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if metadata_is_file_link(metadata) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"此链接被创建成了文件链接,不能作为文件夹使用:{}。请在创建链接的工具中重新建立目录链接",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
if metadata_is_directory_link(metadata) || metadata.file_type().is_symlink() {
|
||||
return match fs::metadata(path) {
|
||||
Ok(target) if target.is_dir() => Ok(()),
|
||||
_ => Err(CodexxError::Config(format!(
|
||||
"文件夹链接已失效或目标不是文件夹:{}",
|
||||
path.display()
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
Err(CodexxError::Config(format!(
|
||||
"此路径已被同名文件占用,不是文件夹:{}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_directory(path: &Path) -> Result<()> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => existing_directory_entry(path, &metadata),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
match fs::create_dir_all(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(create_error) => match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => existing_directory_entry(path, &metadata),
|
||||
Err(_) => Err(io_err(path, create_error)),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(error) => Err(io_err(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_to_string_if_exists(path: &Path) -> Result<String> {
|
||||
if !path.exists() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
fs::read_to_string(path).map_err(|e| io_err(path, e))
|
||||
}
|
||||
|
||||
pub(crate) fn harden_sensitive_file_permissions(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let metadata = match fs::metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(io_err(path, error)),
|
||||
};
|
||||
if !metadata.is_file() || metadata.permissions().mode() & 0o777 == 0o600 {
|
||||
return Ok(());
|
||||
}
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_toml_document(path: &Path, text: &str) -> Result<DocumentMut> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(DocumentMut::new());
|
||||
}
|
||||
text.parse::<DocumentMut>().map_err(|e| CodexxError::Toml {
|
||||
path: path.display().to_string(),
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn atomic_write_mode(path: &Path) -> Option<u32> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let sensitive = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| matches!(name, "config.toml" | "auth.json"));
|
||||
if sensitive {
|
||||
return Some(0o600);
|
||||
}
|
||||
fs::metadata(path)
|
||||
.ok()
|
||||
.map(|metadata| metadata.permissions().mode() & 0o777)
|
||||
}
|
||||
|
||||
fn create_atomic_temp(path: &Path, tmp: &Path, private: bool) -> Result<fs::File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
|
||||
let mode = private.then_some(0o600).or_else(|| atomic_write_mode(path));
|
||||
if let Some(mode) = mode {
|
||||
options.mode(mode);
|
||||
let file = options.open(tmp).map_err(|error| io_err(tmp, error))?;
|
||||
fs::set_permissions(tmp, fs::Permissions::from_mode(mode))
|
||||
.map_err(|error| io_err(tmp, error))?;
|
||||
return Ok(file);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
|
||||
options.open(tmp).map_err(|error| io_err(tmp, error))
|
||||
}
|
||||
|
||||
fn atomic_write_with_privacy_and_check<F>(
|
||||
path: &Path,
|
||||
bytes: &[u8],
|
||||
private: bool,
|
||||
pre_commit: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnOnce() -> Result<()>,
|
||||
{
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static WRITE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension(format!(
|
||||
"tmp.{}.{}.{}",
|
||||
std::process::id(),
|
||||
Local::now().timestamp_nanos_opt().unwrap_or_default(),
|
||||
WRITE_COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let result = (|| {
|
||||
let mut file = create_atomic_temp(path, &tmp, private)?;
|
||||
file.write_all(bytes).map_err(|e| io_err(&tmp, e))?;
|
||||
file.sync_all().map_err(|e| io_err(&tmp, e))?;
|
||||
drop(file);
|
||||
pre_commit()?;
|
||||
fs::rename(&tmp, path).map_err(|e| io_err(path, e))
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
atomic_write_with_privacy_and_check(path, bytes, false, || Ok(()))
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write_checked<F>(path: &Path, bytes: &[u8], pre_commit: F) -> Result<()>
|
||||
where
|
||||
F: FnOnce() -> Result<()>,
|
||||
{
|
||||
atomic_write_with_privacy_and_check(path, bytes, false, pre_commit)
|
||||
}
|
||||
|
||||
pub(crate) fn write_text(path: &Path, text: &str) -> Result<()> {
|
||||
atomic_write(path, text.as_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
let text = serde_json::to_string_pretty(value).map_err(|e| json_err(path, e))?;
|
||||
write_text(path, &(text + "\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn write_private_json(path: &Path, value: &Value) -> Result<()> {
|
||||
let text = serde_json::to_string_pretty(value).map_err(|e| json_err(path, e))?;
|
||||
atomic_write_with_privacy_and_check(path, (text + "\n").as_bytes(), true, || Ok(()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-file-io-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create test directory");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_replaces_existing_file_without_temp_residue() {
|
||||
let dir = temp_dir("replace");
|
||||
let path = dir.join("state.json");
|
||||
fs::write(&path, b"old").expect("write original file");
|
||||
|
||||
atomic_write(&path, b"new").expect("replace file atomically");
|
||||
|
||||
assert_eq!(fs::read(&path).expect("read replaced file"), b"new");
|
||||
let entries = fs::read_dir(&dir)
|
||||
.expect("read test directory")
|
||||
.map(|entry| entry.expect("read directory entry").file_name())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(entries, vec![path.file_name().unwrap().to_os_string()]);
|
||||
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_atomic_write_rejects_a_change_immediately_before_replace() {
|
||||
let dir = temp_dir("checked-race");
|
||||
let path = dir.join("config.toml");
|
||||
fs::write(&path, b"old").expect("write original file");
|
||||
|
||||
let error = atomic_write_checked(&path, b"codex-x", || {
|
||||
fs::write(&path, b"external").expect("simulate external writer");
|
||||
Err(CodexxError::Config("stale snapshot".to_string()))
|
||||
})
|
||||
.expect_err("stale checked write must fail");
|
||||
|
||||
assert!(error.to_string().contains("stale snapshot"));
|
||||
assert_eq!(fs::read(&path).expect("read external value"), b"external");
|
||||
assert_eq!(fs::read_dir(&dir).expect("read directory").count(), 1);
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn atomic_write_restricts_sensitive_codex_files_and_preserves_other_modes() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_dir("permissions");
|
||||
let config = dir.join("config.toml");
|
||||
let auth = dir.join("auth.json");
|
||||
let script = dir.join("tool.sh");
|
||||
for path in [&config, &auth, &script] {
|
||||
fs::write(path, b"old").expect("seed file");
|
||||
}
|
||||
fs::set_permissions(&config, fs::Permissions::from_mode(0o644)).expect("set config mode");
|
||||
fs::set_permissions(&auth, fs::Permissions::from_mode(0o644)).expect("set auth mode");
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).expect("set script mode");
|
||||
|
||||
atomic_write(&config, b"new config").expect("replace config");
|
||||
atomic_write(&auth, b"new auth").expect("replace auth");
|
||||
atomic_write(&script, b"new script").expect("replace script");
|
||||
|
||||
let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&config), 0o600);
|
||||
assert_eq!(mode(&auth), 0o600);
|
||||
assert_eq!(mode(&script), 0o755);
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn harden_sensitive_permissions_updates_existing_files_without_creating_missing_files() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_dir("harden-permissions");
|
||||
let config = dir.join("config.toml");
|
||||
let missing = dir.join("auth.json");
|
||||
fs::write(&config, b"model = \"gpt\"\n").expect("seed config");
|
||||
fs::set_permissions(&config, fs::Permissions::from_mode(0o644)).expect("set open mode");
|
||||
|
||||
harden_sensitive_file_permissions(&config).expect("harden config");
|
||||
harden_sensitive_file_permissions(&missing).expect("ignore missing auth");
|
||||
|
||||
assert_eq!(
|
||||
fs::metadata(&config).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert!(!missing.exists());
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn private_json_is_private_from_its_first_atomic_write() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_dir("private-json");
|
||||
let path = dir.join("official-snapshot.json");
|
||||
|
||||
write_private_json(&path, &serde_json::json!({"token": "secret"}))
|
||||
.expect("write private JSON");
|
||||
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_directory_accepts_existing_and_missing_directories() {
|
||||
let root = temp_dir("ensure-directory");
|
||||
let existing = root.join("existing");
|
||||
let missing = root.join("missing").join("nested");
|
||||
fs::create_dir(&existing).expect("create existing directory");
|
||||
|
||||
ensure_directory(&existing).expect("accept existing directory");
|
||||
ensure_directory(&missing).expect("create missing directory");
|
||||
|
||||
assert!(missing.is_dir());
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_directory_rejects_a_file_without_removing_it() {
|
||||
let root = temp_dir("ensure-directory-file");
|
||||
let occupied = root.join(".codex");
|
||||
fs::write(&occupied, "keep me").expect("create occupying file");
|
||||
|
||||
let error = ensure_directory(&occupied).expect_err("file is not a directory");
|
||||
|
||||
assert!(!directory_exists(&occupied));
|
||||
assert!(error.to_string().contains("不是文件夹"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(&occupied).expect("read occupying file"),
|
||||
"keep me"
|
||||
);
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_directory_accepts_a_directory_symlink_and_rejects_a_broken_link() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = temp_dir("ensure-directory-link");
|
||||
let target = root.join("target");
|
||||
let link = root.join("linked-codex-home");
|
||||
let broken = root.join("broken-codex-home");
|
||||
fs::create_dir(&target).expect("create target directory");
|
||||
symlink(&target, &link).expect("create directory symlink");
|
||||
symlink(root.join("missing-target"), &broken).expect("create broken symlink");
|
||||
|
||||
ensure_directory(&link).expect("accept directory symlink");
|
||||
atomic_write(&link.join("config.toml"), b"linked").expect("write through symlink");
|
||||
let error = ensure_directory(&broken).expect_err("reject broken symlink");
|
||||
|
||||
assert_eq!(
|
||||
fs::read(target.join("config.toml")).expect("read symlink target"),
|
||||
b"linked"
|
||||
);
|
||||
assert!(error.to_string().contains("链接已失效"));
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn ensure_directory_accepts_a_windows_directory_symlink() {
|
||||
use std::os::windows::fs::symlink_dir;
|
||||
|
||||
let root = temp_dir("ensure-directory-windows-link");
|
||||
let target = root.join("目标 文件夹");
|
||||
let link = root.join("linked-codex-home");
|
||||
fs::create_dir(&target).expect("create target directory");
|
||||
match symlink_dir(&target, &link) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.raw_os_error() == Some(1314) => {
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("create directory symlink: {error}"),
|
||||
}
|
||||
|
||||
ensure_directory(&link).expect("accept Windows directory symlink");
|
||||
assert!(directory_exists(&link));
|
||||
atomic_write(&link.join("config.toml"), b"first").expect("first linked write");
|
||||
atomic_write(&link.join("config.toml"), b"second").expect("replace linked file");
|
||||
|
||||
assert_eq!(
|
||||
fs::read(target.join("config.toml")).expect("read linked target"),
|
||||
b"second"
|
||||
);
|
||||
fs::remove_file(target.join("config.toml")).expect("remove target file");
|
||||
fs::remove_dir(&target).expect("remove symlink target");
|
||||
let error = ensure_directory(&link).expect_err("reject broken directory symlink");
|
||||
assert!(!directory_exists(&link));
|
||||
assert!(error.to_string().contains("链接已失效"));
|
||||
fs::remove_dir(&link).expect("remove directory symlink");
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn ensure_directory_accepts_a_windows_junction() {
|
||||
use std::process::Command;
|
||||
|
||||
let root = temp_dir("ensure-directory-windows-junction");
|
||||
let target = root.join("junction-target");
|
||||
let link = root.join("junction-codex-home");
|
||||
fs::create_dir(&target).expect("create junction target");
|
||||
let status = Command::new("cmd")
|
||||
.args(["/C", "mklink", "/J"])
|
||||
.arg(&link)
|
||||
.arg(&target)
|
||||
.status()
|
||||
.expect("run mklink");
|
||||
assert!(status.success(), "create directory junction");
|
||||
|
||||
ensure_directory(&link).expect("accept Windows junction");
|
||||
assert!(directory_exists(&link));
|
||||
atomic_write(&link.join("config.toml"), b"junction").expect("write through junction");
|
||||
|
||||
assert_eq!(
|
||||
fs::read(target.join("config.toml")).expect("read junction target"),
|
||||
b"junction"
|
||||
);
|
||||
fs::remove_dir(&link).expect("remove directory junction");
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn ensure_directory_rejects_a_file_link_to_a_directory_without_removing_it() {
|
||||
use std::os::windows::fs::symlink_file;
|
||||
|
||||
let root = temp_dir("ensure-directory-windows-file-link");
|
||||
let target = root.join("target");
|
||||
let link = root.join("linked-codex-home");
|
||||
fs::create_dir(&target).expect("create target directory");
|
||||
match symlink_file(&target, &link) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.raw_os_error() == Some(1314) => {
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("create file link: {error}"),
|
||||
}
|
||||
|
||||
let error = ensure_directory(&link).expect_err("reject file link as directory");
|
||||
|
||||
assert!(!directory_exists(&link));
|
||||
assert!(error.to_string().contains("文件链接"));
|
||||
assert!(fs::symlink_metadata(&link).is_ok());
|
||||
assert!(target.is_dir());
|
||||
fs::remove_file(&link).expect("remove file link");
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write_checked, ensure_directory, io_err};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) struct LiveConfigLock {
|
||||
file: fs::File,
|
||||
}
|
||||
|
||||
impl Drop for LiveConfigLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.file.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn acquire_live_config_lock(codex_dir: &Path) -> Result<LiveConfigLock> {
|
||||
let tmp_dir = codex_dir.join("tmp");
|
||||
ensure_directory(&tmp_dir)?;
|
||||
let path = tmp_dir.join("codex-x-live-config.lock");
|
||||
if path.is_dir() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex live 配置锁被同名目录占用: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|error| io_err(&path, error))?;
|
||||
file.try_lock().map_err(|_| {
|
||||
CodexxError::Config(format!(
|
||||
"另一个 Codex-X 正在修改 Codex live 配置,请稍后重试: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
file.set_len(0).map_err(|error| io_err(&path, error))?;
|
||||
writeln!(file, "pid={}", std::process::id()).map_err(|error| io_err(&path, error))?;
|
||||
file.sync_all().map_err(|error| io_err(&path, error))?;
|
||||
Ok(LiveConfigLock { file })
|
||||
}
|
||||
|
||||
pub(crate) fn read_file_snapshot(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(io_err(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn text_from_snapshot(path: &Path, snapshot: Option<&[u8]>) -> Result<String> {
|
||||
String::from_utf8(snapshot.unwrap_or_default().to_vec())
|
||||
.map_err(|_| CodexxError::Config(format!("{} 不是有效的 UTF-8 文本", path.display())))
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_file_snapshot_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<()> {
|
||||
if read_file_snapshot(path)?.as_deref() == expected {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CodexxError::Config(format!(
|
||||
"{} 已被其他程序修改,本次写入已取消,请刷新后重试",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write_if_unchanged(
|
||||
path: &Path,
|
||||
expected: Option<&[u8]>,
|
||||
replacement: &[u8],
|
||||
) -> Result<()> {
|
||||
atomic_write_checked(path, replacement, || {
|
||||
ensure_file_snapshot_unchanged(path, expected)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn remove_file_if_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<()> {
|
||||
ensure_file_snapshot_unchanged(path, expected)?;
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_err(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore_file_snapshot_if_unchanged(
|
||||
path: &Path,
|
||||
expected_current: Option<&[u8]>,
|
||||
snapshot: Option<&[u8]>,
|
||||
) -> Result<()> {
|
||||
match snapshot {
|
||||
Some(bytes) => atomic_write_if_unchanged(path, expected_current, bytes),
|
||||
None => remove_file_if_unchanged(path, expected_current),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AppliedFileChange {
|
||||
path: std::path::PathBuf,
|
||||
before: Option<Vec<u8>>,
|
||||
after: Option<Vec<u8>>,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
impl AppliedFileChange {
|
||||
pub(crate) fn rollback(&self) -> Result<()> {
|
||||
if !self.changed {
|
||||
return Ok(());
|
||||
}
|
||||
restore_file_snapshot_if_unchanged(
|
||||
&self.path,
|
||||
self.after.as_deref(),
|
||||
self.before.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_file_change(
|
||||
path: &Path,
|
||||
before: Option<Vec<u8>>,
|
||||
after: Option<Vec<u8>>,
|
||||
) -> Result<AppliedFileChange> {
|
||||
let changed = before != after;
|
||||
if changed {
|
||||
match after.as_deref() {
|
||||
Some(bytes) => atomic_write_if_unchanged(path, before.as_deref(), bytes)?,
|
||||
None => remove_file_if_unchanged(path, before.as_deref())?,
|
||||
}
|
||||
}
|
||||
Ok(AppliedFileChange {
|
||||
path: path.to_path_buf(),
|
||||
before,
|
||||
after,
|
||||
changed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn rollback_file_changes(changes: &[AppliedFileChange]) -> Result<()> {
|
||||
for change in changes.iter().filter(|change| change.changed) {
|
||||
ensure_file_snapshot_unchanged(&change.path, change.after.as_deref())?;
|
||||
}
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for change in changes.iter().rev() {
|
||||
if let Err(error) = change.rollback() {
|
||||
failures.push(error.to_string());
|
||||
}
|
||||
}
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(failures.join(";")))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fail_with_file_rollback<T>(
|
||||
error: CodexxError,
|
||||
changes: &[AppliedFileChange],
|
||||
) -> Result<T> {
|
||||
match rollback_file_changes(changes) {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(CodexxError::Config(format!(
|
||||
"{error};文件回滚失败:{rollback_error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-live-config-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create test directory");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_rejects_a_second_writer_and_releases_on_drop() {
|
||||
let dir = temp_dir("lock");
|
||||
let first = acquire_live_config_lock(&dir).expect("acquire first lock");
|
||||
let error = acquire_live_config_lock(&dir)
|
||||
.err()
|
||||
.expect("second lock must fail");
|
||||
assert!(error.to_string().contains("另一个 Codex-X"));
|
||||
drop(first);
|
||||
acquire_live_config_lock(&dir).expect("lock is released on drop");
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_checked_write_preserves_the_external_value() {
|
||||
let dir = temp_dir("stale");
|
||||
let path = dir.join("config.toml");
|
||||
fs::write(&path, b"old").expect("seed file");
|
||||
let old = read_file_snapshot(&path).expect("capture file");
|
||||
fs::write(&path, b"external").expect("simulate external writer");
|
||||
|
||||
atomic_write_if_unchanged(&path, old.as_deref(), b"codex-x")
|
||||
.expect_err("stale write must fail");
|
||||
|
||||
assert_eq!(fs::read(&path).expect("read file"), b"external");
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
#[cfg(test)]
|
||||
use chrono::Local;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(crate) fn home_dir() -> Result<PathBuf> {
|
||||
dirs::home_dir().ok_or(CodexxError::NoHomeDir)
|
||||
}
|
||||
|
||||
pub(crate) fn app_home() -> Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
use std::sync::OnceLock;
|
||||
static TEST_APP_HOME: OnceLock<PathBuf> = OnceLock::new();
|
||||
Ok(TEST_APP_HOME
|
||||
.get_or_init(|| {
|
||||
std::env::temp_dir().join(format!(
|
||||
"codex-x-test-home-{}-{}",
|
||||
std::process::id(),
|
||||
Local::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
))
|
||||
})
|
||||
.clone())
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
if let Ok(value) = std::env::var("CODEXX_HOME") {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
Ok(home_dir()?.join(".codexx"))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,12 @@ use std::process::{Command, Output};
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::env;
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
const WINDOWS_CODEX_PACKAGE_IDENTITIES: &[&str] =
|
||||
&["OpenAI.Codex", "OpenAI.CodexBeta", "OpenAI.ChatGPT-Desktop"];
|
||||
#[cfg(target_os = "windows")]
|
||||
const WINDOWS_CODEX_EXECUTABLES: &[&str] = &["ChatGPT.exe", "Codex.exe", "codex.exe"];
|
||||
|
||||
fn version_line(stdout: &str, stderr: &str, success: bool) -> Option<String> {
|
||||
let lines = stdout.lines().chain(stderr.lines()).map(str::trim);
|
||||
let preferred = lines.clone().find(|line| {
|
||||
@@ -44,7 +50,7 @@ fn version_from_output(output: Output) -> Option<String> {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn run_program(program: &Path, args: &[&str]) -> Option<Output> {
|
||||
pub fn program_command(program: &Path, args: &[&str]) -> Command {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
@@ -54,7 +60,7 @@ fn run_program(program: &Path, args: &[&str]) -> Option<Output> {
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"));
|
||||
let mut command = if is_script {
|
||||
let mut shell = Command::new("cmd.exe");
|
||||
let command_line = format!("\"{}\" {}", program.display(), args.join(" "));
|
||||
let command_line = format!("\"\"{}\" {}\"", program.display(), args.join(" "));
|
||||
shell.args(["/D", "/S", "/C"]).arg(command_line);
|
||||
shell
|
||||
} else {
|
||||
@@ -62,12 +68,19 @@ fn run_program(program: &Path, args: &[&str]) -> Option<Output> {
|
||||
direct.args(args);
|
||||
direct
|
||||
};
|
||||
command.creation_flags(CREATE_NO_WINDOW).output().ok()
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn program_command(program: &Path, args: &[&str]) -> Command {
|
||||
let mut command = Command::new(program);
|
||||
command.args(args);
|
||||
command
|
||||
}
|
||||
|
||||
fn run_program(program: &Path, args: &[&str]) -> Option<Output> {
|
||||
Command::new(program).args(args).output().ok()
|
||||
program_command(program, args).output().ok()
|
||||
}
|
||||
|
||||
fn command_version(program: &Path) -> Option<String> {
|
||||
@@ -91,6 +104,62 @@ fn push_candidate(candidates: &mut Vec<PathBuf>, seen: &mut HashSet<String>, pat
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn numeric_version(value: &str) -> Option<Vec<u32>> {
|
||||
let parts = value
|
||||
.split('.')
|
||||
.map(str::parse::<u32>)
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.ok()?;
|
||||
(parts.len() >= 2).then_some(parts)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn windows_package_version(package_name: &str) -> Option<(Vec<u32>, String)> {
|
||||
for identity in WINDOWS_CODEX_PACKAGE_IDENTITIES {
|
||||
let prefix_len = identity.len();
|
||||
if !package_name
|
||||
.get(..prefix_len)
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(identity))
|
||||
|| package_name.as_bytes().get(prefix_len) != Some(&b'_')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let version = package_name.get(prefix_len + 1..)?.split('_').next()?;
|
||||
return Some((numeric_version(version)?, version.to_string()));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn latest_windows_package_version<'a>(
|
||||
package_names: impl IntoIterator<Item = &'a str>,
|
||||
) -> Option<String> {
|
||||
package_names
|
||||
.into_iter()
|
||||
.filter_map(windows_package_version)
|
||||
.max_by(|left, right| left.0.cmp(&right.0))
|
||||
.map(|(_, version)| version)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_store_app_version_from_roots(roots: &[PathBuf]) -> Option<String> {
|
||||
let mut package_names = Vec::new();
|
||||
for root in roots {
|
||||
let Ok(entries) = fs::read_dir(root) else {
|
||||
continue;
|
||||
};
|
||||
package_names.extend(entries.flatten().filter_map(|entry| {
|
||||
entry
|
||||
.path()
|
||||
.is_dir()
|
||||
.then(|| entry.file_name().to_string_lossy().to_string())
|
||||
}));
|
||||
}
|
||||
latest_windows_package_version(package_names.iter().map(String::as_str))
|
||||
.map(|version| format!("Codex app {version}"))
|
||||
}
|
||||
|
||||
fn collect_named_files(root: &Path, names: &[&str], depth: usize, output: &mut Vec<PathBuf>) {
|
||||
if depth == 0 || !root.is_dir() {
|
||||
return;
|
||||
@@ -163,8 +232,13 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
PathBuf::from("/Applications/ChatGPT.app/Contents/Resources/codex"),
|
||||
home.join("Applications/ChatGPT.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"),
|
||||
home.join("Applications/Codex.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/OpenAI Codex.app/Contents/Resources/codex"),
|
||||
home.join("Applications/OpenAI Codex.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/OpenAI.Codex.app/Contents/Resources/codex"),
|
||||
home.join("Applications/OpenAI.Codex.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/Applications/ChatGPT Codex.app/Contents/Resources/codex"),
|
||||
home.join("Applications/ChatGPT Codex.app/Contents/Resources/codex"),
|
||||
PathBuf::from("/opt/homebrew/bin/codex"),
|
||||
PathBuf::from("/usr/local/bin/codex"),
|
||||
home.join(".local/bin/codex"),
|
||||
@@ -198,7 +272,9 @@ fn platform_candidates(home: &Path) -> Vec<PathBuf> {
|
||||
for root in [
|
||||
localappdata.join("Programs/ChatGPT"),
|
||||
localappdata.join("Programs/Codex"),
|
||||
localappdata.join("Programs/OpenAI/Codex"),
|
||||
localappdata.join("OpenAI/ChatGPT"),
|
||||
localappdata.join("OpenAI/Codex"),
|
||||
] {
|
||||
collect_named_files(&root, &["codex.exe", "codex.cmd"], 7, &mut candidates);
|
||||
}
|
||||
@@ -264,66 +340,151 @@ fn windows_where_candidates() -> Vec<PathBuf> {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_app_version() -> Option<String> {
|
||||
for app in [
|
||||
"/Applications/ChatGPT.app",
|
||||
"/Applications/Codex.app",
|
||||
"/Applications/OpenAI Codex.app",
|
||||
"/Applications/ChatGPT Codex.app",
|
||||
] {
|
||||
let Some(output) =
|
||||
run_program(Path::new("mdls"), &["-name", "kMDItemVersion", "-raw", app])
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !output.status.success() {
|
||||
continue;
|
||||
}
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !version.is_empty() && version != "(null)" {
|
||||
let app_name = if app.ends_with("ChatGPT.app") {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
for root in [PathBuf::from("/Applications"), home.join("Applications")] {
|
||||
for name in [
|
||||
"Codex.app",
|
||||
"OpenAI Codex.app",
|
||||
"OpenAI.Codex.app",
|
||||
"ChatGPT Codex.app",
|
||||
"ChatGPT.app",
|
||||
] {
|
||||
let app = root.join(name);
|
||||
if !app.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let app_name = if name == "ChatGPT.app" {
|
||||
"ChatGPT app"
|
||||
} else {
|
||||
"Codex app"
|
||||
};
|
||||
return Some(format!("{app_name} {version}"));
|
||||
if let Some(version) = macos_info_plist_version(&app).or_else(|| {
|
||||
let app = app.to_str()?;
|
||||
let output =
|
||||
run_program(Path::new("mdls"), &["-name", "kMDItemVersion", "-raw", app])?;
|
||||
output
|
||||
.status
|
||||
.success()
|
||||
.then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}) {
|
||||
if !version.is_empty() && version != "(null)" {
|
||||
return Some(format!("{app_name} {version}"));
|
||||
}
|
||||
}
|
||||
return Some(format!("{app_name} installed"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_info_plist_version(app: &Path) -> Option<String> {
|
||||
let plist = fs::read_to_string(app.join("Contents/Info.plist")).ok()?;
|
||||
plist_string_value(&plist, "CFBundleShortVersionString")
|
||||
.or_else(|| plist_string_value(&plist, "CFBundleVersion"))
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn plist_string_value(plist: &str, key: &str) -> Option<String> {
|
||||
let (_, after_key) = plist.split_once(&format!("<key>{key}</key>"))?;
|
||||
let (_, after_open) = after_key.split_once("<string>")?;
|
||||
let (value, _) = after_open.split_once("</string>")?;
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn macos_app_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn detect_codex_version() -> Option<String> {
|
||||
for command in ["codex", "codex.exe", "codex.cmd"] {
|
||||
if let Some(version) = command_version(Path::new(command)) {
|
||||
return Some(version);
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_app_version() -> Option<String> {
|
||||
let mut roots = Vec::new();
|
||||
for variable in ["ProgramFiles", "ProgramW6432"] {
|
||||
if let Ok(program_files) = env::var(variable) {
|
||||
roots.push(PathBuf::from(program_files).join("WindowsApps"));
|
||||
}
|
||||
}
|
||||
roots.push(PathBuf::from(r"C:\Program Files\WindowsApps"));
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
if let Some(version) = windows_store_app_version_from_roots(&roots) {
|
||||
return Some(version);
|
||||
}
|
||||
|
||||
let script = "Get-AppxPackage | Where-Object { $_.Name -in @('OpenAI.Codex','OpenAI.CodexBeta','OpenAI.ChatGPT-Desktop') } | ForEach-Object { $_.Version.ToString() }";
|
||||
if let Some(output) = run_program(
|
||||
Path::new("powershell.exe"),
|
||||
&["-NoProfile", "-NonInteractive", "-Command", script],
|
||||
) {
|
||||
if output.status.success() {
|
||||
let versions = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.filter_map(|version| {
|
||||
numeric_version(version).map(|parsed| (parsed, version.to_string()))
|
||||
})
|
||||
.max_by(|left, right| left.0.cmp(&right.0));
|
||||
if let Some((_, version)) = versions {
|
||||
return Some(format!("Codex app {version}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let local_appdata = env::var("LOCALAPPDATA").ok().map(PathBuf::from)?;
|
||||
for directory in [
|
||||
local_appdata.join("OpenAI/Codex/bin"),
|
||||
local_appdata.join("OpenAI/Codex"),
|
||||
local_appdata.join("Programs/OpenAI/Codex"),
|
||||
local_appdata.join("Programs/Codex"),
|
||||
] {
|
||||
if WINDOWS_CODEX_EXECUTABLES.iter().any(|name| {
|
||||
directory.join(name).is_file() || directory.join("app").join(name).is_file()
|
||||
}) {
|
||||
return Some("Codex app installed".to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn windows_app_version() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
pub fn codex_executable_candidates() -> Vec<PathBuf> {
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let mut candidates = windows_where_candidates();
|
||||
let mut candidates = ["codex", "codex.exe", "codex.cmd"]
|
||||
.into_iter()
|
||||
.map(PathBuf::from)
|
||||
.collect::<Vec<_>>();
|
||||
candidates.extend(windows_where_candidates());
|
||||
candidates.extend(platform_candidates(&home));
|
||||
let mut seen = HashSet::new();
|
||||
let mut unique = Vec::new();
|
||||
for candidate in candidates {
|
||||
push_candidate(&mut unique, &mut seen, candidate);
|
||||
}
|
||||
for candidate in unique {
|
||||
if candidate.is_file() {
|
||||
unique
|
||||
}
|
||||
|
||||
pub fn detect_codex_version() -> Option<String> {
|
||||
for candidate in codex_executable_candidates() {
|
||||
let is_bare_command = candidate.components().count() == 1;
|
||||
if is_bare_command || candidate.is_file() {
|
||||
if let Some(version) = command_version(&candidate) {
|
||||
return Some(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
macos_app_version()
|
||||
macos_app_version().or_else(windows_app_version)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::version_line;
|
||||
use super::{latest_windows_package_version, plist_string_value, version_line};
|
||||
|
||||
#[test]
|
||||
fn version_parser_prefers_codex_line_over_warning() {
|
||||
@@ -353,4 +514,28 @@ mod tests {
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_package_detection_accepts_supported_codex_packages() {
|
||||
assert_eq!(
|
||||
latest_windows_package_version([
|
||||
"OpenAI.Codex_1.2.3.4_x64__publisher",
|
||||
"OpenAI.CodexBeta_1.3.0.0_x64__publisher",
|
||||
"Other.App_99.0.0.0_x64__publisher",
|
||||
]),
|
||||
Some("1.3.0.0".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plist_parser_reads_codex_bundle_version() {
|
||||
let plist = r#"<plist><dict>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2026.204</string>
|
||||
</dict></plist>"#;
|
||||
assert_eq!(
|
||||
plist_string_value(plist, "CFBundleShortVersionString").as_deref(),
|
||||
Some("1.2026.204")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
use crate::constants::{
|
||||
AGENTS_FILENAME, AGENTS_MANAGED_BEGIN, AGENTS_MANAGED_END, AGENTS_TEMPLATE_PREFIX,
|
||||
};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::read_to_string_if_exists;
|
||||
#[cfg(test)]
|
||||
use crate::live_config::{apply_file_change, read_file_snapshot, text_from_snapshot};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn agents_path(codex_dir: &Path) -> PathBuf {
|
||||
codex_dir.join(AGENTS_FILENAME)
|
||||
}
|
||||
|
||||
pub(crate) fn managed_agents_bounds(content: &str) -> Result<Option<(usize, usize)>> {
|
||||
let begins = content
|
||||
.match_indices(AGENTS_MANAGED_BEGIN)
|
||||
.map(|(index, _)| index)
|
||||
.collect::<Vec<_>>();
|
||||
let ends = content
|
||||
.match_indices(AGENTS_MANAGED_END)
|
||||
.map(|(index, _)| index)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if begins.is_empty() && ends.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if begins.len() != 1 || ends.len() != 1 || begins[0] >= ends[0] {
|
||||
return Err(CodexxError::Config(
|
||||
"AGENTS.md 中的 Codex-X 受管区块标记不完整或重复,请先修复 BEGIN/END 标记".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some((begins[0], ends[0] + AGENTS_MANAGED_END.len())))
|
||||
}
|
||||
|
||||
pub(crate) fn remove_managed_agents_block_from_content(content: &str) -> Result<(String, bool)> {
|
||||
let Some((start, end)) = managed_agents_bounds(content)? else {
|
||||
return Ok((content.to_string(), false));
|
||||
};
|
||||
let before = content[..start].trim_end();
|
||||
let after = content[end..].trim_start();
|
||||
let merged = match (before.is_empty(), after.is_empty()) {
|
||||
(true, true) => String::new(),
|
||||
(false, true) => format!("{}\n", before),
|
||||
(true, false) => format!("{}\n", after.trim_end()),
|
||||
(false, false) => format!("{}\n\n{}\n", before, after.trim_end()),
|
||||
};
|
||||
Ok((merged, true))
|
||||
}
|
||||
|
||||
pub(crate) fn install_managed_agents_block_in_content(
|
||||
existing: &str,
|
||||
template_key: &str,
|
||||
content: &str,
|
||||
) -> Result<String> {
|
||||
let (base, _) = remove_managed_agents_block_from_content(existing)?;
|
||||
let managed = format!(
|
||||
"{AGENTS_MANAGED_BEGIN}\n{AGENTS_TEMPLATE_PREFIX} {template_key} -->\n{}\n{AGENTS_MANAGED_END}",
|
||||
content.trim()
|
||||
);
|
||||
Ok(if base.trim().is_empty() {
|
||||
format!("{managed}\n")
|
||||
} else {
|
||||
format!("{}\n\n{managed}\n", base.trim_end())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn managed_agents_template_key_from_content(content: &str) -> Option<String> {
|
||||
let (start, end) = managed_agents_bounds(content).ok().flatten()?;
|
||||
content[start..end].lines().find_map(|line| {
|
||||
line.trim()
|
||||
.strip_prefix(AGENTS_TEMPLATE_PREFIX)
|
||||
.and_then(|value| value.strip_suffix("-->"))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn managed_agents_template_key(codex_dir: &Path) -> Result<Option<String>> {
|
||||
let path = agents_path(codex_dir);
|
||||
let content = read_to_string_if_exists(&path)?;
|
||||
Ok(managed_agents_template_key_from_content(&content))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_managed_agents_block(
|
||||
codex_dir: &Path,
|
||||
template_key: &str,
|
||||
content: &str,
|
||||
) -> Result<()> {
|
||||
let path = agents_path(codex_dir);
|
||||
let before = read_file_snapshot(&path)?;
|
||||
let existing = text_from_snapshot(&path, before.as_deref())?;
|
||||
let next = install_managed_agents_block_in_content(&existing, template_key, content)?;
|
||||
apply_file_change(&path, before, Some(next.into_bytes()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn uninstall_managed_agents_block(codex_dir: &Path) -> Result<bool> {
|
||||
let path = agents_path(codex_dir);
|
||||
let before = read_file_snapshot(&path)?;
|
||||
if before.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let existing = text_from_snapshot(&path, before.as_deref())?;
|
||||
let (next, removed) = remove_managed_agents_block_from_content(&existing)?;
|
||||
if !removed {
|
||||
return Ok(false);
|
||||
}
|
||||
let after = (!next.trim().is_empty()).then(|| next.into_bytes());
|
||||
apply_file_change(&path, before, after)?;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
mod catalog;
|
||||
mod managed_agents;
|
||||
mod store;
|
||||
mod types;
|
||||
|
||||
pub(crate) use catalog::{
|
||||
builtin_prompt_content, builtin_prompt_detail_inner, builtin_prompt_status_inner,
|
||||
bundled_prompt_meta, bundled_prompt_metas, refresh_builtin_prompts_with_active,
|
||||
};
|
||||
pub(crate) use managed_agents::{
|
||||
agents_path, install_managed_agents_block_in_content, managed_agents_bounds,
|
||||
managed_agents_template_key, remove_managed_agents_block_from_content,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use managed_agents::{install_managed_agents_block, uninstall_managed_agents_block};
|
||||
pub(crate) use store::{
|
||||
delete_prompt_inner, get_saved_prompt_inner, list_saved_prompts_inner,
|
||||
normalize_prompt_filename, save_builtin_prompt_override_inner, save_prompt_inner,
|
||||
};
|
||||
pub(crate) use types::{BuiltinPromptDetail, BuiltinPromptStatus, SavedPrompt};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use catalog::{
|
||||
cached_prompt_fallback_statuses, delete_cached_prompt_ids, github_prompt_catalog_from_entries,
|
||||
jsdelivr_prompt_catalog_from_entries, prompt_content_source_urls, stable_remote_prompt_id,
|
||||
stale_cached_prompt_ids,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use managed_agents::managed_agents_template_key_from_content;
|
||||
#[cfg(test)]
|
||||
pub(crate) use types::{CachedBuiltinPrompt, GithubContentEntry};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{parse_toml_document, read_to_string_if_exists};
|
||||
use crate::paths::home_dir;
|
||||
use crate::{config_path, sanitize_id, string_value};
|
||||
use catalog::cached_builtin_prompts;
|
||||
use std::path::{Path, PathBuf};
|
||||
use store::find_saved_prompt_by_current_file;
|
||||
|
||||
fn builtin_prompt_id_for_filename(filename: &str) -> Result<Option<String>> {
|
||||
if let Some(meta) = bundled_prompt_metas()
|
||||
.into_iter()
|
||||
.find(|item| item.filename.eq_ignore_ascii_case(filename))
|
||||
{
|
||||
return Ok(Some(meta.id.to_string()));
|
||||
}
|
||||
Ok(cached_builtin_prompts()?
|
||||
.into_iter()
|
||||
.find(|item| item.filename.eq_ignore_ascii_case(filename))
|
||||
.map(|item| item.id))
|
||||
}
|
||||
|
||||
fn saved_prompt_id_for_filename(filename: &str) -> Result<Option<String>> {
|
||||
Ok(list_saved_prompts_inner()?
|
||||
.into_iter()
|
||||
.find(|item| item.filename.eq_ignore_ascii_case(filename))
|
||||
.map(|item| item.id))
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_template_key_for_instruction(value: &str) -> Result<Option<String>> {
|
||||
let normalized = value.replace('\\', "/");
|
||||
let filename = normalized.rsplit('/').next().unwrap_or(&normalized);
|
||||
if let Some(id) = builtin_prompt_id_for_filename(filename)? {
|
||||
return Ok(Some(format!("builtin:{id}")));
|
||||
}
|
||||
Ok(saved_prompt_id_for_filename(filename)?.map(|id| format!("saved:{id}")))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_instruction_path(codex_dir: &Path, value: &str) -> PathBuf {
|
||||
let trimmed = value.trim();
|
||||
let expanded = if trimmed == "~" {
|
||||
home_dir().unwrap_or_else(|_| codex_dir.to_path_buf())
|
||||
} else if let Some(rest) = trimmed.strip_prefix("~/") {
|
||||
home_dir()
|
||||
.map(|home| home.join(rest))
|
||||
.unwrap_or_else(|_| PathBuf::from(trimmed))
|
||||
} else {
|
||||
PathBuf::from(trimmed)
|
||||
};
|
||||
if expanded.is_absolute() {
|
||||
expanded
|
||||
} else {
|
||||
codex_dir.join(expanded)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remember_current_instruction_prompt(codex_dir: &Path) -> Result<Option<SavedPrompt>> {
|
||||
let cfg = config_path(codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
if text.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let Some(current) = string_value(&doc, "model_instructions_file") else {
|
||||
return Ok(None);
|
||||
};
|
||||
if prompt_template_key_for_instruction(¤t)?.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = resolve_instruction_path(codex_dir, ¤t);
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = read_to_string_if_exists(&path)?;
|
||||
if content.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|v| v.to_str())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| "external-prompt.md".to_string());
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or("external-prompt");
|
||||
let normalized_filename = normalize_prompt_filename(&file_name, "external-prompt");
|
||||
let existing = find_saved_prompt_by_current_file(&file_name, &content)?;
|
||||
let (id, title, filename) = existing
|
||||
.map(|prompt| (prompt.id, prompt.title, prompt.filename))
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
format!("external-{}", sanitize_id(stem)),
|
||||
format!("外部提示词 · {stem}"),
|
||||
normalized_filename,
|
||||
)
|
||||
});
|
||||
save_prompt_inner(SavedPrompt {
|
||||
id,
|
||||
title,
|
||||
filename,
|
||||
content,
|
||||
})
|
||||
.map(Some)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
use super::types::SavedPrompt;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::{now_rfc3339, open_db};
|
||||
use rusqlite::params;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub(crate) fn normalize_prompt_filename(input: &str, fallback: &str) -> String {
|
||||
let raw = input.trim().trim_end_matches(".md");
|
||||
let base = if raw.is_empty() { fallback } else { raw };
|
||||
let mut out = String::new();
|
||||
let mut last_dash = false;
|
||||
for ch in base.to_ascii_lowercase().chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
|
||||
out.push(ch);
|
||||
last_dash = false;
|
||||
} else if !last_dash {
|
||||
out.push('-');
|
||||
last_dash = true;
|
||||
}
|
||||
}
|
||||
let out = out.trim_matches('-');
|
||||
format!("{}.md", if out.is_empty() { "custom-prompt" } else { out })
|
||||
}
|
||||
|
||||
fn canonical_prompt_content(input: &str) -> String {
|
||||
input
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', "\n")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn list_saved_prompts_inner() -> Result<Vec<SavedPrompt>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, title, filename, content FROM prompts ORDER BY updated_at DESC, created_at DESC")
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(SavedPrompt {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let mut prompts = Vec::new();
|
||||
for row in rows {
|
||||
let prompt = row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let filename_key = prompt.filename.to_ascii_lowercase();
|
||||
let content_key = canonical_prompt_content(&prompt.content);
|
||||
let duplicate_index = prompts.iter().position(|existing: &SavedPrompt| {
|
||||
existing.filename.to_ascii_lowercase() == filename_key
|
||||
|| (canonical_prompt_content(&existing.content) == content_key
|
||||
&& (existing.id.starts_with("external-") || prompt.id.starts_with("external-")))
|
||||
});
|
||||
if let Some(index) = duplicate_index {
|
||||
let existing_is_external = prompts[index].id.starts_with("external-");
|
||||
let prompt_is_external = prompt.id.starts_with("external-");
|
||||
if existing_is_external && !prompt_is_external {
|
||||
prompts[index] = prompt;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
prompts.push(prompt);
|
||||
}
|
||||
Ok(prompts)
|
||||
}
|
||||
|
||||
pub(crate) fn save_prompt_inner(prompt: SavedPrompt) -> Result<SavedPrompt> {
|
||||
let conn = open_db()?;
|
||||
let now = now_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO prompts (id, title, filename, content, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
filename = excluded.filename,
|
||||
content = excluded.content,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
prompt.id,
|
||||
prompt.title,
|
||||
prompt.filename,
|
||||
prompt.content,
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
list_saved_prompts_inner()?
|
||||
.into_iter()
|
||||
.find(|p| p.id == prompt.id)
|
||||
.ok_or_else(|| CodexxError::Database("prompt saved but not found".to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn get_saved_prompt_inner(id: &str) -> Result<SavedPrompt> {
|
||||
list_saved_prompts_inner()?
|
||||
.into_iter()
|
||||
.find(|p| p.id == id)
|
||||
.ok_or_else(|| CodexxError::Config(format!("提示词不存在: {id}")))
|
||||
}
|
||||
|
||||
pub(crate) fn delete_prompt_inner(id: &str) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
conn.execute("DELETE FROM prompts WHERE id = ?1", params![id])
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn builtin_prompt_override_from_connection(
|
||||
conn: &rusqlite::Connection,
|
||||
template_id: &str,
|
||||
) -> Result<Option<String>> {
|
||||
match conn.query_row(
|
||||
"SELECT content FROM builtin_prompt_overrides WHERE template_id = ?1",
|
||||
[template_id],
|
||||
|row| row.get(0),
|
||||
) {
|
||||
Ok(content) => Ok(Some(content)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(CodexxError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_prompt_override_inner(template_id: &str) -> Result<Option<String>> {
|
||||
let conn = open_db()?;
|
||||
builtin_prompt_override_from_connection(&conn, template_id.trim())
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_prompt_override_ids_inner() -> Result<HashSet<String>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT template_id FROM builtin_prompt_overrides")
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get(0))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
rows.map(|row| row.map_err(|e| CodexxError::Database(e.to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_builtin_prompt_override_on_connection(
|
||||
conn: &rusqlite::Connection,
|
||||
template_id: &str,
|
||||
content: &str,
|
||||
) -> Result<()> {
|
||||
let id = template_id.trim();
|
||||
if id.is_empty() {
|
||||
return Err(CodexxError::Config("提示词模板标识不能为空".to_string()));
|
||||
}
|
||||
if content.trim().is_empty() {
|
||||
return Err(CodexxError::Config("提示词内容不能为空".to_string()));
|
||||
}
|
||||
let now = now_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO builtin_prompt_overrides (template_id, content, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?3)
|
||||
ON CONFLICT(template_id) DO UPDATE SET
|
||||
content = excluded.content,
|
||||
updated_at = excluded.updated_at",
|
||||
params![id, content, now],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn save_builtin_prompt_override_inner(template_id: &str, content: &str) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
save_builtin_prompt_override_on_connection(&conn, template_id, content)
|
||||
}
|
||||
|
||||
fn find_saved_prompt_by_content(content: &str) -> Result<Option<SavedPrompt>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, title, filename, content FROM prompts ORDER BY CASE WHEN id LIKE 'external-%' THEN 1 ELSE 0 END, updated_at DESC, created_at DESC")
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(SavedPrompt {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let target = canonical_prompt_content(content);
|
||||
for row in rows {
|
||||
let prompt = row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
if canonical_prompt_content(&prompt.content) == target {
|
||||
return Ok(Some(prompt));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(super) fn find_saved_prompt_by_current_file(
|
||||
filename: &str,
|
||||
content: &str,
|
||||
) -> Result<Option<SavedPrompt>> {
|
||||
if let Some(prompt) = find_saved_prompt_by_content(content)? {
|
||||
return Ok(Some(prompt));
|
||||
}
|
||||
let normalized_filename = normalize_prompt_filename(filename, "external-prompt");
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, title, filename, content FROM prompts
|
||||
WHERE lower(filename) = lower(?1)
|
||||
ORDER BY CASE WHEN id LIKE 'external-%' THEN 1 ELSE 0 END, updated_at DESC, created_at DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
match stmt.query_row([normalized_filename], |row| {
|
||||
Ok(SavedPrompt {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
})
|
||||
}) {
|
||||
Ok(mut prompt) => {
|
||||
if canonical_prompt_content(&prompt.content) != canonical_prompt_content(content) {
|
||||
prompt.content = content.to_string();
|
||||
}
|
||||
Ok(Some(prompt))
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(CodexxError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn override_connection() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open override database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create override table");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_prompt_override_can_be_edited_repeatedly() {
|
||||
let conn = override_connection();
|
||||
save_builtin_prompt_override_on_connection(&conn, "template", "first")
|
||||
.expect("save initial override");
|
||||
save_builtin_prompt_override_on_connection(&conn, "template", "second")
|
||||
.expect("update override");
|
||||
|
||||
assert_eq!(
|
||||
builtin_prompt_override_from_connection(&conn, "template")
|
||||
.expect("read override")
|
||||
.as_deref(),
|
||||
Some("second")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_prompt_override_rejects_empty_content() {
|
||||
let conn = override_connection();
|
||||
let error = save_builtin_prompt_override_on_connection(&conn, "template", " \n")
|
||||
.expect_err("reject empty override");
|
||||
assert!(error.to_string().contains("内容不能为空"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_prompt_override_survives_database_reopen() {
|
||||
let database_path = std::env::temp_dir().join(format!(
|
||||
"codexx-prompt-override-{}-{}.sqlite",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system time")
|
||||
.as_nanos()
|
||||
));
|
||||
{
|
||||
let conn = Connection::open(&database_path).expect("open override database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create override table");
|
||||
save_builtin_prompt_override_on_connection(&conn, "template", "local content")
|
||||
.expect("save override");
|
||||
}
|
||||
|
||||
let reopened = Connection::open(&database_path).expect("reopen override database");
|
||||
assert_eq!(
|
||||
builtin_prompt_override_from_connection(&reopened, "template")
|
||||
.expect("read persisted override")
|
||||
.as_deref(),
|
||||
Some("local content")
|
||||
);
|
||||
drop(reopened);
|
||||
std::fs::remove_file(database_path).expect("remove override database");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SavedPrompt {
|
||||
pub(crate) id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) filename: String,
|
||||
pub(crate) content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BuiltinPromptStatus {
|
||||
pub(crate) id: String,
|
||||
pub(crate) filename: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) subtitle: String,
|
||||
pub(crate) badge: String,
|
||||
pub(crate) source_url: String,
|
||||
pub(crate) cached: bool,
|
||||
pub(crate) updated: bool,
|
||||
pub(crate) content_source: String,
|
||||
pub(crate) sync_issue: Option<String>,
|
||||
pub(crate) checked_at: Option<String>,
|
||||
pub(crate) message: String,
|
||||
pub(crate) customized: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BuiltinPromptDetail {
|
||||
pub(crate) id: String,
|
||||
pub(crate) filename: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) content: String,
|
||||
pub(crate) customized: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct BundledPromptMeta {
|
||||
pub(super) id: &'static str,
|
||||
pub(super) filename: &'static str,
|
||||
pub(super) title: &'static str,
|
||||
pub(super) subtitle: &'static str,
|
||||
pub(super) badge: &'static str,
|
||||
pub(super) content: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CachedBuiltinPrompt {
|
||||
pub(crate) id: String,
|
||||
pub(crate) filename: String,
|
||||
pub(crate) source_url: String,
|
||||
pub(crate) content: String,
|
||||
pub(crate) checked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct GithubContentEntry {
|
||||
pub(crate) name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: String,
|
||||
pub(crate) download_url: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
use super::{
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
experimental_bearer_token_from_doc, list_saved_providers_on_connection,
|
||||
normalize_saved_provider, open_store, upsert_ccswitch_provider_on_connection,
|
||||
ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
use crate::ccswitch::{ccswitch_db_candidates, default_ccswitch_db_path};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::sqlite_utils::table_column_set;
|
||||
use crate::string_value;
|
||||
use rusqlite::{Connection, OpenFlags, TransactionBehavior};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use toml_edit::{DocumentMut, Table};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ImportResult {
|
||||
imported: usize,
|
||||
added: usize,
|
||||
updated: usize,
|
||||
merged: usize,
|
||||
skipped: usize,
|
||||
warnings: Vec<String>,
|
||||
providers: Vec<SavedProvider>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialAuthCandidate {
|
||||
auth_json: String,
|
||||
model: Option<String>,
|
||||
source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CcSwitchCodexRow {
|
||||
pub(crate) id: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) settings_config: String,
|
||||
pub(crate) category: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn is_official_ccswitch_row(row: &CcSwitchCodexRow) -> bool {
|
||||
row.id.trim().eq_ignore_ascii_case("codex-official")
|
||||
|| row
|
||||
.category
|
||||
.as_deref()
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("official"))
|
||||
}
|
||||
|
||||
pub(crate) fn read_ccswitch_codex_rows(conn: &Connection) -> Result<Vec<CcSwitchCodexRow>> {
|
||||
let provider_columns = table_column_set(conn, "providers")?;
|
||||
let category_column = if provider_columns.contains("category") {
|
||||
"category"
|
||||
} else {
|
||||
"NULL"
|
||||
};
|
||||
let provider_query = format!(
|
||||
"SELECT id, name, settings_config, {category_column} FROM providers
|
||||
WHERE app_type = 'codex' ORDER BY sort_index ASC, created_at ASC"
|
||||
);
|
||||
let mut stmt = conn
|
||||
.prepare(&provider_query)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(CcSwitchCodexRow {
|
||||
id: row.get::<_, String>(0)?,
|
||||
name: row.get::<_, String>(1)?,
|
||||
settings_config: row.get::<_, String>(2)?,
|
||||
category: row.get::<_, Option<String>>(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row.map_err(|e| CodexxError::Database(e.to_string()))?);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CcSwitchCodexSection {
|
||||
pub(crate) id: String,
|
||||
pub(crate) name: Option<String>,
|
||||
pub(crate) base_url: String,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) wire_api: String,
|
||||
pub(crate) requires_openai_auth: bool,
|
||||
pub(crate) experimental_bearer_token: Option<String>,
|
||||
}
|
||||
|
||||
fn table_string(table: &Table, key: &str) -> Option<String> {
|
||||
table
|
||||
.get(key)
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn ccswitch_auth_api_key(settings: &Value) -> Option<String> {
|
||||
settings
|
||||
.get("auth")
|
||||
.and_then(|v| v.get("OPENAI_API_KEY"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
pub(super) fn codex_section_from_table(
|
||||
id: &str,
|
||||
table: &Table,
|
||||
model: Option<String>,
|
||||
) -> Option<CcSwitchCodexSection> {
|
||||
let base_url = table_string(table, "base_url")?
|
||||
.trim_end_matches('/')
|
||||
.to_string();
|
||||
if base_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(CcSwitchCodexSection {
|
||||
id: id.to_string(),
|
||||
name: table_string(table, "name"),
|
||||
base_url,
|
||||
model,
|
||||
wire_api: table_string(table, "wire_api").unwrap_or_else(|| "responses".to_string()),
|
||||
requires_openai_auth: table
|
||||
.get("requires_openai_auth")
|
||||
.and_then(|item| item.as_bool())
|
||||
.unwrap_or(false),
|
||||
experimental_bearer_token: table_string(table, "experimental_bearer_token"),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn codex_sections_from_config(config_text: &str) -> Vec<CcSwitchCodexSection> {
|
||||
let Ok(doc) = config_text.parse::<DocumentMut>() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let model = string_value(&doc, "model");
|
||||
let Some(providers) = doc.get("model_providers").and_then(|item| item.as_table()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
providers
|
||||
.iter()
|
||||
.filter_map(|(id, item)| {
|
||||
item.as_table()
|
||||
.and_then(|table| codex_section_from_table(id, table, model.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn select_ccswitch_section_for_row(
|
||||
row: &CcSwitchCodexRow,
|
||||
settings: &Value,
|
||||
global_sections: &HashMap<String, CcSwitchCodexSection>,
|
||||
) -> Option<CcSwitchCodexSection> {
|
||||
let provider_id = custom_provider_id(&row.id);
|
||||
if let Some(section) = global_sections.get(&provider_id) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
if let Some(section) = global_sections.get(row.id.trim()) {
|
||||
return Some(section.clone());
|
||||
}
|
||||
|
||||
let config_text = settings.get("config").and_then(Value::as_str).unwrap_or("");
|
||||
let doc = config_text.parse::<DocumentMut>().ok()?;
|
||||
let model = string_value(&doc, "model");
|
||||
let active_provider = string_value(&doc, "model_provider");
|
||||
let providers = doc.get("model_providers").and_then(|item| item.as_table());
|
||||
|
||||
if let Some(providers) = providers {
|
||||
for exact_id in [provider_id.as_str(), row.id.trim()] {
|
||||
if let Some(section) = providers
|
||||
.get(exact_id)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(exact_id, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
|
||||
if active_provider.as_deref() == Some(row.id.trim())
|
||||
|| active_provider.as_deref() == Some(provider_id.as_str())
|
||||
{
|
||||
if let Some(active) = active_provider.as_deref() {
|
||||
if let Some(section) = providers
|
||||
.get(active)
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table(active, table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy cc-switch/custom templates often store every third-party provider
|
||||
// under `[model_providers.custom]`. Only use it when the row's own config
|
||||
// explicitly activates custom or contains no other provider identity.
|
||||
if active_provider
|
||||
.as_deref()
|
||||
.is_none_or(|active| active == "custom")
|
||||
{
|
||||
if let Some(section) = providers
|
||||
.get("custom")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|table| codex_section_from_table("custom", table, model.clone()))
|
||||
{
|
||||
return Some(section);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doc.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|base_url| CcSwitchCodexSection {
|
||||
id: provider_id,
|
||||
name: None,
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
model,
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: false,
|
||||
experimental_bearer_token: experimental_bearer_token_from_doc(
|
||||
&doc,
|
||||
active_provider.as_deref(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_ccswitch_codex_provider(
|
||||
row: &CcSwitchCodexRow,
|
||||
global_sections: &HashMap<String, CcSwitchCodexSection>,
|
||||
) -> Option<SavedProvider> {
|
||||
let settings: Value = serde_json::from_str(&row.settings_config).ok()?;
|
||||
let section = select_ccswitch_section_for_row(row, &settings, global_sections)?;
|
||||
let api_key = ccswitch_auth_api_key(&settings).or(section.experimental_bearer_token.clone());
|
||||
Some(SavedProvider {
|
||||
id: custom_provider_id(&row.id),
|
||||
provider_name: if row.name.trim().is_empty() {
|
||||
section.name.unwrap_or_else(|| row.id.clone())
|
||||
} else {
|
||||
row.name.trim().to_string()
|
||||
},
|
||||
base_url: section.base_url,
|
||||
model: section.model.unwrap_or_else(|| "gpt-5.5".to_string()),
|
||||
api_key,
|
||||
toml_config: None,
|
||||
wire_api: section.wire_api,
|
||||
requires_openai_auth: section.requires_openai_auth,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Result<ImportResult> {
|
||||
let db = path
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or(default_ccswitch_db_path()?);
|
||||
|
||||
if !db.exists() {
|
||||
let candidates = ccswitch_db_candidates()?
|
||||
.into_iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n- ");
|
||||
return Err(CodexxError::Config(format!(
|
||||
"cc-switch 数据库不存在: {}\n已检查候选路径:\n- {}",
|
||||
db.display(),
|
||||
candidates
|
||||
)));
|
||||
}
|
||||
|
||||
let conn = Connection::open_with_flags(
|
||||
&db,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!("打开 cc-switch 数据库失败 {}: {e}", db.display()))
|
||||
})?;
|
||||
|
||||
let rows_vec = read_ccswitch_codex_rows(&conn)?;
|
||||
|
||||
let mut global_sections: HashMap<String, CcSwitchCodexSection> = HashMap::new();
|
||||
for row in &rows_vec {
|
||||
if is_official_ccswitch_row(row) {
|
||||
continue;
|
||||
}
|
||||
let Ok(settings) = serde_json::from_str::<Value>(&row.settings_config) else {
|
||||
continue;
|
||||
};
|
||||
let Some(config_text) = settings.get("config").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
for section in codex_sections_from_config(config_text) {
|
||||
if !global_sections.contains_key(§ion.id) {
|
||||
global_sections.insert(section.id.clone(), section);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut imported = 0usize;
|
||||
let mut added = 0usize;
|
||||
let mut updated = 0usize;
|
||||
let mut merged = 0usize;
|
||||
let mut skipped = 0usize;
|
||||
let mut warnings = Vec::new();
|
||||
let mut local_conn = open_store()?;
|
||||
let transaction = local_conn
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
merged += consolidate_legacy_provider_duplicates_on_connection(&transaction)?;
|
||||
|
||||
for row in rows_vec {
|
||||
if is_official_ccswitch_row(&row) {
|
||||
skipped += 1;
|
||||
warnings.push(format!(
|
||||
"跳过 {} ({}):官方认证不作为第三方供应商导入",
|
||||
row.name, row.id
|
||||
));
|
||||
continue;
|
||||
}
|
||||
match build_ccswitch_codex_provider(&row, &global_sections) {
|
||||
Some(provider) => {
|
||||
let provider = normalize_saved_provider(provider)?;
|
||||
let result =
|
||||
upsert_ccswitch_provider_on_connection(&transaction, provider, row.id.trim())?;
|
||||
match result.kind {
|
||||
ProviderUpsertKind::Added => added += 1,
|
||||
ProviderUpsertKind::Updated => updated += 1,
|
||||
ProviderUpsertKind::Merged => merged += 1,
|
||||
}
|
||||
imported += 1;
|
||||
}
|
||||
None => {
|
||||
skipped += 1;
|
||||
warnings.push(format!(
|
||||
"跳过 {} ({}):未找到可用 config/base_url,可能是官方登录或空模板",
|
||||
row.name, row.id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
merged += consolidate_legacy_provider_duplicates_on_connection(&transaction)?;
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let providers = list_saved_providers_on_connection(&local_conn)?;
|
||||
|
||||
Ok(ImportResult {
|
||||
imported,
|
||||
added,
|
||||
updated,
|
||||
merged,
|
||||
skipped,
|
||||
warnings,
|
||||
providers,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn read_ccswitch_official_auth_inner(
|
||||
path: Option<String>,
|
||||
) -> Result<Option<OfficialAuthCandidate>> {
|
||||
let db = path
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or(default_ccswitch_db_path()?);
|
||||
|
||||
if !db.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let conn = Connection::open_with_flags(
|
||||
&db,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!("打开 cc-switch 数据库失败 {}: {e}", db.display()))
|
||||
})?;
|
||||
|
||||
let provider_columns = table_column_set(&conn, "providers")?;
|
||||
let official_filter = if provider_columns.contains("category") {
|
||||
"id = 'codex-official' OR category = 'official'"
|
||||
} else {
|
||||
// Older cc-switch databases predate the category column. The stable
|
||||
// codex-official id is still enough to identify the official row.
|
||||
"id = 'codex-official'"
|
||||
};
|
||||
let query = format!(
|
||||
"SELECT id, name, settings_config FROM providers
|
||||
WHERE app_type = 'codex' AND ({official_filter})
|
||||
ORDER BY CASE WHEN id = 'codex-official' THEN 0 ELSE 1 END
|
||||
LIMIT 1"
|
||||
);
|
||||
let mut stmt = conn
|
||||
.prepare(&query)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
let Some(row) = rows
|
||||
.next()
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let id: String = row
|
||||
.get(0)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let name: String = row
|
||||
.get(1)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let settings_config: String = row
|
||||
.get(2)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let settings: Value = serde_json::from_str(&settings_config).map_err(|e| {
|
||||
CodexxError::Database(format!("cc-switch official settings JSON 解析失败: {e}"))
|
||||
})?;
|
||||
|
||||
let auth = settings
|
||||
.get("auth")
|
||||
.cloned()
|
||||
.filter(|value| value.is_object())
|
||||
.ok_or_else(|| {
|
||||
CodexxError::Database("cc-switch official provider 缺少 auth object".to_string())
|
||||
})?;
|
||||
|
||||
let model = settings
|
||||
.get("config")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|text| text.parse::<DocumentMut>().ok())
|
||||
.and_then(|doc| string_value(&doc, "model"));
|
||||
|
||||
let auth_json = serde_json::to_string_pretty(&auth)
|
||||
.map_err(|e| CodexxError::Database(format!("官方 auth JSON 格式化失败: {e}")))?;
|
||||
|
||||
Ok(Some(OfficialAuthCandidate {
|
||||
auth_json,
|
||||
model,
|
||||
source: format!("cc-switch:{name}:{id}"),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
#[cfg(test)]
|
||||
use crate::remote::ensure_crypto_provider;
|
||||
use crate::remote::{remote_client, remote_request_error, RemoteSource};
|
||||
use reqwest::blocking::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
|
||||
const MODELS_SOURCE_KEY: &str = "获取模型列表";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderConnectionResult {
|
||||
pub(crate) ok: bool,
|
||||
pub(crate) status: Option<u16>,
|
||||
pub(crate) message: String,
|
||||
pub(crate) duration_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderModel {
|
||||
pub(crate) id: String,
|
||||
pub(crate) created: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderModelsResult {
|
||||
pub(crate) models: Vec<ProviderModel>,
|
||||
pub(crate) status: u16,
|
||||
pub(crate) duration_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelsPayload {
|
||||
data: Vec<ModelPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelPayload {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
created: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
enum ProviderModelsAttempt {
|
||||
Success(ProviderModelsResult),
|
||||
HttpError { status: u16, duration_ms: u128 },
|
||||
}
|
||||
|
||||
fn provider_models_url(base_url: &str) -> Result<reqwest::Url> {
|
||||
let trimmed = base_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(CodexxError::Config("base_url 不能为空".to_string()));
|
||||
}
|
||||
|
||||
let mut url = reqwest::Url::parse(trimmed)
|
||||
.map_err(|_| CodexxError::Config("base_url 格式不正确".to_string()))?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
|
||||
return Err(CodexxError::Config(
|
||||
"base_url 必须是有效的 http:// 或 https:// 地址".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let segments = url
|
||||
.path_segments()
|
||||
.ok_or_else(|| CodexxError::Config("base_url 格式不正确".to_string()))?
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let already_models = segments.len() >= 2
|
||||
&& segments[segments.len() - 2].eq_ignore_ascii_case("v1")
|
||||
&& segments[segments.len() - 1].eq_ignore_ascii_case("models");
|
||||
let already_v1 = segments
|
||||
.last()
|
||||
.is_some_and(|segment| segment.eq_ignore_ascii_case("v1"));
|
||||
|
||||
{
|
||||
let mut path = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| CodexxError::Config("base_url 格式不正确".to_string()))?;
|
||||
path.pop_if_empty();
|
||||
if !already_models {
|
||||
if !already_v1 {
|
||||
path.push("v1");
|
||||
}
|
||||
path.push("models");
|
||||
}
|
||||
}
|
||||
url.set_fragment(None);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn parse_created(value: Option<serde_json::Value>) -> Option<i64> {
|
||||
value.and_then(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_str().and_then(|text| text.parse::<i64>().ok()))
|
||||
})
|
||||
}
|
||||
|
||||
fn compare_digit_runs(left: &[u8], right: &[u8]) -> Ordering {
|
||||
let left_significant = left
|
||||
.iter()
|
||||
.position(|byte| *byte != b'0')
|
||||
.map_or(&left[left.len()..], |index| &left[index..]);
|
||||
let right_significant = right
|
||||
.iter()
|
||||
.position(|byte| *byte != b'0')
|
||||
.map_or(&right[right.len()..], |index| &right[index..]);
|
||||
|
||||
left_significant
|
||||
.len()
|
||||
.cmp(&right_significant.len())
|
||||
.then_with(|| left_significant.cmp(right_significant))
|
||||
}
|
||||
|
||||
fn natural_model_id_cmp(left: &str, right: &str) -> Ordering {
|
||||
let left = left.as_bytes();
|
||||
let right = right.as_bytes();
|
||||
let (mut left_index, mut right_index) = (0, 0);
|
||||
|
||||
while left_index < left.len() && right_index < right.len() {
|
||||
if left[left_index].is_ascii_digit() && right[right_index].is_ascii_digit() {
|
||||
let left_end = left[left_index..]
|
||||
.iter()
|
||||
.position(|byte| !byte.is_ascii_digit())
|
||||
.map_or(left.len(), |offset| left_index + offset);
|
||||
let right_end = right[right_index..]
|
||||
.iter()
|
||||
.position(|byte| !byte.is_ascii_digit())
|
||||
.map_or(right.len(), |offset| right_index + offset);
|
||||
let order =
|
||||
compare_digit_runs(&left[left_index..left_end], &right[right_index..right_end]);
|
||||
if order != Ordering::Equal {
|
||||
return order;
|
||||
}
|
||||
left_index = left_end;
|
||||
right_index = right_end;
|
||||
continue;
|
||||
}
|
||||
|
||||
let order = left[left_index]
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&right[right_index].to_ascii_lowercase());
|
||||
if order != Ordering::Equal {
|
||||
return order;
|
||||
}
|
||||
left_index += 1;
|
||||
right_index += 1;
|
||||
}
|
||||
|
||||
left.len().cmp(&right.len()).then_with(|| left.cmp(right))
|
||||
}
|
||||
|
||||
fn parse_models(body: &str) -> Result<Vec<ProviderModel>> {
|
||||
let payload: ModelsPayload = serde_json::from_str(body)
|
||||
.map_err(|_| CodexxError::Config("模型列表返回格式不正确".to_string()))?;
|
||||
let mut models = Vec::<ProviderModel>::new();
|
||||
let mut indexes = HashMap::<String, usize>::new();
|
||||
|
||||
for model in payload.data {
|
||||
let id = model.id.trim();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let created = parse_created(model.created);
|
||||
if let Some(index) = indexes.get(id).copied() {
|
||||
if created > models[index].created {
|
||||
models[index].created = created;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
indexes.insert(id.to_string(), models.len());
|
||||
models.push(ProviderModel {
|
||||
id: id.to_string(),
|
||||
created,
|
||||
});
|
||||
}
|
||||
|
||||
models.sort_by(|left, right| {
|
||||
right
|
||||
.created
|
||||
.cmp(&left.created)
|
||||
.then_with(|| natural_model_id_cmp(&right.id, &left.id))
|
||||
});
|
||||
|
||||
Ok(models)
|
||||
}
|
||||
|
||||
fn request_provider_models_with_client(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
) -> Result<ProviderModelsAttempt> {
|
||||
let url = provider_models_url(base_url)?;
|
||||
let source = RemoteSource::new(MODELS_SOURCE_KEY, url.as_str(), Some("application/json"));
|
||||
let mut request = client
|
||||
.get(url.as_str())
|
||||
.header(reqwest::header::ACCEPT, "application/json");
|
||||
if let Some(api_key) = api_key.map(str::trim).filter(|key| !key.is_empty()) {
|
||||
request = request.bearer_auth(api_key);
|
||||
}
|
||||
|
||||
let started = Instant::now();
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|error| remote_request_error(&source, &error))?;
|
||||
let duration_ms = started.elapsed().as_millis();
|
||||
let status = response.status().as_u16();
|
||||
if !response.status().is_success() {
|
||||
return Ok(ProviderModelsAttempt::HttpError {
|
||||
status,
|
||||
duration_ms,
|
||||
});
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.map_err(|_| CodexxError::Config("模型列表读取失败".to_string()))?;
|
||||
Ok(ProviderModelsAttempt::Success(ProviderModelsResult {
|
||||
models: parse_models(&body)?,
|
||||
status,
|
||||
duration_ms,
|
||||
}))
|
||||
}
|
||||
|
||||
fn request_provider_models(base_url: &str, api_key: Option<&str>) -> Result<ProviderModelsAttempt> {
|
||||
let client = remote_client()?;
|
||||
request_provider_models_with_client(&client, base_url, api_key)
|
||||
}
|
||||
|
||||
pub(crate) fn provider_status_result(status: u16, duration_ms: u128) -> ProviderConnectionResult {
|
||||
ProviderConnectionResult {
|
||||
ok: (200..300).contains(&status),
|
||||
status: Some(status),
|
||||
message: if (200..300).contains(&status) {
|
||||
format!("{duration_ms} ms")
|
||||
} else if status == 401 || status == 403 {
|
||||
format!("HTTP {status} · {duration_ms} ms(认证失败或无权限)")
|
||||
} else {
|
||||
format!("HTTP {status} · {duration_ms} ms")
|
||||
},
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn test_provider_connection_inner(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
) -> Result<ProviderConnectionResult> {
|
||||
match request_provider_models(&base_url, api_key.as_deref())? {
|
||||
ProviderModelsAttempt::Success(result) => {
|
||||
Ok(provider_status_result(result.status, result.duration_ms))
|
||||
}
|
||||
ProviderModelsAttempt::HttpError {
|
||||
status,
|
||||
duration_ms,
|
||||
} => Ok(provider_status_result(status, duration_ms)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_provider_models_inner(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
) -> Result<ProviderModelsResult> {
|
||||
match request_provider_models(&base_url, api_key.as_deref())? {
|
||||
ProviderModelsAttempt::Success(result) => Ok(result),
|
||||
ProviderModelsAttempt::HttpError { status, .. } => {
|
||||
Err(CodexxError::Config(if matches!(status, 401 | 403) {
|
||||
format!("获取模型列表失败(HTTP {status},请检查 API Key)")
|
||||
} else {
|
||||
format!("获取模型列表失败(HTTP {status})")
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn direct_client() -> Client {
|
||||
ensure_crypto_provider();
|
||||
Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.expect("build test client")
|
||||
}
|
||||
|
||||
fn serve_once(status: u16, body: &'static str) -> (String, thread::JoinHandle<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept request");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.expect("set read timeout");
|
||||
let mut bytes = [0_u8; 8192];
|
||||
let read = stream.read(&mut bytes).expect("read request");
|
||||
let status_text = if status == 200 { "OK" } else { "Error" };
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} {status_text}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write response");
|
||||
String::from_utf8_lossy(&bytes[..read]).into_owned()
|
||||
});
|
||||
(base_url, server)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_url_adds_exactly_one_v1_segment() {
|
||||
for (base, expected) in [
|
||||
("https://example.com", "https://example.com/v1/models"),
|
||||
("https://example.com/v1", "https://example.com/v1/models"),
|
||||
("https://example.com/v1/", "https://example.com/v1/models"),
|
||||
(
|
||||
"https://example.com/openai",
|
||||
"https://example.com/openai/v1/models",
|
||||
),
|
||||
] {
|
||||
assert_eq!(provider_models_url(base).unwrap().as_str(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetches_models_with_bearer_auth_and_deduplicates_ids() {
|
||||
let body = r#"{"data":[{"id":" gpt-5.6-sol ","created":20},{"id":"gpt-5.5","created":"10"},{"id":"gpt-5.6-sol","created":30},{"id":" "}]}"#;
|
||||
let (base_url, server) = serve_once(200, body);
|
||||
let attempt = request_provider_models_with_client(
|
||||
&direct_client(),
|
||||
&base_url,
|
||||
Some("sk-private-test"),
|
||||
)
|
||||
.expect("request succeeds");
|
||||
let ProviderModelsAttempt::Success(result) = attempt else {
|
||||
panic!("expected successful models response");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
result.models,
|
||||
vec![
|
||||
ProviderModel {
|
||||
id: "gpt-5.6-sol".to_string(),
|
||||
created: Some(30),
|
||||
},
|
||||
ProviderModel {
|
||||
id: "gpt-5.5".to_string(),
|
||||
created: Some(10),
|
||||
},
|
||||
]
|
||||
);
|
||||
let request = server.join().expect("join mock server");
|
||||
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
|
||||
assert!(request
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer sk-private-test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_by_created_then_natural_model_version_descending() {
|
||||
let models = parse_models(
|
||||
r#"{"data":[{"id":"gpt-5.5","created":20},{"id":"gpt-5.6-sol","created":20},{"id":"gpt-5.9"},{"id":"gpt-5.10"},{"id":"older-by-name","created":30}]}"#,
|
||||
)
|
||||
.expect("parse models");
|
||||
|
||||
assert_eq!(
|
||||
models
|
||||
.iter()
|
||||
.map(|model| model.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"older-by-name",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.5",
|
||||
"gpt-5.10",
|
||||
"gpt-5.9",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_errors_are_not_reported_as_connected_or_leaked() {
|
||||
let private_body = r#"{"error":"secret upstream response"}"#;
|
||||
let (base_url, server) = serve_once(403, private_body);
|
||||
let attempt = request_provider_models_with_client(
|
||||
&direct_client(),
|
||||
&base_url,
|
||||
Some("sk-private-test"),
|
||||
)
|
||||
.expect("HTTP status remains an inspectable result");
|
||||
let ProviderModelsAttempt::HttpError {
|
||||
status,
|
||||
duration_ms,
|
||||
} = attempt
|
||||
else {
|
||||
panic!("expected HTTP error");
|
||||
};
|
||||
let result = provider_status_result(status, duration_ms);
|
||||
|
||||
assert!(!result.ok);
|
||||
assert_eq!(result.status, Some(403));
|
||||
assert!(!result.message.contains("secret upstream response"));
|
||||
assert!(!result.message.contains("sk-private-test"));
|
||||
assert!(!result.message.contains(&base_url));
|
||||
server.join().expect("join mock server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_payload_error_does_not_include_response_body() {
|
||||
let private_body = r#"{"private":"secret response"}"#;
|
||||
let (base_url, server) = serve_once(200, private_body);
|
||||
let error = request_provider_models_with_client(
|
||||
&direct_client(),
|
||||
&base_url,
|
||||
Some("sk-private-test"),
|
||||
)
|
||||
.err()
|
||||
.expect("invalid payload fails");
|
||||
let message = error.to_string();
|
||||
|
||||
assert!(!message.contains("secret response"));
|
||||
assert!(!message.contains("sk-private-test"));
|
||||
assert!(!message.contains(&base_url));
|
||||
server.join().expect("join mock server");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
mod ccswitch;
|
||||
mod connection;
|
||||
mod live;
|
||||
mod official_auth;
|
||||
mod store;
|
||||
|
||||
use crate::error::Result;
|
||||
use rusqlite::Connection;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use ccswitch::{
|
||||
build_ccswitch_codex_provider, codex_sections_from_config, is_official_ccswitch_row,
|
||||
read_ccswitch_codex_rows, CcSwitchCodexRow,
|
||||
};
|
||||
pub(crate) use ccswitch::{
|
||||
import_ccswitch_codex_providers_inner, read_ccswitch_official_auth_inner, ImportResult,
|
||||
OfficialAuthCandidate,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use connection::provider_status_result;
|
||||
pub(crate) use connection::{
|
||||
fetch_provider_models_inner, test_provider_connection_inner, ProviderConnectionResult,
|
||||
ProviderModelsResult,
|
||||
};
|
||||
pub(crate) use live::detected_live_custom_provider;
|
||||
pub(crate) use live::{
|
||||
delete_saved_provider_inner, reset_official_provider_inner, restore_official_provider_inner,
|
||||
save_active_provider_inner, save_official_config_inner, save_provider_toml_config_inner,
|
||||
switch_official_provider_inner, switch_provider_inner, OfficialConfigInput, ProviderInput,
|
||||
ProviderTomlInput,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use live::{
|
||||
save_provider_toml_config_with_pre_persist, switch_official_provider_with_pre_persist,
|
||||
switch_provider_with_pre_persist,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use official_auth::official_snapshot_path_for_test;
|
||||
pub(crate) use official_auth::{
|
||||
capture_live_chatgpt_config, document_is_official, get_official_config_draft_inner,
|
||||
official_auth_available, OfficialConfigDraft,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use store::{
|
||||
canonical_provider_base_url, provider_by_id_on_connection, provider_identity,
|
||||
save_manual_provider_on_connection, upsert_provider_on_connection, ProviderUpsertMode,
|
||||
};
|
||||
pub(crate) use store::{
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
delete_provider_inner, experimental_bearer_token_from_doc, list_saved_providers_inner,
|
||||
list_saved_providers_on_connection, matching_saved_provider_ids_for_live,
|
||||
normalize_saved_provider, provider_template_from_document, reserved_codex_provider_id,
|
||||
rollback_provider_store_inner, save_provider_inner, save_provider_with_rollback_inner,
|
||||
unique_saved_provider_id_for_live, upsert_ccswitch_provider_on_connection, ProviderUpsertKind,
|
||||
SavedProvider,
|
||||
};
|
||||
|
||||
pub(crate) fn open_store() -> Result<Connection> {
|
||||
crate::app_db::open()
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
use crate::backups::{action_backup_root, BackupMeta};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, json_err, parse_toml_document, write_private_json};
|
||||
use crate::paths::app_home;
|
||||
use crate::{auth_path, config_path, string_value};
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const SNAPSHOT_VERSION: u32 = 2;
|
||||
const LEGACY_SNAPSHOT_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct OfficialConfigCandidate {
|
||||
pub(crate) auth: Value,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialConfigDraft {
|
||||
auth_json: String,
|
||||
model: Option<String>,
|
||||
source: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct OfficialConfigSnapshot {
|
||||
version: u32,
|
||||
codex_dir: String,
|
||||
captured_at: String,
|
||||
model: Option<String>,
|
||||
#[serde(default)]
|
||||
auth: Option<Value>,
|
||||
}
|
||||
|
||||
enum SnapshotState {
|
||||
Missing,
|
||||
Reset,
|
||||
Ready(OfficialConfigCandidate),
|
||||
}
|
||||
|
||||
fn canonical_identity(path: &Path) -> String {
|
||||
fs::canonicalize(path)
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn official_snapshot_path(codex_dir: &Path) -> Result<PathBuf> {
|
||||
let identity = canonical_identity(codex_dir);
|
||||
let digest = Sha256::digest(identity.as_bytes());
|
||||
Ok(app_home()?
|
||||
.join("official-configs")
|
||||
.join(format!("{digest:x}.json")))
|
||||
}
|
||||
|
||||
fn value_has_material(value: &Value) -> bool {
|
||||
match value {
|
||||
Value::Null => false,
|
||||
Value::String(value) => !value.trim().is_empty(),
|
||||
Value::Array(values) => values.iter().any(value_has_material),
|
||||
Value::Object(values) => values.values().any(value_has_material),
|
||||
Value::Bool(_) | Value::Number(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn auth_value_has_material(value: &Value) -> bool {
|
||||
value.as_object().is_some_and(|auth| {
|
||||
auth.iter()
|
||||
.filter(|(key, _)| key.as_str() != "auth_mode")
|
||||
.any(|(_, value)| value_has_material(value))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_chatgpt_auth(value: &Value) -> bool {
|
||||
let chatgpt_mode = value
|
||||
.get("auth_mode")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|mode| mode.eq_ignore_ascii_case("chatgpt"));
|
||||
let has_api_key = value
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|key| !key.trim().is_empty());
|
||||
chatgpt_mode
|
||||
&& !has_api_key
|
||||
&& value
|
||||
.get("tokens")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|tokens| {
|
||||
["access_token", "refresh_token", "id_token"]
|
||||
.iter()
|
||||
.any(|key| {
|
||||
tokens
|
||||
.get(*key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn has_openai_api_key(value: &Value) -> bool {
|
||||
value
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|key| !key.trim().is_empty())
|
||||
}
|
||||
|
||||
fn read_auth_value(path: &Path) -> Result<Option<Value>> {
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|error| io_err(path, error))?;
|
||||
let value: Value = serde_json::from_str(&text).map_err(|error| json_err(path, error))?;
|
||||
if !value.is_object() || !auth_value_has_material(&value) {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
fn official_model(codex_dir: &Path) -> Result<Option<String>> {
|
||||
let path = config_path(codex_dir);
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
Ok(string_value(&doc, "model"))
|
||||
}
|
||||
|
||||
pub(crate) fn live_config_is_official(codex_dir: &Path) -> Result<bool> {
|
||||
let path = config_path(codex_dir);
|
||||
if !path.is_file() {
|
||||
return Ok(true);
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
Ok(document_is_official(&doc))
|
||||
}
|
||||
|
||||
pub(crate) fn document_is_official(doc: &toml_edit::DocumentMut) -> bool {
|
||||
let Some(provider) = string_value(doc, "model_provider") else {
|
||||
return true;
|
||||
};
|
||||
if provider.eq_ignore_ascii_case("openai") {
|
||||
return true;
|
||||
}
|
||||
if provider != "custom" {
|
||||
return false;
|
||||
}
|
||||
doc.get("model_providers")
|
||||
.and_then(|item| item.as_table())
|
||||
.and_then(|providers| providers.get("custom"))
|
||||
.and_then(|item| item.as_table())
|
||||
.is_some_and(|table| {
|
||||
let has_no_endpoint = table
|
||||
.get("base_url")
|
||||
.and_then(|item| item.as_str())
|
||||
.is_none_or(|value| value.trim().is_empty());
|
||||
let is_openai = table
|
||||
.get("name")
|
||||
.and_then(|item| item.as_str())
|
||||
.is_some_and(|value| value.trim().eq_ignore_ascii_case("openai"));
|
||||
has_no_endpoint
|
||||
&& is_openai
|
||||
&& table
|
||||
.get("requires_openai_auth")
|
||||
.and_then(|item| item.as_bool())
|
||||
== Some(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn write_snapshot(codex_dir: &Path, model: Option<String>, auth: Option<Value>) -> Result<()> {
|
||||
let path = official_snapshot_path(codex_dir)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let snapshot = OfficialConfigSnapshot {
|
||||
version: SNAPSHOT_VERSION,
|
||||
codex_dir: canonical_identity(codex_dir),
|
||||
captured_at: Local::now().to_rfc3339(),
|
||||
model,
|
||||
auth,
|
||||
};
|
||||
let value = serde_json::to_value(snapshot)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化官方配置快照失败: {error}")))?;
|
||||
write_private_json(&path, &value)
|
||||
}
|
||||
|
||||
pub(crate) fn save_official_config_snapshot(
|
||||
codex_dir: &Path,
|
||||
model: Option<String>,
|
||||
auth: &Value,
|
||||
) -> Result<()> {
|
||||
if !auth.is_object() || !auth_value_has_material(auth) {
|
||||
return Err(CodexxError::Config(
|
||||
"官方 auth.json 没有可用认证信息,请先完成官方登录".to_string(),
|
||||
));
|
||||
}
|
||||
write_snapshot(codex_dir, model, Some(auth.clone()))
|
||||
}
|
||||
|
||||
pub(crate) fn mark_official_config_reset(codex_dir: &Path, model: Option<String>) -> Result<()> {
|
||||
write_snapshot(codex_dir, model, None)
|
||||
}
|
||||
|
||||
pub(crate) fn capture_live_official_config_before_provider_switch(
|
||||
codex_dir: &Path,
|
||||
) -> Result<bool> {
|
||||
capture_live_official_auth(codex_dir, |auth| {
|
||||
is_chatgpt_auth(auth) || has_openai_api_key(auth)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn capture_live_chatgpt_config(codex_dir: &Path) -> Result<bool> {
|
||||
capture_live_official_auth(codex_dir, is_chatgpt_auth)
|
||||
}
|
||||
|
||||
fn capture_live_official_auth(
|
||||
codex_dir: &Path,
|
||||
is_trusted: impl FnOnce(&Value) -> bool,
|
||||
) -> Result<bool> {
|
||||
if !live_config_is_official(codex_dir)? {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(auth) = read_auth_value(&auth_path(codex_dir))? else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !is_trusted(&auth) {
|
||||
return Ok(false);
|
||||
}
|
||||
save_official_config_snapshot(codex_dir, official_model(codex_dir)?, &auth)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn load_snapshot(codex_dir: &Path) -> Result<SnapshotState> {
|
||||
let path = official_snapshot_path(codex_dir)?;
|
||||
if !path.is_file() {
|
||||
return Ok(SnapshotState::Missing);
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let snapshot: OfficialConfigSnapshot =
|
||||
serde_json::from_str(&text).map_err(|error| json_err(&path, error))?;
|
||||
if !matches!(snapshot.version, SNAPSHOT_VERSION | LEGACY_SNAPSHOT_VERSION)
|
||||
|| snapshot.codex_dir != canonical_identity(codex_dir)
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"官方配置快照与当前 CODEX_HOME 不匹配: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let Some(auth) = snapshot.auth else {
|
||||
return Ok(SnapshotState::Reset);
|
||||
};
|
||||
if !auth.is_object() || !auth_value_has_material(&auth) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"官方配置快照不包含可用认证: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
// Version 1 could be populated automatically from a proxy API key. Its
|
||||
// API-key-only snapshots are ambiguous, so never restore or promote them.
|
||||
if snapshot.version == LEGACY_SNAPSHOT_VERSION && !is_chatgpt_auth(&auth) {
|
||||
return Ok(SnapshotState::Missing);
|
||||
}
|
||||
Ok(SnapshotState::Ready(OfficialConfigCandidate {
|
||||
auth,
|
||||
model: snapshot.model,
|
||||
source: "Codex-X 官方配置快照".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn backup_config_is_official(dir: &Path, meta: &BackupMeta) -> bool {
|
||||
if !meta.had_config {
|
||||
return true;
|
||||
}
|
||||
let path = dir.join("config.toml");
|
||||
let Ok(text) = fs::read_to_string(&path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(doc) = parse_toml_document(&path, &text) else {
|
||||
return false;
|
||||
};
|
||||
document_is_official(&doc)
|
||||
}
|
||||
|
||||
fn backup_model(dir: &Path, meta: &BackupMeta) -> Option<String> {
|
||||
if !meta.had_config {
|
||||
return None;
|
||||
}
|
||||
let path = dir.join("config.toml");
|
||||
let text = fs::read_to_string(&path).ok()?;
|
||||
let doc = parse_toml_document(&path, &text).ok()?;
|
||||
string_value(&doc, "model")
|
||||
}
|
||||
|
||||
fn latest_official_backup(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
|
||||
let root = action_backup_root(codex_dir)?;
|
||||
if !root.is_dir() {
|
||||
return Ok(None);
|
||||
}
|
||||
let identity = canonical_identity(codex_dir);
|
||||
let mut candidates = Vec::new();
|
||||
for entry in fs::read_dir(&root).map_err(|error| io_err(&root, error))? {
|
||||
let entry = entry.map_err(|error| io_err(&root, error))?;
|
||||
let dir = entry.path();
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let meta_path = dir.join("meta.json");
|
||||
let Ok(meta_text) = fs::read_to_string(&meta_path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(meta) = serde_json::from_str::<BackupMeta>(&meta_text) else {
|
||||
continue;
|
||||
};
|
||||
if !meta.had_auth
|
||||
|| canonical_identity(Path::new(&meta.codex_dir)) != identity
|
||||
|| !backup_config_is_official(&dir, &meta)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(Some(auth)) = read_auth_value(&dir.join("auth.json")) else {
|
||||
continue;
|
||||
};
|
||||
// Old Codex-X versions could mark config.toml as official while leaving
|
||||
// a proxy API key in auth.json. Historical auto-recovery therefore only
|
||||
// trusts unambiguous ChatGPT login backups. Official API keys remain
|
||||
// supported through an explicit Codex-X snapshot/save.
|
||||
if !is_chatgpt_auth(&auth) {
|
||||
continue;
|
||||
}
|
||||
candidates.push((meta.created_at.clone(), dir, meta, auth));
|
||||
}
|
||||
candidates.sort_by(|left, right| right.0.cmp(&left.0));
|
||||
Ok(candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.map(|(_, dir, meta, auth)| OfficialConfigCandidate {
|
||||
auth,
|
||||
model: backup_model(&dir, &meta),
|
||||
source: format!("Codex-X 历史备份 {}", meta.created_at),
|
||||
}))
|
||||
}
|
||||
|
||||
fn live_chatgpt_candidate(codex_dir: &Path) -> Result<Option<OfficialConfigCandidate>> {
|
||||
let Some(auth) = read_auth_value(&auth_path(codex_dir))? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !is_chatgpt_auth(&auth) {
|
||||
return Ok(None);
|
||||
}
|
||||
let model = if live_config_is_official(codex_dir)? {
|
||||
official_model(codex_dir)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Some(OfficialConfigCandidate {
|
||||
auth,
|
||||
model,
|
||||
source: "当前 ChatGPT 官方登录".to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn official_config_candidate(
|
||||
codex_dir: &Path,
|
||||
include_history_after_reset: bool,
|
||||
) -> Result<Option<OfficialConfigCandidate>> {
|
||||
match load_snapshot(codex_dir)? {
|
||||
SnapshotState::Ready(candidate) => return Ok(Some(candidate)),
|
||||
SnapshotState::Reset if !include_history_after_reset => return Ok(None),
|
||||
SnapshotState::Missing | SnapshotState::Reset => {}
|
||||
}
|
||||
|
||||
if let Some(candidate) = live_chatgpt_candidate(codex_dir)? {
|
||||
return Ok(Some(candidate));
|
||||
}
|
||||
latest_official_backup(codex_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn official_auth_available(codex_dir: &Path) -> Result<bool> {
|
||||
match load_snapshot(codex_dir)? {
|
||||
SnapshotState::Ready(_) => return Ok(true),
|
||||
SnapshotState::Reset => return Ok(false),
|
||||
SnapshotState::Missing => {}
|
||||
}
|
||||
if let Some(auth) = read_auth_value(&auth_path(codex_dir))? {
|
||||
if is_chatgpt_auth(&auth) {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(latest_official_backup(codex_dir)?.is_some())
|
||||
}
|
||||
|
||||
pub(crate) fn get_official_config_draft_inner(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<Option<OfficialConfigDraft>> {
|
||||
let codex_dir = crate::resolve_codex_dir(config_dir)?;
|
||||
let Some(candidate) = official_config_candidate(&codex_dir, true)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let auth_json = serde_json::to_string_pretty(&candidate.auth)
|
||||
.map_err(|error| CodexxError::Config(format!("格式化官方配置快照失败: {error}")))?;
|
||||
Ok(Some(OfficialConfigDraft {
|
||||
auth_json,
|
||||
model: candidate.model,
|
||||
source: candidate.source,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn official_snapshot_path_for_test(codex_dir: &Path) -> Result<PathBuf> {
|
||||
official_snapshot_path(codex_dir)
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
const REMOTE_USER_AGENT: &str = "Codex-X";
|
||||
const REMOTE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct RemoteSource<'a> {
|
||||
pub(crate) key: &'static str,
|
||||
pub(crate) url: &'a str,
|
||||
pub(crate) accept: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl<'a> RemoteSource<'a> {
|
||||
pub(crate) const fn new(key: &'static str, url: &'a str, accept: Option<&'static str>) -> Self {
|
||||
Self { key, url, accept }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_crypto_provider() {
|
||||
static CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new();
|
||||
CRYPTO_PROVIDER.get_or_init(|| {
|
||||
// Another TLS client may already have initialized the process-wide provider.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn remote_client() -> Result<reqwest::blocking::Client> {
|
||||
ensure_crypto_provider();
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(REMOTE_TIMEOUT)
|
||||
.user_agent(REMOTE_USER_AGENT)
|
||||
.build()
|
||||
.map_err(|_| CodexxError::Config("网络客户端初始化失败".to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn remote_request_error(
|
||||
source: &RemoteSource<'_>,
|
||||
error: &reqwest::Error,
|
||||
) -> CodexxError {
|
||||
let reason = if error.is_timeout() {
|
||||
"请求超时"
|
||||
} else if error.is_connect() {
|
||||
"网络连接失败"
|
||||
} else {
|
||||
"网络请求失败"
|
||||
};
|
||||
CodexxError::Config(format!("{} {reason}", source.key))
|
||||
}
|
||||
|
||||
fn fetch_remote_text(source: &RemoteSource<'_>) -> Result<String> {
|
||||
// A fresh client picks up proxy changes made while the app is running. Reqwest's
|
||||
// system proxy resolver also handles proxy environment variables and NO_PROXY.
|
||||
let client = remote_client()?;
|
||||
let mut request = client.get(source.url);
|
||||
if let Some(accept) = source.accept {
|
||||
request = request.header(reqwest::header::ACCEPT, accept);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|error| remote_request_error(source, &error))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"{} 请求失败(HTTP {})",
|
||||
source.key,
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
response
|
||||
.text()
|
||||
.map_err(|_| CodexxError::Config(format!("{} 响应读取失败", source.key)))
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_first_valid<T, Parse>(sources: &[RemoteSource<'_>], parse: Parse) -> Result<T>
|
||||
where
|
||||
Parse: FnMut(&RemoteSource<'_>, &str) -> Result<T>,
|
||||
{
|
||||
fetch_first_valid_with(sources, fetch_remote_text, parse)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_first_valid_with<T, Fetch, Parse>(
|
||||
sources: &[RemoteSource<'_>],
|
||||
mut fetch: Fetch,
|
||||
mut parse: Parse,
|
||||
) -> Result<T>
|
||||
where
|
||||
Fetch: FnMut(&RemoteSource<'_>) -> Result<String>,
|
||||
Parse: FnMut(&RemoteSource<'_>, &str) -> Result<T>,
|
||||
{
|
||||
let mut errors = Vec::new();
|
||||
for source in sources {
|
||||
match fetch(source) {
|
||||
Ok(body) if body.trim().is_empty() => {
|
||||
errors.push(format!("{} 返回空内容", source.key));
|
||||
}
|
||||
Ok(body) => match parse(source, &body) {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(error) => errors.push(format!("{}: {error}", source.key)),
|
||||
},
|
||||
Err(error) => errors.push(format!("{}: {error}", source.key)),
|
||||
}
|
||||
}
|
||||
|
||||
Err(CodexxError::Config(format!(
|
||||
"远程内容获取失败{}",
|
||||
if errors.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(":{}", errors.join(";"))
|
||||
}
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::process::Command;
|
||||
use std::thread;
|
||||
use std::time::Instant;
|
||||
|
||||
const SOURCES: [RemoteSource<'static>; 2] = [
|
||||
RemoteSource::new("cdn", "https://cdn.example.test", None),
|
||||
RemoteSource::new("origin", "https://origin.example.test", None),
|
||||
];
|
||||
const PROXY_TEST_URL: &str = "CODEXX_REMOTE_PROXY_TEST_URL";
|
||||
const PROXY_TEST_BODY: &str = "CODEXX_REMOTE_PROXY_TEST_BODY";
|
||||
|
||||
fn clear_proxy_environment(command: &mut Command) {
|
||||
for name in [
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
"REQUEST_METHOD",
|
||||
] {
|
||||
command.env_remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_once(listener: TcpListener, body: &'static str) -> String {
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.expect("set listener nonblocking");
|
||||
let started = Instant::now();
|
||||
let (mut stream, _) = loop {
|
||||
match listener.accept() {
|
||||
Ok(connection) => break connection,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(8),
|
||||
"proxy test connection timed out"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
Err(error) => panic!("accept proxy test connection: {error}"),
|
||||
}
|
||||
};
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.expect("set read timeout");
|
||||
let mut request = [0_u8; 8192];
|
||||
let read = stream.read(&mut request).expect("read proxy request");
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write proxy response");
|
||||
String::from_utf8_lossy(&request[..read]).into_owned()
|
||||
}
|
||||
|
||||
fn run_proxy_test_child(url: &str, body: &str, configure: impl FnOnce(&mut Command)) {
|
||||
let mut command = Command::new(std::env::current_exe().expect("current test executable"));
|
||||
command.args([
|
||||
"--exact",
|
||||
"remote::tests::proxy_environment_child",
|
||||
"--nocapture",
|
||||
]);
|
||||
clear_proxy_environment(&mut command);
|
||||
command.env(PROXY_TEST_URL, url).env(PROXY_TEST_BODY, body);
|
||||
configure(&mut command);
|
||||
let output = command.output().expect("run proxy test child");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"proxy test child failed:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_environment_child() {
|
||||
let Ok(url) = std::env::var(PROXY_TEST_URL) else {
|
||||
return;
|
||||
};
|
||||
let expected = std::env::var(PROXY_TEST_BODY).expect("proxy test body");
|
||||
let source = RemoteSource::new("proxy test", &url, None);
|
||||
assert_eq!(
|
||||
fetch_remote_text(&source).expect("fetch test response"),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_source_stops_fallback_chain() {
|
||||
let mut calls = Vec::new();
|
||||
let value = fetch_first_valid_with(
|
||||
&SOURCES,
|
||||
|source| {
|
||||
calls.push(source.key);
|
||||
Ok("valid".to_string())
|
||||
},
|
||||
|_, body| Ok(body.to_string()),
|
||||
)
|
||||
.expect("first source succeeds");
|
||||
|
||||
assert_eq!(value, "valid");
|
||||
assert_eq!(calls, ["cdn"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_unparseable_responses_try_later_sources() {
|
||||
let mut calls = Vec::new();
|
||||
let value = fetch_first_valid_with(
|
||||
&SOURCES,
|
||||
|source| {
|
||||
calls.push(source.key);
|
||||
if source.key == "cdn" {
|
||||
Ok(" ".to_string())
|
||||
} else {
|
||||
Ok("42".to_string())
|
||||
}
|
||||
},
|
||||
|_, body| {
|
||||
body.parse::<u32>()
|
||||
.map_err(|error| CodexxError::Config(error.to_string()))
|
||||
},
|
||||
)
|
||||
.expect("origin succeeds after empty CDN response");
|
||||
|
||||
assert_eq!(value, 42);
|
||||
assert_eq!(calls, ["cdn", "origin"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_client_uses_lowercase_http_proxy() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test proxy");
|
||||
let proxy_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || serve_once(listener, "through proxy"));
|
||||
|
||||
run_proxy_test_child(
|
||||
"http://remote.invalid/template.md",
|
||||
"through proxy",
|
||||
|command| {
|
||||
command.env("http_proxy", proxy_url);
|
||||
},
|
||||
);
|
||||
|
||||
let request = server.join().expect("join proxy server");
|
||||
assert!(request.starts_with("GET http://remote.invalid/template.md "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_client_respects_lowercase_no_proxy() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test origin");
|
||||
let origin_url = format!("http://{}/direct.md", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || serve_once(listener, "direct response"));
|
||||
|
||||
run_proxy_test_child(&origin_url, "direct response", |command| {
|
||||
command
|
||||
.env("HTTP_PROXY", "http://127.0.0.1:9")
|
||||
.env("no_proxy", "127.0.0.1");
|
||||
});
|
||||
|
||||
let request = server.join().expect("join origin server");
|
||||
assert!(request.starts_with("GET /direct.md "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_errors_do_not_expose_request_or_proxy_details() {
|
||||
remote_client().expect("initialize TLS provider");
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_millis(200))
|
||||
.build()
|
||||
.expect("build direct client");
|
||||
let error = client
|
||||
.get("http://127.0.0.1:0/private")
|
||||
.send()
|
||||
.expect_err("reserved port should fail");
|
||||
let message = remote_request_error(&SOURCES[0], &error).to_string();
|
||||
|
||||
assert!(message.contains("cdn"));
|
||||
assert!(!message.contains("127.0.0.1"));
|
||||
assert!(!message.contains("private"));
|
||||
assert!(!message.contains('@'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
use crate::error::Result;
|
||||
use crate::platform;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct OfficialSessionDeleteOutcome {
|
||||
pub(super) deleted_ids: HashSet<String>,
|
||||
pub(super) completed_roots: HashSet<String>,
|
||||
pub(super) failed_roots: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AppServerDeleteAttempt {
|
||||
Success(OfficialSessionDeleteOutcome),
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
fn send_app_server_message(
|
||||
stdin: &mut impl Write,
|
||||
value: &Value,
|
||||
) -> std::result::Result<(), String> {
|
||||
let mut line = serde_json::to_vec(value).map_err(|error| error.to_string())?;
|
||||
line.push(b'\n');
|
||||
stdin.write_all(&line).map_err(|error| error.to_string())?;
|
||||
stdin.flush().map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn recv_app_server_message(
|
||||
receiver: &mpsc::Receiver<std::io::Result<String>>,
|
||||
deadline: Instant,
|
||||
) -> std::result::Result<Value, String> {
|
||||
loop {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.ok_or_else(|| "Codex App Server 响应超时".to_string())?;
|
||||
let line = receiver
|
||||
.recv_timeout(remaining)
|
||||
.map_err(|error| format!("Codex App Server 响应失败: {error}"))?
|
||||
.map_err(|error| format!("读取 Codex App Server 输出失败: {error}"))?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
return serde_json::from_str(&line)
|
||||
.map_err(|error| format!("解析 Codex App Server 输出失败: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn app_server_error(value: &Value) -> Option<(i64, String)> {
|
||||
let error = value.get("error")?;
|
||||
Some((
|
||||
error
|
||||
.get("code")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or_default(),
|
||||
error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Codex App Server 返回未知错误")
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn stop_app_server_child(child: &mut Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn run_app_server_delete_attempt(
|
||||
mut child: Child,
|
||||
codex_dir: &Path,
|
||||
roots: &[String],
|
||||
) -> AppServerDeleteAttempt {
|
||||
let Some(mut stdin) = child.stdin.take() else {
|
||||
stop_app_server_child(&mut child);
|
||||
return AppServerDeleteAttempt::Unsupported("Codex App Server stdin 不可用".to_string());
|
||||
};
|
||||
let Some(stdout) = child.stdout.take() else {
|
||||
stop_app_server_child(&mut child);
|
||||
return AppServerDeleteAttempt::Unsupported("Codex App Server stdout 不可用".to_string());
|
||||
};
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
if sender.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = (|| -> AppServerDeleteAttempt {
|
||||
let initialize_id = 1i64;
|
||||
let initialize = json!({
|
||||
"id": initialize_id,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"clientInfo": {
|
||||
"name": "codex_x",
|
||||
"title": "Codex-X",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
},
|
||||
"capabilities": null
|
||||
}
|
||||
});
|
||||
if let Err(error) = send_app_server_message(&mut stdin, &initialize) {
|
||||
return AppServerDeleteAttempt::Unsupported(error);
|
||||
}
|
||||
let initialize_deadline = Instant::now() + Duration::from_secs(8);
|
||||
loop {
|
||||
let message = match recv_app_server_message(&receiver, initialize_deadline) {
|
||||
Ok(message) => message,
|
||||
Err(error) => return AppServerDeleteAttempt::Unsupported(error),
|
||||
};
|
||||
if message.get("id").and_then(Value::as_i64) != Some(initialize_id) {
|
||||
continue;
|
||||
}
|
||||
if let Some((_, message)) = app_server_error(&message) {
|
||||
return AppServerDeleteAttempt::Unsupported(message);
|
||||
}
|
||||
let Some(server_home) = message
|
||||
.get("result")
|
||||
.and_then(|value| value.get("codexHome"))
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
return AppServerDeleteAttempt::Unsupported(
|
||||
"Codex App Server 未返回 CODEX_HOME".to_string(),
|
||||
);
|
||||
};
|
||||
let requested_home = codex_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| codex_dir.to_path_buf());
|
||||
let returned_home = PathBuf::from(server_home)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| PathBuf::from(server_home));
|
||||
if requested_home != returned_home {
|
||||
return AppServerDeleteAttempt::Unsupported(format!(
|
||||
"Codex App Server 使用了不同的 CODEX_HOME: {}",
|
||||
returned_home.display()
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let Err(error) = send_app_server_message(&mut stdin, &json!({"method": "initialized"})) {
|
||||
return AppServerDeleteAttempt::Unsupported(error);
|
||||
}
|
||||
|
||||
let mut outcome = OfficialSessionDeleteOutcome::default();
|
||||
'delete_roots: for (index, root) in roots.iter().enumerate() {
|
||||
let request_id = 1000 + index as i64;
|
||||
if let Err(error) = send_app_server_message(
|
||||
&mut stdin,
|
||||
&json!({
|
||||
"id": request_id,
|
||||
"method": "thread/delete",
|
||||
"params": { "threadId": root }
|
||||
}),
|
||||
) {
|
||||
for pending in &roots[index..] {
|
||||
outcome.failed_roots.push((pending.clone(), error.clone()));
|
||||
}
|
||||
break 'delete_roots;
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let message = match recv_app_server_message(&receiver, deadline) {
|
||||
Ok(message) => message,
|
||||
Err(error) => {
|
||||
for pending in &roots[index..] {
|
||||
outcome.failed_roots.push((pending.clone(), error.clone()));
|
||||
}
|
||||
break 'delete_roots;
|
||||
}
|
||||
};
|
||||
if message.get("method").and_then(Value::as_str) == Some("thread/deleted") {
|
||||
if let Some(id) = message
|
||||
.get("params")
|
||||
.and_then(|value| value.get("threadId"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
outcome.deleted_ids.insert(id.to_string());
|
||||
}
|
||||
}
|
||||
if message.get("id").and_then(Value::as_i64) == Some(request_id) {
|
||||
if let Some((code, error)) = app_server_error(&message) {
|
||||
let lower = error.to_ascii_lowercase();
|
||||
if code == -32601 {
|
||||
return AppServerDeleteAttempt::Unsupported(error);
|
||||
}
|
||||
if code == -32600
|
||||
&& (lower.contains("no rollout")
|
||||
|| lower.contains("not found")
|
||||
|| lower.contains("does not exist"))
|
||||
{
|
||||
outcome.completed_roots.insert(root.clone());
|
||||
break;
|
||||
}
|
||||
outcome.failed_roots.push((root.clone(), error));
|
||||
break;
|
||||
}
|
||||
outcome.completed_roots.insert(root.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
AppServerDeleteAttempt::Success(outcome)
|
||||
})();
|
||||
|
||||
drop(stdin);
|
||||
stop_app_server_child(&mut child);
|
||||
let _ = reader.join();
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn delete_sessions_via_codex_app_server(
|
||||
codex_dir: &Path,
|
||||
roots: &[String],
|
||||
) -> Result<Option<OfficialSessionDeleteOutcome>> {
|
||||
let mut unsupported_messages = Vec::new();
|
||||
for program in platform::codex_executable_candidates() {
|
||||
let is_bare_command = program.components().count() == 1;
|
||||
if !is_bare_command && !program.is_file() {
|
||||
continue;
|
||||
}
|
||||
let mut command = platform::program_command(&program, &["app-server", "--stdio"]);
|
||||
command
|
||||
.env("CODEX_HOME", codex_dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
let child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
unsupported_messages.push(format!("{}: {error}", program.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match run_app_server_delete_attempt(child, codex_dir, roots) {
|
||||
AppServerDeleteAttempt::Success(outcome) => return Ok(Some(outcome)),
|
||||
AppServerDeleteAttempt::Unsupported(message) => {
|
||||
unsupported_messages.push(format!("{}: {message}", program.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = unsupported_messages;
|
||||
Ok(None)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, write_json};
|
||||
use chrono::Local;
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct BackupSnapshot {
|
||||
live_path: PathBuf,
|
||||
backup_path: PathBuf,
|
||||
existed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ProviderSyncBackup {
|
||||
pub(super) dir: PathBuf,
|
||||
}
|
||||
|
||||
pub(crate) fn provider_sync_backup_root(codex_dir: &Path) -> PathBuf {
|
||||
codex_dir.join("backups_state").join("provider-sync")
|
||||
}
|
||||
|
||||
fn backup_relative_path(codex_dir: &Path, source: &Path) -> PathBuf {
|
||||
match source.strip_prefix(codex_dir) {
|
||||
Ok(relative) if !relative.as_os_str().is_empty() => relative.to_path_buf(),
|
||||
_ => {
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest = Sha256::digest(source.to_string_lossy().as_bytes());
|
||||
let key = digest[..8]
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
PathBuf::from("external").join(key).join(
|
||||
source
|
||||
.file_name()
|
||||
.unwrap_or_else(|| std::ffi::OsStr::new("file")),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backup_target_path(codex_dir: &Path, backup_dir: &Path, source: &Path) -> Result<PathBuf> {
|
||||
let target = backup_dir.join(backup_relative_path(codex_dir, source));
|
||||
if target == source {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"拒绝将备份写回源文件: {}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn copy_file_to_backup(
|
||||
codex_dir: &Path,
|
||||
backup_dir: &Path,
|
||||
source: &Path,
|
||||
) -> Result<BackupSnapshot> {
|
||||
let target = backup_target_path(codex_dir, backup_dir, source)?;
|
||||
let existed = source.exists();
|
||||
if existed {
|
||||
if let Some(parent) = target.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
fs::copy(source, &target).map_err(|e| io_err(&target, e))?;
|
||||
}
|
||||
Ok(BackupSnapshot {
|
||||
live_path: source.to_path_buf(),
|
||||
backup_path: target,
|
||||
existed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn backup_sqlite_to_backup(
|
||||
codex_dir: &Path,
|
||||
backup_dir: &Path,
|
||||
source: &Path,
|
||||
) -> Result<()> {
|
||||
use rusqlite::backup::{Backup, StepResult};
|
||||
|
||||
if !source.exists() {
|
||||
return Err(CodexxError::Database(format!(
|
||||
"SQLite 快照源不存在: {}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
let target = backup_target_path(codex_dir, backup_dir, source)?;
|
||||
if let Some(parent) = target.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let from = Connection::open_with_flags(
|
||||
source,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!("打开 SQLite 备份源失败 {}: {e}", source.display()))
|
||||
})?;
|
||||
from.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let mut to = Connection::open(&target).map_err(|e| {
|
||||
CodexxError::Database(format!("创建 SQLite 备份失败 {}: {e}", target.display()))
|
||||
})?;
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
{
|
||||
let backup = Backup::new(&from, &mut to)
|
||||
.map_err(|e| CodexxError::Database(format!("初始化 SQLite 快照失败: {e}")))?;
|
||||
loop {
|
||||
if Instant::now() >= deadline {
|
||||
return Err(CodexxError::Database(format!(
|
||||
"SQLite 快照超时: {}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
match backup
|
||||
.step(128)
|
||||
.map_err(|e| CodexxError::Database(format!("写入 SQLite 快照失败: {e}")))?
|
||||
{
|
||||
StepResult::Done => break,
|
||||
StepResult::More => {}
|
||||
StepResult::Busy | StepResult::Locked => {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let quick_check: String = to
|
||||
.query_row("PRAGMA quick_check", [], |row| row.get(0))
|
||||
.map_err(|e| CodexxError::Database(format!("校验 SQLite 备份失败: {e}")))?;
|
||||
if quick_check != "ok" {
|
||||
return Err(CodexxError::Database(format!(
|
||||
"SQLite 备份校验失败 {}: {quick_check}",
|
||||
target.display()
|
||||
)));
|
||||
}
|
||||
if !target.is_file() {
|
||||
return Err(CodexxError::Database(format!(
|
||||
"SQLite 快照未生成: {}",
|
||||
target.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn prune_provider_sync_backups(codex_dir: &Path) -> Result<()> {
|
||||
let root = provider_sync_backup_root(codex_dir);
|
||||
if !root.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut dirs = Vec::new();
|
||||
for entry in fs::read_dir(&root).map_err(|e| io_err(&root, e))? {
|
||||
let entry = entry.map_err(|e| io_err(&root, e))?;
|
||||
let path = entry.path();
|
||||
let metadata_path = path.join("metadata.json");
|
||||
if !path.is_dir() || !metadata_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let is_v2_provider_sync_backup = fs::read_to_string(&metadata_path)
|
||||
.ok()
|
||||
.and_then(|text| serde_json::from_str::<Value>(&text).ok())
|
||||
.is_some_and(|metadata| {
|
||||
metadata.get("managedBy").and_then(Value::as_str)
|
||||
== Some("Codex-X provider sync v2")
|
||||
});
|
||||
if is_v2_provider_sync_backup {
|
||||
dirs.push(path);
|
||||
}
|
||||
}
|
||||
dirs.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
|
||||
for path in dirs.into_iter().skip(5) {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn create_provider_sync_backup(
|
||||
codex_dir: &Path,
|
||||
target_provider: &str,
|
||||
changed_rollouts: &[PathBuf],
|
||||
sqlite_paths: &[PathBuf],
|
||||
) -> Result<ProviderSyncBackup> {
|
||||
let root = provider_sync_backup_root(codex_dir);
|
||||
ensure_directory(&root)?;
|
||||
let mut backup_dir = root.join(Local::now().format("%Y%m%d%H%M%S").to_string());
|
||||
let mut suffix = 0;
|
||||
while backup_dir.exists() {
|
||||
suffix += 1;
|
||||
backup_dir = root.join(format!("{}-{suffix}", Local::now().format("%Y%m%d%H%M%S")));
|
||||
}
|
||||
ensure_directory(&backup_dir)?;
|
||||
|
||||
let mut snapshots = Vec::new();
|
||||
for path in sqlite_paths {
|
||||
backup_sqlite_to_backup(codex_dir, &backup_dir, path)?;
|
||||
snapshots.push(BackupSnapshot {
|
||||
live_path: path.clone(),
|
||||
backup_path: backup_target_path(codex_dir, &backup_dir, path)?,
|
||||
existed: path.exists(),
|
||||
});
|
||||
}
|
||||
for path in changed_rollouts {
|
||||
snapshots.push(copy_file_to_backup(codex_dir, &backup_dir, path)?);
|
||||
}
|
||||
write_json(
|
||||
&backup_dir.join("metadata.json"),
|
||||
&json!({
|
||||
"version": 1,
|
||||
"namespace": "provider-sync",
|
||||
"managedBy": "Codex-X provider sync v2",
|
||||
"codexHome": codex_dir.display().to_string(),
|
||||
"targetProvider": target_provider,
|
||||
"createdAt": Local::now().to_rfc3339(),
|
||||
"changedRolloutFiles": changed_rollouts.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
|
||||
"snapshots": snapshots.iter().map(|snapshot| json!({
|
||||
"livePath": snapshot.live_path.display().to_string(),
|
||||
"backupPath": snapshot.backup_path.display().to_string(),
|
||||
"existed": snapshot.existed,
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
)?;
|
||||
Ok(ProviderSyncBackup { dir: backup_dir })
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
pub(super) fn normalize_workspace_path(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let lower = trimmed.to_ascii_lowercase();
|
||||
if lower.starts_with(r"\\?\unc\") {
|
||||
return Some(format!(r"\\{}", trimmed[8..].replace('/', r"\")));
|
||||
}
|
||||
if let Some(stripped) = trimmed.strip_prefix(r"\\?\") {
|
||||
return Some(stripped.replace('\\', "/"));
|
||||
}
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
mod app_server;
|
||||
mod backup;
|
||||
mod delete;
|
||||
mod global_state;
|
||||
mod storage;
|
||||
mod sync;
|
||||
mod transaction;
|
||||
mod types;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use backup::{
|
||||
backup_sqlite_to_backup, provider_sync_backup_root, prune_provider_sync_backups,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use delete::{active_session_ids_present, hard_delete_sessions_locally};
|
||||
pub(crate) use delete::{delete_codex_sessions_inner, SessionDeleteInput, SessionDeleteResult};
|
||||
pub(crate) use storage::sqlite_candidate_paths;
|
||||
#[cfg(test)]
|
||||
pub(crate) use storage::{
|
||||
apply_session_changes, list_session_previews, restore_session_changes, scan_rollouts,
|
||||
scan_sqlite, sqlite_session_db_paths,
|
||||
};
|
||||
pub(crate) use sync::{session_sync_status_inner, sync_sessions_provider_inner};
|
||||
pub(crate) use types::{SessionSyncResult, SessionSyncStatus};
|
||||
@@ -0,0 +1,895 @@
|
||||
use super::backup::{create_provider_sync_backup, prune_provider_sync_backups};
|
||||
use super::storage::{
|
||||
current_model_provider, discover_sqlite_databases, list_session_previews_with_paths,
|
||||
scan_rollouts_for_thread_ids, scan_sqlite_with_paths, SqliteDiscovery,
|
||||
};
|
||||
use super::transaction::{
|
||||
execute_provider_sync_mutation, mutation_error, prepare_sqlite_updates, rollback_mutation,
|
||||
rollback_open_transactions, MutationJournal, MutationPoint,
|
||||
};
|
||||
use super::types::{RolloutScan, SessionSyncResult, SessionSyncStatus, SqliteScan};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err};
|
||||
use crate::resolve_codex_dir;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
const SHARED_SESSION_PROVIDER: &str = "custom";
|
||||
|
||||
fn live_route_failure(codex_dir: &Path) -> Option<String> {
|
||||
match current_model_provider(codex_dir, None) {
|
||||
Ok(provider) if provider.trim() == SHARED_SESSION_PROVIDER => None,
|
||||
Ok(provider) => Some(format!(
|
||||
"当前 Codex 配置的 model_provider 为 {provider:?},未路由到共享 custom 会话;已停止同步,请先重新启用官方配置或供应商。"
|
||||
)),
|
||||
Err(error) => Some(format!("无法验证当前 Codex 配置的 model_provider: {error}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_failure_error(failures: &[String]) -> CodexxError {
|
||||
CodexxError::Config(
|
||||
failures
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "无法确认当前会话同步状态。".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn scan_provider_buckets(
|
||||
codex_dir: &Path,
|
||||
target_provider: &str,
|
||||
sqlite: &SqliteScan,
|
||||
) -> Result<RolloutScan> {
|
||||
let mut rollouts = scan_rollouts_for_thread_ids(
|
||||
codex_dir,
|
||||
target_provider,
|
||||
&sqlite.thread_ids,
|
||||
&sqlite.rollout_paths_by_thread_id,
|
||||
)?;
|
||||
// Provider synchronization must not repair or rewrite the independent cwd index.
|
||||
rollouts.cwd_by_thread_id.clear();
|
||||
Ok(rollouts)
|
||||
}
|
||||
|
||||
pub(crate) fn session_sync_status_inner(
|
||||
config_dir: Option<String>,
|
||||
_target_provider: Option<String>,
|
||||
) -> Result<SessionSyncStatus> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let target = SHARED_SESSION_PROVIDER.to_string();
|
||||
let discovery = discover_sqlite_databases(&codex_dir);
|
||||
session_sync_status_with_discovery(&codex_dir, target, &discovery)
|
||||
}
|
||||
|
||||
pub(super) fn session_sync_status_with_discovery(
|
||||
codex_dir: &Path,
|
||||
_target: String,
|
||||
discovery: &SqliteDiscovery,
|
||||
) -> Result<SessionSyncStatus> {
|
||||
let target = SHARED_SESSION_PROVIDER.to_string();
|
||||
let mut scan_failures = Vec::new();
|
||||
if let Some(failure) = live_route_failure(codex_dir) {
|
||||
scan_failures.push(failure);
|
||||
}
|
||||
scan_failures.extend(discovery.active_scan_failures.iter().cloned());
|
||||
let sqlite =
|
||||
match scan_sqlite_with_paths(&discovery.active_paths, &RolloutScan::default(), &target) {
|
||||
Ok(sqlite) => sqlite,
|
||||
Err(error) => {
|
||||
scan_failures.push(format!("无法扫描当前活动会话数据库: {error}"));
|
||||
Default::default()
|
||||
}
|
||||
};
|
||||
scan_failures.extend(sqlite.scan_failures.iter().cloned());
|
||||
if !discovery.active_paths.is_empty() && sqlite.sqlite_dbs != discovery.active_paths.len() {
|
||||
scan_failures.push("当前活动会话数据库未被完整扫描。".to_string());
|
||||
}
|
||||
let rollouts = scan_provider_buckets(codex_dir, &target, &sqlite)?;
|
||||
scan_failures.extend(rollouts.scan_failures.iter().cloned());
|
||||
if discovery.active_paths.is_empty()
|
||||
&& (!discovery.thread_paths.is_empty() || rollouts.discovered_rollout_files > 0)
|
||||
{
|
||||
scan_failures.push(
|
||||
"未找到当前活动会话数据库;旧数据库和单独的 JSONL 不会被当作客户端活动会话。"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
let session_limit = sqlite.sqlite_threads.clamp(50, 1000);
|
||||
let (sessions, session_warnings) = match list_session_previews_with_paths(
|
||||
&discovery.active_paths,
|
||||
&rollouts,
|
||||
&target,
|
||||
session_limit,
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
scan_failures.push(format!("无法读取当前活动会话列表: {error}"));
|
||||
(Vec::new(), Vec::new())
|
||||
}
|
||||
};
|
||||
scan_failures.extend(session_warnings);
|
||||
let mut seen_failures = HashSet::new();
|
||||
scan_failures.retain(|failure| seen_failures.insert(failure.clone()));
|
||||
let mut warnings = rollouts.warnings;
|
||||
warnings.extend(sqlite.warnings);
|
||||
let scan_complete = scan_failures.is_empty();
|
||||
let mismatched_sessions = sqlite
|
||||
.mismatched_thread_ids
|
||||
.union(&rollouts.mismatched_thread_ids)
|
||||
.count();
|
||||
Ok(SessionSyncStatus {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
target_provider: target,
|
||||
rollout_files: rollouts.rollout_files,
|
||||
session_meta_count: rollouts.session_meta_count,
|
||||
mismatched_rollouts: rollouts.mismatched_rollouts,
|
||||
mismatched_session_meta: rollouts.mismatched_session_meta,
|
||||
sqlite_dbs: sqlite.sqlite_dbs,
|
||||
sqlite_threads: sqlite.sqlite_threads,
|
||||
top_level_threads: sqlite.top_level_threads,
|
||||
subagent_threads: sqlite.subagent_threads,
|
||||
mismatched_threads: sqlite.mismatched_threads,
|
||||
mismatched_sessions,
|
||||
needs_sync: mismatched_sessions > 0,
|
||||
scan_complete,
|
||||
scan_failures,
|
||||
backup_dir: None,
|
||||
warnings,
|
||||
sessions,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) struct SessionMaintenanceLock {
|
||||
file: fs::File,
|
||||
}
|
||||
|
||||
impl Drop for SessionMaintenanceLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.file.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn acquire_session_maintenance_lock(codex_dir: &Path) -> Result<SessionMaintenanceLock> {
|
||||
let tmp_dir = codex_dir.join("tmp");
|
||||
ensure_directory(&tmp_dir)?;
|
||||
let legacy_lock = tmp_dir.join("provider-sync.lock");
|
||||
if legacy_lock.exists() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话维护正在进行: {}",
|
||||
legacy_lock.display()
|
||||
)));
|
||||
}
|
||||
let path = tmp_dir.join("session-maintenance.lock");
|
||||
if path.is_dir() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到旧版会话维护锁,请确认没有其他 Codex-X 正在维护会话后删除: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|e| io_err(&path, e))?;
|
||||
file.try_lock()
|
||||
.map_err(|_| CodexxError::Config(format!("会话维护正在进行: {}", path.display())))?;
|
||||
file.set_len(0).map_err(|e| io_err(&path, e))?;
|
||||
writeln!(file, "pid={}", std::process::id()).map_err(|e| io_err(&path, e))?;
|
||||
file.sync_all().map_err(|e| io_err(&path, e))?;
|
||||
Ok(SessionMaintenanceLock { file })
|
||||
}
|
||||
|
||||
pub(crate) fn sync_sessions_provider_inner(
|
||||
config_dir: Option<String>,
|
||||
target_provider: Option<String>,
|
||||
) -> Result<SessionSyncResult> {
|
||||
sync_sessions_provider_with_hook(config_dir, target_provider, |_| Ok(()))
|
||||
}
|
||||
|
||||
pub(super) fn sync_sessions_provider_with_hook<F>(
|
||||
config_dir: Option<String>,
|
||||
_target_provider: Option<String>,
|
||||
mut hook: F,
|
||||
) -> Result<SessionSyncResult>
|
||||
where
|
||||
F: FnMut(MutationPoint) -> Result<()>,
|
||||
{
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let target_provider = SHARED_SESSION_PROVIDER.to_string();
|
||||
let _maintenance_lock = acquire_session_maintenance_lock(&codex_dir)?;
|
||||
let discovery = discover_sqlite_databases(&codex_dir);
|
||||
let initial_status =
|
||||
session_sync_status_with_discovery(&codex_dir, target_provider.clone(), &discovery)?;
|
||||
if !initial_status.scan_complete {
|
||||
return Err(scan_failure_error(&initial_status.scan_failures));
|
||||
}
|
||||
let sqlite = scan_sqlite_with_paths(
|
||||
&discovery.active_paths,
|
||||
&RolloutScan::default(),
|
||||
&target_provider,
|
||||
)?;
|
||||
if !sqlite.scan_failures.is_empty() {
|
||||
return Err(scan_failure_error(&sqlite.scan_failures));
|
||||
}
|
||||
let rollouts = scan_provider_buckets(&codex_dir, &target_provider, &sqlite)?;
|
||||
if !rollouts.scan_failures.is_empty() {
|
||||
return Err(scan_failure_error(&rollouts.scan_failures));
|
||||
}
|
||||
if let Some(failure) = live_route_failure(&codex_dir) {
|
||||
return Err(CodexxError::Config(failure));
|
||||
}
|
||||
if rollouts.changes.is_empty() && sqlite.mismatched_threads == 0 {
|
||||
return Ok(SessionSyncResult {
|
||||
status: initial_status,
|
||||
updated_rollouts: 0,
|
||||
updated_threads: 0,
|
||||
backup_dir: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let changed_rollouts = rollouts
|
||||
.changes
|
||||
.iter()
|
||||
.map(|change| change.path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
let mut pending_sqlite = prepare_sqlite_updates(&discovery.active_paths)?;
|
||||
let sqlite_snapshot_paths = pending_sqlite
|
||||
.iter()
|
||||
.map(|update| update.path().to_path_buf())
|
||||
.collect::<Vec<_>>();
|
||||
let backup = match create_provider_sync_backup(
|
||||
&codex_dir,
|
||||
&target_provider,
|
||||
&changed_rollouts,
|
||||
&sqlite_snapshot_paths,
|
||||
) {
|
||||
Ok(backup) => backup,
|
||||
Err(error) => {
|
||||
rollback_open_transactions(&mut pending_sqlite);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let mut journal = MutationJournal::default();
|
||||
let mutation = execute_provider_sync_mutation(
|
||||
&rollouts,
|
||||
&mut pending_sqlite,
|
||||
&target_provider,
|
||||
&mut journal,
|
||||
&mut hook,
|
||||
);
|
||||
let mutation = match mutation {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
let recovery_errors = rollback_mutation(&journal, &mut pending_sqlite);
|
||||
return Err(mutation_error(error, recovery_errors));
|
||||
}
|
||||
};
|
||||
|
||||
let prune_warning = prune_provider_sync_backups(&codex_dir).err();
|
||||
let mut status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)
|
||||
.map_err(|error| {
|
||||
CodexxError::Config(format!(
|
||||
"同步已完成,但刷新会话列表失败,请重新进入页面:{error}"
|
||||
))
|
||||
})?;
|
||||
status.backup_dir = Some(backup.dir.display().to_string());
|
||||
if prune_warning.is_some() {
|
||||
status
|
||||
.warnings
|
||||
.push("同步已完成,但旧备份暂未清理。".to_string());
|
||||
}
|
||||
if !mutation.skipped_rollouts.is_empty() {
|
||||
status.warnings.push(format!(
|
||||
"有 {} 个会话正在使用,已跳过;退出 Codex 后再同步即可。",
|
||||
mutation.skipped_rollouts.len()
|
||||
));
|
||||
}
|
||||
Ok(SessionSyncResult {
|
||||
status,
|
||||
updated_rollouts: mutation.applied_rollouts,
|
||||
updated_threads: mutation.sqlite_updates.total(),
|
||||
backup_dir: backup.dir.display().to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn temp_codex_dir(label: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-session-sync-{label}-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create test Codex directory");
|
||||
path
|
||||
}
|
||||
|
||||
fn write_config(codex_dir: &Path, provider: &str) {
|
||||
fs::write(
|
||||
codex_dir.join("config.toml"),
|
||||
format!("model_provider = {provider:?}\n"),
|
||||
)
|
||||
.expect("write Codex config");
|
||||
}
|
||||
|
||||
fn create_thread_database(path: &Path, id: &str, provider: &str) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create SQLite parent");
|
||||
}
|
||||
let conn = Connection::open(path).expect("create session database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
model_provider TEXT NOT NULL,
|
||||
title TEXT
|
||||
);",
|
||||
)
|
||||
.expect("create threads table");
|
||||
conn.execute(
|
||||
"INSERT INTO threads (id, model_provider, title) VALUES (?1, ?2, 'test')",
|
||||
(id, provider),
|
||||
)
|
||||
.expect("insert thread");
|
||||
}
|
||||
|
||||
fn create_thread_database_with_rollout(path: &Path, id: &str, provider: &str, rollout: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create SQLite parent");
|
||||
}
|
||||
let conn = Connection::open(path).expect("create session database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
model_provider TEXT NOT NULL,
|
||||
title TEXT,
|
||||
rollout_path TEXT
|
||||
);",
|
||||
)
|
||||
.expect("create threads table with rollout path");
|
||||
conn.execute(
|
||||
"INSERT INTO threads (id, model_provider, title, rollout_path)
|
||||
VALUES (?1, ?2, 'test', ?3)",
|
||||
(id, provider, rollout.display().to_string()),
|
||||
)
|
||||
.expect("insert thread with rollout path");
|
||||
}
|
||||
|
||||
fn thread_provider(path: &Path, id: &str) -> String {
|
||||
Connection::open(path)
|
||||
.expect("open session database")
|
||||
.query_row(
|
||||
"SELECT model_provider FROM threads WHERE id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("read thread provider")
|
||||
}
|
||||
|
||||
fn write_rollout(codex_dir: &Path, id: &str, provider: &str) -> std::path::PathBuf {
|
||||
let path = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
write_rollout_at(&path, id, provider);
|
||||
path
|
||||
}
|
||||
|
||||
fn write_rollout_at(path: &Path, id: &str, provider: &str) {
|
||||
fs::create_dir_all(path.parent().expect("rollout parent")).expect("create rollout parent");
|
||||
fs::write(
|
||||
path,
|
||||
format!(
|
||||
"{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"{provider}\"}}}}\n"
|
||||
),
|
||||
)
|
||||
.expect("write rollout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_provider_must_route_to_custom_before_status_can_be_complete() {
|
||||
let codex_dir = temp_codex_dir("live-provider-gate");
|
||||
write_config(&codex_dir, "openai");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("read blocked status");
|
||||
assert!(!status.scan_complete);
|
||||
assert!(!status.needs_sync);
|
||||
assert!(status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("model_provider") && failure.contains("openai")));
|
||||
|
||||
let error = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect_err("non-custom live route must block synchronization");
|
||||
assert!(error.to_string().contains("未路由到共享 custom 会话"));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_jsonl_cannot_report_all_sessions_synced() {
|
||||
let codex_dir = temp_codex_dir("unreadable-jsonl");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "019f6000-0000-7000-8000-000000000501";
|
||||
create_thread_database(&codex_dir.join("state_5.sqlite"), id, "custom");
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
fs::create_dir_all(rollout.parent().expect("rollout parent"))
|
||||
.expect("create rollout parent");
|
||||
fs::write(&rollout, "not-json\n").expect("write malformed rollout");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("read incomplete status");
|
||||
assert!(!status.scan_complete);
|
||||
assert!(!status.needs_sync);
|
||||
assert!(status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("无法解析的 JSON")));
|
||||
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect_err("incomplete JSONL scan must block synchronization");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_standard_orphan_rollout_does_not_block_active_sessions() {
|
||||
let codex_dir = temp_codex_dir("malformed-standard-orphan");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active_id = "019f6000-0000-7000-8000-000000000502";
|
||||
let orphan_id = "019f6000-0000-7000-8000-000000000503";
|
||||
create_thread_database(
|
||||
&codex_dir.join("state_5.sqlite"),
|
||||
active_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
fs::write(
|
||||
codex_dir.join(format!("sessions/rollout-test-{orphan_id}.jsonl")),
|
||||
b"\xff",
|
||||
)
|
||||
.expect("write invalid orphan rollout");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan active sessions only");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert_eq!(status.rollout_files, 1);
|
||||
assert_eq!(status.session_meta_count, 1);
|
||||
assert!(!status.needs_sync);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_unreferenced_nonstandard_rollout_does_not_block_active_sessions() {
|
||||
let codex_dir = temp_codex_dir("malformed-unreferenced-rollout");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active_id = "019f6000-0000-7000-8000-000000000504";
|
||||
create_thread_database(
|
||||
&codex_dir.join("state_5.sqlite"),
|
||||
active_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
|
||||
fs::write(
|
||||
codex_dir.join("sessions/rollout-imported-orphan.jsonl"),
|
||||
b"\xff",
|
||||
)
|
||||
.expect("write invalid unreferenced rollout");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan referenced sessions only");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert_eq!(status.rollout_files, 1);
|
||||
assert_eq!(status.session_meta_count, 1);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_referenced_nonstandard_rollout_is_synchronized() {
|
||||
let codex_dir = temp_codex_dir("referenced-nonstandard-rollout");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "019f6000-0000-7000-8000-000000000505";
|
||||
let rollout = codex_dir.join("sessions/rollout-imported-name.jsonl");
|
||||
write_rollout_at(&rollout, id, "openai");
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &rollout);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan referenced imported rollout");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(status.needs_sync);
|
||||
assert_eq!(status.rollout_files, 1);
|
||||
assert_eq!(status.mismatched_sessions, 1);
|
||||
|
||||
let result = sync_sessions_provider_inner(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some(SHARED_SESSION_PROVIDER.to_string()),
|
||||
)
|
||||
.expect("synchronize referenced imported rollout");
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(result.updated_threads, 1);
|
||||
assert_eq!(thread_provider(&database, id), SHARED_SESSION_PROVIDER);
|
||||
assert!(fs::read_to_string(&rollout)
|
||||
.expect("read synchronized imported rollout")
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_referenced_rollout_with_a_different_session_id_blocks_sync() {
|
||||
let codex_dir = temp_codex_dir("referenced-rollout-id-mismatch");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let sqlite_id = "019f6000-0000-7000-8000-000000000506";
|
||||
let rollout_id = "019f6000-0000-7000-8000-000000000507";
|
||||
let rollout = codex_dir.join("sessions/rollout-imported-name.jsonl");
|
||||
write_rollout_at(&rollout, rollout_id, "openai");
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, sqlite_id, "openai", &rollout);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan mismatched referenced rollout");
|
||||
assert!(!status.scan_complete);
|
||||
assert!(status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("线程 ID 不一致")));
|
||||
|
||||
let error = sync_sessions_provider_inner(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some(SHARED_SESSION_PROVIDER.to_string()),
|
||||
)
|
||||
.expect_err("mismatched referenced rollout must block synchronization");
|
||||
assert!(error.to_string().contains("线程 ID 不一致"));
|
||||
assert_eq!(thread_provider(&database, sqlite_id), "openai");
|
||||
assert!(fs::read_to_string(&rollout)
|
||||
.expect("read unchanged rollout")
|
||||
.contains("\"model_provider\":\"openai\""));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_sqlite_referenced_rollout_blocks_sync_without_using_uuid_fallback() {
|
||||
let codex_dir = temp_codex_dir("missing-referenced-rollout");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "019f6000-0000-7000-8000-000000000506";
|
||||
let missing = codex_dir.join("sessions/missing/rollout-selected.jsonl");
|
||||
let duplicate = write_rollout(&codex_dir, id, "openai");
|
||||
create_thread_database_with_rollout(
|
||||
&codex_dir.join("state_5.sqlite"),
|
||||
id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
&missing,
|
||||
);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan missing referenced rollout");
|
||||
assert!(!status.scan_complete);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.rollout_files, 0);
|
||||
assert!(status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("会话文件不存在")));
|
||||
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect_err("missing referenced rollout must block synchronization");
|
||||
assert!(fs::read_to_string(duplicate)
|
||||
.expect("read untouched UUID fallback")
|
||||
.contains("\"model_provider\":\"openai\""));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_sqlite_referenced_rollout_cannot_report_synced() {
|
||||
let codex_dir = temp_codex_dir("unreadable-referenced-rollout");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "019f6000-0000-7000-8000-000000000507";
|
||||
let rollout = codex_dir.join("sessions/rollout-unreadable.jsonl");
|
||||
fs::create_dir_all(rollout.parent().expect("rollout parent"))
|
||||
.expect("create rollout parent");
|
||||
fs::write(&rollout, b"\xff").expect("write invalid UTF-8 rollout");
|
||||
create_thread_database_with_rollout(
|
||||
&codex_dir.join("state_5.sqlite"),
|
||||
id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
&rollout,
|
||||
);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan unreadable referenced rollout");
|
||||
assert!(!status.scan_complete);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.rollout_files, 1);
|
||||
assert!(status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("无法读取会话文件")));
|
||||
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect_err("unreadable referenced rollout must block synchronization");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_rollout_path_excludes_other_files_with_the_same_thread_id() {
|
||||
let codex_dir = temp_codex_dir("referenced-rollout-excludes-duplicates");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "019f6000-0000-7000-8000-000000000508";
|
||||
let selected = codex_dir.join("sessions/rollout-selected-name.jsonl");
|
||||
write_rollout_at(&selected, id, "openai");
|
||||
let duplicate = write_rollout(&codex_dir, id, "openai");
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "openai", &selected);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("scan only the SQLite-selected rollout");
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert_eq!(status.rollout_files, 1);
|
||||
assert_eq!(status.mismatched_rollouts, 1);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("synchronize only the SQLite-selected rollout");
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(result.updated_threads, 1);
|
||||
assert_eq!(thread_provider(&database, id), SHARED_SESSION_PROVIDER);
|
||||
assert!(fs::read_to_string(&selected)
|
||||
.expect("read selected rollout")
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
assert!(fs::read_to_string(&duplicate)
|
||||
.expect("read duplicate rollout")
|
||||
.contains("\"model_provider\":\"openai\""));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_referenced_rollout_outside_session_storage_is_rejected() {
|
||||
let codex_dir = temp_codex_dir("outside-referenced-rollout");
|
||||
let outside_dir = temp_codex_dir("outside-referenced-rollout-target");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "019f6000-0000-7000-8000-000000000509";
|
||||
let rollout = outside_dir.join("rollout-external.jsonl");
|
||||
write_rollout_at(&rollout, id, "openai");
|
||||
create_thread_database_with_rollout(
|
||||
&codex_dir.join("state_5.sqlite"),
|
||||
id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
&rollout,
|
||||
);
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("reject external referenced rollout");
|
||||
assert!(!status.scan_complete);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.rollout_files, 0);
|
||||
assert!(status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("超出 Codex 会话目录")));
|
||||
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect_err("external referenced rollout must block synchronization");
|
||||
assert!(fs::read_to_string(&rollout)
|
||||
.expect("read untouched external rollout")
|
||||
.contains("\"model_provider\":\"openai\""));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
fs::remove_dir_all(outside_dir).expect("remove external test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_active_sqlite_cannot_report_all_sessions_synced() {
|
||||
let codex_dir = temp_codex_dir("unreadable-active-sqlite");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
fs::write(codex_dir.join("state_5.sqlite"), b"SQLite format 3\0")
|
||||
.expect("write truncated SQLite");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("read incomplete status");
|
||||
assert!(!status.scan_complete);
|
||||
assert_eq!(status.sqlite_threads, 0);
|
||||
assert!(status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("活动会话数据库")));
|
||||
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect_err("unreadable active SQLite must block synchronization");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_sqlite_is_not_counted_or_modified() {
|
||||
let codex_dir = temp_codex_dir("active-only");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active = codex_dir.join("state_5.sqlite");
|
||||
let legacy = codex_dir.join("sqlite/state_5.sqlite");
|
||||
let active_id = "019f6000-0000-7000-8000-000000000511";
|
||||
let legacy_id = "019f6000-0000-7000-8000-000000000512";
|
||||
create_thread_database(&active, active_id, "openai");
|
||||
create_thread_database(&legacy, legacy_id, "openai");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("read active status");
|
||||
assert!(status.scan_complete);
|
||||
assert_eq!(status.sqlite_dbs, 1);
|
||||
assert_eq!(status.sqlite_threads, 1);
|
||||
assert_eq!(status.mismatched_threads, 1);
|
||||
assert_eq!(status.sessions.len(), 1);
|
||||
assert_eq!(status.sessions[0].id, active_id);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("sync active database");
|
||||
assert_eq!(result.updated_threads, 1);
|
||||
assert_eq!(thread_provider(&active, active_id), "custom");
|
||||
assert_eq!(thread_provider(&legacy, legacy_id), "openai");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_sqlite_home_is_the_only_active_database_root() {
|
||||
let codex_dir = temp_codex_dir("configured-sqlite-home");
|
||||
fs::write(
|
||||
codex_dir.join("config.toml"),
|
||||
"model_provider = \"custom\"\nsqlite_home = \"active-sqlite\"\n",
|
||||
)
|
||||
.expect("write configured SQLite home");
|
||||
let configured = codex_dir.join("active-sqlite/state_5.sqlite");
|
||||
let root_copy = codex_dir.join("state_10.sqlite");
|
||||
let configured_id = "019f6000-0000-7000-8000-000000000515";
|
||||
let root_id = "019f6000-0000-7000-8000-000000000516";
|
||||
create_thread_database(&configured, configured_id, "openai");
|
||||
create_thread_database(&root_copy, root_id, "openai");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("read configured active status");
|
||||
assert!(status.scan_complete);
|
||||
assert_eq!(status.sqlite_threads, 1);
|
||||
assert_eq!(status.sessions[0].id, configured_id);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("sync configured active database");
|
||||
assert_eq!(result.updated_threads, 1);
|
||||
assert_eq!(thread_provider(&configured, configured_id), "custom");
|
||||
assert_eq!(thread_provider(&root_copy, root_id), "openai");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_only_database_is_not_treated_as_active() {
|
||||
let codex_dir = temp_codex_dir("legacy-only");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let legacy = codex_dir.join("sqlite/state_5.sqlite");
|
||||
let id = "019f6000-0000-7000-8000-000000000521";
|
||||
create_thread_database(&legacy, id, "openai");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("read legacy-only status");
|
||||
assert!(!status.scan_complete);
|
||||
assert_eq!(status.sqlite_threads, 0);
|
||||
assert!(status.sessions.is_empty());
|
||||
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect_err("legacy-only database must not be synchronized");
|
||||
assert_eq!(thread_provider(&legacy, id), "openai");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_rollout_is_not_counted_or_modified() {
|
||||
let codex_dir = temp_codex_dir("orphan-rollout");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let active_id = "019f6000-0000-7000-8000-000000000531";
|
||||
let orphan_id = "019f6000-0000-7000-8000-000000000532";
|
||||
create_thread_database(
|
||||
&codex_dir.join("state_5.sqlite"),
|
||||
active_id,
|
||||
SHARED_SESSION_PROVIDER,
|
||||
);
|
||||
let orphan = write_rollout(&codex_dir, orphan_id, "openai");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("read active-only status");
|
||||
assert!(status.scan_complete);
|
||||
assert!(!status.needs_sync);
|
||||
assert_eq!(status.mismatched_sessions, 0);
|
||||
assert_eq!(status.mismatched_rollouts, 0);
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("orphan rollout does not require synchronization");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert!(fs::read_to_string(orphan)
|
||||
.expect("read orphan rollout")
|
||||
.contains("\"model_provider\":\"openai\""));
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_count_is_the_union_of_active_session_ids() {
|
||||
let codex_dir = temp_codex_dir("mismatch-union");
|
||||
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
|
||||
let database = codex_dir.join("state_5.sqlite");
|
||||
let sqlite_mismatch = "019f6000-0000-7000-8000-000000000541";
|
||||
let rollout_mismatch = "019f6000-0000-7000-8000-000000000542";
|
||||
create_thread_database(&database, sqlite_mismatch, "openai");
|
||||
Connection::open(&database)
|
||||
.expect("open active database")
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, title) VALUES (?1, 'custom', 'test')",
|
||||
[rollout_mismatch],
|
||||
)
|
||||
.expect("insert second active thread");
|
||||
write_rollout(&codex_dir, rollout_mismatch, "openai");
|
||||
|
||||
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("read mismatch union");
|
||||
assert!(status.scan_complete);
|
||||
assert_eq!(status.mismatched_threads, 1);
|
||||
assert_eq!(status.mismatched_rollouts, 1);
|
||||
assert_eq!(status.mismatched_sessions, 2);
|
||||
assert!(status
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|session| session.id == rollout_mismatch)
|
||||
.is_some_and(|session| session.needs_sync));
|
||||
|
||||
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
|
||||
.expect("sync mismatch union");
|
||||
assert_eq!(result.updated_threads, 1);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(result.status.mismatched_sessions, 0);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_state_drift_does_not_trigger_provider_sync() {
|
||||
let codex_dir = temp_codex_dir("ignore-global-state-drift");
|
||||
fs::write(
|
||||
codex_dir.join("config.toml"),
|
||||
"model_provider = \"custom\"\n",
|
||||
)
|
||||
.expect("write official config");
|
||||
let global_state = codex_dir.join(".codex-global-state.json");
|
||||
let original = br#"{"electron-saved-workspace-roots":"/tmp/project"}"#;
|
||||
fs::write(&global_state, original).expect("write global state drift");
|
||||
|
||||
let status = session_sync_status_inner(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("custom".to_string()),
|
||||
)
|
||||
.expect("read shared session status");
|
||||
assert_eq!(status.target_provider, SHARED_SESSION_PROVIDER);
|
||||
assert!(!status.needs_sync);
|
||||
|
||||
let result = sync_sessions_provider_inner(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("custom".to_string()),
|
||||
)
|
||||
.expect("global state drift is not a provider migration");
|
||||
assert_eq!(result.status.target_provider, SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert_eq!(result.updated_threads, 0);
|
||||
assert!(result.backup_dir.is_empty());
|
||||
assert_eq!(
|
||||
fs::read(&global_state).expect("read unchanged state"),
|
||||
original
|
||||
);
|
||||
assert!(!codex_dir.join(".codex-global-state.json.bak").exists());
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
use super::storage::{apply_session_changes, restore_session_changes};
|
||||
use super::types::{RolloutScan, SessionFileChange};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use crate::sqlite_utils::{sqlite_has_table, table_column_set};
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct SqliteUpdateCounts {
|
||||
provider_rows: usize,
|
||||
cwd_rows: usize,
|
||||
}
|
||||
|
||||
impl SqliteUpdateCounts {
|
||||
pub(super) fn total(&self) -> usize {
|
||||
self.provider_rows + self.cwd_rows
|
||||
}
|
||||
|
||||
fn add(&mut self, other: Self) {
|
||||
self.provider_rows += other.provider_rows;
|
||||
self.cwd_rows += other.cwd_rows;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct MutationJournal {
|
||||
applied_rollouts: Vec<SessionFileChange>,
|
||||
sqlite_restore_attempts: Vec<SqliteRestoreAttempt>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SqliteRestoreAttempt {
|
||||
path: PathBuf,
|
||||
expected_data_version: i64,
|
||||
}
|
||||
|
||||
pub(super) struct PendingSqliteUpdate {
|
||||
path: PathBuf,
|
||||
conn: Connection,
|
||||
observer: Connection,
|
||||
columns: HashSet<String>,
|
||||
counts: SqliteUpdateCounts,
|
||||
transaction_open: bool,
|
||||
}
|
||||
|
||||
impl PendingSqliteUpdate {
|
||||
pub(super) fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn rollback_open_transactions(updates: &mut [PendingSqliteUpdate]) {
|
||||
for update in updates.iter_mut().rev() {
|
||||
if update.transaction_open {
|
||||
let _ = update.conn.execute_batch("ROLLBACK");
|
||||
update.transaction_open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sqlite_data_version(conn: &Connection) -> Result<i64> {
|
||||
conn.query_row("PRAGMA data_version", [], |row| row.get(0))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
fn create_sqlite_rollback_table(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TEMP TABLE codexx_session_rollback (
|
||||
id TEXT PRIMARY KEY,
|
||||
model_provider TEXT,
|
||||
cwd TEXT,
|
||||
provider_changed INTEGER NOT NULL DEFAULT 0,
|
||||
cwd_changed INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(super) fn prepare_sqlite_updates(sqlite_paths: &[PathBuf]) -> Result<Vec<PendingSqliteUpdate>> {
|
||||
let mut pending = Vec::new();
|
||||
let mut seen_databases = HashSet::new();
|
||||
let prepare_result = (|| -> Result<()> {
|
||||
for path in sqlite_paths {
|
||||
if !path.exists() {
|
||||
return Err(CodexxError::Database(format!(
|
||||
"SQLite 文件不存在: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let identity = path.canonicalize().map_err(|error| io_err(path, error))?;
|
||||
if !seen_databases.insert(identity.clone()) {
|
||||
continue;
|
||||
}
|
||||
let conn = Connection::open_with_flags(
|
||||
&identity,
|
||||
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|error| {
|
||||
CodexxError::Database(format!("打开 SQLite 失败 {}: {error}", path.display()))
|
||||
})?;
|
||||
conn.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if !sqlite_has_table(&conn, "threads")? {
|
||||
continue;
|
||||
}
|
||||
let columns = table_column_set(&conn, "threads")?;
|
||||
if !columns.contains("id") || !columns.contains("model_provider") {
|
||||
continue;
|
||||
}
|
||||
conn.execute_batch("BEGIN IMMEDIATE")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if let Err(error) = create_sqlite_rollback_table(&conn) {
|
||||
let _ = conn.execute_batch("ROLLBACK");
|
||||
return Err(error);
|
||||
}
|
||||
let observer = match (|| -> Result<Connection> {
|
||||
let observer = Connection::open_with_flags(
|
||||
&identity,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|error| {
|
||||
CodexxError::Database(format!(
|
||||
"打开 SQLite 观察连接失败 {}: {error}",
|
||||
identity.display()
|
||||
))
|
||||
})?;
|
||||
observer
|
||||
.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
sqlite_data_version(&observer)?;
|
||||
Ok(observer)
|
||||
})() {
|
||||
Ok(observer) => observer,
|
||||
Err(error) => {
|
||||
let _ = conn.execute_batch("ROLLBACK");
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
pending.push(PendingSqliteUpdate {
|
||||
path: identity,
|
||||
conn,
|
||||
observer,
|
||||
columns,
|
||||
counts: SqliteUpdateCounts::default(),
|
||||
transaction_open: true,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = prepare_result {
|
||||
rollback_open_transactions(&mut pending);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(pending)
|
||||
}
|
||||
|
||||
fn apply_sqlite_updates(
|
||||
pending: &mut [PendingSqliteUpdate],
|
||||
rollouts: &RolloutScan,
|
||||
target_provider: &str,
|
||||
) -> Result<()> {
|
||||
for update in pending.iter_mut() {
|
||||
update
|
||||
.conn
|
||||
.execute(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
(id, model_provider, provider_changed)
|
||||
SELECT id, model_provider, 1 FROM threads
|
||||
WHERE COALESCE(model_provider, '') <> ?1
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
model_provider = excluded.model_provider,
|
||||
provider_changed = 1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.provider_rows = update
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE threads SET model_provider = ?1 WHERE COALESCE(model_provider, '') <> ?1",
|
||||
[target_provider],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
|
||||
if update.columns.contains("id") && update.columns.contains("cwd") {
|
||||
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
|
||||
update
|
||||
.conn
|
||||
.execute(
|
||||
"INSERT INTO temp.codexx_session_rollback
|
||||
(id, cwd, cwd_changed)
|
||||
SELECT id, cwd, 1 FROM threads
|
||||
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
cwd = excluded.cwd,
|
||||
cwd_changed = 1",
|
||||
(cwd, thread_id),
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.counts.cwd_rows += update
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE threads SET cwd = ?1 WHERE id = ?2 AND COALESCE(cwd, '') <> ?1",
|
||||
(cwd, thread_id),
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn commit_sqlite_updates<F>(
|
||||
pending: &mut [PendingSqliteUpdate],
|
||||
journal: &mut MutationJournal,
|
||||
hook: &mut F,
|
||||
) -> Result<SqliteUpdateCounts>
|
||||
where
|
||||
F: FnMut(MutationPoint) -> Result<()>,
|
||||
{
|
||||
let mut updated = SqliteUpdateCounts::default();
|
||||
for index in 0..pending.len() {
|
||||
let before_commit = sqlite_data_version(&pending[index].observer)?;
|
||||
journal.sqlite_restore_attempts.push(SqliteRestoreAttempt {
|
||||
path: pending[index].path.clone(),
|
||||
expected_data_version: before_commit,
|
||||
});
|
||||
let attempt_index = journal.sqlite_restore_attempts.len() - 1;
|
||||
if let Err(error) = pending[index].conn.execute_batch("COMMIT") {
|
||||
let rollback_succeeded = !pending[index].conn.is_autocommit()
|
||||
&& pending[index].conn.execute_batch("ROLLBACK").is_ok()
|
||||
&& pending[index].conn.is_autocommit();
|
||||
pending[index].transaction_open = !pending[index].conn.is_autocommit();
|
||||
if rollback_succeeded {
|
||||
journal.sqlite_restore_attempts.remove(attempt_index);
|
||||
}
|
||||
rollback_open_transactions(&mut pending[index + 1..]);
|
||||
return Err(CodexxError::Database(error.to_string()));
|
||||
}
|
||||
pending[index].transaction_open = false;
|
||||
journal.sqlite_restore_attempts[attempt_index].expected_data_version =
|
||||
sqlite_data_version(&pending[index].observer)?;
|
||||
updated.add(std::mem::take(&mut pending[index].counts));
|
||||
if let Err(error) = hook(MutationPoint::AfterSqliteCommit(index)) {
|
||||
rollback_open_transactions(&mut pending[index + 1..]);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
fn restore_sqlite_update(
|
||||
update: &mut PendingSqliteUpdate,
|
||||
expected_data_version: i64,
|
||||
) -> Result<()> {
|
||||
update
|
||||
.conn
|
||||
.execute_batch("BEGIN IMMEDIATE")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.transaction_open = true;
|
||||
let result = (|| -> Result<()> {
|
||||
let current = sqlite_data_version(&update.observer)?;
|
||||
if current != expected_data_version {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话数据库已发生变化,已保留备份且未覆盖: {}",
|
||||
update.path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let mut statements = vec![
|
||||
"UPDATE threads
|
||||
SET model_provider = (
|
||||
SELECT rollback.model_provider
|
||||
FROM temp.codexx_session_rollback AS rollback
|
||||
WHERE rollback.id = threads.id
|
||||
)
|
||||
WHERE id IN (
|
||||
SELECT id FROM temp.codexx_session_rollback WHERE provider_changed = 1
|
||||
)",
|
||||
];
|
||||
if update.columns.contains("cwd") {
|
||||
statements.push(
|
||||
"UPDATE threads
|
||||
SET cwd = (
|
||||
SELECT rollback.cwd
|
||||
FROM temp.codexx_session_rollback AS rollback
|
||||
WHERE rollback.id = threads.id
|
||||
)
|
||||
WHERE id IN (
|
||||
SELECT id FROM temp.codexx_session_rollback WHERE cwd_changed = 1
|
||||
)",
|
||||
);
|
||||
}
|
||||
statements.push("DROP TABLE temp.codexx_session_rollback");
|
||||
update
|
||||
.conn
|
||||
.execute_batch(&statements.join(";"))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update
|
||||
.conn
|
||||
.execute_batch("COMMIT")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
update.transaction_open = false;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() && update.transaction_open {
|
||||
let _ = update.conn.execute_batch("ROLLBACK");
|
||||
update.transaction_open = false;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn rollback_mutation(
|
||||
journal: &MutationJournal,
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
) -> Vec<String> {
|
||||
let mut errors = Vec::new();
|
||||
for attempt in journal.sqlite_restore_attempts.iter().rev() {
|
||||
let Some(update) = pending_sqlite
|
||||
.iter_mut()
|
||||
.find(|update| update.path == attempt.path)
|
||||
else {
|
||||
errors.push(format!("缺少 SQLite 恢复连接: {}", attempt.path.display()));
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = restore_sqlite_update(update, attempt.expected_data_version) {
|
||||
errors.push(error.to_string());
|
||||
}
|
||||
}
|
||||
if let Err(error) = restore_session_changes(&journal.applied_rollouts) {
|
||||
errors.push(error.to_string());
|
||||
}
|
||||
errors
|
||||
}
|
||||
|
||||
pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>) -> CodexxError {
|
||||
if recovery_errors.is_empty() {
|
||||
original
|
||||
} else {
|
||||
CodexxError::Config(format!(
|
||||
"同步失败,自动恢复也未完成:{original};{}",
|
||||
recovery_errors.join(";")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MutationPoint {
|
||||
AfterSqliteCommit(usize),
|
||||
}
|
||||
|
||||
pub(super) struct MutationResult {
|
||||
pub(super) applied_rollouts: usize,
|
||||
pub(super) skipped_rollouts: Vec<PathBuf>,
|
||||
pub(super) sqlite_updates: SqliteUpdateCounts,
|
||||
}
|
||||
|
||||
pub(super) fn execute_provider_sync_mutation<F>(
|
||||
rollouts: &RolloutScan,
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
target_provider: &str,
|
||||
journal: &mut MutationJournal,
|
||||
hook: &mut F,
|
||||
) -> Result<MutationResult>
|
||||
where
|
||||
F: FnMut(MutationPoint) -> Result<()>,
|
||||
{
|
||||
let result = (|| -> Result<MutationResult> {
|
||||
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
|
||||
journal.applied_rollouts = applied_rollouts;
|
||||
apply_sqlite_updates(pending_sqlite, rollouts, target_provider)?;
|
||||
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
|
||||
Ok(MutationResult {
|
||||
applied_rollouts: journal.applied_rollouts.len(),
|
||||
skipped_rollouts,
|
||||
sqlite_updates,
|
||||
})
|
||||
})();
|
||||
if result.is_err() {
|
||||
rollback_open_transactions(pending_sqlite);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "transaction_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,453 @@
|
||||
use super::*;
|
||||
use crate::sessions::backup::provider_sync_backup_root;
|
||||
use crate::sessions::sync::sync_sessions_provider_with_hook;
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-session-sync-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create test directory");
|
||||
fs::write(path.join("config.toml"), "model_provider = \"custom\"\n")
|
||||
.expect("write shared provider config");
|
||||
path
|
||||
}
|
||||
|
||||
fn write_rollout(path: &Path, id: &str) -> Vec<u8> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create rollout parent");
|
||||
}
|
||||
let content = format!(
|
||||
"{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"openai\",\"cwd\":\"/tmp/project\"}}}}\n"
|
||||
);
|
||||
fs::write(path, content.as_bytes()).expect("write rollout");
|
||||
content.into_bytes()
|
||||
}
|
||||
|
||||
fn create_thread_database(path: &Path, id: &str, rollout: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create sqlite parent");
|
||||
}
|
||||
let conn = Connection::open(path).expect("create thread database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
model_provider TEXT NOT NULL,
|
||||
rollout_path TEXT
|
||||
);",
|
||||
)
|
||||
.expect("create threads table");
|
||||
conn.execute(
|
||||
"INSERT INTO threads (id, model_provider, rollout_path)
|
||||
VALUES (?1, 'openai', ?2)",
|
||||
(id, rollout.display().to_string()),
|
||||
)
|
||||
.expect("insert thread");
|
||||
}
|
||||
|
||||
fn thread_provider(path: &Path, id: &str) -> String {
|
||||
Connection::open(path)
|
||||
.expect("open thread database")
|
||||
.query_row(
|
||||
"SELECT model_provider FROM threads WHERE id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("read thread provider")
|
||||
}
|
||||
|
||||
fn sqlite_quick_check(path: &Path) -> String {
|
||||
Connection::open(path)
|
||||
.expect("open sqlite for quick check")
|
||||
.query_row("PRAGMA quick_check", [], |row| row.get(0))
|
||||
.expect("run quick check")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_update_failure_rolls_back_every_open_database_and_jsonl() {
|
||||
let codex_dir = temp_dir("multi-sqlite-update-failure");
|
||||
let id = "019f6000-0000-7000-8000-000000000401";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
let original_rollout = write_rollout(&rollout, id);
|
||||
let first = codex_dir.join("sqlite/custom.db");
|
||||
let second = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&first, id, &rollout);
|
||||
create_thread_database(&second, id, &rollout);
|
||||
Connection::open(&second)
|
||||
.expect("open failing database")
|
||||
.execute_batch(
|
||||
"CREATE TRIGGER reject_provider_update
|
||||
BEFORE UPDATE OF model_provider ON threads
|
||||
BEGIN SELECT RAISE(ABORT, 'provider update blocked'); END;",
|
||||
)
|
||||
.expect("install rejecting trigger");
|
||||
|
||||
let error = sync_sessions_provider_with_hook(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("custom".to_string()),
|
||||
|_| Ok(()),
|
||||
)
|
||||
.expect_err("second database update must fail");
|
||||
assert!(error.to_string().contains("provider update blocked"));
|
||||
assert_eq!(thread_provider(&first, id), "openai");
|
||||
assert_eq!(thread_provider(&second, id), "openai");
|
||||
assert_eq!(
|
||||
fs::read(&rollout).expect("read restored rollout"),
|
||||
original_rollout
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_commit_failpoint_restores_committed_and_uncommitted_databases() {
|
||||
let codex_dir = temp_dir("sqlite-commit-failpoint");
|
||||
let id = "019f6000-0000-7000-8000-000000000406";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
let original_rollout = write_rollout(&rollout, id);
|
||||
let first = codex_dir.join("sqlite/custom.db");
|
||||
let second = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&first, id, &rollout);
|
||||
create_thread_database(&second, id, &rollout);
|
||||
|
||||
let error = sync_sessions_provider_with_hook(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("custom".to_string()),
|
||||
|point| match point {
|
||||
MutationPoint::AfterSqliteCommit(0) => {
|
||||
Err(CodexxError::Config("提交后注入失败".to_string()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
)
|
||||
.expect_err("fail after first sqlite commit");
|
||||
|
||||
assert_eq!(error.to_string(), "配置错误: 提交后注入失败");
|
||||
assert_eq!(thread_provider(&first, id), "openai");
|
||||
assert_eq!(thread_provider(&second, id), "openai");
|
||||
assert_eq!(
|
||||
fs::read(&rollout).expect("read restored rollout"),
|
||||
original_rollout
|
||||
);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_sqlite_commit_rolls_back_without_snapshot_restore() {
|
||||
let codex_dir = temp_dir("failed-sqlite-commit");
|
||||
let id = "019f6000-0000-7000-8000-000000000407";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
write_rollout(&rollout, id);
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id, &rollout);
|
||||
Connection::open(&database)
|
||||
.expect("open database for deferred constraint")
|
||||
.execute_batch(
|
||||
"CREATE TABLE parent (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE child (
|
||||
parent_id TEXT REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED
|
||||
);",
|
||||
)
|
||||
.expect("create deferred foreign key");
|
||||
|
||||
let conn = Connection::open(&database).expect("open writer");
|
||||
conn.pragma_update(None, "foreign_keys", true)
|
||||
.expect("enable foreign keys");
|
||||
conn.execute_batch(
|
||||
"BEGIN IMMEDIATE;
|
||||
INSERT INTO child (parent_id) VALUES ('missing');",
|
||||
)
|
||||
.expect("defer invalid foreign key until commit");
|
||||
let observer = Connection::open_with_flags(
|
||||
&database,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.expect("open observer");
|
||||
let mut pending = vec![PendingSqliteUpdate {
|
||||
path: database.clone(),
|
||||
conn,
|
||||
observer,
|
||||
columns: HashSet::new(),
|
||||
counts: SqliteUpdateCounts::default(),
|
||||
transaction_open: true,
|
||||
}];
|
||||
let mut journal = MutationJournal::default();
|
||||
|
||||
let error = commit_sqlite_updates(&mut pending, &mut journal, &mut |_| Ok(()))
|
||||
.expect_err("deferred foreign key must reject commit");
|
||||
|
||||
assert!(error.to_string().contains("FOREIGN KEY constraint failed"));
|
||||
assert!(journal.sqlite_restore_attempts.is_empty());
|
||||
assert!(!pending[0].transaction_open);
|
||||
let child_rows: i64 = Connection::open(&database)
|
||||
.expect("reopen database")
|
||||
.query_row("SELECT COUNT(*) FROM child", [], |row| row.get(0))
|
||||
.expect("count rolled-back child rows");
|
||||
assert_eq!(child_rows, 0);
|
||||
|
||||
drop(pending);
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_restore_does_not_overwrite_new_codex_writes() {
|
||||
let codex_dir = temp_dir("sqlite-concurrent-write");
|
||||
let id = "019f6000-0000-7000-8000-000000000409";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
let original_rollout = write_rollout(&rollout, id);
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id, &rollout);
|
||||
|
||||
let error = sync_sessions_provider_with_hook(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("custom".to_string()),
|
||||
|point| match point {
|
||||
MutationPoint::AfterSqliteCommit(0) => {
|
||||
Connection::open(&database)
|
||||
.expect("open concurrent Codex writer")
|
||||
.execute_batch(
|
||||
"CREATE TABLE concurrent_marker (value TEXT NOT NULL);
|
||||
INSERT INTO concurrent_marker (value) VALUES ('keep-new-write');",
|
||||
)
|
||||
.expect("write after provider sync commit");
|
||||
Err(CodexxError::Config("并发写入后注入失败".to_string()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
)
|
||||
.expect_err("recovery must detect the concurrent write");
|
||||
|
||||
assert!(error.to_string().contains("会话数据库已发生变化"));
|
||||
assert_eq!(thread_provider(&database, id), "custom");
|
||||
let marker: String = Connection::open(&database)
|
||||
.expect("open database with concurrent write")
|
||||
.query_row("SELECT value FROM concurrent_marker", [], |row| row.get(0))
|
||||
.expect("read concurrent marker");
|
||||
assert_eq!(marker, "keep-new-write");
|
||||
assert_eq!(
|
||||
fs::read(&rollout).expect("read restored rollout"),
|
||||
original_rollout
|
||||
);
|
||||
assert!(fs::read_dir(provider_sync_backup_root(&codex_dir))
|
||||
.expect("read retained provider sync backup")
|
||||
.next()
|
||||
.is_some());
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_restore_rechecks_after_waiting_for_a_writer() {
|
||||
let codex_dir = temp_dir("sqlite-writer-during-restore");
|
||||
let id = "019f6000-0000-7000-8000-000000000412";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
write_rollout(&rollout, id);
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id, &rollout);
|
||||
let mut writer = None;
|
||||
|
||||
let error = sync_sessions_provider_with_hook(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("custom".to_string()),
|
||||
|point| match point {
|
||||
MutationPoint::AfterSqliteCommit(0) => {
|
||||
let writer_database = database.clone();
|
||||
let (ready_tx, ready_rx) = mpsc::channel();
|
||||
writer = Some(thread::spawn(move || {
|
||||
let conn = Connection::open(writer_database).expect("open concurrent writer");
|
||||
conn.execute_batch(
|
||||
"BEGIN IMMEDIATE;
|
||||
CREATE TABLE concurrent_during_restore (value TEXT NOT NULL);
|
||||
INSERT INTO concurrent_during_restore (value)
|
||||
VALUES ('keep-writer-commit');",
|
||||
)
|
||||
.expect("stage concurrent write");
|
||||
ready_tx.send(()).expect("signal writer lock");
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
conn.execute_batch("COMMIT")
|
||||
.expect("commit while recovery waits");
|
||||
}));
|
||||
ready_rx
|
||||
.recv_timeout(Duration::from_secs(2))
|
||||
.expect("wait for concurrent writer lock");
|
||||
Err(CodexxError::Config("持锁写入期间注入失败".to_string()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
)
|
||||
.expect_err("recovery must recheck after waiting for the writer");
|
||||
|
||||
writer
|
||||
.take()
|
||||
.expect("writer handle")
|
||||
.join()
|
||||
.expect("join concurrent writer");
|
||||
assert!(error.to_string().contains("会话数据库已发生变化"));
|
||||
assert_eq!(thread_provider(&database, id), "custom");
|
||||
let marker: String = Connection::open(&database)
|
||||
.expect("open database after concurrent commit")
|
||||
.query_row("SELECT value FROM concurrent_during_restore", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.expect("read concurrent marker");
|
||||
assert_eq!(marker, "keep-writer-commit");
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn sqlite_prepare_deduplicates_symlink_aliases() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let codex_dir = temp_dir("sqlite-symlink-alias");
|
||||
let id = "019f6000-0000-7000-8000-000000000410";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
write_rollout(&rollout, id);
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
let alias = codex_dir.join("state-alias.sqlite");
|
||||
create_thread_database(&database, id, &rollout);
|
||||
symlink(&database, &alias).expect("create database symlink");
|
||||
|
||||
let mut pending =
|
||||
prepare_sqlite_updates(&[database.clone(), alias]).expect("prepare aliased database once");
|
||||
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(
|
||||
pending[0].path,
|
||||
database.canonicalize().expect("canonical db")
|
||||
);
|
||||
rollback_open_transactions(&mut pending);
|
||||
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn injected_failure_restores_sqlite_and_jsonl_without_touching_global_state() {
|
||||
let codex_dir = temp_dir("full-mutation-rollback");
|
||||
let id = "019f6000-0000-7000-8000-000000000411";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
write_rollout(&rollout, id);
|
||||
let mut rollout_file = fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&rollout)
|
||||
.expect("open rollout for user event");
|
||||
writeln!(
|
||||
rollout_file,
|
||||
"{{\"type\":\"event_msg\",\"payload\":{{\"type\":\"user_message\",\"message\":\"hello\"}}}}"
|
||||
)
|
||||
.expect("append user event");
|
||||
drop(rollout_file);
|
||||
let original_rollout = fs::read(&rollout).expect("read original rollout");
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id, &rollout);
|
||||
let wal_guard = Connection::open(&database).expect("open database for WAL mode");
|
||||
wal_guard
|
||||
.pragma_update(None, "journal_mode", "WAL")
|
||||
.expect("enable WAL mode");
|
||||
wal_guard
|
||||
.execute_batch(
|
||||
"ALTER TABLE threads ADD COLUMN has_user_event INTEGER DEFAULT 0;
|
||||
ALTER TABLE threads ADD COLUMN cwd TEXT;
|
||||
UPDATE threads SET cwd = '/tmp/wrong';
|
||||
CREATE TABLE rollback_marker (value TEXT NOT NULL);
|
||||
INSERT INTO rollback_marker (value) VALUES ('keep-me');",
|
||||
)
|
||||
.expect("write WAL marker");
|
||||
let wal_path = PathBuf::from(format!("{}-wal", database.display()));
|
||||
assert!(wal_path.exists());
|
||||
let global = codex_dir.join(".codex-global-state.json");
|
||||
let global_backup = codex_dir.join(".codex-global-state.json.bak");
|
||||
let original_global = br#"{
|
||||
"electron-saved-workspace-roots": "/tmp/project",
|
||||
"unrelated-setting": true
|
||||
}"#;
|
||||
fs::write(&global, original_global).expect("write original global state");
|
||||
assert!(!global_backup.exists());
|
||||
|
||||
let mut hook_called = false;
|
||||
let error = sync_sessions_provider_with_hook(
|
||||
Some(codex_dir.display().to_string()),
|
||||
Some("custom".to_string()),
|
||||
|point| match point {
|
||||
MutationPoint::AfterSqliteCommit(0) => {
|
||||
hook_called = true;
|
||||
assert_eq!(thread_provider(&database, id), "custom");
|
||||
let user_event_flag: i64 = Connection::open(&database)
|
||||
.expect("open committed session metadata")
|
||||
.query_row(
|
||||
"SELECT has_user_event FROM threads WHERE id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("read committed user event flag");
|
||||
assert_eq!(user_event_flag, 0);
|
||||
assert!(fs::read_to_string(&rollout)
|
||||
.expect("read mutated rollout")
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
assert_eq!(
|
||||
fs::read(&global).expect("read untouched global state"),
|
||||
original_global
|
||||
);
|
||||
assert!(!global_backup.exists());
|
||||
Err(CodexxError::Config("测试注入失败".to_string()))
|
||||
}
|
||||
_ => Ok(()),
|
||||
},
|
||||
)
|
||||
.expect_err("hook must fail after all writes");
|
||||
|
||||
assert!(hook_called);
|
||||
assert_eq!(error.to_string(), "配置错误: 测试注入失败");
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
let restored_index: (i64, String) = Connection::open(&database)
|
||||
.expect("open restored session metadata")
|
||||
.query_row(
|
||||
"SELECT has_user_event, cwd FROM threads WHERE id = ?1",
|
||||
[id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.expect("read restored session metadata");
|
||||
assert_eq!(restored_index, (0, "/tmp/wrong".to_string()));
|
||||
assert_eq!(sqlite_quick_check(&database), "ok");
|
||||
let marker: String = Connection::open(&database)
|
||||
.expect("open restored database")
|
||||
.query_row("SELECT value FROM rollback_marker", [], |row| row.get(0))
|
||||
.expect("read restored WAL marker");
|
||||
assert_eq!(marker, "keep-me");
|
||||
assert_eq!(
|
||||
fs::read(&rollout).expect("read restored rollout"),
|
||||
original_rollout
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(&global).expect("read restored global"),
|
||||
original_global
|
||||
);
|
||||
assert!(!global_backup.exists());
|
||||
let retained_backup = fs::read_dir(provider_sync_backup_root(&codex_dir))
|
||||
.expect("read retained provider sync backup")
|
||||
.next()
|
||||
.expect("retained provider sync backup")
|
||||
.expect("read retained provider sync backup entry")
|
||||
.path();
|
||||
let metadata = fs::read_to_string(retained_backup.join("metadata.json"))
|
||||
.expect("read retained provider sync metadata");
|
||||
assert!(!metadata.contains("config.toml"));
|
||||
assert!(!metadata.contains(".codex-global-state.json"));
|
||||
assert!(!retained_backup.join("config.toml").exists());
|
||||
assert!(!retained_backup.join(".codex-global-state.json").exists());
|
||||
|
||||
drop(wal_guard);
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
use serde::Serialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SessionPreview {
|
||||
pub(crate) id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) model_provider: Option<String>,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) cwd: Option<String>,
|
||||
pub(crate) rollout_path: Option<String>,
|
||||
pub(crate) updated_at_ms: Option<i64>,
|
||||
pub(crate) archived: bool,
|
||||
pub(crate) has_user_event: bool,
|
||||
pub(crate) is_subagent: bool,
|
||||
pub(crate) needs_sync: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SessionSyncStatus {
|
||||
pub(crate) codex_dir: String,
|
||||
pub(crate) target_provider: String,
|
||||
pub(crate) rollout_files: usize,
|
||||
pub(crate) session_meta_count: usize,
|
||||
pub(crate) mismatched_rollouts: usize,
|
||||
pub(crate) mismatched_session_meta: usize,
|
||||
pub(crate) sqlite_dbs: usize,
|
||||
pub(crate) sqlite_threads: usize,
|
||||
pub(crate) top_level_threads: usize,
|
||||
pub(crate) subagent_threads: usize,
|
||||
pub(crate) mismatched_threads: usize,
|
||||
pub(crate) mismatched_sessions: usize,
|
||||
pub(crate) needs_sync: bool,
|
||||
pub(crate) scan_complete: bool,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
pub(crate) backup_dir: Option<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) sessions: Vec<SessionPreview>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SessionSyncResult {
|
||||
pub(crate) status: SessionSyncStatus,
|
||||
pub(crate) updated_rollouts: usize,
|
||||
pub(crate) updated_threads: usize,
|
||||
pub(crate) backup_dir: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct RolloutScan {
|
||||
pub(crate) discovered_rollout_files: usize,
|
||||
pub(crate) rollout_files: usize,
|
||||
pub(crate) session_meta_count: usize,
|
||||
pub(crate) mismatched_rollouts: usize,
|
||||
pub(crate) mismatched_session_meta: usize,
|
||||
pub(crate) changes: Vec<SessionFileChange>,
|
||||
pub(crate) cwd_by_thread_id: HashMap<String, String>,
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct SessionFileChange {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) original_text: String,
|
||||
pub(crate) next_text: String,
|
||||
pub(crate) original_mtime: Option<SystemTime>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct SqliteScan {
|
||||
pub(crate) sqlite_dbs: usize,
|
||||
pub(crate) sqlite_threads: usize,
|
||||
pub(crate) top_level_threads: usize,
|
||||
pub(crate) subagent_threads: usize,
|
||||
pub(crate) mismatched_threads: usize,
|
||||
pub(crate) thread_ids: HashSet<String>,
|
||||
pub(crate) rollout_paths_by_thread_id: HashMap<String, String>,
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
use super::build_skills_mcp_state_inner;
|
||||
use super::types::{ManagedMcpServer, SkillsMcpState};
|
||||
use crate::ccswitch::default_ccswitch_db_path;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, parse_toml_document, read_to_string_if_exists, write_text};
|
||||
use crate::toml_utils::ensure_table;
|
||||
use crate::{config_path, now_rfc3339, open_db, resolve_codex_dir};
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use toml_edit::{value, Item, Table};
|
||||
|
||||
fn toml_value_to_json(value: &toml_edit::Value) -> Value {
|
||||
if let Some(s) = value.as_str() {
|
||||
return json!(s);
|
||||
}
|
||||
if let Some(i) = value.as_integer() {
|
||||
return json!(i);
|
||||
}
|
||||
if let Some(f) = value.as_float() {
|
||||
return json!(f);
|
||||
}
|
||||
if let Some(b) = value.as_bool() {
|
||||
return json!(b);
|
||||
}
|
||||
if let Some(arr) = value.as_array() {
|
||||
return Value::Array(arr.iter().map(toml_value_to_json).collect());
|
||||
}
|
||||
Value::String(value.to_string())
|
||||
}
|
||||
|
||||
fn toml_item_to_json(item: &Item) -> Value {
|
||||
if let Some(v) = item.as_value() {
|
||||
return toml_value_to_json(v);
|
||||
}
|
||||
if let Some(tbl) = item.as_table() {
|
||||
let mut obj = serde_json::Map::new();
|
||||
for (k, v) in tbl.iter() {
|
||||
obj.insert(k.to_string(), toml_item_to_json(v));
|
||||
}
|
||||
return Value::Object(obj);
|
||||
}
|
||||
Value::Null
|
||||
}
|
||||
|
||||
fn json_to_toml_item(value_json: &Value) -> Item {
|
||||
match value_json {
|
||||
Value::String(s) => value(s.clone()),
|
||||
Value::Bool(b) => value(*b),
|
||||
Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
value(i)
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
value(f)
|
||||
} else {
|
||||
value(n.to_string())
|
||||
}
|
||||
}
|
||||
Value::Array(arr) => {
|
||||
let mut toml_arr = toml_edit::Array::default();
|
||||
for item in arr {
|
||||
match item {
|
||||
Value::String(s) => {
|
||||
toml_arr.push(s.as_str());
|
||||
}
|
||||
Value::Bool(b) => {
|
||||
toml_arr.push(*b);
|
||||
}
|
||||
Value::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
toml_arr.push(i);
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
toml_arr.push(f);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
value(toml_arr)
|
||||
}
|
||||
Value::Object(obj) => {
|
||||
let mut table = Table::new();
|
||||
for (k, v) in obj {
|
||||
table.insert(k, json_to_toml_item(v));
|
||||
}
|
||||
Item::Table(table)
|
||||
}
|
||||
Value::Null => value(""),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mcp_summary(config: &Value) -> (String, Option<String>, Option<String>, String) {
|
||||
let transport = config
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_else(|| {
|
||||
if config.get("url").is_some() {
|
||||
"http"
|
||||
} else {
|
||||
"stdio"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let command = config
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
let url = config
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string);
|
||||
let args = config
|
||||
.get("args")
|
||||
.and_then(Value::as_array)
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let summary = if let Some(cmd) = &command {
|
||||
if args.is_empty() {
|
||||
cmd.clone()
|
||||
} else {
|
||||
format!("{cmd} {args}")
|
||||
}
|
||||
} else if let Some(url) = &url {
|
||||
url.clone()
|
||||
} else {
|
||||
transport.clone()
|
||||
};
|
||||
(transport, command, url, summary)
|
||||
}
|
||||
|
||||
pub(super) fn save_managed_mcp(id: &str, name: &str, config: &Value, enabled: bool) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
conn.execute(
|
||||
"INSERT INTO managed_mcp_servers (id, name, server_config, enabled, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
server_config = excluded.server_config,
|
||||
enabled = excluded.enabled,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
id,
|
||||
name,
|
||||
serde_json::to_string(config).unwrap_or_default(),
|
||||
enabled,
|
||||
now_rfc3339()
|
||||
],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn db_managed_mcp() -> Result<Vec<(String, String, Value, bool)>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn.prepare("SELECT id, name, server_config, enabled FROM managed_mcp_servers ORDER BY name ASC, id ASC")
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let text: String = row.get(2)?;
|
||||
let config = serde_json::from_str(&text).unwrap_or(Value::Object(Default::default()));
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
config,
|
||||
row.get::<_, bool>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row.map_err(|e| CodexxError::Database(e.to_string()))?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(super) fn list_mcp_from_config(codex_dir: &Path) -> Result<Vec<ManagedMcpServer>> {
|
||||
let cfg = config_path(codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
if text.trim().is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let Some(mcp_item) = doc.get("mcp_servers") else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let Some(mcp_tbl) = mcp_item.as_table() else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (id, item) in mcp_tbl.iter() {
|
||||
if !item.is_table() {
|
||||
continue;
|
||||
}
|
||||
let config = toml_item_to_json(item);
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
out.push(ManagedMcpServer {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
transport,
|
||||
enabled: true,
|
||||
source: "config.toml".to_string(),
|
||||
summary,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn sort_managed_mcp_servers(servers: &mut [ManagedMcpServer]) {
|
||||
servers.sort_by(|a, b| {
|
||||
a.name
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&b.name.to_ascii_lowercase())
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn import_ccswitch_mcp_servers_for_codex(
|
||||
codex_dir: &Path,
|
||||
imported_ids: &mut HashSet<String>,
|
||||
) -> Result<usize> {
|
||||
let db = default_ccswitch_db_path()?;
|
||||
if !db.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let conn = Connection::open_with_flags(
|
||||
&db,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!(
|
||||
"打开 cc-switch MCP 数据库失败 {}: {e}",
|
||||
db.display()
|
||||
))
|
||||
})?;
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT id, name, server_config, enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, server_config, 0 AS enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
}) {
|
||||
Ok(stmt) => stmt,
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
|
||||
if message.to_lowercase().contains("no such table") =>
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
Err(e) => return Err(CodexxError::Database(e.to_string())),
|
||||
};
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, bool>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
let cfg = config_path(codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
let live_enabled = list_mcp_from_config(codex_dir)?
|
||||
.into_iter()
|
||||
.map(|server| server.id)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut imported = 0usize;
|
||||
let mut changed_config = false;
|
||||
for row in rows {
|
||||
let (id, name, config_text, enabled_codex) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
|
||||
if !imported_ids.insert(id.clone()) {
|
||||
continue;
|
||||
}
|
||||
let enabled = enabled_codex || live_enabled.contains(&id);
|
||||
save_managed_mcp(&id, &name, &config, enabled)?;
|
||||
if enabled_codex && !live_enabled.contains(&id) {
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?
|
||||
.insert(&id, json_to_toml_item(&config));
|
||||
changed_config = true;
|
||||
}
|
||||
imported += 1;
|
||||
}
|
||||
if changed_config {
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
}
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
codex_dir: &Path,
|
||||
) -> Result<Vec<ManagedMcpServer>> {
|
||||
let db = default_ccswitch_db_path()?;
|
||||
if !db.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let conn = Connection::open_with_flags(
|
||||
&db,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!(
|
||||
"打开 cc-switch MCP 数据库失败 {}: {e}",
|
||||
db.display()
|
||||
))
|
||||
})?;
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT id, name, server_config, enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, server_config, 0 AS enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
}) {
|
||||
Ok(stmt) => stmt,
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
|
||||
if message.to_lowercase().contains("no such table") =>
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Err(e) => return Err(CodexxError::Database(e.to_string())),
|
||||
};
|
||||
let live_enabled = list_mcp_from_config(codex_dir)?
|
||||
.into_iter()
|
||||
.map(|server| server.id)
|
||||
.collect::<HashSet<_>>();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, bool>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, config_text, enabled_codex) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
out.push(ManagedMcpServer {
|
||||
id: id.clone(),
|
||||
name,
|
||||
transport,
|
||||
enabled: enabled_codex || live_enabled.contains(&id),
|
||||
source: "cc-switch".to_string(),
|
||||
summary,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_codex_mcp_inner(
|
||||
config_dir: Option<String>,
|
||||
id: String,
|
||||
enabled: bool,
|
||||
) -> Result<SkillsMcpState> {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let mut doc = parse_toml_document(&cfg, &text)?;
|
||||
if enabled {
|
||||
let db = db_managed_mcp()?
|
||||
.into_iter()
|
||||
.find(|(sid, _, _, _)| sid == &id)
|
||||
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&db.2));
|
||||
save_managed_mcp(&id, &db.1, &db.2, true)?;
|
||||
} else {
|
||||
if let Some(item) = doc
|
||||
.get("mcp_servers")
|
||||
.and_then(|m| m.as_table())
|
||||
.and_then(|tbl| tbl.get(&id))
|
||||
{
|
||||
let config = toml_item_to_json(item);
|
||||
save_managed_mcp(&id, &id, &config, false)?;
|
||||
}
|
||||
if let Some(tbl) = doc.get_mut("mcp_servers").and_then(|m| m.as_table_mut()) {
|
||||
tbl.remove(&id);
|
||||
}
|
||||
}
|
||||
write_text(&cfg, &doc.to_string())?;
|
||||
build_skills_mcp_state_inner(config_dir)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
mod mcp;
|
||||
mod skills;
|
||||
mod types;
|
||||
|
||||
pub(crate) use mcp::{sort_managed_mcp_servers, toggle_codex_mcp_inner};
|
||||
pub(crate) use skills::{
|
||||
check_skill_updates_inner, install_skill_zip_inner, normalize_legacy_zip_skill_dirs,
|
||||
sort_managed_skills, toggle_codex_skill_inner,
|
||||
};
|
||||
pub(crate) use types::{
|
||||
ManagedMcpServer, SkillsMcpActionResult, SkillsMcpImportPreview, SkillsMcpState,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use skills::read_skill_metadata;
|
||||
#[cfg(test)]
|
||||
pub(crate) use types::ManagedSkill;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{ensure_directory, io_err};
|
||||
use crate::paths::home_dir;
|
||||
use crate::resolve_codex_dir;
|
||||
use mcp::{
|
||||
db_managed_mcp, import_ccswitch_mcp_servers_for_codex, list_mcp_from_config, mcp_summary,
|
||||
preview_ccswitch_mcp_servers_for_codex, save_managed_mcp,
|
||||
};
|
||||
use skills::{
|
||||
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, sanitize_dir_name, scan_skill_dir,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
|
||||
fn extend_unmanaged_mcp_candidates(
|
||||
output: &mut Vec<ManagedMcpServer>,
|
||||
seen_ids: &mut HashSet<String>,
|
||||
candidates: impl IntoIterator<Item = ManagedMcpServer>,
|
||||
) {
|
||||
for server in candidates {
|
||||
if seen_ids.insert(server.id.clone()) {
|
||||
output.push(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result<SkillsMcpState> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
let disabled_dir = disabled_skills_dir()?;
|
||||
let mut warnings = Vec::new();
|
||||
let mut skills = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
if let Err(e) = normalize_legacy_zip_skill_dirs(&skills_dir) {
|
||||
warnings.push(format!("修正 ZIP Skill 目录名失败: {e}"));
|
||||
}
|
||||
if let Err(e) = normalize_legacy_zip_skill_dirs(&disabled_dir) {
|
||||
warnings.push(format!("修正已禁用 ZIP Skill 目录名失败: {e}"));
|
||||
}
|
||||
if let Err(e) = scan_skill_dir(&skills_dir, true, "Codex", &mut skills, &mut seen) {
|
||||
warnings.push(e.to_string());
|
||||
}
|
||||
if let Err(e) = scan_skill_dir(
|
||||
&disabled_dir,
|
||||
false,
|
||||
"Codex-X 已禁用",
|
||||
&mut skills,
|
||||
&mut seen,
|
||||
) {
|
||||
warnings.push(e.to_string());
|
||||
}
|
||||
|
||||
let mut mcp_servers = list_mcp_from_config(&codex_dir)?;
|
||||
let enabled_ids: HashSet<String> = mcp_servers.iter().map(|s| s.id.clone()).collect();
|
||||
for (id, name, config, enabled) in db_managed_mcp()? {
|
||||
if enabled_ids.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
mcp_servers.push(ManagedMcpServer {
|
||||
id,
|
||||
name,
|
||||
transport,
|
||||
enabled,
|
||||
source: "Codex-X".to_string(),
|
||||
summary,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
});
|
||||
}
|
||||
sort_managed_mcp_servers(&mut mcp_servers);
|
||||
sort_managed_skills(&mut skills);
|
||||
Ok(SkillsMcpState {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
codex_skills_dir: skills_dir.display().to_string(),
|
||||
disabled_skills_dir: disabled_dir.display().to_string(),
|
||||
skills,
|
||||
mcp_servers,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn import_existing_skills_mcp_inner(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
ensure_directory(&skills_dir)?;
|
||||
let mut imported_skills = 0usize;
|
||||
let candidates = vec![
|
||||
home_dir()?.join(".agents").join("skills"),
|
||||
home_dir()?.join(".cc-switch").join("skills"),
|
||||
];
|
||||
for base in candidates {
|
||||
if !base.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in fs::read_dir(&base).map_err(|e| io_err(&base, e))? {
|
||||
let entry = entry.map_err(|e| io_err(&base, e))?;
|
||||
let src = entry.path();
|
||||
if !src.is_dir() || !src.join("SKILL.md").is_file() {
|
||||
continue;
|
||||
}
|
||||
let directory = sanitize_dir_name(&entry.file_name().to_string_lossy(), "skill");
|
||||
let dst = skills_dir.join(&directory);
|
||||
if !dst.exists() {
|
||||
copy_dir_recursive(&src, &dst)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut imported_mcp = 0usize;
|
||||
let mut imported_mcp_ids = db_managed_mcp()?
|
||||
.into_iter()
|
||||
.map(|(id, _, _, _)| id)
|
||||
.collect::<HashSet<_>>();
|
||||
for server in list_mcp_from_config(&codex_dir)? {
|
||||
if !imported_mcp_ids.insert(server.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
save_managed_mcp(&server.id, &server.name, &server.config_json, true)?;
|
||||
imported_mcp += 1;
|
||||
}
|
||||
imported_mcp += import_ccswitch_mcp_servers_for_codex(&codex_dir, &mut imported_mcp_ids)?;
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
Ok(SkillsMcpActionResult {
|
||||
imported_skills,
|
||||
imported_mcp,
|
||||
message: format!("已导入 {imported_skills} 个 Skills,纳管 {imported_mcp} 个 MCP"),
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn preview_existing_skills_mcp_inner(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<SkillsMcpImportPreview> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
let mut warnings = Vec::new();
|
||||
let mut skills = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let candidates = vec![
|
||||
home_dir()?.join(".agents").join("skills"),
|
||||
home_dir()?.join(".cc-switch").join("skills"),
|
||||
];
|
||||
for base in candidates {
|
||||
if !base.exists() {
|
||||
continue;
|
||||
}
|
||||
let source = base
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "外部目录".to_string());
|
||||
let before = skills.len();
|
||||
if let Err(e) = scan_skill_dir(&base, false, &source, &mut skills, &mut seen) {
|
||||
warnings.push(e.to_string());
|
||||
}
|
||||
for skill in &mut skills[before..] {
|
||||
if skills_dir.join(&skill.directory).exists() {
|
||||
skill.update_status = "已存在,将跳过".to_string();
|
||||
} else {
|
||||
skill.update_status = "可导入".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
skills.retain(|skill| skill.update_status != "已存在,将跳过");
|
||||
|
||||
let mut config_mcp_servers = list_mcp_from_config(&codex_dir)?;
|
||||
for server in &mut config_mcp_servers {
|
||||
server.source = "config.toml".to_string();
|
||||
}
|
||||
let mut seen_mcp = db_managed_mcp()?
|
||||
.into_iter()
|
||||
.map(|(id, _, _, _)| id)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut mcp_servers = Vec::new();
|
||||
extend_unmanaged_mcp_candidates(&mut mcp_servers, &mut seen_mcp, config_mcp_servers);
|
||||
extend_unmanaged_mcp_candidates(
|
||||
&mut mcp_servers,
|
||||
&mut seen_mcp,
|
||||
preview_ccswitch_mcp_servers_for_codex(&codex_dir)?,
|
||||
);
|
||||
sort_managed_skills(&mut skills);
|
||||
sort_managed_mcp_servers(&mut mcp_servers);
|
||||
Ok(SkillsMcpImportPreview {
|
||||
skills,
|
||||
mcp_servers,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn mcp_candidate(id: &str, source: &str) -> ManagedMcpServer {
|
||||
ManagedMcpServer {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
transport: "stdio".to_string(),
|
||||
enabled: true,
|
||||
source: source.to_string(),
|
||||
summary: id.to_string(),
|
||||
command: Some(id.to_string()),
|
||||
url: None,
|
||||
config_json: json!({ "command": id }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_import_preview_excludes_managed_and_duplicate_ids() {
|
||||
let mut seen = HashSet::from(["alpha".to_string()]);
|
||||
let mut candidates = Vec::new();
|
||||
extend_unmanaged_mcp_candidates(
|
||||
&mut candidates,
|
||||
&mut seen,
|
||||
[
|
||||
mcp_candidate("alpha", "config.toml"),
|
||||
mcp_candidate("beta", "config.toml"),
|
||||
],
|
||||
);
|
||||
extend_unmanaged_mcp_candidates(
|
||||
&mut candidates,
|
||||
&mut seen,
|
||||
[
|
||||
mcp_candidate("beta", "cc-switch"),
|
||||
mcp_candidate("gamma", "cc-switch"),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
candidates
|
||||
.iter()
|
||||
.map(|server| (server.id.as_str(), server.source.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("beta", "config.toml"), ("gamma", "cc-switch")]
|
||||
);
|
||||
|
||||
let mut imported_ids = seen;
|
||||
let mut second_preview = Vec::new();
|
||||
extend_unmanaged_mcp_candidates(
|
||||
&mut second_preview,
|
||||
&mut imported_ids,
|
||||
[
|
||||
mcp_candidate("alpha", "config.toml"),
|
||||
mcp_candidate("beta", "config.toml"),
|
||||
mcp_candidate("gamma", "cc-switch"),
|
||||
],
|
||||
);
|
||||
assert!(second_preview.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
use super::build_skills_mcp_state_inner;
|
||||
use super::types::{CcSwitchSkillMeta, ManagedSkill, SkillsMcpActionResult, SkillsMcpState};
|
||||
use crate::ccswitch::default_ccswitch_db_path;
|
||||
use crate::constants::MAX_SKILL_ZIP_BYTES;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, read_to_string_if_exists};
|
||||
use crate::paths::app_home;
|
||||
use crate::{now_rfc3339, open_db, resolve_codex_dir};
|
||||
use chrono::Local;
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) fn codex_skills_dir(codex_dir: &Path) -> PathBuf {
|
||||
codex_dir.join("skills")
|
||||
}
|
||||
|
||||
pub(super) fn disabled_skills_dir() -> Result<PathBuf> {
|
||||
Ok(app_home()?.join("disabled-skills"))
|
||||
}
|
||||
|
||||
pub(super) fn sanitize_dir_name(input: &str, fallback: &str) -> String {
|
||||
let raw = input.trim().trim_matches('/').trim_matches('\\');
|
||||
let base = if raw.is_empty() { fallback } else { raw };
|
||||
let mut out = String::new();
|
||||
let mut last_dash = false;
|
||||
for ch in base.to_ascii_lowercase().chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
|
||||
out.push(ch);
|
||||
last_dash = false;
|
||||
} else if !last_dash {
|
||||
out.push('-');
|
||||
last_dash = true;
|
||||
}
|
||||
}
|
||||
let out = out.trim_matches('-');
|
||||
if out.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
out.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
|
||||
ensure_directory(dst)?;
|
||||
for entry in fs::read_dir(src).map_err(|e| io_err(src, e))? {
|
||||
let entry = entry.map_err(|e| io_err(src, e))?;
|
||||
let path = entry.path();
|
||||
let file_name = entry.file_name();
|
||||
let target = dst.join(file_name);
|
||||
let meta = fs::symlink_metadata(&path).map_err(|e| io_err(&path, e))?;
|
||||
if meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if meta.is_dir() {
|
||||
copy_dir_recursive(&path, &target)?;
|
||||
} else if meta.is_file() {
|
||||
if let Some(parent) = target.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
fs::copy(&path, &target).map_err(|e| io_err(&target, e))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compute_dir_hash(dir: &Path) -> Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
fn collect(base: &Path, current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
for entry in fs::read_dir(current).map_err(|e| io_err(current, e))? {
|
||||
let entry = entry.map_err(|e| io_err(current, e))?;
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let meta = fs::symlink_metadata(&path).map_err(|e| io_err(&path, e))?;
|
||||
if meta.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
if meta.is_dir() {
|
||||
collect(base, &path, out)?;
|
||||
} else if meta.is_file() {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
let _ = base;
|
||||
Ok(())
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
collect(dir, dir, &mut files)?;
|
||||
files.sort();
|
||||
let mut hasher = Sha256::new();
|
||||
for path in files {
|
||||
let rel = path
|
||||
.strip_prefix(dir)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
hasher.update(rel.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
let bytes = fs::read(&path).map_err(|e| io_err(&path, e))?;
|
||||
hasher.update(&bytes);
|
||||
hasher.update(b"\0");
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
}
|
||||
|
||||
fn clean_skill_metadata_value(value: &str) -> String {
|
||||
value
|
||||
.trim()
|
||||
.trim_matches('`')
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn read_skill_metadata(skill_dir: &Path, fallback: &str) -> (String, Option<String>) {
|
||||
let skill_md = skill_dir.join("SKILL.md");
|
||||
let text = read_to_string_if_exists(&skill_md).unwrap_or_default();
|
||||
let mut frontmatter_name: Option<String> = None;
|
||||
let mut frontmatter_desc: Option<String> = None;
|
||||
let mut heading_title: Option<String> = None;
|
||||
let mut body_desc: Option<String> = None;
|
||||
let mut in_frontmatter = false;
|
||||
let mut frontmatter_seen = false;
|
||||
|
||||
for (index, line) in text.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
if index == 0 && trimmed == "---" {
|
||||
in_frontmatter = true;
|
||||
frontmatter_seen = true;
|
||||
continue;
|
||||
}
|
||||
if in_frontmatter {
|
||||
if trimmed == "---" {
|
||||
in_frontmatter = false;
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = trimmed.split_once(':') {
|
||||
let key = key.trim().to_ascii_lowercase();
|
||||
let value = clean_skill_metadata_value(value);
|
||||
if key == "name" && frontmatter_name.is_none() && !value.is_empty() {
|
||||
frontmatter_name = Some(value);
|
||||
} else if key == "description" && frontmatter_desc.is_none() && !value.is_empty() {
|
||||
frontmatter_desc = Some(value);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if heading_title.is_none() && trimmed.starts_with('#') {
|
||||
heading_title = Some(trimmed.trim_start_matches('#').trim().to_string());
|
||||
continue;
|
||||
}
|
||||
if body_desc.is_none()
|
||||
&& !trimmed.starts_with('#')
|
||||
&& !trimmed.starts_with("---")
|
||||
&& !frontmatter_seen
|
||||
{
|
||||
body_desc = Some(clean_skill_metadata_value(trimmed));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let title = frontmatter_name
|
||||
.or(heading_title)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| fallback.to_string());
|
||||
let desc = frontmatter_desc.or(body_desc).filter(|s| !s.is_empty());
|
||||
(title, desc)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_legacy_zip_skill_dirs(base: &Path) -> Result<()> {
|
||||
if !base.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(base).map_err(|e| io_err(base, e))? {
|
||||
let entry = entry.map_err(|e| io_err(base, e))?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() || !path.join("SKILL.md").is_file() {
|
||||
continue;
|
||||
}
|
||||
let directory = entry.file_name().to_string_lossy().to_string();
|
||||
if !directory.starts_with("skill-zip-") {
|
||||
continue;
|
||||
}
|
||||
let (name, _) = read_skill_metadata(&path, &directory);
|
||||
let dst_name = sanitize_dir_name(&name, "skill");
|
||||
if dst_name == directory || dst_name.starts_with("skill-zip-") {
|
||||
continue;
|
||||
}
|
||||
let dst = base.join(dst_name);
|
||||
if dst.exists() {
|
||||
continue;
|
||||
}
|
||||
fs::rename(&path, &dst).map_err(|e| io_err(&dst, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save_managed_skill(skill: &ManagedSkill) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
conn.execute(
|
||||
"INSERT INTO managed_skills (id, name, description, directory, source_path, content_hash, enabled, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
description = excluded.description,
|
||||
directory = excluded.directory,
|
||||
source_path = excluded.source_path,
|
||||
content_hash = excluded.content_hash,
|
||||
enabled = excluded.enabled,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
skill.id,
|
||||
skill.name,
|
||||
skill.description,
|
||||
skill.directory,
|
||||
skill.path,
|
||||
skill.content_hash,
|
||||
skill.enabled,
|
||||
now_rfc3339()
|
||||
],
|
||||
).map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn scan_skill_dir(
|
||||
base: &Path,
|
||||
enabled: bool,
|
||||
source: &str,
|
||||
out: &mut Vec<ManagedSkill>,
|
||||
seen: &mut HashSet<String>,
|
||||
) -> Result<()> {
|
||||
if !base.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(base).map_err(|e| io_err(base, e))? {
|
||||
let entry = entry.map_err(|e| io_err(base, e))?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() || !path.join("SKILL.md").is_file() {
|
||||
continue;
|
||||
}
|
||||
let directory = entry.file_name().to_string_lossy().to_string();
|
||||
let id = sanitize_dir_name(&directory, "skill");
|
||||
if seen.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let (name, description) = read_skill_metadata(&path, &directory);
|
||||
let hash = compute_dir_hash(&path).ok();
|
||||
out.push(ManagedSkill {
|
||||
id: id.clone(),
|
||||
name,
|
||||
description,
|
||||
directory,
|
||||
enabled,
|
||||
source: source.to_string(),
|
||||
path: path.display().to_string(),
|
||||
content_hash: hash,
|
||||
update_status: "未检查".to_string(),
|
||||
});
|
||||
seen.insert(id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn sort_managed_skills(skills: &mut [ManagedSkill]) {
|
||||
skills.sort_by(|a, b| {
|
||||
a.name
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&b.name.to_ascii_lowercase())
|
||||
.then_with(|| a.id.cmp(&b.id))
|
||||
});
|
||||
}
|
||||
|
||||
fn move_dir_replace(src: &Path, dst: &Path) -> Result<()> {
|
||||
if !src.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
if dst.exists() {
|
||||
fs::remove_dir_all(dst).map_err(|e| io_err(dst, e))?;
|
||||
}
|
||||
fs::rename(src, dst)
|
||||
.or_else(|_| {
|
||||
copy_dir_recursive(src, dst).map(|_| {
|
||||
let _ = fs::remove_dir_all(src);
|
||||
})
|
||||
})
|
||||
.map_err(|e| {
|
||||
CodexxError::Config(format!(
|
||||
"移动目录失败 {} -> {}: {e}",
|
||||
src.display(),
|
||||
dst.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_codex_skill_inner(
|
||||
config_dir: Option<String>,
|
||||
id: String,
|
||||
enabled: bool,
|
||||
) -> Result<SkillsMcpState> {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
let disabled_dir = disabled_skills_dir()?;
|
||||
ensure_directory(&skills_dir)?;
|
||||
ensure_directory(&disabled_dir)?;
|
||||
let current_state = build_skills_mcp_state_inner(config_dir.clone())?;
|
||||
let name = current_state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.id == id)
|
||||
.map(|skill| skill.directory.clone())
|
||||
.unwrap_or_else(|| sanitize_dir_name(&id, "skill"));
|
||||
let enabled_path = skills_dir.join(&name);
|
||||
let disabled_path = disabled_dir.join(&name);
|
||||
if enabled {
|
||||
if disabled_path.exists() {
|
||||
move_dir_replace(&disabled_path, &enabled_path)
|
||||
.map_err(|e| CodexxError::Config(format!("启用 Skill 失败: {e}")))?;
|
||||
}
|
||||
} else if enabled_path.exists() {
|
||||
move_dir_replace(&enabled_path, &disabled_path)
|
||||
.map_err(|e| CodexxError::Config(format!("禁用 Skill 失败: {e}")))?;
|
||||
}
|
||||
build_skills_mcp_state_inner(config_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn install_skill_zip_inner(
|
||||
config_dir: Option<String>,
|
||||
file_name: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
ensure_directory(&skills_dir)?;
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
|
||||
.map_err(|e| CodexxError::Config(format!("读取 ZIP 失败: {e}")))?;
|
||||
let tmp = app_home()?
|
||||
.join("tmp")
|
||||
.join(format!("skill-zip-{}", Local::now().timestamp_millis()));
|
||||
ensure_directory(&tmp)?;
|
||||
let install_result = (|| -> Result<usize> {
|
||||
let mut total_size = 0u64;
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| CodexxError::Config(format!("读取 ZIP 条目失败: {e}")))?;
|
||||
let Some(path) = file.enclosed_name().map(|p| p.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
total_size += file.size();
|
||||
if total_size > MAX_SKILL_ZIP_BYTES {
|
||||
return Err(CodexxError::Config("ZIP 解压后超过 20MB".to_string()));
|
||||
}
|
||||
let out = tmp.join(path);
|
||||
if file.name().ends_with('/') {
|
||||
ensure_directory(&out)?;
|
||||
} else {
|
||||
if let Some(parent) = out.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let mut outfile = fs::File::create(&out).map_err(|e| io_err(&out, e))?;
|
||||
std::io::copy(&mut file, &mut outfile).map_err(|e| io_err(&out, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut skill_dirs = Vec::new();
|
||||
fn find_skill_dirs(current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
if current.join("SKILL.md").is_file() {
|
||||
out.push(current.to_path_buf());
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(current).map_err(|e| io_err(current, e))? {
|
||||
let entry = entry.map_err(|e| io_err(current, e))?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
find_skill_dirs(&path, out)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
find_skill_dirs(&tmp, &mut skill_dirs)?;
|
||||
if skill_dirs.is_empty() {
|
||||
return Err(CodexxError::Config("ZIP 中没有找到 SKILL.md".to_string()));
|
||||
}
|
||||
let mut imported_skills = 0usize;
|
||||
for src in skill_dirs {
|
||||
let fallback = file_name.trim_end_matches(".zip");
|
||||
let dir_name = src.file_name().and_then(|v| v.to_str()).unwrap_or(fallback);
|
||||
let (skill_name, _) = read_skill_metadata(&src, dir_name);
|
||||
let dst_name = sanitize_dir_name(&skill_name, "skill");
|
||||
let dst = skills_dir.join(dst_name);
|
||||
if dst.exists() {
|
||||
fs::remove_dir_all(&dst).map_err(|e| io_err(&dst, e))?;
|
||||
}
|
||||
copy_dir_recursive(&src, &dst)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
Ok(imported_skills)
|
||||
})();
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
let imported_skills = install_result?;
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
Ok(SkillsMcpActionResult {
|
||||
imported_skills,
|
||||
imported_mcp: 0,
|
||||
message: format!("已从 ZIP 安装 {imported_skills} 个 Skill"),
|
||||
state,
|
||||
})
|
||||
}
|
||||
|
||||
fn ccswitch_skill_meta_by_directory() -> Result<HashMap<String, CcSwitchSkillMeta>> {
|
||||
let db = default_ccswitch_db_path()?;
|
||||
if !db.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let conn = Connection::open_with_flags(
|
||||
&db,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!(
|
||||
"打开 cc-switch Skills 数据库失败 {}: {e}",
|
||||
db.display()
|
||||
))
|
||||
})?;
|
||||
let mut stmt = match conn.prepare(
|
||||
"SELECT directory, repo_owner, repo_name, repo_branch, content_hash FROM skills
|
||||
WHERE repo_owner IS NOT NULL AND repo_name IS NOT NULL",
|
||||
) {
|
||||
Ok(stmt) => stmt,
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
|
||||
if message.to_lowercase().contains("no such table") =>
|
||||
{
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
Err(e) => return Err(CodexxError::Database(e.to_string())),
|
||||
};
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
row.get::<_, Option<String>>(3)?,
|
||||
row.get::<_, Option<String>>(4)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
let mut out = HashMap::new();
|
||||
for row in rows {
|
||||
let (directory, owner, repo, branch, content_hash) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let (Some(repo_owner), Some(repo_name)) = (owner, repo) else {
|
||||
continue;
|
||||
};
|
||||
out.insert(
|
||||
directory.to_ascii_lowercase(),
|
||||
CcSwitchSkillMeta {
|
||||
repo_owner,
|
||||
repo_name,
|
||||
repo_branch: branch.unwrap_or_else(|| "main".to_string()),
|
||||
content_hash,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn download_repo_skill_hashes(
|
||||
owner: &str,
|
||||
repo: &str,
|
||||
branch: &str,
|
||||
) -> std::result::Result<HashMap<String, String>, String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
const MAX_ZIP_BYTES: u64 = 100 * 1024 * 1024;
|
||||
let url = format!("https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip");
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(18))
|
||||
.build();
|
||||
let response = agent
|
||||
.get(&url)
|
||||
.set("User-Agent", "Codex-X")
|
||||
.call()
|
||||
.map_err(|e| format!("下载 {owner}/{repo}@{branch} 失败: {e}"))?;
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.into_reader()
|
||||
.take(MAX_ZIP_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| format!("读取 {owner}/{repo}@{branch} ZIP 失败: {e}"))?;
|
||||
if bytes.len() as u64 > MAX_ZIP_BYTES {
|
||||
return Err(format!("{owner}/{repo}@{branch} ZIP 超过 100MB"));
|
||||
}
|
||||
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
|
||||
.map_err(|e| format!("解析 {owner}/{repo}@{branch} ZIP 失败: {e}"))?;
|
||||
let mut files = Vec::<(String, Vec<u8>)>::new();
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("读取 ZIP 条目失败: {e}"))?;
|
||||
if file.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(path) = file.enclosed_name().map(|p| p.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
let normalized = path.to_string_lossy().replace('\\', "/");
|
||||
if normalized
|
||||
.split('/')
|
||||
.any(|part| part.starts_with('.') && part != ".")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
file.read_to_end(&mut data)
|
||||
.map_err(|e| format!("读取 ZIP 文件失败: {e}"))?;
|
||||
files.push((normalized, data));
|
||||
}
|
||||
|
||||
let mut prefixes = HashMap::<String, String>::new();
|
||||
for (path, _) in &files {
|
||||
if !path.ends_with("/SKILL.md") && path != "SKILL.md" {
|
||||
continue;
|
||||
}
|
||||
let Some(prefix) = path.strip_suffix("/SKILL.md") else {
|
||||
continue;
|
||||
};
|
||||
let Some(name) = prefix.rsplit('/').next() else {
|
||||
continue;
|
||||
};
|
||||
prefixes.insert(name.to_ascii_lowercase(), prefix.to_string());
|
||||
}
|
||||
|
||||
let mut hashes = HashMap::new();
|
||||
for (skill_name, prefix) in prefixes {
|
||||
let prefix_with_slash = format!("{prefix}/");
|
||||
let mut scoped = files
|
||||
.iter()
|
||||
.filter_map(|(path, data)| {
|
||||
path.strip_prefix(&prefix_with_slash)
|
||||
.map(|rel| (rel.to_string(), data.as_slice()))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
scoped.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let mut hasher = Sha256::new();
|
||||
for (rel, data) in scoped {
|
||||
hasher.update(rel.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
hasher.update(data);
|
||||
hasher.update(b"\0");
|
||||
}
|
||||
hashes.insert(skill_name, format!("{:x}", hasher.finalize()));
|
||||
}
|
||||
Ok(hashes)
|
||||
}
|
||||
|
||||
pub(crate) fn check_skill_updates_inner(config_dir: Option<String>) -> Result<SkillsMcpState> {
|
||||
let mut next = build_skills_mcp_state_inner(config_dir)?;
|
||||
let conn = open_db()?;
|
||||
let ccswitch_meta = ccswitch_skill_meta_by_directory().unwrap_or_default();
|
||||
let mut remote_hash_cache = HashMap::<
|
||||
(String, String, String),
|
||||
std::result::Result<HashMap<String, String>, String>,
|
||||
>::new();
|
||||
for skill in &mut next.skills {
|
||||
let old: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT content_hash FROM managed_skills WHERE id = ?1",
|
||||
[&skill.id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
let local_status = match (&old, &skill.content_hash) {
|
||||
(Some(a), Some(b)) if a != b => "本地有变化".to_string(),
|
||||
(Some(_), Some(_)) => "已是最新".to_string(),
|
||||
_ => "已记录".to_string(),
|
||||
};
|
||||
let meta = ccswitch_meta.get(&skill.directory.to_ascii_lowercase());
|
||||
skill.update_status = if let Some(meta) = meta {
|
||||
let key = (
|
||||
meta.repo_owner.clone(),
|
||||
meta.repo_name.clone(),
|
||||
meta.repo_branch.clone(),
|
||||
);
|
||||
let remote = remote_hash_cache
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| download_repo_skill_hashes(&key.0, &key.1, &key.2));
|
||||
match remote {
|
||||
Ok(remote_hashes) => {
|
||||
let remote_hash = remote_hashes.get(&skill.directory.to_ascii_lowercase());
|
||||
let local_hash = skill.content_hash.as_ref().or(meta.content_hash.as_ref());
|
||||
match (local_hash, remote_hash) {
|
||||
(Some(local), Some(remote)) if local != remote => "有新版本".to_string(),
|
||||
(Some(_), Some(_)) => "已是最新".to_string(),
|
||||
(_, Some(_)) => "已记录远程".to_string(),
|
||||
_ => "未找到远程目录".to_string(),
|
||||
}
|
||||
}
|
||||
Err(e) => format!("远程检查失败:{e}"),
|
||||
}
|
||||
} else {
|
||||
local_status
|
||||
};
|
||||
save_managed_skill(skill)?;
|
||||
}
|
||||
Ok(next)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ManagedMcpServer {
|
||||
pub(crate) id: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) transport: String,
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) source: String,
|
||||
pub(crate) summary: String,
|
||||
pub(crate) command: Option<String>,
|
||||
pub(crate) url: Option<String>,
|
||||
pub(crate) config_json: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ManagedSkill {
|
||||
pub(crate) id: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) directory: String,
|
||||
pub(crate) enabled: bool,
|
||||
pub(crate) source: String,
|
||||
pub(crate) path: String,
|
||||
pub(crate) content_hash: Option<String>,
|
||||
pub(crate) update_status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkillsMcpState {
|
||||
pub(crate) codex_dir: String,
|
||||
pub(crate) codex_skills_dir: String,
|
||||
pub(crate) disabled_skills_dir: String,
|
||||
pub(crate) skills: Vec<ManagedSkill>,
|
||||
pub(crate) mcp_servers: Vec<ManagedMcpServer>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkillsMcpActionResult {
|
||||
pub(crate) imported_skills: usize,
|
||||
pub(crate) imported_mcp: usize,
|
||||
pub(crate) message: String,
|
||||
pub(crate) state: SkillsMcpState,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkillsMcpImportPreview {
|
||||
pub(crate) skills: Vec<ManagedSkill>,
|
||||
pub(crate) mcp_servers: Vec<ManagedMcpServer>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct CcSwitchSkillMeta {
|
||||
pub(super) repo_owner: String,
|
||||
pub(super) repo_name: String,
|
||||
pub(super) repo_branch: String,
|
||||
pub(super) content_hash: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
pub(crate) struct BuiltinSkinAsset {
|
||||
pub(crate) id: &'static str,
|
||||
pub(crate) manifest: &'static str,
|
||||
pub(crate) image: &'static [u8],
|
||||
}
|
||||
|
||||
pub(crate) const BUILTIN_SKIN_ID: &str = "shiina-mashiro-blossom";
|
||||
|
||||
pub(crate) fn retired_builtin_skin_ids() -> &'static [&'static str] {
|
||||
&[
|
||||
"preset-midnight-aurora",
|
||||
"preset-sakura-dawn",
|
||||
"preset-amber-dusk",
|
||||
"preset-forest-mist",
|
||||
"preset-cyber-neon",
|
||||
]
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_skin_assets() -> &'static [BuiltinSkinAsset] {
|
||||
&[]
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, ensure_directory};
|
||||
use crate::skins::skins_root;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) const RUNTIME_VERSION: &str = "1.2.2-codexx.3";
|
||||
pub(super) const UPSTREAM_COMMIT: &str = "5fd8af532efbaa87d2d0092297fd2d45cd56574e";
|
||||
|
||||
struct EmbeddedRuntimeAsset {
|
||||
relative_path: &'static str,
|
||||
bytes: &'static [u8],
|
||||
}
|
||||
|
||||
fn embedded_assets() -> &'static [EmbeddedRuntimeAsset] {
|
||||
&[
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "scripts/injector.mjs",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/scripts/injector.mjs"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "scripts/image-metadata.mjs",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/scripts/image-metadata.mjs"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "scripts/stage-theme.mjs",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/scripts/stage-theme.mjs"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "assets/dream-skin.css",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/assets/dream-skin.css"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "assets/renderer-inject.js",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/assets/renderer-inject.js"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "windows/codexx-windows.ps1",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/windows/codexx-windows.ps1"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "windows/common-windows.ps1",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/windows/common-windows.ps1"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "windows/config-utf8.ps1",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/windows/config-utf8.ps1"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "LICENSE",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/LICENSE"),
|
||||
},
|
||||
EmbeddedRuntimeAsset {
|
||||
relative_path: "NOTICE.md",
|
||||
bytes: include_bytes!("../../resources/skin-runtime/NOTICE.md"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn bytes_match(path: &Path, expected: &[u8]) -> bool {
|
||||
if !fs::symlink_metadata(path)
|
||||
.is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Ok(actual) = fs::read(path) else {
|
||||
return false;
|
||||
};
|
||||
Sha256::digest(actual) == Sha256::digest(expected)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_runtime_directory(path: &Path) -> Result<()> {
|
||||
ensure_directory(path)?;
|
||||
let metadata =
|
||||
fs::symlink_metadata(path).map_err(|source| crate::file_io::io_err(path, source))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"皮肤运行时目录不能是符号链接或普通文件: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
set_private_permissions(path, true)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn set_private_permissions(path: &Path, directory: bool) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = if directory { 0o700 } else { 0o600 };
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(mode))
|
||||
.map_err(|source| crate::file_io::io_err(path, source))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn set_private_permissions(_path: &Path, _directory: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) struct RuntimeAssets {
|
||||
pub(super) injector: PathBuf,
|
||||
pub(super) stage_theme: PathBuf,
|
||||
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
|
||||
#[cfg_attr(feature = "windows-runtime-check", allow(dead_code))]
|
||||
pub(super) windows_adapter: PathBuf,
|
||||
}
|
||||
|
||||
pub(super) fn ensure_runtime_assets() -> Result<RuntimeAssets> {
|
||||
let base = skins_root()?.join("runtime");
|
||||
ensure_runtime_directory(&base)?;
|
||||
let root = base.join(RUNTIME_VERSION);
|
||||
ensure_runtime_directory(&root)?;
|
||||
for asset in embedded_assets() {
|
||||
let path = root.join(asset.relative_path);
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
CodexxError::Config(format!("内置皮肤资源路径无效: {}", path.display()))
|
||||
})?;
|
||||
ensure_runtime_directory(parent)?;
|
||||
if !bytes_match(&path, asset.bytes) {
|
||||
atomic_write(&path, asset.bytes)?;
|
||||
}
|
||||
set_private_permissions(&path, false)?;
|
||||
}
|
||||
Ok(RuntimeAssets {
|
||||
injector: root.join("scripts/injector.mjs"),
|
||||
stage_theme: root.join("scripts/stage-theme.mjs"),
|
||||
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
|
||||
windows_adapter: root.join("windows/codexx-windows.ps1"),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
use super::assets::{
|
||||
ensure_runtime_assets, ensure_runtime_directory, RuntimeAssets, RUNTIME_VERSION,
|
||||
UPSTREAM_COMMIT,
|
||||
};
|
||||
use super::macos_injector::{
|
||||
launch_watcher, remove_live_skin, run_injector_once, stage_theme_snapshot,
|
||||
};
|
||||
use super::macos_system::{discover_codex_runtime, CodexRuntime};
|
||||
use super::{SkinRuntimeAction, SkinRuntimeStatus};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, io_err};
|
||||
use crate::now_rfc3339;
|
||||
use crate::skins::skins_root;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_CDP_PORT: u16 = 9341;
|
||||
const MAX_CDP_PORT_OFFSET: u16 = 100;
|
||||
const RUNTIME_STATE_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RuntimeStateFile {
|
||||
schema_version: u32,
|
||||
runtime_version: String,
|
||||
upstream_commit: String,
|
||||
session: String,
|
||||
port: u16,
|
||||
injector_pid: u32,
|
||||
injector_started_at: String,
|
||||
injector_path: String,
|
||||
node_path: String,
|
||||
node_version: String,
|
||||
codex_bundle: String,
|
||||
codex_executable: String,
|
||||
codex_version: String,
|
||||
codex_team_id: String,
|
||||
theme_id: String,
|
||||
theme_dir: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
fn runtime_root() -> Result<PathBuf> {
|
||||
Ok(skins_root()?.join("runtime"))
|
||||
}
|
||||
|
||||
fn state_path() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("state.json"))
|
||||
}
|
||||
|
||||
pub(super) fn snapshots_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("themes"))
|
||||
}
|
||||
|
||||
fn logs_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("logs"))
|
||||
}
|
||||
|
||||
fn read_runtime_state() -> Result<Option<RuntimeStateFile>> {
|
||||
let path = state_path()?;
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 256 * 1024 {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态文件不是安全的普通文件,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
let bytes = fs::read(&path).map_err(|source| io_err(&path, source))?;
|
||||
let state: RuntimeStateFile = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| CodexxError::Config(format!("解析皮肤运行状态失败: {error}")))?;
|
||||
if state.schema_version != RUNTIME_STATE_SCHEMA || !(1024..=65535).contains(&state.port) {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态版本或端口无效,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
fn write_runtime_state(state: &RuntimeStateFile) -> Result<()> {
|
||||
let path = state_path()?;
|
||||
let text = serde_json::to_string_pretty(state)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化皮肤运行状态失败: {error}")))?;
|
||||
atomic_write(&path, format!("{text}\n").as_bytes())?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|source| io_err(&path, source))
|
||||
}
|
||||
|
||||
fn remove_runtime_state() -> Result<()> {
|
||||
let path = state_path()?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_err(&path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn process_table() -> Vec<(u32, String)> {
|
||||
let Ok(output) = Command::new("/bin/ps")
|
||||
.args(["-axo", "pid=,command="])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
let split = trimmed.find(char::is_whitespace)?;
|
||||
let pid = trimmed[..split].parse::<u32>().ok()?;
|
||||
Some((pid, trimmed[split..].trim_start().to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn process_command(pid: u32) -> Option<String> {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "command="])
|
||||
.output()
|
||||
.ok()?;
|
||||
let command = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
(!command.is_empty()).then_some(command)
|
||||
}
|
||||
|
||||
pub(super) fn process_started_at(pid: u32) -> Option<String> {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "lstart="])
|
||||
.output()
|
||||
.ok()?;
|
||||
let value = String::from_utf8_lossy(&output.stdout)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
(!value.is_empty()).then_some(value)
|
||||
}
|
||||
|
||||
pub(super) fn process_alive(pid: u32) -> bool {
|
||||
pid > 0
|
||||
&& Command::new("/bin/kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.status()
|
||||
.is_ok_and(|status| status.success())
|
||||
}
|
||||
|
||||
fn codex_main_pids(runtime: &CodexRuntime) -> Vec<u32> {
|
||||
let prefix = runtime.executable.to_string_lossy();
|
||||
process_table()
|
||||
.into_iter()
|
||||
.filter_map(|(pid, command)| command.starts_with(prefix.as_ref()).then_some(pid))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn codex_is_running(runtime: &CodexRuntime) -> bool {
|
||||
!codex_main_pids(runtime).is_empty()
|
||||
}
|
||||
|
||||
fn debug_port_from_command(command: &str) -> Option<u16> {
|
||||
command.split_whitespace().find_map(|argument| {
|
||||
argument
|
||||
.strip_prefix("--remote-debugging-port=")?
|
||||
.parse::<u16>()
|
||||
.ok()
|
||||
.filter(|port| *port >= 1024)
|
||||
})
|
||||
}
|
||||
|
||||
fn process_parent(pid: u32) -> Option<u32> {
|
||||
let output = Command::new("/bin/ps")
|
||||
.args(["-p", &pid.to_string(), "-o", "ppid="])
|
||||
.output()
|
||||
.ok()?;
|
||||
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
|
||||
}
|
||||
|
||||
fn pid_is_codex_descendant(runtime: &CodexRuntime, mut pid: u32) -> bool {
|
||||
let executable = runtime.executable.to_string_lossy();
|
||||
for _ in 0..32 {
|
||||
if process_command(pid).is_some_and(|command| command.starts_with(executable.as_ref())) {
|
||||
return true;
|
||||
}
|
||||
let Some(parent) = process_parent(pid) else {
|
||||
return false;
|
||||
};
|
||||
if parent <= 1 || parent == pid {
|
||||
return false;
|
||||
}
|
||||
pid = parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn listener_pids(port: u16) -> Vec<u32> {
|
||||
let Ok(output) = Command::new("/usr/sbin/lsof")
|
||||
.args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-t"])
|
||||
.output()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter_map(|line| line.trim().parse().ok())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn port_belongs_to_codex(runtime: &CodexRuntime, port: u16) -> bool {
|
||||
let pids = listener_pids(port);
|
||||
!pids.is_empty()
|
||||
&& pids
|
||||
.into_iter()
|
||||
.all(|pid| pid_is_codex_descendant(runtime, pid))
|
||||
}
|
||||
|
||||
fn cdp_http_ready(port: u16) -> bool {
|
||||
let Ok(client) = reqwest::blocking::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
client
|
||||
.get(format!("http://127.0.0.1:{port}/json/version"))
|
||||
.send()
|
||||
.is_ok_and(|response| response.status().is_success())
|
||||
}
|
||||
|
||||
fn verified_cdp_endpoint(runtime: &CodexRuntime, port: u16) -> bool {
|
||||
port_belongs_to_codex(runtime, port) && cdp_http_ready(port)
|
||||
}
|
||||
|
||||
fn running_cdp_port(runtime: &CodexRuntime) -> Option<u16> {
|
||||
codex_main_pids(runtime)
|
||||
.into_iter()
|
||||
.filter_map(process_command)
|
||||
.filter_map(|command| debug_port_from_command(&command))
|
||||
.find(|port| verified_cdp_endpoint(runtime, *port))
|
||||
}
|
||||
|
||||
fn select_available_port(preferred: u16) -> Result<u16> {
|
||||
let last = preferred.saturating_add(MAX_CDP_PORT_OFFSET);
|
||||
(preferred..=last)
|
||||
.find(|port| listener_pids(*port).is_empty())
|
||||
.ok_or_else(|| {
|
||||
CodexxError::Config(format!("未找到可用的本机 CDP 端口: {preferred}-{last}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_for_cdp(runtime: &CodexRuntime, port: u16, timeout: Duration) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if verified_cdp_endpoint(runtime, port) {
|
||||
return Ok(());
|
||||
}
|
||||
thread::sleep(Duration::from_millis(350));
|
||||
}
|
||||
Err(CodexxError::Config(format!(
|
||||
"Codex 未在 127.0.0.1:{port} 打开经过验证的调试端口"
|
||||
)))
|
||||
}
|
||||
|
||||
fn external_skin_watcher_pid(own_state: Option<&RuntimeStateFile>) -> Option<u32> {
|
||||
process_table().into_iter().find_map(|(pid, command)| {
|
||||
let is_external = command.contains("codex-dream-skin-studio")
|
||||
&& command.contains("injector.mjs")
|
||||
&& command.contains("--watch");
|
||||
(is_external && own_state.is_none_or(|state| state.injector_pid != pid)).then_some(pid)
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_managed_snapshot(path: &Path) -> Option<PathBuf> {
|
||||
let metadata = fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let root = fs::canonicalize(snapshots_root().ok()?).ok()?;
|
||||
let candidate = fs::canonicalize(path).ok()?;
|
||||
candidate.starts_with(&root).then_some(candidate)
|
||||
}
|
||||
|
||||
fn is_managed_injector(path: &Path) -> bool {
|
||||
let Ok(metadata) = fs::symlink_metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return false;
|
||||
}
|
||||
let Ok(root) = runtime_root()
|
||||
.and_then(|path| fs::canonicalize(&path).map_err(|source| io_err(&path, source)))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
fs::canonicalize(path).is_ok_and(|candidate| {
|
||||
candidate.starts_with(root)
|
||||
&& candidate.file_name().and_then(|name| name.to_str()) == Some("injector.mjs")
|
||||
})
|
||||
}
|
||||
|
||||
fn state_process_matches(state: &RuntimeStateFile, runtime: &CodexRuntime) -> bool {
|
||||
if state.injector_pid == 0
|
||||
|| state.node_path != runtime.node.to_string_lossy()
|
||||
|| !is_managed_injector(Path::new(&state.injector_path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if canonical_managed_snapshot(Path::new(&state.theme_dir)).is_none() {
|
||||
return false;
|
||||
}
|
||||
let Some(command) = process_command(state.injector_pid) else {
|
||||
return false;
|
||||
};
|
||||
let expected = format!(
|
||||
"{} --watch --port {} --theme-dir {}",
|
||||
state.injector_path, state.port, state.theme_dir
|
||||
);
|
||||
command.starts_with(runtime.node.to_string_lossy().as_ref())
|
||||
&& command.contains(&expected)
|
||||
&& process_started_at(state.injector_pid).as_deref()
|
||||
== Some(state.injector_started_at.as_str())
|
||||
}
|
||||
|
||||
fn stop_recorded_injector(state: &RuntimeStateFile, runtime: &CodexRuntime) -> Result<()> {
|
||||
if state.injector_pid == 0 || !process_alive(state.injector_pid) {
|
||||
return Ok(());
|
||||
}
|
||||
if !state_process_matches(state, runtime) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"记录的皮肤进程 PID {} 身份不匹配,已拒绝终止",
|
||||
state.injector_pid
|
||||
)));
|
||||
}
|
||||
let pid = state.injector_pid.to_string();
|
||||
let _ = Command::new("/bin/kill").args(["-TERM", &pid]).status();
|
||||
let deadline = Instant::now() + Duration::from_secs(6);
|
||||
while Instant::now() < deadline && state_process_matches(state, runtime) {
|
||||
thread::sleep(Duration::from_millis(150));
|
||||
}
|
||||
if state_process_matches(state, runtime) {
|
||||
let _ = Command::new("/bin/kill").args(["-KILL", &pid]).status();
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline && state_process_matches(state, runtime) {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
if state_process_matches(state, runtime) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"无法停止皮肤注入器 PID {}",
|
||||
state.injector_pid
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop_codex(runtime: &CodexRuntime) -> Result<()> {
|
||||
let _ = Command::new("/usr/bin/osascript")
|
||||
.args(["-e", "tell application id \"com.openai.codex\" to quit"])
|
||||
.status();
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
while Instant::now() < deadline && codex_is_running(runtime) {
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
if !codex_is_running(runtime) {
|
||||
return Ok(());
|
||||
}
|
||||
for pid in codex_main_pids(runtime) {
|
||||
let _ = Command::new("/bin/kill")
|
||||
.args(["-TERM", &pid.to_string()])
|
||||
.status();
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline && codex_is_running(runtime) {
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
if codex_is_running(runtime) {
|
||||
for pid in codex_main_pids(runtime) {
|
||||
let _ = Command::new("/bin/kill")
|
||||
.args(["-KILL", &pid.to_string()])
|
||||
.status();
|
||||
}
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
if codex_is_running(runtime) {
|
||||
Err(CodexxError::Config(
|
||||
"无法安全关闭 Codex,换肤操作已中止".to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn log_files(prefix: &str) -> Result<(File, File)> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let root = logs_root()?;
|
||||
ensure_runtime_directory(&root)?;
|
||||
let stdout_path = root.join(format!("{prefix}.log"));
|
||||
let stderr_path = root.join(format!("{prefix}-error.log"));
|
||||
let stdout = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stdout_path)
|
||||
.map_err(|source| io_err(&stdout_path, source))?;
|
||||
let stderr = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stderr_path)
|
||||
.map_err(|source| io_err(&stderr_path, source))?;
|
||||
fs::set_permissions(&stdout_path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|source| io_err(&stdout_path, source))?;
|
||||
fs::set_permissions(&stderr_path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|source| io_err(&stderr_path, source))?;
|
||||
Ok((stdout, stderr))
|
||||
}
|
||||
|
||||
fn launch_codex_with_cdp(runtime: &CodexRuntime, port: u16) -> Result<()> {
|
||||
let (stdout, stderr) = log_files("codex-launch")?;
|
||||
let status = Command::new("/usr/bin/open")
|
||||
.arg("-na")
|
||||
.arg(&runtime.bundle)
|
||||
.arg("--args")
|
||||
.arg("--remote-debugging-address=127.0.0.1")
|
||||
.arg(format!("--remote-debugging-port={port}"))
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.status()
|
||||
.map_err(|source| io_err(&runtime.bundle, source))?;
|
||||
if !status.success() {
|
||||
return Err(CodexxError::Config(
|
||||
"使用本机调试端口启动 Codex 失败".to_string(),
|
||||
));
|
||||
}
|
||||
if wait_for_cdp(runtime, port, Duration::from_secs(12)).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if codex_is_running(runtime) {
|
||||
stop_codex(runtime)?;
|
||||
}
|
||||
let (stdout, stderr) = log_files("codex-launch-direct")?;
|
||||
let mut child = Command::new(&runtime.executable)
|
||||
.arg("--remote-debugging-address=127.0.0.1")
|
||||
.arg(format!("--remote-debugging-port={port}"))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&runtime.executable, source))?;
|
||||
thread::Builder::new()
|
||||
.name("codex-x-codex-launch-reaper".to_string())
|
||||
.spawn(move || {
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|error| CodexxError::Config(format!("启动 Codex 进程监控失败: {error}")))?;
|
||||
wait_for_cdp(runtime, port, Duration::from_secs(45))
|
||||
}
|
||||
|
||||
fn launch_codex_normally(runtime: &CodexRuntime) -> Result<()> {
|
||||
let status = Command::new("/usr/bin/open")
|
||||
.arg("-na")
|
||||
.arg(&runtime.bundle)
|
||||
.status()
|
||||
.map_err(|source| io_err(&runtime.bundle, source))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(
|
||||
"恢复官方外观后重新启动 Codex 失败".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn active_state(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_id: &str,
|
||||
theme_dir: &Path,
|
||||
pid: u32,
|
||||
started_at: String,
|
||||
) -> RuntimeStateFile {
|
||||
let timestamp = now_rfc3339();
|
||||
RuntimeStateFile {
|
||||
schema_version: RUNTIME_STATE_SCHEMA,
|
||||
runtime_version: RUNTIME_VERSION.to_string(),
|
||||
upstream_commit: UPSTREAM_COMMIT.to_string(),
|
||||
session: "active".to_string(),
|
||||
port,
|
||||
injector_pid: pid,
|
||||
injector_started_at: started_at,
|
||||
injector_path: assets.injector.to_string_lossy().to_string(),
|
||||
node_path: runtime.node.to_string_lossy().to_string(),
|
||||
node_version: runtime.node_version.clone(),
|
||||
codex_bundle: runtime.bundle.to_string_lossy().to_string(),
|
||||
codex_executable: runtime.executable.to_string_lossy().to_string(),
|
||||
codex_version: runtime.version.clone(),
|
||||
codex_team_id: runtime.team_id.clone(),
|
||||
theme_id: theme_id.to_string(),
|
||||
theme_dir: theme_dir.to_string_lossy().to_string(),
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
fn cleanup_snapshot(path: &str, keep: &Path) {
|
||||
let Some(path) = canonical_managed_snapshot(Path::new(path)) else {
|
||||
return;
|
||||
};
|
||||
let keep = canonical_managed_snapshot(keep);
|
||||
if keep.as_ref() != Some(&path) {
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_previous_after_failure(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
previous: Option<&RuntimeStateFile>,
|
||||
) {
|
||||
let Some(previous) = previous else {
|
||||
return;
|
||||
};
|
||||
let theme_dir = Path::new(&previous.theme_dir);
|
||||
if previous.session != "active"
|
||||
|| !theme_dir.is_dir()
|
||||
|| !verified_cdp_endpoint(runtime, previous.port)
|
||||
|| run_injector_once(runtime, assets, previous.port, theme_dir).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok((pid, started_at)) = launch_watcher(runtime, assets, previous.port, theme_dir) {
|
||||
let restored = active_state(
|
||||
runtime,
|
||||
assets,
|
||||
previous.port,
|
||||
&previous.theme_id,
|
||||
theme_dir,
|
||||
pid,
|
||||
started_at,
|
||||
);
|
||||
let _ = write_runtime_state(&restored);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_theme(
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
restart_existing: bool,
|
||||
) -> Result<SkinRuntimeAction> {
|
||||
let runtime = discover_codex_runtime()?;
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let previous = read_runtime_state()?;
|
||||
if let Some(pid) = external_skin_watcher_pid(previous.as_ref()) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex Dream Skin 注入器 PID {pid},请先在原工具中暂停或恢复官方外观"
|
||||
)));
|
||||
}
|
||||
|
||||
let running = codex_is_running(&runtime);
|
||||
let verified_port = previous
|
||||
.as_ref()
|
||||
.map(|state| state.port)
|
||||
.filter(|port| verified_cdp_endpoint(&runtime, *port))
|
||||
.or_else(|| running_cdp_port(&runtime));
|
||||
if running && verified_port.is_none() && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"Codex 需要重启一次才能打开仅限本机的换肤调试端口".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let snapshot = stage_theme_snapshot(&runtime, &assets, source, theme_id)?;
|
||||
let apply_result = (|| -> Result<RuntimeStateFile> {
|
||||
if let Some(state) = previous.as_ref() {
|
||||
stop_recorded_injector(state, &runtime)?;
|
||||
}
|
||||
let port = if let Some(port) = verified_port {
|
||||
port
|
||||
} else {
|
||||
if running {
|
||||
stop_codex(&runtime)?;
|
||||
}
|
||||
let preferred = previous
|
||||
.as_ref()
|
||||
.map_or(DEFAULT_CDP_PORT, |state| state.port);
|
||||
let port = select_available_port(preferred)?;
|
||||
launch_codex_with_cdp(&runtime, port)?;
|
||||
port
|
||||
};
|
||||
run_injector_once(&runtime, &assets, port, &snapshot)?;
|
||||
let (pid, started_at) = launch_watcher(&runtime, &assets, port, &snapshot)?;
|
||||
let state = active_state(
|
||||
&runtime, &assets, port, theme_id, &snapshot, pid, started_at,
|
||||
);
|
||||
if let Err(error) = write_runtime_state(&state) {
|
||||
let _ = stop_recorded_injector(&state, &runtime);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(state)
|
||||
})();
|
||||
|
||||
match apply_result {
|
||||
Ok(_) => {
|
||||
if let Some(previous) = previous.as_ref() {
|
||||
cleanup_snapshot(&previous.theme_dir, &snapshot);
|
||||
}
|
||||
Ok(SkinRuntimeAction::Applied("已应用 Codex 皮肤".to_string()))
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&snapshot);
|
||||
restore_previous_after_failure(&runtime, &assets, previous.as_ref());
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pause_theme() -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(mut state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Paused(
|
||||
"Codex 皮肤当前没有运行".to_string(),
|
||||
));
|
||||
};
|
||||
let runtime = discover_codex_runtime()?;
|
||||
if let Some(pid) = external_skin_watcher_pid(Some(&state)) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex Dream Skin 注入器 PID {pid},未处理该进程"
|
||||
)));
|
||||
}
|
||||
stop_recorded_injector(&state, &runtime)?;
|
||||
if verified_cdp_endpoint(&runtime, state.port) {
|
||||
if let Err(error) =
|
||||
remove_live_skin(&runtime, &assets, state.port, Path::new(&state.theme_dir))
|
||||
{
|
||||
restore_previous_after_failure(&runtime, &assets, Some(&state));
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
state.session = "paused".to_string();
|
||||
state.injector_pid = 0;
|
||||
state.injector_started_at.clear();
|
||||
state.updated_at = now_rfc3339();
|
||||
write_runtime_state(&state)?;
|
||||
Ok(SkinRuntimeAction::Paused(
|
||||
"已关闭 Codex 皮肤;Codex 保持打开".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn restore_official(restart_existing: bool) -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Restored(
|
||||
"Codex 当前已使用官方外观".to_string(),
|
||||
));
|
||||
};
|
||||
let runtime = discover_codex_runtime()?;
|
||||
if let Some(pid) = external_skin_watcher_pid(Some(&state)) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex Dream Skin 注入器 PID {pid},未处理该进程"
|
||||
)));
|
||||
}
|
||||
let running = codex_is_running(&runtime);
|
||||
let debug_ready = verified_cdp_endpoint(&runtime, state.port);
|
||||
if running && (debug_ready || state.session == "active") && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"完整恢复需要重启 Codex,以关闭本机调试端口并清除当前渲染状态".to_string(),
|
||||
));
|
||||
}
|
||||
stop_recorded_injector(&state, &runtime)?;
|
||||
if debug_ready {
|
||||
remove_live_skin(&runtime, &assets, state.port, Path::new(&state.theme_dir))?;
|
||||
}
|
||||
if running && (debug_ready || state.session == "active") {
|
||||
stop_codex(&runtime)?;
|
||||
launch_codex_normally(&runtime)?;
|
||||
}
|
||||
remove_runtime_state()?;
|
||||
cleanup_snapshot(&state.theme_dir, Path::new(""));
|
||||
Ok(SkinRuntimeAction::Restored(
|
||||
"已恢复 Codex 官方外观并关闭 Codex-X 管理的换肤运行时".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn runtime_status() -> SkinRuntimeStatus {
|
||||
if let Err(error) = ensure_runtime_assets() {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
};
|
||||
}
|
||||
let state = match read_runtime_state() {
|
||||
Ok(Some(state)) => state,
|
||||
Ok(None) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "inactive".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: "尚未应用 Codex 皮肤".to_string(),
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
if state.session == "paused" {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "paused".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: "皮肤已暂停,Codex 保持打开".to_string(),
|
||||
};
|
||||
}
|
||||
let runtime = match discover_codex_runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "unavailable".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let active =
|
||||
state_process_matches(&state, &runtime) && verified_cdp_endpoint(&runtime, state.port);
|
||||
SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active,
|
||||
phase: if active { "active" } else { "stale" }.to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: if active {
|
||||
"Codex 皮肤运行中".to_string()
|
||||
} else {
|
||||
"皮肤运行状态已失效,可重新应用或恢复官方外观".to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn debug_port_parser_accepts_only_bounded_numeric_arguments() {
|
||||
assert_eq!(
|
||||
debug_port_from_command(
|
||||
"/Applications/ChatGPT.app/Contents/MacOS/ChatGPT --remote-debugging-port=9341"
|
||||
),
|
||||
Some(9341)
|
||||
);
|
||||
assert_eq!(
|
||||
debug_port_from_command("Codex --remote-debugging-port=80"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
debug_port_from_command("Codex --remote-debugging-port=not-a-port"),
|
||||
None
|
||||
);
|
||||
assert_eq!(debug_port_from_command("Codex"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
use super::assets::RuntimeAssets;
|
||||
use super::macos::{log_files, process_alive, process_started_at, snapshots_root};
|
||||
use super::macos_system::CodexRuntime;
|
||||
use super::node_runtime;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
pub(super) fn stage_theme_snapshot(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
) -> Result<PathBuf> {
|
||||
node_runtime::stage_theme_snapshot(&runtime.node, assets, &snapshots_root()?, source, theme_id)
|
||||
}
|
||||
|
||||
pub(super) fn run_injector_once(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
node_runtime::run_injector_once(&runtime.node, assets, port, theme_dir)
|
||||
}
|
||||
|
||||
pub(super) fn remove_live_skin(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
node_runtime::remove_live_skin(&runtime.node, assets, port, theme_dir)
|
||||
}
|
||||
|
||||
pub(super) fn launch_watcher(
|
||||
runtime: &CodexRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<(u32, String)> {
|
||||
let (stdout, stderr) = log_files("injector")?;
|
||||
let mut child = Command::new(&runtime.node)
|
||||
.arg(&assets.injector)
|
||||
.args(["--watch", "--port"])
|
||||
.arg(port.to_string())
|
||||
.arg("--theme-dir")
|
||||
.arg(theme_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr))
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&assets.injector, source))?;
|
||||
let pid = child.id();
|
||||
thread::Builder::new()
|
||||
.name("codex-x-skin-injector-reaper".to_string())
|
||||
.spawn(move || {
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|error| CodexxError::Config(format!("启动皮肤进程监控失败: {error}")))?;
|
||||
thread::sleep(Duration::from_millis(180));
|
||||
if !process_alive(pid) {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤注入器启动后立即退出,请检查运行日志".to_string(),
|
||||
));
|
||||
}
|
||||
let started_at = process_started_at(pid)
|
||||
.ok_or_else(|| CodexxError::Config("无法记录皮肤注入器启动时间".to_string()))?;
|
||||
Ok((pid, started_at))
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use super::node_runtime::command_detail;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
const EXPECTED_BUNDLE_ID: &str = "com.openai.codex";
|
||||
const EXPECTED_TEAM_ID: &str = "2DC432GLL2";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct CodexRuntime {
|
||||
pub(super) bundle: PathBuf,
|
||||
pub(super) executable: PathBuf,
|
||||
pub(super) version: String,
|
||||
pub(super) node: PathBuf,
|
||||
pub(super) node_version: String,
|
||||
pub(super) team_id: String,
|
||||
}
|
||||
|
||||
fn output_text(program: &Path, args: &[&str], label: &str) -> Result<String> {
|
||||
let output = Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.map_err(|source| io_err(program, source))?;
|
||||
if !output.status.success() {
|
||||
let detail = command_detail(&output);
|
||||
return Err(CodexxError::Config(if detail.is_empty() {
|
||||
format!("{label}失败")
|
||||
} else {
|
||||
format!("{label}失败: {detail}")
|
||||
}));
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
fn plist_value(bundle: &Path, key: &str) -> Result<String> {
|
||||
let plist = bundle.join("Contents/Info.plist");
|
||||
output_text(
|
||||
Path::new("/usr/bin/plutil"),
|
||||
&["-extract", key, "raw", "-o", "-", &plist.to_string_lossy()],
|
||||
"读取 Codex 应用信息",
|
||||
)
|
||||
}
|
||||
|
||||
fn bundle_candidates() -> Result<Vec<PathBuf>> {
|
||||
let home = crate::paths::home_dir()?;
|
||||
let mut candidates = vec![
|
||||
PathBuf::from("/Applications/ChatGPT.app"),
|
||||
home.join("Applications/ChatGPT.app"),
|
||||
PathBuf::from("/Applications/Codex.app"),
|
||||
home.join("Applications/Codex.app"),
|
||||
];
|
||||
if let Ok(output) = Command::new("/usr/bin/mdfind")
|
||||
.arg(format!(
|
||||
"kMDItemCFBundleIdentifier == \"{EXPECTED_BUNDLE_ID}\""
|
||||
))
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
candidates.extend(
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from),
|
||||
);
|
||||
}
|
||||
}
|
||||
candidates.dedup();
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
fn codesign_team_id(path: &Path) -> Result<String> {
|
||||
let output = Command::new("/usr/bin/codesign")
|
||||
.args(["-dv", "--verbose=4"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|source| io_err(path, source))?;
|
||||
let combined = format!(
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
combined
|
||||
.lines()
|
||||
.find_map(|line| line.trim().strip_prefix("TeamIdentifier="))
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| CodexxError::Config("无法读取 Codex 签名 Team ID".to_string()))
|
||||
}
|
||||
|
||||
fn verify_signature(path: &Path, deep: bool) -> Result<()> {
|
||||
let mut command = Command::new("/usr/bin/codesign");
|
||||
command.arg("--verify");
|
||||
if deep {
|
||||
command.arg("--deep");
|
||||
}
|
||||
let output = command
|
||||
.arg("--strict")
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|source| io_err(path, source))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(format!(
|
||||
"官方 Codex 代码签名校验失败: {}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn discover_codex_runtime() -> Result<CodexRuntime> {
|
||||
let mut bundle = None;
|
||||
for candidate in bundle_candidates()? {
|
||||
if !candidate.join("Contents/Info.plist").is_file() {
|
||||
continue;
|
||||
}
|
||||
if plist_value(&candidate, "CFBundleIdentifier")
|
||||
.ok()
|
||||
.as_deref()
|
||||
== Some(EXPECTED_BUNDLE_ID)
|
||||
{
|
||||
bundle = Some(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let bundle = bundle.ok_or_else(|| {
|
||||
CodexxError::Config("未找到官方 Codex Desktop (com.openai.codex)".to_string())
|
||||
})?;
|
||||
let executable_name = plist_value(&bundle, "CFBundleExecutable")?;
|
||||
if executable_name.is_empty() || executable_name.contains('/') || executable_name.contains('\\')
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"Codex 应用声明了无效的可执行文件名".to_string(),
|
||||
));
|
||||
}
|
||||
let executable = bundle.join("Contents/MacOS").join(executable_name);
|
||||
let node = bundle.join("Contents/Resources/cua_node/bin/node");
|
||||
if !executable.is_file() || !node.is_file() {
|
||||
return Err(CodexxError::Config(
|
||||
"Codex 可执行文件或内置 Node.js 不存在,请更新或重新安装官方 Codex".to_string(),
|
||||
));
|
||||
}
|
||||
verify_signature(&bundle, true)?;
|
||||
verify_signature(&node, false)?;
|
||||
let team_id = codesign_team_id(&bundle)?;
|
||||
let node_team_id = codesign_team_id(&node)?;
|
||||
if team_id != EXPECTED_TEAM_ID || node_team_id != team_id {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex 签名身份不匹配,拒绝启动换肤运行时: {team_id}"
|
||||
)));
|
||||
}
|
||||
let node_version = output_text(&node, &["--version"], "读取 Codex Node.js 版本")?;
|
||||
let node_major = node_version
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.ok_or_else(|| CodexxError::Config(format!("无法解析 Node.js 版本: {node_version}")))?;
|
||||
if node_major < 20 {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex 内置 Node.js 版本过低: {node_version},需要 20 或更高版本"
|
||||
)));
|
||||
}
|
||||
let machine_arch = output_text(Path::new("/usr/bin/uname"), &["-m"], "读取系统架构")?;
|
||||
let node_file = output_text(
|
||||
Path::new("/usr/bin/file"),
|
||||
&[&node.to_string_lossy()],
|
||||
"读取 Codex Node.js 架构",
|
||||
)?;
|
||||
if !node_file.contains(&machine_arch) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex 内置 Node.js 与当前 Mac 架构不匹配: {machine_arch}"
|
||||
)));
|
||||
}
|
||||
Ok(CodexRuntime {
|
||||
version: plist_value(&bundle, "CFBundleShortVersionString")?,
|
||||
bundle,
|
||||
executable,
|
||||
node,
|
||||
node_version,
|
||||
team_id,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_detail_is_bounded() {
|
||||
let output = Command::new("/usr/bin/printf")
|
||||
.arg("%0900d")
|
||||
.arg("1")
|
||||
.output()
|
||||
.expect("create fixture output");
|
||||
assert!(command_detail(&output).chars().count() <= 800);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
use crate::error::CodexxError;
|
||||
use crate::error::Result;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
|
||||
mod assets;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_injector;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos_system;
|
||||
mod node_runtime;
|
||||
#[cfg(any(target_os = "windows", feature = "windows-runtime-check"))]
|
||||
#[cfg_attr(feature = "windows-runtime-check", allow(dead_code))]
|
||||
mod windows;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkinRuntimeStatus {
|
||||
pub(crate) supported: bool,
|
||||
pub(crate) active: bool,
|
||||
pub(crate) phase: String,
|
||||
pub(crate) port: Option<u16>,
|
||||
pub(crate) theme_id: Option<String>,
|
||||
pub(crate) message: String,
|
||||
}
|
||||
|
||||
impl SkinRuntimeStatus {
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
fn unsupported() -> Self {
|
||||
Self {
|
||||
supported: false,
|
||||
active: false,
|
||||
phase: "unsupported".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: "当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum SkinRuntimeAction {
|
||||
Applied(String),
|
||||
RestartRequired(String),
|
||||
Paused(String),
|
||||
Restored(String),
|
||||
}
|
||||
|
||||
pub(crate) fn skin_runtime_status() -> SkinRuntimeStatus {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::runtime_status()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::runtime_status()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
SkinRuntimeStatus::unsupported()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_skin_runtime(
|
||||
theme_dir: &Path,
|
||||
theme_id: &str,
|
||||
restart_existing: bool,
|
||||
) -> Result<SkinRuntimeAction> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::apply_theme(theme_dir, theme_id, restart_existing)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::apply_theme(theme_dir, theme_id, restart_existing)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = (theme_dir, theme_id, restart_existing);
|
||||
Err(CodexxError::Config(
|
||||
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn pause_skin_runtime() -> Result<SkinRuntimeAction> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::pause_theme()
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::pause_theme()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
Err(CodexxError::Config(
|
||||
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore_skin_runtime(restart_existing: bool) -> Result<SkinRuntimeAction> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
macos::restore_official(restart_existing)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
windows::restore_official(restart_existing)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = restart_existing;
|
||||
Err(CodexxError::Config(
|
||||
"当前版本的 Codex 实机换肤仅支持 macOS 和 Windows".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::assets::{ensure_runtime_directory, RuntimeAssets};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const COMMAND_POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
const THEME_STAGE_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const PAYLOAD_CHECK_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const INJECTOR_ONCE_TIMEOUT: Duration = Duration::from_secs(35);
|
||||
const INJECTOR_REMOVE_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
pub(super) fn command_detail(output: &std::process::Output) -> String {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let detail = if stderr.is_empty() { stdout } else { stderr };
|
||||
if detail.chars().count() <= 800 {
|
||||
detail
|
||||
} else {
|
||||
detail
|
||||
.chars()
|
||||
.rev()
|
||||
.take(800)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wait_for_output(
|
||||
mut child: std::process::Child,
|
||||
program: &Path,
|
||||
label: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<std::process::Output> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match child.try_wait().map_err(|source| io_err(program, source))? {
|
||||
Some(_) => {
|
||||
return child
|
||||
.wait_with_output()
|
||||
.map_err(|source| io_err(program, source))
|
||||
}
|
||||
None if Instant::now() >= deadline => {
|
||||
let _ = child.kill();
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|source| io_err(program, source))?;
|
||||
let detail = command_detail(&output);
|
||||
let suffix = if detail.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(": {detail}")
|
||||
};
|
||||
return Err(CodexxError::Config(format!(
|
||||
"{label}超时({} 秒){suffix}",
|
||||
timeout.as_secs()
|
||||
)));
|
||||
}
|
||||
None => thread::sleep(COMMAND_POLL_INTERVAL),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_node(
|
||||
node: &Path,
|
||||
script: &Path,
|
||||
args: &[String],
|
||||
label: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<String> {
|
||||
let child = Command::new(node)
|
||||
.arg(script)
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|source| io_err(script, source))?;
|
||||
let output = wait_for_output(child, script, label, timeout)?;
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
let detail = command_detail(&output);
|
||||
Err(CodexxError::Config(if detail.is_empty() {
|
||||
format!("{label}失败")
|
||||
} else {
|
||||
format!("{label}失败: {detail}")
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn stage_theme_snapshot(
|
||||
node: &Path,
|
||||
assets: &RuntimeAssets,
|
||||
snapshots_root: &Path,
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
) -> Result<PathBuf> {
|
||||
ensure_runtime_directory(snapshots_root)?;
|
||||
let snapshot = snapshots_root.join(format!(
|
||||
"{}-{}-{}",
|
||||
theme_id,
|
||||
std::process::id(),
|
||||
chrono::Local::now()
|
||||
.timestamp_nanos_opt()
|
||||
.unwrap_or_default()
|
||||
));
|
||||
ensure_runtime_directory(&snapshot)?;
|
||||
let result = (|| -> Result<()> {
|
||||
run_node(
|
||||
node,
|
||||
&assets.stage_theme,
|
||||
&[
|
||||
source.to_string_lossy().to_string(),
|
||||
snapshot.to_string_lossy().to_string(),
|
||||
],
|
||||
"暂存主题",
|
||||
THEME_STAGE_TIMEOUT,
|
||||
)?;
|
||||
run_node(
|
||||
node,
|
||||
&assets.injector,
|
||||
&[
|
||||
"--check-payload".to_string(),
|
||||
"--theme-dir".to_string(),
|
||||
snapshot.to_string_lossy().to_string(),
|
||||
],
|
||||
"校验主题注入负载",
|
||||
PAYLOAD_CHECK_TIMEOUT,
|
||||
)?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_dir_all(&snapshot);
|
||||
}
|
||||
result.map(|_| snapshot)
|
||||
}
|
||||
|
||||
pub(super) fn run_injector_once(
|
||||
node: &Path,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
run_node(
|
||||
node,
|
||||
&assets.injector,
|
||||
&[
|
||||
"--once".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--theme-dir".to_string(),
|
||||
theme_dir.to_string_lossy().to_string(),
|
||||
"--timeout-ms".to_string(),
|
||||
"20000".to_string(),
|
||||
],
|
||||
"注入并验证 Codex 皮肤",
|
||||
INJECTOR_ONCE_TIMEOUT,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn remove_live_skin(
|
||||
node: &Path,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<()> {
|
||||
run_node(
|
||||
node,
|
||||
&assets.injector,
|
||||
&[
|
||||
"--remove".to_string(),
|
||||
"--port".to_string(),
|
||||
port.to_string(),
|
||||
"--theme-dir".to_string(),
|
||||
theme_dir.to_string_lossy().to_string(),
|
||||
"--timeout-ms".to_string(),
|
||||
"10000".to_string(),
|
||||
],
|
||||
"移除 Codex 皮肤",
|
||||
INJECTOR_REMOVE_TIMEOUT,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wait_for_output_collects_a_completed_child() {
|
||||
let child = Command::new("/bin/echo")
|
||||
.arg("skin-ready")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn echo fixture");
|
||||
let output = wait_for_output(
|
||||
child,
|
||||
Path::new("/bin/echo"),
|
||||
"测试皮肤命令",
|
||||
Duration::from_secs(1),
|
||||
)
|
||||
.expect("collect output");
|
||||
assert!(output.status.success());
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "skin-ready");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wait_for_output_terminates_a_stuck_child() {
|
||||
let child = Command::new("/bin/sleep")
|
||||
.arg("5")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn sleep fixture");
|
||||
let started = Instant::now();
|
||||
let error = wait_for_output(
|
||||
child,
|
||||
Path::new("/bin/sleep"),
|
||||
"测试皮肤命令",
|
||||
Duration::from_millis(80),
|
||||
)
|
||||
.expect_err("timeout stuck child");
|
||||
assert!(error.to_string().contains("超时"));
|
||||
assert!(started.elapsed() < Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
use super::assets::{ensure_runtime_assets, ensure_runtime_directory, RuntimeAssets};
|
||||
use super::node_runtime;
|
||||
use super::{SkinRuntimeAction, SkinRuntimeStatus};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write, io_err};
|
||||
use crate::now_rfc3339;
|
||||
use crate::skins::skins_root;
|
||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||
use std::env;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
const DEFAULT_CDP_PORT: u16 = 9341;
|
||||
const RUNTIME_STATE_SCHEMA: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RuntimeStateFile {
|
||||
schema_version: u32,
|
||||
platform: String,
|
||||
session: String,
|
||||
port: u16,
|
||||
browser_id: String,
|
||||
injector_pid: u32,
|
||||
injector_started_at: String,
|
||||
injector_path: String,
|
||||
node_path: String,
|
||||
node_version: String,
|
||||
codex_package_root: String,
|
||||
codex_executable: String,
|
||||
codex_version: String,
|
||||
codex_package_full_name: String,
|
||||
codex_package_family_name: String,
|
||||
codex_app_user_model_id: String,
|
||||
theme_id: String,
|
||||
theme_dir: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CodexRuntime {
|
||||
package_root: PathBuf,
|
||||
executable: PathBuf,
|
||||
version: String,
|
||||
package_full_name: String,
|
||||
package_family_name: String,
|
||||
app_user_model_id: String,
|
||||
running: bool,
|
||||
debug_port: Option<u16>,
|
||||
browser_id: Option<String>,
|
||||
watcher_pids: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PortResult {
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LaunchResult {
|
||||
port: u16,
|
||||
browser_id: String,
|
||||
strategy: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct VerifyPortResult {
|
||||
verified: bool,
|
||||
browser_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProcessInfo {
|
||||
alive: bool,
|
||||
path: Option<String>,
|
||||
command_line: Option<String>,
|
||||
started_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ActiveResult {
|
||||
active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct NodeRuntime {
|
||||
path: PathBuf,
|
||||
version: String,
|
||||
}
|
||||
|
||||
struct ActiveStateContext<'a> {
|
||||
runtime: &'a CodexRuntime,
|
||||
node: &'a NodeRuntime,
|
||||
assets: &'a RuntimeAssets,
|
||||
port: u16,
|
||||
browser_id: String,
|
||||
theme_id: &'a str,
|
||||
theme_dir: &'a Path,
|
||||
injector_pid: u32,
|
||||
injector_started_at: String,
|
||||
}
|
||||
|
||||
fn runtime_root() -> Result<PathBuf> {
|
||||
Ok(skins_root()?.join("runtime"))
|
||||
}
|
||||
|
||||
fn state_path() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("state.json"))
|
||||
}
|
||||
|
||||
fn snapshots_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("themes"))
|
||||
}
|
||||
|
||||
fn logs_root() -> Result<PathBuf> {
|
||||
Ok(runtime_root()?.join("logs"))
|
||||
}
|
||||
|
||||
fn powershell_path() -> PathBuf {
|
||||
env::var_os("SystemRoot")
|
||||
.map(PathBuf::from)
|
||||
.map(|root| {
|
||||
root.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe")
|
||||
})
|
||||
.filter(|path| path.is_file())
|
||||
.unwrap_or_else(|| PathBuf::from("powershell.exe"))
|
||||
}
|
||||
|
||||
fn configure_background_command(command: &mut Command) {
|
||||
#[cfg(target_os = "windows")]
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let _ = command;
|
||||
}
|
||||
|
||||
fn parse_last_json<T: DeserializeOwned>(stdout: &[u8], label: &str) -> Result<T> {
|
||||
let text = String::from_utf8_lossy(stdout);
|
||||
text.lines()
|
||||
.rev()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.find_map(|line| serde_json::from_str(line).ok())
|
||||
.ok_or_else(|| CodexxError::Config(format!("{label}没有返回有效结果")))
|
||||
}
|
||||
|
||||
fn run_adapter<T: DeserializeOwned>(
|
||||
assets: &RuntimeAssets,
|
||||
action: &str,
|
||||
args: &[String],
|
||||
timeout: Duration,
|
||||
) -> Result<T> {
|
||||
let powershell = powershell_path();
|
||||
let mut command = Command::new(&powershell);
|
||||
command
|
||||
.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-File"])
|
||||
.arg(&assets.windows_adapter)
|
||||
.args(["-Action", action])
|
||||
.args(args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
configure_background_command(&mut command);
|
||||
let child = command
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&powershell, source))?;
|
||||
let output = node_runtime::wait_for_output(child, &powershell, action, timeout)?;
|
||||
if !output.status.success() {
|
||||
let detail = node_runtime::command_detail(&output);
|
||||
return Err(CodexxError::Config(if detail.is_empty() {
|
||||
format!("Windows 皮肤操作失败: {action}")
|
||||
} else {
|
||||
format!("Windows 皮肤操作失败: {detail}")
|
||||
}));
|
||||
}
|
||||
parse_last_json(&output.stdout, action)
|
||||
}
|
||||
|
||||
fn inspect_codex(assets: &RuntimeAssets) -> Result<CodexRuntime> {
|
||||
run_adapter(assets, "inspect", &[], Duration::from_secs(15))
|
||||
}
|
||||
|
||||
fn select_port(assets: &RuntimeAssets, preferred: u16) -> Result<u16> {
|
||||
let result: PortResult = run_adapter(
|
||||
assets,
|
||||
"selectPort",
|
||||
&["-Port".to_string(), preferred.to_string()],
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
Ok(result.port)
|
||||
}
|
||||
|
||||
fn verify_port(assets: &RuntimeAssets, port: u16) -> Result<VerifyPortResult> {
|
||||
run_adapter(
|
||||
assets,
|
||||
"verifyPort",
|
||||
&["-Port".to_string(), port.to_string()],
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
}
|
||||
|
||||
fn stop_codex(assets: &RuntimeAssets) -> Result<()> {
|
||||
let _: serde_json::Value = run_adapter(assets, "stopCodex", &[], Duration::from_secs(25))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn launch_codex_normally(assets: &RuntimeAssets) -> Result<()> {
|
||||
let _: serde_json::Value = run_adapter(assets, "launchNormal", &[], Duration::from_secs(15))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn launch_codex_with_cdp(assets: &RuntimeAssets, port: u16) -> Result<LaunchResult> {
|
||||
let result: LaunchResult = run_adapter(
|
||||
assets,
|
||||
"launch",
|
||||
&["-Port".to_string(), port.to_string()],
|
||||
Duration::from_secs(60),
|
||||
)?;
|
||||
if result.port != port || result.browser_id.is_empty() {
|
||||
return Err(CodexxError::Config(
|
||||
"Windows Codex 启动器返回了无效的调试端口身份".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn read_runtime_state() -> Result<Option<RuntimeStateFile>> {
|
||||
let path = state_path()?;
|
||||
let metadata = match fs::symlink_metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > 256 * 1024 {
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态文件不是安全的普通文件,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
let bytes = fs::read(&path).map_err(|source| io_err(&path, source))?;
|
||||
let state: RuntimeStateFile = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| CodexxError::Config(format!("解析皮肤运行状态失败: {error}")))?;
|
||||
if state.schema_version != RUNTIME_STATE_SCHEMA
|
||||
|| state.platform != "windows"
|
||||
|| !(1024..=65535).contains(&state.port)
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤运行状态版本或端口无效,已停止自动处理".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
fn write_runtime_state(state: &RuntimeStateFile) -> Result<()> {
|
||||
let path = state_path()?;
|
||||
let text = serde_json::to_string_pretty(state)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化皮肤运行状态失败: {error}")))?;
|
||||
atomic_write(&path, format!("{text}\n").as_bytes())
|
||||
}
|
||||
|
||||
fn remove_runtime_state() -> Result<()> {
|
||||
let path = state_path()?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_err(&path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_version(path: &Path) -> Result<String> {
|
||||
let mut command = Command::new(path);
|
||||
command.arg("--version");
|
||||
configure_background_command(&mut command);
|
||||
let output = command.output().map_err(|source| io_err(path, source))?;
|
||||
if !output.status.success() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"无法运行 Windows 皮肤 Node.js: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let major = version
|
||||
.trim_start_matches('v')
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|value| value.parse::<u32>().ok())
|
||||
.ok_or_else(|| CodexxError::Config(format!("无法解析 Node.js 版本: {version}")))?;
|
||||
if major < 22 {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Windows 皮肤运行时需要 Node.js 22 或更高版本,当前为 {version}"
|
||||
)));
|
||||
}
|
||||
Ok(version)
|
||||
}
|
||||
|
||||
fn resolve_node_candidate(path: &Path) -> Option<PathBuf> {
|
||||
if let Ok(canonical) = fs::canonicalize(path) {
|
||||
return Some(canonical);
|
||||
}
|
||||
if path.components().count() != 1 {
|
||||
return None;
|
||||
}
|
||||
let mut command = Command::new("where.exe");
|
||||
command.arg(path);
|
||||
configure_background_command(&mut command);
|
||||
let output = command.output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.find_map(|candidate| fs::canonicalize(candidate).ok())
|
||||
}
|
||||
|
||||
fn discover_node_runtime() -> Result<NodeRuntime> {
|
||||
let mut candidates = Vec::new();
|
||||
if let Some(path) = env::var_os("CODEX_X_SKIN_NODE") {
|
||||
candidates.push(PathBuf::from(path));
|
||||
}
|
||||
if let Ok(executable) = env::current_exe() {
|
||||
if let Some(parent) = executable.parent() {
|
||||
candidates.push(parent.join("skin-runtime").join("node").join("node.exe"));
|
||||
}
|
||||
}
|
||||
candidates.push(PathBuf::from("node.exe"));
|
||||
for candidate in candidates {
|
||||
if let Some(path) = resolve_node_candidate(&candidate) {
|
||||
if let Ok(version) = node_version(&path) {
|
||||
return Ok(NodeRuntime { path, version });
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(CodexxError::Config(
|
||||
"Windows 皮肤运行时缺少内置 Node.js;请重新安装 Codex-X".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn canonical_managed_snapshot(path: &Path) -> Option<PathBuf> {
|
||||
let metadata = fs::symlink_metadata(path).ok()?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let root = fs::canonicalize(snapshots_root().ok()?).ok()?;
|
||||
let candidate = fs::canonicalize(path).ok()?;
|
||||
candidate.starts_with(&root).then_some(candidate)
|
||||
}
|
||||
|
||||
fn cleanup_snapshot(path: &str, keep: &Path) {
|
||||
let Some(path) = canonical_managed_snapshot(Path::new(path)) else {
|
||||
return;
|
||||
};
|
||||
let keep = canonical_managed_snapshot(keep);
|
||||
if keep.as_ref() != Some(&path) {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn process_info(assets: &RuntimeAssets, pid: u32) -> Result<ProcessInfo> {
|
||||
run_adapter(
|
||||
assets,
|
||||
"processInfo",
|
||||
&["-TargetPid".to_string(), pid.to_string()],
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
}
|
||||
|
||||
fn injector_active(assets: &RuntimeAssets) -> Result<bool> {
|
||||
let result: ActiveResult = run_adapter(
|
||||
assets,
|
||||
"injectorStatus",
|
||||
&[
|
||||
"-StatePath".to_string(),
|
||||
state_path()?.to_string_lossy().to_string(),
|
||||
],
|
||||
Duration::from_secs(10),
|
||||
)?;
|
||||
Ok(result.active)
|
||||
}
|
||||
|
||||
fn write_adapter_state(state: &RuntimeStateFile) -> Result<PathBuf> {
|
||||
let root = runtime_root()?;
|
||||
ensure_runtime_directory(&root)?;
|
||||
let path = root.join(format!(
|
||||
".process-state-{}-{}.json",
|
||||
std::process::id(),
|
||||
state.injector_pid
|
||||
));
|
||||
let text = serde_json::to_string(state)
|
||||
.map_err(|error| CodexxError::Config(format!("序列化皮肤进程状态失败: {error}")))?;
|
||||
atomic_write(&path, text.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn stop_recorded_injector(assets: &RuntimeAssets, state: &RuntimeStateFile) -> Result<()> {
|
||||
if state.injector_pid == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let info = process_info(assets, state.injector_pid)?;
|
||||
if !info.alive {
|
||||
return Ok(());
|
||||
}
|
||||
let adapter_state = write_adapter_state(state)?;
|
||||
let result = run_adapter::<serde_json::Value>(
|
||||
assets,
|
||||
"stopInjector",
|
||||
&[
|
||||
"-StatePath".to_string(),
|
||||
adapter_state.to_string_lossy().to_string(),
|
||||
],
|
||||
Duration::from_secs(15),
|
||||
);
|
||||
let _ = fs::remove_file(&adapter_state);
|
||||
result.map(|_| ())
|
||||
}
|
||||
|
||||
fn log_files(prefix: &str) -> Result<(File, File)> {
|
||||
let root = logs_root()?;
|
||||
ensure_runtime_directory(&root)?;
|
||||
let stdout_path = root.join(format!("{prefix}.log"));
|
||||
let stderr_path = root.join(format!("{prefix}-error.log"));
|
||||
let stdout = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stdout_path)
|
||||
.map_err(|source| io_err(&stdout_path, source))?;
|
||||
let stderr = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&stderr_path)
|
||||
.map_err(|source| io_err(&stderr_path, source))?;
|
||||
Ok((stdout, stderr))
|
||||
}
|
||||
|
||||
fn launch_watcher(
|
||||
node: &NodeRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
port: u16,
|
||||
theme_dir: &Path,
|
||||
) -> Result<(u32, String)> {
|
||||
let (stdout, stderr) = log_files("injector")?;
|
||||
let mut command = Command::new(&node.path);
|
||||
command
|
||||
.arg(&assets.injector)
|
||||
.args(["--watch", "--port"])
|
||||
.arg(port.to_string())
|
||||
.arg("--theme-dir")
|
||||
.arg(theme_dir)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout))
|
||||
.stderr(Stdio::from(stderr));
|
||||
configure_background_command(&mut command);
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|source| io_err(&assets.injector, source))?;
|
||||
let pid = child.id();
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
if child
|
||||
.try_wait()
|
||||
.map_err(|source| io_err(&assets.injector, source))?
|
||||
.is_some()
|
||||
{
|
||||
return Err(CodexxError::Config(
|
||||
"皮肤注入器启动后立即退出,请检查运行日志".to_string(),
|
||||
));
|
||||
}
|
||||
let info = process_info(assets, pid)?;
|
||||
let started_at = info
|
||||
.started_at
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| CodexxError::Config("无法记录皮肤注入器启动时间".to_string()))?;
|
||||
let expected_node = node.path.to_string_lossy();
|
||||
let path_matches = info.path.as_deref().is_some_and(|path| {
|
||||
Path::new(path)
|
||||
.canonicalize()
|
||||
.is_ok_and(|actual| actual == node.path)
|
||||
});
|
||||
let command_matches = info.command_line.as_deref().is_some_and(|command| {
|
||||
command.contains(assets.injector.to_string_lossy().as_ref())
|
||||
&& command.contains("--watch")
|
||||
&& command.contains(&format!("--port {port}"))
|
||||
});
|
||||
if !info.alive
|
||||
|| (!path_matches
|
||||
&& !expected_node.eq_ignore_ascii_case(info.path.as_deref().unwrap_or("")))
|
||||
|| !command_matches
|
||||
{
|
||||
let _ = child.kill();
|
||||
return Err(CodexxError::Config(
|
||||
"Windows 皮肤注入器进程身份校验失败".to_string(),
|
||||
));
|
||||
}
|
||||
drop(child);
|
||||
Ok((pid, started_at))
|
||||
}
|
||||
|
||||
fn active_state(context: ActiveStateContext<'_>) -> RuntimeStateFile {
|
||||
let timestamp = now_rfc3339();
|
||||
RuntimeStateFile {
|
||||
schema_version: RUNTIME_STATE_SCHEMA,
|
||||
platform: "windows".to_string(),
|
||||
session: "active".to_string(),
|
||||
port: context.port,
|
||||
browser_id: context.browser_id,
|
||||
injector_pid: context.injector_pid,
|
||||
injector_started_at: context.injector_started_at,
|
||||
injector_path: context.assets.injector.to_string_lossy().to_string(),
|
||||
node_path: context.node.path.to_string_lossy().to_string(),
|
||||
node_version: context.node.version.clone(),
|
||||
codex_package_root: context.runtime.package_root.to_string_lossy().to_string(),
|
||||
codex_executable: context.runtime.executable.to_string_lossy().to_string(),
|
||||
codex_version: context.runtime.version.clone(),
|
||||
codex_package_full_name: context.runtime.package_full_name.clone(),
|
||||
codex_package_family_name: context.runtime.package_family_name.clone(),
|
||||
codex_app_user_model_id: context.runtime.app_user_model_id.clone(),
|
||||
theme_id: context.theme_id.to_string(),
|
||||
theme_dir: context.theme_dir.to_string_lossy().to_string(),
|
||||
created_at: timestamp.clone(),
|
||||
updated_at: timestamp,
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_previous_after_failure(
|
||||
runtime: &CodexRuntime,
|
||||
node: &NodeRuntime,
|
||||
assets: &RuntimeAssets,
|
||||
previous: Option<&RuntimeStateFile>,
|
||||
) {
|
||||
let Some(previous) = previous else { return };
|
||||
let theme_dir = Path::new(&previous.theme_dir);
|
||||
let verified = verify_port(assets, previous.port).is_ok_and(|result| result.verified);
|
||||
if previous.session != "active"
|
||||
|| !theme_dir.is_dir()
|
||||
|| !verified
|
||||
|| node_runtime::run_injector_once(&node.path, assets, previous.port, theme_dir).is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Ok((pid, started_at)) = launch_watcher(node, assets, previous.port, theme_dir) {
|
||||
let restored = active_state(ActiveStateContext {
|
||||
runtime,
|
||||
node,
|
||||
assets,
|
||||
port: previous.port,
|
||||
browser_id: previous.browser_id.clone(),
|
||||
theme_id: &previous.theme_id,
|
||||
theme_dir,
|
||||
injector_pid: pid,
|
||||
injector_started_at: started_at,
|
||||
});
|
||||
let _ = write_runtime_state(&restored);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn apply_theme(
|
||||
source: &Path,
|
||||
theme_id: &str,
|
||||
restart_existing: bool,
|
||||
) -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let runtime = inspect_codex(&assets)?;
|
||||
let node = discover_node_runtime()?;
|
||||
let previous = read_runtime_state()?;
|
||||
if let Some(pid) = runtime.watcher_pids.iter().copied().find(|pid| {
|
||||
previous
|
||||
.as_ref()
|
||||
.is_none_or(|state| state.injector_pid != *pid)
|
||||
}) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"检测到外部 Codex 换肤注入器 PID {pid},请先在原工具中关闭皮肤"
|
||||
)));
|
||||
}
|
||||
if runtime.running && runtime.debug_port.is_none() && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"Codex 需要重启一次才能打开仅限本机的换肤调试端口".to_string(),
|
||||
));
|
||||
}
|
||||
let snapshot = node_runtime::stage_theme_snapshot(
|
||||
&node.path,
|
||||
&assets,
|
||||
&snapshots_root()?,
|
||||
source,
|
||||
theme_id,
|
||||
)?;
|
||||
let mut launched_new = false;
|
||||
let apply_result = (|| -> Result<RuntimeStateFile> {
|
||||
if let Some(state) = previous.as_ref() {
|
||||
stop_recorded_injector(&assets, state)?;
|
||||
}
|
||||
let (port, browser_id) = if let Some(port) = runtime.debug_port {
|
||||
let browser_id = runtime.browser_id.clone().ok_or_else(|| {
|
||||
CodexxError::Config("无法验证当前 Windows Codex 调试会话".to_string())
|
||||
})?;
|
||||
(port, browser_id)
|
||||
} else {
|
||||
if runtime.running {
|
||||
stop_codex(&assets)?;
|
||||
}
|
||||
let preferred = previous
|
||||
.as_ref()
|
||||
.map_or(DEFAULT_CDP_PORT, |state| state.port);
|
||||
let port = select_port(&assets, preferred)?;
|
||||
let launch = launch_codex_with_cdp(&assets, port)?;
|
||||
launched_new = true;
|
||||
let _strategy = launch.strategy;
|
||||
(port, launch.browser_id)
|
||||
};
|
||||
node_runtime::run_injector_once(&node.path, &assets, port, &snapshot)?;
|
||||
let (pid, started_at) = launch_watcher(&node, &assets, port, &snapshot)?;
|
||||
let state = active_state(ActiveStateContext {
|
||||
runtime: &runtime,
|
||||
node: &node,
|
||||
assets: &assets,
|
||||
port,
|
||||
browser_id,
|
||||
theme_id,
|
||||
theme_dir: &snapshot,
|
||||
injector_pid: pid,
|
||||
injector_started_at: started_at,
|
||||
});
|
||||
if let Err(error) = write_runtime_state(&state) {
|
||||
let _ = stop_recorded_injector(&assets, &state);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(state)
|
||||
})();
|
||||
match apply_result {
|
||||
Ok(_) => {
|
||||
if let Some(previous) = previous.as_ref() {
|
||||
cleanup_snapshot(&previous.theme_dir, &snapshot);
|
||||
}
|
||||
Ok(SkinRuntimeAction::Applied(
|
||||
"已应用 Windows Codex 皮肤".to_string(),
|
||||
))
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&snapshot);
|
||||
restore_previous_after_failure(&runtime, &node, &assets, previous.as_ref());
|
||||
if launched_new && previous.is_none() {
|
||||
let _ = stop_codex(&assets);
|
||||
let _ = launch_codex_normally(&assets);
|
||||
}
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn pause_theme() -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(mut state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Paused(
|
||||
"Codex 皮肤当前没有运行".to_string(),
|
||||
));
|
||||
};
|
||||
let node = discover_node_runtime()?;
|
||||
stop_recorded_injector(&assets, &state)?;
|
||||
if verify_port(&assets, state.port).is_ok_and(|result| result.verified) {
|
||||
node_runtime::remove_live_skin(
|
||||
&node.path,
|
||||
&assets,
|
||||
state.port,
|
||||
Path::new(&state.theme_dir),
|
||||
)?;
|
||||
}
|
||||
state.session = "paused".to_string();
|
||||
state.injector_pid = 0;
|
||||
state.injector_started_at.clear();
|
||||
state.updated_at = now_rfc3339();
|
||||
write_runtime_state(&state)?;
|
||||
Ok(SkinRuntimeAction::Paused(
|
||||
"已关闭 Codex 皮肤;Codex 保持打开".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn restore_official(restart_existing: bool) -> Result<SkinRuntimeAction> {
|
||||
let assets = ensure_runtime_assets()?;
|
||||
let Some(state) = read_runtime_state()? else {
|
||||
return Ok(SkinRuntimeAction::Restored(
|
||||
"Codex 当前已使用官方外观".to_string(),
|
||||
));
|
||||
};
|
||||
let node = discover_node_runtime()?;
|
||||
let runtime = inspect_codex(&assets)?;
|
||||
let debug_ready = verify_port(&assets, state.port).is_ok_and(|result| result.verified);
|
||||
if runtime.running && (debug_ready || state.session == "active") && !restart_existing {
|
||||
return Ok(SkinRuntimeAction::RestartRequired(
|
||||
"完整恢复需要重启 Codex,以关闭本机调试端口并清除当前渲染状态".to_string(),
|
||||
));
|
||||
}
|
||||
stop_recorded_injector(&assets, &state)?;
|
||||
if debug_ready {
|
||||
node_runtime::remove_live_skin(
|
||||
&node.path,
|
||||
&assets,
|
||||
state.port,
|
||||
Path::new(&state.theme_dir),
|
||||
)?;
|
||||
}
|
||||
if runtime.running && (debug_ready || state.session == "active") {
|
||||
stop_codex(&assets)?;
|
||||
launch_codex_normally(&assets)?;
|
||||
}
|
||||
remove_runtime_state()?;
|
||||
cleanup_snapshot(&state.theme_dir, Path::new(""));
|
||||
Ok(SkinRuntimeAction::Restored(
|
||||
"已恢复 Windows Codex 官方外观并关闭换肤运行时".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn runtime_status() -> SkinRuntimeStatus {
|
||||
let assets = match ensure_runtime_assets() {
|
||||
Ok(assets) => assets,
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let state = match read_runtime_state() {
|
||||
Ok(Some(state)) => state,
|
||||
Ok(None) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "inactive".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: "尚未应用 Codex 皮肤".to_string(),
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "error".to_string(),
|
||||
port: None,
|
||||
theme_id: None,
|
||||
message: error.to_string(),
|
||||
}
|
||||
}
|
||||
};
|
||||
if state.session == "paused" {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "paused".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: "皮肤已关闭,Codex 保持打开".to_string(),
|
||||
};
|
||||
}
|
||||
if let Err(error) = inspect_codex(&assets) {
|
||||
return SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active: false,
|
||||
phase: "unavailable".to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: error.to_string(),
|
||||
};
|
||||
}
|
||||
let active = injector_active(&assets).unwrap_or(false)
|
||||
&& verify_port(&assets, state.port).is_ok_and(|result| {
|
||||
result.verified && result.browser_id.as_deref() == Some(state.browser_id.as_str())
|
||||
});
|
||||
SkinRuntimeStatus {
|
||||
supported: true,
|
||||
active,
|
||||
phase: if active { "active" } else { "stale" }.to_string(),
|
||||
port: Some(state.port),
|
||||
theme_id: Some(state.theme_id),
|
||||
message: if active {
|
||||
"Windows Codex 皮肤运行中".to_string()
|
||||
} else {
|
||||
"皮肤运行状态已失效,可重新应用主题".to_string()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_state() -> RuntimeStateFile {
|
||||
RuntimeStateFile {
|
||||
schema_version: 1,
|
||||
platform: "windows".to_string(),
|
||||
session: "active".to_string(),
|
||||
port: 9341,
|
||||
browser_id: "browser-1".to_string(),
|
||||
injector_pid: 42,
|
||||
injector_started_at: "2026-07-24T00:00:00.0000000Z".to_string(),
|
||||
injector_path: r"C:\Users\test\.codexx\runtime\injector.mjs".to_string(),
|
||||
node_path: r"C:\Program Files\Codex-X\skin-runtime\node\node.exe".to_string(),
|
||||
node_version: "v22.23.1".to_string(),
|
||||
codex_package_root: r"C:\Program Files\WindowsApps\OpenAI.Codex_1.0.0.0_x64"
|
||||
.to_string(),
|
||||
codex_executable:
|
||||
r"C:\Program Files\WindowsApps\OpenAI.Codex_1.0.0.0_x64\app\ChatGPT.exe".to_string(),
|
||||
codex_version: "1.0.0.0".to_string(),
|
||||
codex_package_full_name: "OpenAI.Codex_1.0.0.0_x64__test".to_string(),
|
||||
codex_package_family_name: "OpenAI.Codex_test".to_string(),
|
||||
codex_app_user_model_id: "OpenAI.Codex_test!App".to_string(),
|
||||
theme_id: "theme".to_string(),
|
||||
theme_dir: r"C:\Users\test\.codexx\runtime\themes\theme".to_string(),
|
||||
created_at: "2026-07-24T00:00:00Z".to_string(),
|
||||
updated_at: "2026-07-24T00:00:00Z".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_last_json_line_after_powershell_warnings() {
|
||||
let result: PortResult = parse_last_json(
|
||||
b"WARNING: package activation fallback\r\n{\"port\":9342}\r\n",
|
||||
"test",
|
||||
)
|
||||
.expect("parse adapter JSON");
|
||||
assert_eq!(result.port, 9342);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_state_uses_powershell_contract_field_names() {
|
||||
let value = serde_json::to_value(test_state()).expect("serialize state");
|
||||
assert_eq!(value["platform"], "windows");
|
||||
assert_eq!(value["injectorPid"], 42);
|
||||
assert_eq!(
|
||||
value["injectorPath"],
|
||||
r"C:\Users\test\.codexx\runtime\injector.mjs"
|
||||
);
|
||||
assert_eq!(
|
||||
value["nodePath"],
|
||||
r"C:\Program Files\Codex-X\skin-runtime\node\node.exe"
|
||||
);
|
||||
assert_eq!(value["browserId"], "browser-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_keeps_store_identity_and_loopback_guards() {
|
||||
let source = include_str!("../../resources/skin-runtime/windows/codexx-windows.ps1");
|
||||
assert!(source.contains("Get-DreamSkinCodexInstall"));
|
||||
assert!(source.contains("Start-DreamSkinCodexForDebugging"));
|
||||
assert!(source.contains("--remote-debugging-address=127.0.0.1"));
|
||||
assert!(source.contains("Get-DreamSkinVerifiedCdpIdentity"));
|
||||
assert!(!source.contains("takeown"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static FIXTURE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
fn temp_theme_dir(name: &str) -> PathBuf {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-skin-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create theme fixture");
|
||||
path
|
||||
}
|
||||
|
||||
fn write_theme_fixture(dir: &Path, manifest: &str) {
|
||||
fs::write(dir.join("theme.json"), manifest).expect("write manifest");
|
||||
fs::write(dir.join("background.jpg"), [0xff, 0xd8, 0xff, 0xd9]).expect("write image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_accepts_adaptive_fields_and_preserves_extensions() {
|
||||
let dir = temp_theme_dir("adaptive");
|
||||
write_theme_fixture(
|
||||
&dir,
|
||||
r##"{
|
||||
"schemaVersion": 1,
|
||||
"id": "adaptive",
|
||||
"name": "Adaptive",
|
||||
"image": "background.jpg",
|
||||
"appearance": "auto",
|
||||
"art": { "focusX": 0.72, "safeArea": "left", "taskMode": "ambient" },
|
||||
"promoTitle": "kept"
|
||||
}"##,
|
||||
);
|
||||
|
||||
let manifest = read_manifest(&dir).expect("parse adaptive manifest");
|
||||
|
||||
assert!(manifest.colors.is_none());
|
||||
assert_eq!(
|
||||
manifest.art.as_ref().and_then(|art| art.focus_x),
|
||||
Some(0.72)
|
||||
);
|
||||
assert_eq!(
|
||||
manifest.extra.get("promoTitle"),
|
||||
Some(&Value::String("kept".to_string()))
|
||||
);
|
||||
let serialized = serde_json::to_value(manifest).expect("serialize manifest");
|
||||
assert_eq!(serialized["promoTitle"], "kept");
|
||||
fs::remove_dir_all(dir).expect("remove theme fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_rejects_out_of_range_art_focus() {
|
||||
let dir = temp_theme_dir("focus");
|
||||
write_theme_fixture(
|
||||
&dir,
|
||||
r#"{
|
||||
"schemaVersion": 1,
|
||||
"id": "bad-focus",
|
||||
"name": "Bad focus",
|
||||
"image": "background.jpg",
|
||||
"art": { "focusX": 2 }
|
||||
}"#,
|
||||
);
|
||||
|
||||
let error = read_manifest(&dir).expect_err("reject invalid focus");
|
||||
|
||||
assert!(error.to_string().contains("focusX"));
|
||||
fs::remove_dir_all(dir).expect("remove theme fixture");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn manifest_rejects_symlinked_images() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = temp_theme_dir("symlink");
|
||||
let outside = dir.with_extension("outside.jpg");
|
||||
fs::write(&outside, [0xff, 0xd8, 0xff, 0xd9]).expect("write outside image");
|
||||
fs::write(
|
||||
dir.join("theme.json"),
|
||||
r#"{"schemaVersion":1,"id":"linked","name":"Linked","image":"background.jpg"}"#,
|
||||
)
|
||||
.expect("write manifest");
|
||||
symlink(&outside, dir.join("background.jpg")).expect("create image symlink");
|
||||
|
||||
let error = read_manifest(&dir).expect_err("reject image symlink");
|
||||
|
||||
assert!(error.to_string().contains("普通文件"));
|
||||
fs::remove_dir_all(dir).expect("remove theme fixture");
|
||||
fs::remove_file(outside).expect("remove outside image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_import_rejects_parent_traversal() {
|
||||
let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
archive
|
||||
.start_file("../theme.json", SimpleFileOptions::default())
|
||||
.expect("start unsafe entry");
|
||||
archive.write_all(b"{}").expect("write unsafe entry");
|
||||
let bytes = archive.finish().expect("finish archive").into_inner();
|
||||
|
||||
let error = import_skin_theme_zip_inner("unsafe.zip".to_string(), bytes)
|
||||
.expect_err("reject traversal archive");
|
||||
|
||||
assert!(error.to_string().contains("越界路径"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_id_length_is_bounded() {
|
||||
let error =
|
||||
normalize_theme_id(&"a".repeat(MAX_THEME_ID_BYTES + 1)).expect_err("reject long theme id");
|
||||
assert!(error.to_string().contains("主题 ID"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uploaded_image_requires_matching_supported_signature() {
|
||||
assert_eq!(
|
||||
uploaded_image_extension(
|
||||
"wallpaper.png",
|
||||
&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
)
|
||||
.expect("accept PNG signature"),
|
||||
"png"
|
||||
);
|
||||
assert_eq!(
|
||||
uploaded_image_extension("wallpaper.jpeg", &[0xff, 0xd8, 0xff, 0xd9])
|
||||
.expect("accept JPEG signature"),
|
||||
"jpg"
|
||||
);
|
||||
assert!(uploaded_image_extension("wallpaper.png", b"not an image")
|
||||
.expect_err("reject disguised image")
|
||||
.to_string()
|
||||
.contains("格式不匹配"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_theme_names_and_ids_are_safe_and_unique() {
|
||||
assert_eq!(uploaded_theme_name(" sakura night.webp "), "sakura night");
|
||||
let root = temp_theme_dir("unique-image-id");
|
||||
fs::create_dir_all(root.join("image-sakura-night")).expect("create existing theme");
|
||||
assert_eq!(
|
||||
unique_theme_id_in(&root, "image-sakura-night").expect("create unique id"),
|
||||
"image-sakura-night-2"
|
||||
);
|
||||
fs::remove_dir_all(root).expect("remove unique id fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_upload_creates_an_adaptive_theme_without_overwriting() {
|
||||
let file_name = format!(
|
||||
"direct-image-test-{}.png",
|
||||
FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let bytes = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
let result = create_skin_theme_from_image_inner(file_name, bytes)
|
||||
.expect("create adaptive theme from image");
|
||||
let theme = result
|
||||
.state
|
||||
.themes
|
||||
.iter()
|
||||
.find(|theme| theme.name.starts_with("direct-image-test-"))
|
||||
.expect("created theme is listed");
|
||||
let theme_dir = themes_root().expect("theme root").join(&theme.id);
|
||||
let manifest = read_manifest(&theme_dir).expect("read created theme");
|
||||
|
||||
assert_eq!(manifest.appearance.as_deref(), Some("auto"));
|
||||
assert_eq!(manifest.surface_opacity, Some(IMAGE_THEME_SURFACE_OPACITY));
|
||||
assert!(manifest.colors.is_none());
|
||||
assert!(theme.adaptive);
|
||||
assert_eq!(theme.surface_opacity, IMAGE_THEME_SURFACE_OPACITY);
|
||||
assert_eq!(
|
||||
manifest
|
||||
.art
|
||||
.as_ref()
|
||||
.and_then(|art| art.safe_area.as_deref()),
|
||||
Some("auto")
|
||||
);
|
||||
fs::remove_dir_all(theme_dir).expect("remove created image theme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_theme_metadata_can_be_edited_without_changing_theme_assets() {
|
||||
let id = format!(
|
||||
"metadata-edit-test-{}",
|
||||
FIXTURE_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
);
|
||||
let theme_dir = themes_root().expect("theme root").join(&id);
|
||||
fs::create_dir_all(&theme_dir).expect("create editable theme");
|
||||
fs::write(theme_dir.join("background.jpg"), [0xff, 0xd8, 0xff, 0xd9])
|
||||
.expect("write editable theme image");
|
||||
let manifest = SkinThemeManifest {
|
||||
schema_version: 1,
|
||||
id: id.clone(),
|
||||
name: "Before".to_string(),
|
||||
brand_subtitle: "Keep brand".to_string(),
|
||||
tagline: "Before tagline".to_string(),
|
||||
project_prefix: String::new(),
|
||||
project_label: String::new(),
|
||||
status_text: String::new(),
|
||||
quote: "Keep quote".to_string(),
|
||||
image: "background.jpg".to_string(),
|
||||
appearance: Some("auto".to_string()),
|
||||
surface_opacity: None,
|
||||
art: Some(SkinThemeArt {
|
||||
safe_area: Some("left".to_string()),
|
||||
..SkinThemeArt::default()
|
||||
}),
|
||||
colors: None,
|
||||
extra: BTreeMap::from([("customField".to_string(), Value::Bool(true))]),
|
||||
};
|
||||
write_manifest(&theme_dir, &manifest).expect("write editable manifest");
|
||||
|
||||
let result = update_skin_theme_settings_inner(
|
||||
id.clone(),
|
||||
" After ".to_string(),
|
||||
" Updated tagline ".to_string(),
|
||||
0.55,
|
||||
)
|
||||
.expect("update metadata");
|
||||
let updated = read_manifest(&theme_dir).expect("read updated manifest");
|
||||
|
||||
assert_eq!(updated.name, "After");
|
||||
assert_eq!(updated.tagline, "Updated tagline");
|
||||
assert_eq!(updated.brand_subtitle, "Keep brand");
|
||||
assert_eq!(updated.quote, "Keep quote");
|
||||
assert_eq!(updated.image, "background.jpg");
|
||||
assert_eq!(updated.surface_opacity, Some(0.55));
|
||||
assert_eq!(
|
||||
updated.art.and_then(|art| art.safe_area),
|
||||
Some("left".to_string())
|
||||
);
|
||||
assert_eq!(updated.extra.get("customField"), Some(&Value::Bool(true)));
|
||||
assert_eq!(
|
||||
result
|
||||
.state
|
||||
.themes
|
||||
.iter()
|
||||
.find(|theme| theme.id == id)
|
||||
.map(|theme| theme.name.as_str()),
|
||||
Some("After")
|
||||
);
|
||||
fs::remove_dir_all(theme_dir).expect("remove editable theme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_catalog_is_not_embedded_but_source_remains_valid() {
|
||||
let assets = builtin_skin_assets();
|
||||
assert!(assets.is_empty());
|
||||
let manifest: SkinThemeManifest = serde_json::from_str(include_str!(
|
||||
"../resources/skin-presets/shiina-mashiro-blossom/theme.json"
|
||||
))
|
||||
.expect("parse retained Shiina theme source");
|
||||
assert_eq!(manifest.id, BUILTIN_SKIN_ID);
|
||||
assert_eq!(manifest.name, "椎名真白·樱花画室");
|
||||
assert_eq!(manifest.quote, "安静创作,也会发光");
|
||||
assert!(retired_builtin_skin_ids()
|
||||
.iter()
|
||||
.all(|retired| *retired != manifest.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_destination_adds_zip_extension_and_rejects_other_types() {
|
||||
let dir = temp_theme_dir("export-destination");
|
||||
let without_extension = dir.join("my-theme");
|
||||
let normalized = normalize_export_destination(without_extension.to_string_lossy().as_ref())
|
||||
.expect("append zip extension");
|
||||
assert_eq!(normalized, dir.join("my-theme.zip"));
|
||||
|
||||
let error = normalize_export_destination(dir.join("my-theme.txt").to_string_lossy().as_ref())
|
||||
.expect_err("reject non-zip extension");
|
||||
assert!(error.to_string().contains(".zip"));
|
||||
fs::remove_dir_all(dir).expect("remove export fixture");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub(crate) fn sql_select_column(cols: &HashSet<String>, name: &str, fallback: &str) -> String {
|
||||
if cols.contains(name) {
|
||||
format!("\"{}\"", name.replace('"', "\"\""))
|
||||
} else {
|
||||
fallback.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sqlite_has_table(conn: &Connection, table: &str) -> Result<bool> {
|
||||
conn.query_row(
|
||||
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
|
||||
[table],
|
||||
|_| Ok(()),
|
||||
)
|
||||
.map(|_| true)
|
||||
.or_else(|e| match e {
|
||||
rusqlite::Error::QueryReturnedNoRows => Ok(false),
|
||||
other => Err(CodexxError::Database(other.to_string())),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn table_column_set(conn: &Connection, table: &str) -> Result<HashSet<String>> {
|
||||
let mut stmt = conn
|
||||
.prepare(&format!(
|
||||
"PRAGMA table_info(\"{}\")",
|
||||
table.replace('"', "\"\"")
|
||||
))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let mut cols = HashSet::new();
|
||||
for row in rows {
|
||||
cols.insert(row.map_err(|e| CodexxError::Database(e.to_string()))?);
|
||||
}
|
||||
Ok(cols)
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use crate::backups::{latest_backup, BackupEntry};
|
||||
use crate::config_migration::migrate_legacy_prompt_config;
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{
|
||||
harden_sensitive_file_permissions, io_err, json_err, parse_toml_document,
|
||||
read_to_string_if_exists,
|
||||
};
|
||||
use crate::prompts::{
|
||||
agents_path, managed_agents_template_key, prompt_template_key_for_instruction,
|
||||
};
|
||||
use crate::providers::{
|
||||
detected_live_custom_provider, document_is_official, list_saved_providers_inner,
|
||||
official_auth_available, unique_saved_provider_id_for_live, SavedProvider,
|
||||
};
|
||||
use crate::{auth_path, config_path, string_value};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use toml_edit::{DocumentMut, Item};
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProviderSummary {
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
base_url: Option<String>,
|
||||
wire_api: Option<String>,
|
||||
requires_openai_auth: Option<bool>,
|
||||
is_current: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CodexState {
|
||||
codex_dir: String,
|
||||
config_path: String,
|
||||
auth_path: String,
|
||||
config_exists: bool,
|
||||
auth_exists: bool,
|
||||
official_auth_available: bool,
|
||||
pub(crate) model: Option<String>,
|
||||
pub(crate) model_provider: Option<String>,
|
||||
pub(crate) is_official_provider: bool,
|
||||
instruction_file: Option<String>,
|
||||
pub(crate) instruction_enabled: bool,
|
||||
pub(crate) instruction_injection_mode: Option<String>,
|
||||
pub(crate) instruction_template_key: Option<String>,
|
||||
agents_path: String,
|
||||
active_saved_provider_id: Option<String>,
|
||||
providers: Vec<ProviderSummary>,
|
||||
pub(crate) config_text: String,
|
||||
auth_preview: Option<Value>,
|
||||
auth_text: String,
|
||||
last_backup: Option<BackupEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ActionResult {
|
||||
pub(crate) ok: bool,
|
||||
pub(crate) message: String,
|
||||
pub(crate) backup_id: Option<String>,
|
||||
pub(crate) state: CodexState,
|
||||
}
|
||||
|
||||
fn redacted_auth_preview(path: &Path) -> Result<Option<Value>> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
|
||||
let mut value: Value = serde_json::from_str(&text).map_err(|e| json_err(path, e))?;
|
||||
if let Some(obj) = value.as_object_mut() {
|
||||
for (key, val) in obj.iter_mut() {
|
||||
let lower = key.to_ascii_lowercase();
|
||||
if (lower.contains("key")
|
||||
|| lower.contains("token")
|
||||
|| lower.contains("secret")
|
||||
|| lower.contains("password"))
|
||||
&& val.as_str().is_some_and(|s| !s.trim().is_empty())
|
||||
{
|
||||
*val = Value::String("••••••••".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(value))
|
||||
}
|
||||
|
||||
pub(crate) fn auth_has_material(path: &Path) -> Result<bool> {
|
||||
if !path.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
|
||||
let value: Value = serde_json::from_str(&text).map_err(|e| json_err(path, e))?;
|
||||
let Some(obj) = value.as_object() else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(obj.iter().any(|(key, value)| {
|
||||
if key == "auth_mode" {
|
||||
return false;
|
||||
}
|
||||
match value {
|
||||
Value::Null => false,
|
||||
Value::String(s) => !s.trim().is_empty(),
|
||||
Value::Array(a) => !a.is_empty(),
|
||||
Value::Object(o) => !o.is_empty(),
|
||||
_ => true,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn bool_from_item(item: Option<&Item>) -> Option<bool> {
|
||||
item.and_then(|i| i.as_bool())
|
||||
}
|
||||
|
||||
fn extract_providers(doc: &DocumentMut, current: Option<&str>) -> Vec<ProviderSummary> {
|
||||
let Some(providers) = doc.get("model_providers").and_then(|i| i.as_table()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
providers
|
||||
.iter()
|
||||
.filter_map(|(id, item)| {
|
||||
let table = item.as_table()?;
|
||||
Some(ProviderSummary {
|
||||
id: id.to_string(),
|
||||
name: table
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string),
|
||||
base_url: table
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string),
|
||||
wire_api: table
|
||||
.get("wire_api")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(ToString::to_string),
|
||||
requires_openai_auth: bool_from_item(table.get("requires_openai_auth")),
|
||||
is_current: current.is_some_and(|c| c == id),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalized_provider_toml_for_match(text: &str) -> String {
|
||||
text.replace("\r\n", "\n")
|
||||
.replace('\r', "\n")
|
||||
.lines()
|
||||
.filter(|line| !line.trim_start().starts_with("experimental_bearer_token"))
|
||||
.map(str::trim_end)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn active_saved_provider_id_from_config(
|
||||
config_text: &str,
|
||||
providers: &[SavedProvider],
|
||||
) -> Option<String> {
|
||||
let live = normalized_provider_toml_for_match(config_text);
|
||||
if live.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let matches = providers
|
||||
.iter()
|
||||
.filter(|provider| {
|
||||
provider
|
||||
.toml_config
|
||||
.as_deref()
|
||||
.is_some_and(|toml| normalized_provider_toml_for_match(toml) == live)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
(matches.len() == 1).then(|| matches[0].id.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn build_state(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
migrate_legacy_prompt_config(&codex_dir)?;
|
||||
build_state_after_migration(codex_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexState> {
|
||||
let cfg = config_path(&codex_dir);
|
||||
let auth = auth_path(&codex_dir);
|
||||
harden_sensitive_file_permissions(&cfg)?;
|
||||
harden_sensitive_file_permissions(&auth)?;
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let model = string_value(&doc, "model");
|
||||
let model_provider = string_value(&doc, "model_provider");
|
||||
let is_official_provider = document_is_official(&doc);
|
||||
let instruction_file = string_value(&doc, "model_instructions_file");
|
||||
let model_template_key = instruction_file
|
||||
.as_deref()
|
||||
.map(prompt_template_key_for_instruction)
|
||||
.transpose()?
|
||||
.flatten();
|
||||
let agents_template_key = managed_agents_template_key(&codex_dir)?;
|
||||
let (instruction_injection_mode, instruction_template_key) =
|
||||
if let Some(key) = agents_template_key {
|
||||
(Some("append".to_string()), Some(key))
|
||||
} else if let Some(key) = model_template_key {
|
||||
(Some("replace".to_string()), Some(key))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
let instruction_enabled = instruction_template_key.is_some();
|
||||
let providers = extract_providers(&doc, model_provider.as_deref());
|
||||
let saved_providers = list_saved_providers_inner()?;
|
||||
let active_saved_provider_id = if is_official_provider {
|
||||
None
|
||||
} else if let Some(live) = detected_live_custom_provider(&codex_dir)? {
|
||||
unique_saved_provider_id_for_live(&live, &saved_providers)
|
||||
} else {
|
||||
active_saved_provider_id_from_config(&text, &saved_providers)
|
||||
};
|
||||
|
||||
Ok(CodexState {
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
config_path: cfg.display().to_string(),
|
||||
auth_path: auth.display().to_string(),
|
||||
config_exists: cfg.exists(),
|
||||
auth_exists: auth.exists(),
|
||||
official_auth_available: official_auth_available(&codex_dir)?,
|
||||
model,
|
||||
model_provider,
|
||||
is_official_provider,
|
||||
instruction_file,
|
||||
instruction_enabled,
|
||||
instruction_injection_mode,
|
||||
instruction_template_key,
|
||||
agents_path: agents_path(&codex_dir).display().to_string(),
|
||||
active_saved_provider_id,
|
||||
providers,
|
||||
config_text: text,
|
||||
auth_preview: redacted_auth_preview(&auth)?,
|
||||
auth_text: read_to_string_if_exists(&auth)?,
|
||||
last_backup: latest_backup()?,
|
||||
})
|
||||
}
|
||||