diff --git a/.github/workflows/recover-v1.0.5-macos-arm64.yml b/.github/workflows/recover-v1.0.5-macos-arm64.yml new file mode 100644 index 000000000..c83e10883 --- /dev/null +++ b/.github/workflows/recover-v1.0.5-macos-arm64.yml @@ -0,0 +1,901 @@ +name: Recover v1.0.5 macOS ARM64 +'on': + push: + branches: + - codex/recover-v1.0.5-macos-arm64 +permissions: + contents: read + actions: read +concurrency: + group: recover-v1.0.5-macos-arm64 + cancel-in-progress: false +jobs: + build-web-ui: + name: web-dist + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: v1.0.5 + - name: Check bundled models.dev freshness for release + if: 'true' + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + python3 - <<'PY' + import datetime as dt + import json + from pathlib import Path + + manifest_path = Path("assets/models_dev/MANIFEST.json") + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + generated_at = dt.datetime.fromisoformat( + manifest["generated_at"].replace("Z", "+00:00") + ) + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit(f"Invalid {manifest_path}: {exc}") + + if generated_at.tzinfo is None: + raise SystemExit(f"{manifest_path}: generated_at must include a timezone") + now = dt.datetime.now(dt.timezone.utc) + age = now - generated_at.astimezone(dt.timezone.utc) + if age < -dt.timedelta(minutes=5): + raise SystemExit(f"{manifest_path}: generated_at is in the future") + if age > dt.timedelta(days=30): + raise SystemExit( + f"Bundled models.dev registry is {age.days} days old; " + "run scripts/sync_models_dev before creating a release tag" + ) + print(f"Bundled models.dev registry age: {age.days} days") + PY + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.32.1 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + - name: Build web UI + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + cd web + pnpm install --frozen-lockfile + pnpm build + - name: Upload web UI + uses: actions/upload-artifact@v4 + with: + name: web-dist + path: web/dist + if-no-files-found: error + build-and-package: + name: ${{ matrix.id }} + needs: build-web-ui + runs-on: ${{ matrix.os }} + env: + VCPKG_BINARY_SOURCES: clear;default,readwrite + strategy: + fail-fast: false + matrix: + include: + - id: macos-arm64 + os: macos-15 + installer_arch: arm64 + triplet: arm64-osx + executable: acecode + archive_extension: tar.gz + build_desktop: true + cmake_extra_args: -DCMAKE_OSX_ARCHITECTURES=arm64 -DACECODE_BUILD_DESKTOP=ON + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + ref: v1.0.5 + - name: Verify immutable v1.0.5 source + shell: bash + run: | + test "$(git rev-parse HEAD)" = "f4dd8e767142cdd611d562f1bebf4634009925a8" + git diff --exit-code + - name: Resolve package version + id: package-version + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + declared_version="$(awk '$1 == "project(acecode" && $2 == "VERSION" { print $3; exit }' CMakeLists.txt)" + if [ -z "$declared_version" ]; then + echo "::error::Unable to read the ACECode version from CMakeLists.txt" + exit 1 + fi + if [[ "$GITHUB_REF" == refs/tags/v* && "${GITHUB_REF_NAME#v}" != "$declared_version" ]]; then + echo "::error::Tag $GITHUB_REF_NAME does not match CMake version $declared_version" + exit 1 + fi + echo "version=$declared_version" >> "$GITHUB_OUTPUT" + - name: Check macOS release credentials + if: runner.os == 'macOS' + id: macos-release + shell: bash + env: + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_BASE64 }} + MACOS_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + application_missing=() + for name in \ + MACOS_CERTIFICATE_BASE64 \ + MACOS_CERTIFICATE_PASSWORD \ + APPLE_ID \ + APPLE_TEAM_ID \ + APPLE_APP_SPECIFIC_PASSWORD; do + if [ -z "${!name}" ]; then + application_missing+=("$name") + fi + done + + if [ "${#application_missing[@]}" -ne 0 ]; then + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "::error::Tagged macOS application releases require secrets: ${application_missing[*]}" + exit 1 + fi + echo "::notice::macOS signing/notarization disabled; missing: ${application_missing[*]}" + echo "enabled=false" >> "$GITHUB_OUTPUT" + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + fi + + installer_certificate_set=false + installer_password_set=false + if [ -n "$MACOS_INSTALLER_CERTIFICATE_BASE64" ]; then + installer_certificate_set=true + fi + if [ -n "$MACOS_INSTALLER_CERTIFICATE_PASSWORD" ]; then + installer_password_set=true + fi + + if [ "$installer_certificate_set" != "$installer_password_set" ]; then + echo "::error::macOS Installer credentials must configure both MACOS_INSTALLER_CERTIFICATE_BASE64 and MACOS_INSTALLER_CERTIFICATE_PASSWORD" + exit 1 + fi + if [ "$installer_certificate_set" = "true" ]; then + if [ "${#application_missing[@]}" -eq 0 ]; then + echo "pkg_enabled=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::macOS PKG creation disabled because application signing/notarization is disabled" + echo "pkg_enabled=false" >> "$GITHUB_OUTPUT" + fi + else + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "::error::Tagged macOS releases require MACOS_INSTALLER_CERTIFICATE_BASE64 and MACOS_INSTALLER_CERTIFICATE_PASSWORD to build both PKGs" + exit 1 + fi + echo "::notice::macOS PKG creation disabled; Developer ID Installer credentials are not configured" + echo "pkg_enabled=false" >> "$GITHUB_OUTPUT" + fi + - name: Configure MSVC + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: ${{ matrix.msvc_arch }} + - name: Install Linux ARMv7 cross toolchain + if: matrix.id == 'linux-armv7' + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + sudo apt-get update + sudo apt-get install -y \ + binutils-arm-linux-gnueabihf \ + gcc-arm-linux-gnueabihf \ + g++-arm-linux-gnueabihf \ + qemu-user + - name: Run vcpkg + uses: lukka/run-vcpkg@v11 + - name: Restore vcpkg binary cache + uses: actions/cache@v4 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-v2-${{ matrix.os }}-${{ matrix.triplet }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json', 'ports/**') + }} + restore-keys: | + vcpkg-v2-${{ matrix.os }}-${{ matrix.triplet }}- + - name: Install Linux desktop dependencies + if: runner.os == 'Linux' && matrix.build_desktop + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev + - name: Download web UI + uses: actions/download-artifact@v4 + with: + name: web-dist + path: web/dist + - name: Configure + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=MinSizeRel \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_TARGET_TRIPLET="${{ matrix.triplet }}" \ + -DBUILD_TESTING=OFF \ + ${{ matrix.cmake_extra_args }} + - name: Build + shell: bash + run: |- + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + cmake --build build --config MinSizeRel --target acecode + - name: Build desktop + if: matrix.build_desktop + shell: bash + run: |- + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + cmake --build build --config MinSizeRel --target acecode-desktop + - name: Verify Linux ARMv7 executable + if: matrix.id == 'linux-armv7' + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + export LC_ALL=C + elf_header="$(arm-linux-gnueabihf-readelf -h build/acecode)" + arm_attributes="$(arm-linux-gnueabihf-readelf -A build/acecode)" + program_headers="$(arm-linux-gnueabihf-readelf -l build/acecode)" + + printf '%s\n' "$elf_header" + printf '%s\n' "$arm_attributes" + + grep -Eq 'Class:[[:space:]]+ELF32' <<<"$elf_header" + grep -Eq 'Machine:[[:space:]]+ARM' <<<"$elf_header" + grep -Eq 'Tag_CPU_arch:[[:space:]]+v7' <<<"$arm_attributes" + grep -Eq 'Tag_ABI_VFP_args:[[:space:]]+VFP registers' <<<"$arm_attributes" + grep -Fq '/lib/ld-linux-armhf.so.3' <<<"$program_headers" + + version_output="$(qemu-arm -L /usr/arm-linux-gnueabihf build/acecode --version)" + printf '%s\n' "$version_output" + grep -Eq '^acecode v[^[:space:]]+$' <<<"$version_output" + - name: Verify bundled resources in macOS app + if: runner.os == 'macOS' && matrix.build_desktop + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + registry_dir="build/ACECode.app/Contents/Resources/share/acecode/models_dev" + file_count="$(find "$registry_dir" -type f | wc -l | tr -d '[:space:]')" + if [ "$file_count" != "3" ]; then + echo "Expected exactly 3 models.dev resources in $registry_dir, found $file_count" >&2 + exit 1 + fi + for file in api.json MANIFEST.json LICENSE; do + if [ ! -f "$registry_dir/$file" ] || + ! cmp -s "assets/models_dev/$file" "$registry_dir/$file"; then + echo "Missing or mismatched macOS app models.dev resource: $file" >&2 + exit 1 + fi + done + python3 scripts/verify_seed_bundle.py \ + --source assets/seed \ + --packaged build/ACECode.app/Contents/Resources/share/acecode/seed + - name: Extract debug symbols (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + binutils_prefix="" + if [ "${{ matrix.id }}" = "linux-armv7" ]; then + binutils_prefix="arm-linux-gnueabihf-" + fi + "${binutils_prefix}objcopy" --only-keep-debug \ + "build/${{ matrix.executable }}" \ + "build/${{ matrix.executable }}.debug" + "${binutils_prefix}strip" --strip-unneeded "build/${{ matrix.executable }}" + "${binutils_prefix}objcopy" \ + --add-gnu-debuglink="build/${{ matrix.executable }}.debug" \ + "build/${{ matrix.executable }}" + - name: Extract desktop debug symbols (Linux) + if: runner.os == 'Linux' && matrix.build_desktop + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + objcopy --only-keep-debug "build/acecode-desktop" "build/acecode-desktop.debug" + strip --strip-unneeded "build/acecode-desktop" + objcopy --add-gnu-debuglink="build/acecode-desktop.debug" "build/acecode-desktop" + - name: Extract debug symbols (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + dsymutil "build/${{ matrix.executable }}" -o "build/${{ matrix.executable }}.dSYM" + strip "build/${{ matrix.executable }}" + - name: Extract desktop debug symbols (macOS) + if: runner.os == 'macOS' && matrix.build_desktop + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + app_exec="build/ACECode.app/Contents/MacOS/ACECode" + app_daemon="build/ACECode.app/Contents/MacOS/acecode-daemon" + if [ ! -f "$app_exec" ]; then + echo "Missing desktop app executable: $app_exec" >&2 + exit 1 + fi + if [ ! -f "$app_daemon" ]; then + echo "Missing desktop app daemon: $app_daemon" >&2 + exit 1 + fi + dsymutil "$app_exec" -o "build/ACECode.app.dSYM" + strip "$app_exec" + dsymutil "$app_daemon" -o "build/acecode-daemon.dSYM" + strip "$app_daemon" + - name: Import Developer ID identities (macOS) + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + id: macos-keychain + shell: bash + env: + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_BASE64 }} + MACOS_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + MACOS_CODESIGN_IDENTITY: ${{ vars.MACOS_CODESIGN_IDENTITY }} + MACOS_INSTALLER_SIGNING_IDENTITY: ${{ vars.MACOS_INSTALLER_SIGNING_IDENTITY }} + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + set +x + certificate_path="$RUNNER_TEMP/acecode-developer-id.p12" + installer_certificate_path="$RUNNER_TEMP/acecode-developer-id-installer.p12" + keychain_path="$RUNNER_TEMP/acecode-signing.keychain-db" + keychain_password="$(openssl rand -hex 32)" + + printf '%s' "$MACOS_CERTIFICATE_BASE64" | /usr/bin/base64 -D > "$certificate_path" + chmod 600 "$certificate_path" + if [ "${{ steps.macos-release.outputs.pkg_enabled }}" = "true" ]; then + printf '%s' "$MACOS_INSTALLER_CERTIFICATE_BASE64" | /usr/bin/base64 -D > "$installer_certificate_path" + chmod 600 "$installer_certificate_path" + fi + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + + existing_user_keychains=() + while IFS= read -r existing_keychain; do + if [ -n "$existing_keychain" ]; then + existing_user_keychains+=("$existing_keychain") + fi + done < <( + security list-keychains -d user | + sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//' + ) + security list-keychains -d user -s \ + "$keychain_path" \ + "${existing_user_keychains[@]}" + + security import "$certificate_path" \ + -k "$keychain_path" \ + -P "$MACOS_CERTIFICATE_PASSWORD" \ + -f pkcs12 \ + -T /usr/bin/codesign \ + -T /usr/bin/security + if [ "${{ steps.macos-release.outputs.pkg_enabled }}" = "true" ]; then + security import "$installer_certificate_path" \ + -k "$keychain_path" \ + -P "$MACOS_INSTALLER_CERTIFICATE_PASSWORD" \ + -f pkcs12 \ + -T /usr/bin/pkgbuild \ + -T /usr/bin/productbuild \ + -T /usr/bin/security + fi + security set-key-partition-list \ + -S apple-tool:,apple: \ + -s \ + -k "$keychain_password" \ + "$keychain_path" + + identity_record="$(security find-identity -v -p codesigning "$keychain_path" | + awk '/Developer ID Application:/ { print; exit }')" + identity_fingerprint="$(printf '%s\n' "$identity_record" | awk '{ print $2 }')" + identity_name="$(printf '%s\n' "$identity_record" | awk -F '"' '{ print $2 }')" + if [[ ! "$identity_fingerprint" =~ ^[[:xdigit:]]{40}$ ]] || [ -z "$identity_name" ]; then + echo "::error::No Developer ID Application identity found in the imported certificate" + exit 1 + fi + if [ -n "$MACOS_CODESIGN_IDENTITY" ] && [ "$identity_name" != "$MACOS_CODESIGN_IDENTITY" ]; then + echo "::error::Imported signing identity does not match MACOS_CODESIGN_IDENTITY" + exit 1 + fi + if [ -n "$APPLE_TEAM_ID" ] && [[ "$identity_name" != *"($APPLE_TEAM_ID)" ]]; then + echo "::error::Developer ID Application identity does not match APPLE_TEAM_ID" + exit 1 + fi + + if [ "${{ steps.macos-release.outputs.pkg_enabled }}" = "true" ]; then + installer_identity_record="$(security find-identity -v -p basic "$keychain_path" | + awk '/Developer ID Installer:/ { print; exit }')" + installer_identity_fingerprint="$(printf '%s\n' "$installer_identity_record" | awk '{ print $2 }')" + installer_identity_name="$(printf '%s\n' "$installer_identity_record" | awk -F '"' '{ print $2 }')" + if [[ ! "$installer_identity_fingerprint" =~ ^[[:xdigit:]]{40}$ ]] || [ -z "$installer_identity_name" ]; then + echo "::error::No Developer ID Installer identity found in the imported certificate" + exit 1 + fi + if [ -n "$MACOS_INSTALLER_SIGNING_IDENTITY" ] && [ "$installer_identity_name" != "$MACOS_INSTALLER_SIGNING_IDENTITY" ]; then + echo "::error::Imported Installer identity does not match MACOS_INSTALLER_SIGNING_IDENTITY" + exit 1 + fi + if [ -n "$APPLE_TEAM_ID" ] && [[ "$installer_identity_name" != *"($APPLE_TEAM_ID)" ]]; then + echo "::error::Developer ID Installer identity does not match APPLE_TEAM_ID" + exit 1 + fi + echo "installer_identity=$installer_identity_name" >> "$GITHUB_OUTPUT" + fi + + echo "keychain=$keychain_path" >> "$GITHUB_OUTPUT" + # Sign by the certificate fingerprint. codesign can fail to resolve a + # human-readable identity name inside an isolated temporary keychain. + echo "identity=$identity_fingerprint" >> "$GITHUB_OUTPUT" + - name: Sign macOS release payloads + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + scripts/macos_codesign.sh \ + --identity "${{ steps.macos-keychain.outputs.identity }}" \ + --keychain "${{ steps.macos-keychain.outputs.keychain }}" \ + --binary "build/${{ matrix.executable }}" \ + --binary "build/acecode-computer-use" \ + --app "build/ACECode.app" + - name: Notarize and staple macOS app + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: |- + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + scripts/macos_notarize_app.sh --app "build/ACECode.app" + - name: Test macOS installer integration + if: runner.os == 'macOS' + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + bash tests/scripts/macos_installer_test.sh + python3 tests/scripts/macos_installer_release_test.py + - name: Create current-user macOS PKG + if: runner.os == 'macOS' && steps.macos-release.outputs.pkg_enabled == 'true' + id: macos-pkg + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + pkg_path="ACECode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}.pkg" + scripts/macos_create_pkg.sh \ + --app "build/ACECode.app" \ + --output "$pkg_path" \ + --installer-identity "${{ steps.macos-keychain.outputs.installer_identity }}" \ + --keychain "${{ steps.macos-keychain.outputs.keychain }}" + echo "path=$pkg_path" >> "$GITHUB_OUTPUT" + - name: Notarize and validate macOS PKG + if: runner.os == 'macOS' && steps.macos-release.outputs.pkg_enabled == 'true' + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: |- + xcrun() { + if [[ "${1:-}" != "stapler" ]]; then + command xcrun "$@" + return $? + fi + local result=0 + for attempt in 1 2 3 4 5 6; do + if command xcrun "$@"; then return 0; else result=$?; fi + if [[ "$result" != "68" || "$attempt" == "6" ]]; then return "$result"; fi + echo "Apple ticket service disconnected (68), retry $attempt/6 in 15 seconds" + sleep 15 + done + return "$result" + } + export -f xcrun + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + scripts/macos_notarize_pkg.sh --pkg "${{ steps.macos-pkg.outputs.path }}" + - name: Upload macOS PKG + if: runner.os == 'macOS' && steps.macos-release.outputs.pkg_enabled == 'true' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-pkg + path: ${{ steps.macos-pkg.outputs.path }} + if-no-files-found: error + - name: Build signed and notarized macOS installer DMG + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + id: macos-dmg + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + xcrun() { + if [[ "${1:-}" != "stapler" ]]; then + command xcrun "$@" + return $? + fi + local result=0 + for attempt in 1 2 3 4 5 6; do + if command xcrun "$@"; then return 0; else result=$?; fi + if [[ "$result" != "68" || "$attempt" == "6" ]]; then return "$result"; fi + echo "Apple ticket service disconnected (68), retry $attempt/6 in 15 seconds" + sleep 15 + done + return "$result" + } + export -f xcrun + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + dmg_path="ACECode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}.dmg" + bash scripts/macos_create_installer_dmg.sh \ + --app "build/ACECode.app" \ + --output "$dmg_path" \ + --arch "${{ matrix.installer_arch }}" \ + --identity "${{ steps.macos-keychain.outputs.identity }}" \ + --keychain "${{ steps.macos-keychain.outputs.keychain }}" + echo "path=$dmg_path" >> "$GITHUB_OUTPUT" + - name: Create macOS self-update archive + if: runner.os == 'macOS' + id: macos-update + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + unsigned_suffix="" + trust_args=() + if [ "${{ steps.macos-release.outputs.enabled }}" = "true" ]; then + trust_args+=(--require-trusted) + else + unsigned_suffix="-unsigned" + fi + update_path="ACECode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}-update${unsigned_suffix}.zip" + scripts/macos_create_update_zip.sh \ + --app "build/ACECode.app" \ + --output "$update_path" \ + "${trust_args[@]}" + echo "path=$update_path" >> "$GITHUB_OUTPUT" + - name: Package (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + package_dir="dist/acecode-${{ matrix.id }}" + mkdir -p "$package_dir" + + validate_models_dev_registry() { + registry_dir="$1" + file_count="$(find "$registry_dir" -type f | wc -l | tr -d '[:space:]')" + if [ "$file_count" != "3" ]; then + echo "Expected exactly 3 models.dev files in $registry_dir, found $file_count" >&2 + exit 1 + fi + for file in api.json MANIFEST.json LICENSE; do + if [ ! -f "$registry_dir/$file" ] || + ! cmp -s "assets/models_dev/$file" "$registry_dir/$file"; then + echo "Missing or mismatched models.dev package file: $registry_dir/$file" >&2 + exit 1 + fi + done + } + + if [ ! -f "build/${{ matrix.executable }}" ]; then + echo "Missing terminal executable: build/${{ matrix.executable }}" >&2 + exit 1 + fi + cp "build/${{ matrix.executable }}" "$package_dir/" + cp README.md README_CN.md "$package_dir/" + cmake --install build \ + --config MinSizeRel \ + --prefix "$PWD/$package_dir" \ + --component models_dev_registry + cmake --install build \ + --config MinSizeRel \ + --prefix "$PWD/$package_dir" \ + --component default_seed_bundle + cmake --install build --config MinSizeRel --prefix "$PWD/$package_dir" --component whatsapp_channel + if [ "$RUNNER_OS" = "macOS" ]; then + cmake --install build --config MinSizeRel --prefix "$PWD/$package_dir" --component computer_use_runtime + test -x "$package_dir/acecode-computer-use" + test -x "build/ACECode.app/Contents/MacOS/acecode-computer-use" + fi + validate_models_dev_registry "$package_dir/share/acecode/models_dev" + python3 scripts/verify_seed_bundle.py \ + --source assets/seed \ + --packaged "$package_dir/share/acecode/seed" + + if [ "${{ matrix.build_desktop }}" = "true" ]; then + if [ "$RUNNER_OS" = "macOS" ]; then + if [ ! -d "build/ACECode.app" ]; then + echo "Missing desktop app bundle: build/ACECode.app" >&2 + exit 1 + fi + cp -r "build/ACECode.app" "$package_dir/" + else + if [ ! -f "build/acecode-desktop" ]; then + echo "Missing desktop executable: build/acecode-desktop" >&2 + exit 1 + fi + cp "build/acecode-desktop" "$package_dir/" + cp "build/acecode-logo.png" "$package_dir/" + fi + fi + legacy="$(find "$package_dir" -maxdepth 1 -iname 'ace-browser-*' -print -quit)" + if [ -n "$legacy" ]; then + echo "Legacy browser artifact must not be packaged: $legacy" >&2 + exit 1 + fi + tar -C dist -czf "acecode-${{ matrix.id }}.tar.gz" "acecode-${{ matrix.id }}" + + verify_root="$(mktemp -d)" + tar -xzf "acecode-${{ matrix.id }}.tar.gz" -C "$verify_root" + extracted_package="$verify_root/acecode-${{ matrix.id }}" + validate_models_dev_registry "$extracted_package/share/acecode/models_dev" + python3 scripts/verify_seed_bundle.py \ + --source assets/seed \ + --packaged "$extracted_package/share/acecode/seed" + if [ "$RUNNER_OS" = "macOS" ]; then + test -x "$extracted_package/acecode-computer-use" + test -x "$extracted_package/ACECode.app/Contents/MacOS/acecode-computer-use" + validate_models_dev_registry \ + "$extracted_package/ACECode.app/Contents/Resources/share/acecode/models_dev" + python3 scripts/verify_seed_bundle.py \ + --source assets/seed \ + --packaged "$extracted_package/ACECode.app/Contents/Resources/share/acecode/seed" + fi + rm -rf -- "$verify_root" + - name: Create Linux self-update archive + if: matrix.id == 'linux-x64' || matrix.id == 'linux-arm64' + id: linux-update + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + update_path="acecode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}-update.zip" + bash scripts/linux_create_update_zip.sh \ + --package-dir "dist/acecode-${{ matrix.id }}" \ + --output "$update_path" \ + --expected-version "${{ steps.package-version.outputs.version }}" + echo "path=$update_path" >> "$GITHUB_OUTPUT" + - name: Clean up macOS signing material + if: always() && runner.os == 'macOS' + shell: bash + run: | + GITHUB_REF=refs/tags/v1.0.5 + GITHUB_REF_NAME=v1.0.5 + set +e + security delete-keychain "$RUNNER_TEMP/acecode-signing.keychain-db" 2>/dev/null + rm -f -- \ + "$RUNNER_TEMP/acecode-developer-id.p12" \ + "$RUNNER_TEMP/acecode-developer-id-installer.p12" + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $packageDir = "dist/acecode-${{ matrix.id }}" + New-Item -ItemType Directory -Force -Path $packageDir | Out-Null + + function Assert-ModelsDevRegistry([string] $RegistryDir) { + if (-not (Test-Path -LiteralPath $RegistryDir -PathType Container)) { + throw "Missing models.dev directory: $RegistryDir" + } + $actualFiles = @(Get-ChildItem -LiteralPath $RegistryDir -File -Recurse) + if ($actualFiles.Count -ne 3) { + throw "Expected exactly 3 models.dev files in ${RegistryDir}, found $($actualFiles.Count)" + } + foreach ($name in @("api.json", "MANIFEST.json", "LICENSE")) { + $source = Join-Path "assets/models_dev" $name + $packaged = Join-Path $RegistryDir $name + if (-not (Test-Path -LiteralPath $packaged -PathType Leaf)) { + throw "Missing models.dev package file: $packaged" + } + $sourceHash = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash + $packagedHash = (Get-FileHash -LiteralPath $packaged -Algorithm SHA256).Hash + if ($sourceHash -ne $packagedHash) { + throw "Mismatched models.dev package file: $packaged" + } + } + } + + $terminalExe = "build/${{ matrix.executable }}" + if (-not (Test-Path -LiteralPath $terminalExe)) { + throw "Missing terminal executable: $terminalExe" + } + Copy-Item -LiteralPath $terminalExe -Destination "$packageDir/" + Copy-Item "README.md","README_CN.md" "$packageDir/" + $packageRoot = (Resolve-Path -LiteralPath $packageDir).Path + & cmake --install build --config MinSizeRel --prefix $packageRoot --component models_dev_registry + if ($LASTEXITCODE -ne 0) { + throw "Failed to install the bundled models.dev registry" + } + & cmake --install build --config MinSizeRel --prefix $packageRoot --component default_seed_bundle + if ($LASTEXITCODE -ne 0) { + throw "Failed to install the default seed bundle" + } + & cmake --install build --config MinSizeRel --prefix $packageRoot --component whatsapp_channel + if ($LASTEXITCODE -ne 0) { + throw "Failed to install the WhatsApp channel bridge" + } + & cmake --install build --config MinSizeRel --prefix $packageRoot --component computer_use_runtime + if ($LASTEXITCODE -ne 0) { + throw "Failed to install the Computer Use runtime" + } + $computerUseExe = Join-Path $packageRoot "acecode-computer-use.exe" + if (-not (Test-Path -LiteralPath $computerUseExe -PathType Leaf) -or + (Get-Item -LiteralPath $computerUseExe).Length -eq 0) { + throw "Missing Computer Use runtime: $computerUseExe" + } + Assert-ModelsDevRegistry (Join-Path $packageDir "share/acecode/models_dev") + & python scripts/verify_seed_bundle.py ` + --source assets/seed ` + --packaged (Join-Path $packageDir "share/acecode/seed") + if ($LASTEXITCODE -ne 0) { + throw "Failed to verify the packaged default seed bundle" + } + + if ("${{ matrix.build_desktop }}" -eq "true") { + $desktopCandidates = @( + "build/acecode-desktop.exe", + "build/Release/acecode-desktop.exe", + "build/Debug/acecode-desktop.exe", + "build/RelWithDebInfo/acecode-desktop.exe", + "build/MinSizeRel/acecode-desktop.exe" + ) + $desktopExe = $desktopCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + if (-not $desktopExe) { + $desktopExe = Get-ChildItem -Path "build" -Recurse -Filter "acecode-desktop.exe" -File | + Select-Object -ExpandProperty FullName -First 1 + } + if (-not $desktopExe) { + throw "Missing desktop executable: acecode-desktop.exe" + } + Copy-Item -LiteralPath $desktopExe -Destination "$packageDir/" + } + $legacyBrowserArtifact = Get-ChildItem -LiteralPath $packageDir -Filter "ace-browser-*" -Force | + Select-Object -First 1 + if ($legacyBrowserArtifact) { + throw "Legacy browser artifact must not be packaged: $($legacyBrowserArtifact.FullName)" + } + Compress-Archive -Path "$packageDir/*" -DestinationPath "acecode-${{ matrix.id }}.zip" -Force + + $verifyRoot = Join-Path $env:RUNNER_TEMP ("acecode-package-verify-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $verifyRoot | Out-Null + Expand-Archive -LiteralPath "acecode-${{ matrix.id }}.zip" -DestinationPath $verifyRoot + $archivedComputerUseExe = Join-Path $verifyRoot "acecode-computer-use.exe" + if (-not (Test-Path -LiteralPath $archivedComputerUseExe -PathType Leaf) -or + (Get-FileHash -LiteralPath $archivedComputerUseExe -Algorithm SHA256).Hash -ne + (Get-FileHash -LiteralPath $computerUseExe -Algorithm SHA256).Hash) { + throw "Archived Computer Use runtime is missing or does not match the build" + } + Assert-ModelsDevRegistry (Join-Path $verifyRoot "share/acecode/models_dev") + & python scripts/verify_seed_bundle.py ` + --source assets/seed ` + --packaged (Join-Path $verifyRoot "share/acecode/seed") + if ($LASTEXITCODE -ne 0) { + throw "Failed to verify the archived default seed bundle" + } + - name: Verify Windows ARM64 package + if: matrix.id == 'windows-arm64' + shell: pwsh + run: | + $packageDir = "dist/acecode-${{ matrix.id }}" + $expectedExecutables = @( + "acecode.exe", + "acecode-desktop.exe", + "acecode-computer-use.exe" + ) + foreach ($name in $expectedExecutables) { + $path = Join-Path $packageDir $name + if (-not (Test-Path -LiteralPath $path)) { + throw "Missing ARM64 package executable: $path" + } + $headers = (& dumpbin /headers $path | Out-String) + if ($LASTEXITCODE -ne 0 -or $headers -notmatch '(?im)\bAA64 machine\b') { + throw "$name is not an ARM64 PE executable" + } + Write-Host "${name}: ARM64" + } + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }} + path: acecode-${{ matrix.id }}.${{ matrix.archive_extension }} + if-no-files-found: error + - name: Upload Linux self-update archive + if: matrix.id == 'linux-x64' || matrix.id == 'linux-arm64' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-update + path: ${{ steps.linux-update.outputs.path }} + if-no-files-found: error + - name: Upload macOS installer DMG + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-dmg + path: ${{ steps.macos-dmg.outputs.path }} + if-no-files-found: error + - name: Upload macOS self-update archive + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-update + path: ${{ steps.macos-update.outputs.path }} + if-no-files-found: error + - name: Upload PDB + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-pdb + path: build/**/acecode.pdb + if-no-files-found: warn + - name: Upload desktop PDB + if: runner.os == 'Windows' && matrix.build_desktop + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-desktop-pdb + path: build/**/acecode-desktop.pdb + if-no-files-found: warn + - name: Upload debug symbols (Linux) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-debug + path: build/${{ matrix.executable }}.debug + if-no-files-found: warn + - name: Upload desktop debug symbols (Linux) + if: runner.os == 'Linux' && matrix.build_desktop + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-desktop-debug + path: build/acecode-desktop.debug + if-no-files-found: warn + - name: Upload dSYM (macOS) + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-dsym + path: build/${{ matrix.executable }}.dSYM + if-no-files-found: warn + - name: Upload desktop dSYM (macOS) + if: runner.os == 'macOS' && matrix.build_desktop + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-desktop-dsym + path: | + build/ACECode.app.dSYM + build/acecode-daemon.dSYM + if-no-files-found: warn diff --git a/.github/workflows/recover-v1.0.6-platform-assets.yml b/.github/workflows/recover-v1.0.6-platform-assets.yml new file mode 100644 index 000000000..e2973bf8b --- /dev/null +++ b/.github/workflows/recover-v1.0.6-platform-assets.yml @@ -0,0 +1,876 @@ +name: Recover v1.0.6 Windows x64 asset +'on': + push: + branches: + - codex/recover-v1.0.6-platform-assets +permissions: + contents: read + actions: read +concurrency: + group: recover-v1.0.6-windows-x64 + cancel-in-progress: false +jobs: + build-web-ui: + name: web-dist + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: v1.0.6 + - name: Verify immutable v1.0.6 source + shell: bash + run: | + test "$(git rev-parse HEAD)" = "e090be90c668f5fce760ed9b04d3f9a677d030eb" + git diff --exit-code + - name: Check bundled models.dev freshness for release + if: 'true' + shell: bash + run: | + python3 - <<'PY' + import datetime as dt + import json + from pathlib import Path + + manifest_path = Path("assets/models_dev/MANIFEST.json") + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + generated_at = dt.datetime.fromisoformat( + manifest["generated_at"].replace("Z", "+00:00") + ) + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit(f"Invalid {manifest_path}: {exc}") + + if generated_at.tzinfo is None: + raise SystemExit(f"{manifest_path}: generated_at must include a timezone") + now = dt.datetime.now(dt.timezone.utc) + age = now - generated_at.astimezone(dt.timezone.utc) + if age < -dt.timedelta(minutes=5): + raise SystemExit(f"{manifest_path}: generated_at is in the future") + if age > dt.timedelta(days=30): + raise SystemExit( + f"Bundled models.dev registry is {age.days} days old; " + "run scripts/sync_models_dev before creating a release tag" + ) + print(f"Bundled models.dev registry age: {age.days} days") + PY + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.32.1 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + - name: Build web UI + shell: bash + run: | + cd web + pnpm install --frozen-lockfile + pnpm build + - name: Upload web UI + uses: actions/upload-artifact@v4 + with: + name: web-dist + path: web/dist + if-no-files-found: error + timeout-minutes: 60 + env: + ACE_RECOVERY_REF: refs/tags/v1.0.6 + ACE_RECOVERY_REF_NAME: v1.0.6 + build-and-package: + name: ${{ matrix.id }} + needs: build-web-ui + runs-on: ${{ matrix.os }} + env: + VCPKG_BINARY_SOURCES: clear;default,readwrite + ACE_RECOVERY_REF: refs/tags/v1.0.6 + ACE_RECOVERY_REF_NAME: v1.0.6 + strategy: + fail-fast: false + matrix: + include: + - id: windows-x64 + os: windows-2022 + triplet: x64-windows-static + executable: acecode.exe + archive_extension: zip + msvc_arch: amd64 + build_desktop: true + cmake_extra_args: -DACECODE_BUILD_DESKTOP=ON + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + ref: v1.0.6 + - name: Verify immutable v1.0.6 source + shell: bash + run: | + test "$(git rev-parse HEAD)" = "e090be90c668f5fce760ed9b04d3f9a677d030eb" + git diff --exit-code + - name: Resolve package version + id: package-version + shell: bash + run: | + declared_version="$(awk '$1 == "project(acecode" && $2 == "VERSION" { print $3; exit }' CMakeLists.txt)" + if [ -z "$declared_version" ]; then + echo "::error::Unable to read the ACECode version from CMakeLists.txt" + exit 1 + fi + if [[ "$ACE_RECOVERY_REF" == refs/tags/v* && "${ACE_RECOVERY_REF_NAME#v}" != "$declared_version" ]]; then + echo "::error::Tag $ACE_RECOVERY_REF_NAME does not match CMake version $declared_version" + exit 1 + fi + echo "version=$declared_version" >> "$GITHUB_OUTPUT" + - name: Check macOS release credentials + if: runner.os == 'macOS' + id: macos-release + shell: bash + env: + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_BASE64 }} + MACOS_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + application_missing=() + for name in \ + MACOS_CERTIFICATE_BASE64 \ + MACOS_CERTIFICATE_PASSWORD \ + APPLE_ID \ + APPLE_TEAM_ID \ + APPLE_APP_SPECIFIC_PASSWORD; do + if [ -z "${!name}" ]; then + application_missing+=("$name") + fi + done + + if [ "${#application_missing[@]}" -ne 0 ]; then + if [[ "$ACE_RECOVERY_REF" == refs/tags/v* ]]; then + echo "::error::Tagged macOS application releases require secrets: ${application_missing[*]}" + exit 1 + fi + echo "::notice::macOS signing/notarization disabled; missing: ${application_missing[*]}" + echo "enabled=false" >> "$GITHUB_OUTPUT" + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + fi + + installer_certificate_set=false + installer_password_set=false + if [ -n "$MACOS_INSTALLER_CERTIFICATE_BASE64" ]; then + installer_certificate_set=true + fi + if [ -n "$MACOS_INSTALLER_CERTIFICATE_PASSWORD" ]; then + installer_password_set=true + fi + + if [ "$installer_certificate_set" != "$installer_password_set" ]; then + echo "::error::macOS Installer credentials must configure both MACOS_INSTALLER_CERTIFICATE_BASE64 and MACOS_INSTALLER_CERTIFICATE_PASSWORD" + exit 1 + fi + if [ "$installer_certificate_set" = "true" ]; then + if [ "${#application_missing[@]}" -eq 0 ]; then + echo "pkg_enabled=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::macOS PKG creation disabled because application signing/notarization is disabled" + echo "pkg_enabled=false" >> "$GITHUB_OUTPUT" + fi + else + if [[ "$ACE_RECOVERY_REF" == refs/tags/v* ]]; then + echo "::error::Tagged macOS releases require MACOS_INSTALLER_CERTIFICATE_BASE64 and MACOS_INSTALLER_CERTIFICATE_PASSWORD to build both PKGs" + exit 1 + fi + echo "::notice::macOS PKG creation disabled; Developer ID Installer credentials are not configured" + echo "pkg_enabled=false" >> "$GITHUB_OUTPUT" + fi + - name: Configure MSVC + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: ${{ matrix.msvc_arch }} + - name: Install Linux ARMv7 cross toolchain + if: matrix.id == 'linux-armv7' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y \ + binutils-arm-linux-gnueabihf \ + gcc-arm-linux-gnueabihf \ + g++-arm-linux-gnueabihf \ + qemu-user + - name: Run vcpkg + uses: lukka/run-vcpkg@v11 + - name: Restore vcpkg binary cache + uses: actions/cache@v4 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-v2-${{ matrix.os }}-${{ matrix.triplet }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json', 'ports/**') + }} + restore-keys: | + vcpkg-v2-${{ matrix.os }}-${{ matrix.triplet }}- + - name: Install Linux desktop dependencies + if: runner.os == 'Linux' && matrix.build_desktop + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev + - name: Download web UI + uses: actions/download-artifact@v4 + with: + name: web-dist + path: web/dist + - name: Configure + shell: bash + run: | + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=MinSizeRel \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_TARGET_TRIPLET="${{ matrix.triplet }}" \ + -DBUILD_TESTING=OFF \ + ${{ matrix.cmake_extra_args }} + - name: Build + shell: bash + run: | + cmake --build build --config MinSizeRel --target acecode --parallel 2 + - name: Build desktop + if: matrix.build_desktop + shell: bash + run: | + cmake --build build --config MinSizeRel --target acecode-desktop --parallel 2 + - name: Verify Linux ARMv7 executable + if: matrix.id == 'linux-armv7' + shell: bash + run: | + export LC_ALL=C + elf_header="$(arm-linux-gnueabihf-readelf -h build/acecode)" + arm_attributes="$(arm-linux-gnueabihf-readelf -A build/acecode)" + program_headers="$(arm-linux-gnueabihf-readelf -l build/acecode)" + + printf '%s\n' "$elf_header" + printf '%s\n' "$arm_attributes" + + grep -Eq 'Class:[[:space:]]+ELF32' <<<"$elf_header" + grep -Eq 'Machine:[[:space:]]+ARM' <<<"$elf_header" + grep -Eq 'Tag_CPU_arch:[[:space:]]+v7' <<<"$arm_attributes" + grep -Eq 'Tag_ABI_VFP_args:[[:space:]]+VFP registers' <<<"$arm_attributes" + grep -Fq '/lib/ld-linux-armhf.so.3' <<<"$program_headers" + + version_output="$(qemu-arm -L /usr/arm-linux-gnueabihf build/acecode --version)" + printf '%s\n' "$version_output" + grep -Eq '^acecode v[^[:space:]]+$' <<<"$version_output" + - name: Verify bundled resources in macOS app + if: runner.os == 'macOS' && matrix.build_desktop + shell: bash + run: | + registry_dir="build/ACECode.app/Contents/Resources/share/acecode/models_dev" + file_count="$(find "$registry_dir" -type f | wc -l | tr -d '[:space:]')" + if [ "$file_count" != "3" ]; then + echo "Expected exactly 3 models.dev resources in $registry_dir, found $file_count" >&2 + exit 1 + fi + for file in api.json MANIFEST.json LICENSE; do + if [ ! -f "$registry_dir/$file" ] || + ! cmp -s "assets/models_dev/$file" "$registry_dir/$file"; then + echo "Missing or mismatched macOS app models.dev resource: $file" >&2 + exit 1 + fi + done + python3 scripts/verify_seed_bundle.py \ + --source assets/seed \ + --packaged build/ACECode.app/Contents/Resources/share/acecode/seed + - name: Extract debug symbols (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + binutils_prefix="" + if [ "${{ matrix.id }}" = "linux-armv7" ]; then + binutils_prefix="arm-linux-gnueabihf-" + fi + "${binutils_prefix}objcopy" --only-keep-debug \ + "build/${{ matrix.executable }}" \ + "build/${{ matrix.executable }}.debug" + "${binutils_prefix}strip" --strip-unneeded "build/${{ matrix.executable }}" + "${binutils_prefix}objcopy" \ + --add-gnu-debuglink="build/${{ matrix.executable }}.debug" \ + "build/${{ matrix.executable }}" + - name: Extract desktop debug symbols (Linux) + if: runner.os == 'Linux' && matrix.build_desktop + shell: bash + run: | + objcopy --only-keep-debug "build/acecode-desktop" "build/acecode-desktop.debug" + strip --strip-unneeded "build/acecode-desktop" + objcopy --add-gnu-debuglink="build/acecode-desktop.debug" "build/acecode-desktop" + - name: Extract debug symbols (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + dsymutil "build/${{ matrix.executable }}" -o "build/${{ matrix.executable }}.dSYM" + strip "build/${{ matrix.executable }}" + - name: Extract desktop debug symbols (macOS) + if: runner.os == 'macOS' && matrix.build_desktop + shell: bash + run: | + app_exec="build/ACECode.app/Contents/MacOS/ACECode" + app_daemon="build/ACECode.app/Contents/MacOS/acecode-daemon" + if [ ! -f "$app_exec" ]; then + echo "Missing desktop app executable: $app_exec" >&2 + exit 1 + fi + if [ ! -f "$app_daemon" ]; then + echo "Missing desktop app daemon: $app_daemon" >&2 + exit 1 + fi + dsymutil "$app_exec" -o "build/ACECode.app.dSYM" + strip "$app_exec" + dsymutil "$app_daemon" -o "build/acecode-daemon.dSYM" + strip "$app_daemon" + - name: Import Developer ID identities (macOS) + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + id: macos-keychain + shell: bash + env: + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_BASE64 }} + MACOS_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + MACOS_CODESIGN_IDENTITY: ${{ vars.MACOS_CODESIGN_IDENTITY }} + MACOS_INSTALLER_SIGNING_IDENTITY: ${{ vars.MACOS_INSTALLER_SIGNING_IDENTITY }} + run: | + set +x + certificate_path="$RUNNER_TEMP/acecode-developer-id.p12" + installer_certificate_path="$RUNNER_TEMP/acecode-developer-id-installer.p12" + keychain_path="$RUNNER_TEMP/acecode-signing.keychain-db" + keychain_password="$(openssl rand -hex 32)" + + printf '%s' "$MACOS_CERTIFICATE_BASE64" | /usr/bin/base64 -D > "$certificate_path" + chmod 600 "$certificate_path" + if [ "${{ steps.macos-release.outputs.pkg_enabled }}" = "true" ]; then + printf '%s' "$MACOS_INSTALLER_CERTIFICATE_BASE64" | /usr/bin/base64 -D > "$installer_certificate_path" + chmod 600 "$installer_certificate_path" + fi + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + + existing_user_keychains=() + while IFS= read -r existing_keychain; do + if [ -n "$existing_keychain" ]; then + existing_user_keychains+=("$existing_keychain") + fi + done < <( + security list-keychains -d user | + sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//' + ) + security list-keychains -d user -s \ + "$keychain_path" \ + "${existing_user_keychains[@]}" + + security import "$certificate_path" \ + -k "$keychain_path" \ + -P "$MACOS_CERTIFICATE_PASSWORD" \ + -f pkcs12 \ + -T /usr/bin/codesign \ + -T /usr/bin/security + if [ "${{ steps.macos-release.outputs.pkg_enabled }}" = "true" ]; then + security import "$installer_certificate_path" \ + -k "$keychain_path" \ + -P "$MACOS_INSTALLER_CERTIFICATE_PASSWORD" \ + -f pkcs12 \ + -T /usr/bin/pkgbuild \ + -T /usr/bin/productbuild \ + -T /usr/bin/security + fi + security set-key-partition-list \ + -S apple-tool:,apple: \ + -s \ + -k "$keychain_password" \ + "$keychain_path" + + identity_record="$(security find-identity -v -p codesigning "$keychain_path" | + awk '/Developer ID Application:/ { print; exit }')" + identity_fingerprint="$(printf '%s\n' "$identity_record" | awk '{ print $2 }')" + identity_name="$(printf '%s\n' "$identity_record" | awk -F '"' '{ print $2 }')" + if [[ ! "$identity_fingerprint" =~ ^[[:xdigit:]]{40}$ ]] || [ -z "$identity_name" ]; then + echo "::error::No Developer ID Application identity found in the imported certificate" + exit 1 + fi + if [ -n "$MACOS_CODESIGN_IDENTITY" ] && [ "$identity_name" != "$MACOS_CODESIGN_IDENTITY" ]; then + echo "::error::Imported signing identity does not match MACOS_CODESIGN_IDENTITY" + exit 1 + fi + if [ -n "$APPLE_TEAM_ID" ] && [[ "$identity_name" != *"($APPLE_TEAM_ID)" ]]; then + echo "::error::Developer ID Application identity does not match APPLE_TEAM_ID" + exit 1 + fi + + if [ "${{ steps.macos-release.outputs.pkg_enabled }}" = "true" ]; then + installer_identity_record="$(security find-identity -v -p basic "$keychain_path" | + awk '/Developer ID Installer:/ { print; exit }')" + installer_identity_fingerprint="$(printf '%s\n' "$installer_identity_record" | awk '{ print $2 }')" + installer_identity_name="$(printf '%s\n' "$installer_identity_record" | awk -F '"' '{ print $2 }')" + if [[ ! "$installer_identity_fingerprint" =~ ^[[:xdigit:]]{40}$ ]] || [ -z "$installer_identity_name" ]; then + echo "::error::No Developer ID Installer identity found in the imported certificate" + exit 1 + fi + if [ -n "$MACOS_INSTALLER_SIGNING_IDENTITY" ] && [ "$installer_identity_name" != "$MACOS_INSTALLER_SIGNING_IDENTITY" ]; then + echo "::error::Imported Installer identity does not match MACOS_INSTALLER_SIGNING_IDENTITY" + exit 1 + fi + if [ -n "$APPLE_TEAM_ID" ] && [[ "$installer_identity_name" != *"($APPLE_TEAM_ID)" ]]; then + echo "::error::Developer ID Installer identity does not match APPLE_TEAM_ID" + exit 1 + fi + echo "installer_identity=$installer_identity_name" >> "$GITHUB_OUTPUT" + fi + + echo "keychain=$keychain_path" >> "$GITHUB_OUTPUT" + # Sign by the certificate fingerprint. codesign can fail to resolve a + # human-readable identity name inside an isolated temporary keychain. + echo "identity=$identity_fingerprint" >> "$GITHUB_OUTPUT" + - name: Sign macOS release payloads + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + shell: bash + run: | + scripts/macos_codesign.sh \ + --identity "${{ steps.macos-keychain.outputs.identity }}" \ + --keychain "${{ steps.macos-keychain.outputs.keychain }}" \ + --binary "build/${{ matrix.executable }}" \ + --binary "build/acecode-computer-use" \ + --app "build/ACECode.app" + - name: Notarize and staple macOS app + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: |- + xcrun() { + if [[ "${1:-}" != "stapler" ]]; then + command xcrun "$@" + return $? + fi + local result=0 + for attempt in 1 2 3 4 5 6; do + if command xcrun "$@"; then return 0; else result=$?; fi + if [[ "$result" != "68" || "$attempt" == "6" ]]; then return "$result"; fi + echo "Apple ticket service disconnected (68), retry $attempt/6 in 15 seconds" + sleep 15 + done + return "$result" + } + export -f xcrun + scripts/macos_notarize_app.sh --app "build/ACECode.app" + - name: Test macOS installer integration + if: runner.os == 'macOS' + shell: bash + run: | + bash tests/scripts/macos_installer_test.sh + python3 tests/scripts/macos_installer_release_test.py + - name: Create current-user macOS PKG + if: runner.os == 'macOS' && steps.macos-release.outputs.pkg_enabled == 'true' + id: macos-pkg + shell: bash + run: | + pkg_path="ACECode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}.pkg" + scripts/macos_create_pkg.sh \ + --app "build/ACECode.app" \ + --output "$pkg_path" \ + --installer-identity "${{ steps.macos-keychain.outputs.installer_identity }}" \ + --keychain "${{ steps.macos-keychain.outputs.keychain }}" + echo "path=$pkg_path" >> "$GITHUB_OUTPUT" + - name: Notarize and validate macOS PKG + if: runner.os == 'macOS' && steps.macos-release.outputs.pkg_enabled == 'true' + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: |- + xcrun() { + if [[ "${1:-}" != "stapler" ]]; then + command xcrun "$@" + return $? + fi + local result=0 + for attempt in 1 2 3 4 5 6; do + if command xcrun "$@"; then return 0; else result=$?; fi + if [[ "$result" != "68" || "$attempt" == "6" ]]; then return "$result"; fi + echo "Apple ticket service disconnected (68), retry $attempt/6 in 15 seconds" + sleep 15 + done + return "$result" + } + export -f xcrun + scripts/macos_notarize_pkg.sh --pkg "${{ steps.macos-pkg.outputs.path }}" + - name: Upload macOS PKG + if: runner.os == 'macOS' && steps.macos-release.outputs.pkg_enabled == 'true' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-pkg + path: ${{ steps.macos-pkg.outputs.path }} + if-no-files-found: error + - name: Build signed and notarized macOS installer DMG + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + id: macos-dmg + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + xcrun() { + if [[ "${1:-}" != "stapler" ]]; then + command xcrun "$@" + return $? + fi + local result=0 + for attempt in 1 2 3 4 5 6; do + if command xcrun "$@"; then return 0; else result=$?; fi + if [[ "$result" != "68" || "$attempt" == "6" ]]; then return "$result"; fi + echo "Apple ticket service disconnected (68), retry $attempt/6 in 15 seconds" + sleep 15 + done + return "$result" + } + export -f xcrun + dmg_path="ACECode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}.dmg" + bash scripts/macos_create_installer_dmg.sh \ + --app "build/ACECode.app" \ + --output "$dmg_path" \ + --arch "${{ matrix.installer_arch }}" \ + --identity "${{ steps.macos-keychain.outputs.identity }}" \ + --keychain "${{ steps.macos-keychain.outputs.keychain }}" + echo "path=$dmg_path" >> "$GITHUB_OUTPUT" + - name: Create macOS self-update archive + if: runner.os == 'macOS' + id: macos-update + shell: bash + run: | + unsigned_suffix="" + trust_args=() + if [ "${{ steps.macos-release.outputs.enabled }}" = "true" ]; then + trust_args+=(--require-trusted) + else + unsigned_suffix="-unsigned" + fi + update_path="ACECode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}-update${unsigned_suffix}.zip" + scripts/macos_create_update_zip.sh \ + --app "build/ACECode.app" \ + --output "$update_path" \ + "${trust_args[@]}" + echo "path=$update_path" >> "$GITHUB_OUTPUT" + - name: Package (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + package_dir="dist/acecode-${{ matrix.id }}" + mkdir -p "$package_dir" + + validate_models_dev_registry() { + registry_dir="$1" + file_count="$(find "$registry_dir" -type f | wc -l | tr -d '[:space:]')" + if [ "$file_count" != "3" ]; then + echo "Expected exactly 3 models.dev files in $registry_dir, found $file_count" >&2 + exit 1 + fi + for file in api.json MANIFEST.json LICENSE; do + if [ ! -f "$registry_dir/$file" ] || + ! cmp -s "assets/models_dev/$file" "$registry_dir/$file"; then + echo "Missing or mismatched models.dev package file: $registry_dir/$file" >&2 + exit 1 + fi + done + } + + if [ ! -f "build/${{ matrix.executable }}" ]; then + echo "Missing terminal executable: build/${{ matrix.executable }}" >&2 + exit 1 + fi + cp "build/${{ matrix.executable }}" "$package_dir/" + cp README.md README_CN.md "$package_dir/" + cmake --install build \ + --config MinSizeRel \ + --prefix "$PWD/$package_dir" \ + --component models_dev_registry + cmake --install build \ + --config MinSizeRel \ + --prefix "$PWD/$package_dir" \ + --component default_seed_bundle + cmake --install build --config MinSizeRel --prefix "$PWD/$package_dir" --component whatsapp_channel + if [ "$RUNNER_OS" = "macOS" ]; then + cmake --install build --config MinSizeRel --prefix "$PWD/$package_dir" --component computer_use_runtime + test -x "$package_dir/acecode-computer-use" + test -x "build/ACECode.app/Contents/MacOS/acecode-computer-use" + fi + validate_models_dev_registry "$package_dir/share/acecode/models_dev" + python3 scripts/verify_seed_bundle.py \ + --source assets/seed \ + --packaged "$package_dir/share/acecode/seed" + + if [ "${{ matrix.build_desktop }}" = "true" ]; then + if [ "$RUNNER_OS" = "macOS" ]; then + if [ ! -d "build/ACECode.app" ]; then + echo "Missing desktop app bundle: build/ACECode.app" >&2 + exit 1 + fi + cp -r "build/ACECode.app" "$package_dir/" + else + if [ ! -f "build/acecode-desktop" ]; then + echo "Missing desktop executable: build/acecode-desktop" >&2 + exit 1 + fi + cp "build/acecode-desktop" "$package_dir/" + cp "build/acecode-logo.png" "$package_dir/" + fi + fi + legacy="$(find "$package_dir" -maxdepth 1 -iname 'ace-browser-*' -print -quit)" + if [ -n "$legacy" ]; then + echo "Legacy browser artifact must not be packaged: $legacy" >&2 + exit 1 + fi + tar -C dist -czf "acecode-${{ matrix.id }}.tar.gz" "acecode-${{ matrix.id }}" + + verify_root="$(mktemp -d)" + tar -xzf "acecode-${{ matrix.id }}.tar.gz" -C "$verify_root" + extracted_package="$verify_root/acecode-${{ matrix.id }}" + validate_models_dev_registry "$extracted_package/share/acecode/models_dev" + python3 scripts/verify_seed_bundle.py \ + --source assets/seed \ + --packaged "$extracted_package/share/acecode/seed" + if [ "$RUNNER_OS" = "macOS" ]; then + test -x "$extracted_package/acecode-computer-use" + test -x "$extracted_package/ACECode.app/Contents/MacOS/acecode-computer-use" + validate_models_dev_registry \ + "$extracted_package/ACECode.app/Contents/Resources/share/acecode/models_dev" + python3 scripts/verify_seed_bundle.py \ + --source assets/seed \ + --packaged "$extracted_package/ACECode.app/Contents/Resources/share/acecode/seed" + fi + rm -rf -- "$verify_root" + - name: Create Linux self-update archive + if: matrix.id == 'linux-x64' || matrix.id == 'linux-arm64' + id: linux-update + shell: bash + run: | + update_path="acecode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}-update.zip" + bash scripts/linux_create_update_zip.sh \ + --package-dir "dist/acecode-${{ matrix.id }}" \ + --output "$update_path" \ + --expected-version "${{ steps.package-version.outputs.version }}" + echo "path=$update_path" >> "$GITHUB_OUTPUT" + - name: Clean up macOS signing material + if: always() && runner.os == 'macOS' + shell: bash + run: | + set +e + security delete-keychain "$RUNNER_TEMP/acecode-signing.keychain-db" 2>/dev/null + rm -f -- \ + "$RUNNER_TEMP/acecode-developer-id.p12" \ + "$RUNNER_TEMP/acecode-developer-id-installer.p12" + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $packageDir = "dist/acecode-${{ matrix.id }}" + New-Item -ItemType Directory -Force -Path $packageDir | Out-Null + + function Assert-ModelsDevRegistry([string] $RegistryDir) { + if (-not (Test-Path -LiteralPath $RegistryDir -PathType Container)) { + throw "Missing models.dev directory: $RegistryDir" + } + $actualFiles = @(Get-ChildItem -LiteralPath $RegistryDir -File -Recurse) + if ($actualFiles.Count -ne 3) { + throw "Expected exactly 3 models.dev files in ${RegistryDir}, found $($actualFiles.Count)" + } + foreach ($name in @("api.json", "MANIFEST.json", "LICENSE")) { + $source = Join-Path "assets/models_dev" $name + $packaged = Join-Path $RegistryDir $name + if (-not (Test-Path -LiteralPath $packaged -PathType Leaf)) { + throw "Missing models.dev package file: $packaged" + } + $sourceHash = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash + $packagedHash = (Get-FileHash -LiteralPath $packaged -Algorithm SHA256).Hash + if ($sourceHash -ne $packagedHash) { + throw "Mismatched models.dev package file: $packaged" + } + } + } + + $terminalExe = "build/${{ matrix.executable }}" + if (-not (Test-Path -LiteralPath $terminalExe)) { + throw "Missing terminal executable: $terminalExe" + } + Copy-Item -LiteralPath $terminalExe -Destination "$packageDir/" + Copy-Item "README.md","README_CN.md" "$packageDir/" + $packageRoot = (Resolve-Path -LiteralPath $packageDir).Path + & cmake --install build --config MinSizeRel --prefix $packageRoot --component models_dev_registry + if ($LASTEXITCODE -ne 0) { + throw "Failed to install the bundled models.dev registry" + } + & cmake --install build --config MinSizeRel --prefix $packageRoot --component default_seed_bundle + if ($LASTEXITCODE -ne 0) { + throw "Failed to install the default seed bundle" + } + & cmake --install build --config MinSizeRel --prefix $packageRoot --component whatsapp_channel + if ($LASTEXITCODE -ne 0) { + throw "Failed to install the WhatsApp channel bridge" + } + & cmake --install build --config MinSizeRel --prefix $packageRoot --component computer_use_runtime + if ($LASTEXITCODE -ne 0) { + throw "Failed to install the Computer Use runtime" + } + $computerUseExe = Join-Path $packageRoot "acecode-computer-use.exe" + if (-not (Test-Path -LiteralPath $computerUseExe -PathType Leaf) -or + (Get-Item -LiteralPath $computerUseExe).Length -eq 0) { + throw "Missing Computer Use runtime: $computerUseExe" + } + Assert-ModelsDevRegistry (Join-Path $packageDir "share/acecode/models_dev") + & python scripts/verify_seed_bundle.py ` + --source assets/seed ` + --packaged (Join-Path $packageDir "share/acecode/seed") + if ($LASTEXITCODE -ne 0) { + throw "Failed to verify the packaged default seed bundle" + } + + if ("${{ matrix.build_desktop }}" -eq "true") { + $desktopCandidates = @( + "build/acecode-desktop.exe", + "build/Release/acecode-desktop.exe", + "build/Debug/acecode-desktop.exe", + "build/RelWithDebInfo/acecode-desktop.exe", + "build/MinSizeRel/acecode-desktop.exe" + ) + $desktopExe = $desktopCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + if (-not $desktopExe) { + $desktopExe = Get-ChildItem -Path "build" -Recurse -Filter "acecode-desktop.exe" -File | + Select-Object -ExpandProperty FullName -First 1 + } + if (-not $desktopExe) { + throw "Missing desktop executable: acecode-desktop.exe" + } + Copy-Item -LiteralPath $desktopExe -Destination "$packageDir/" + } + $legacyBrowserArtifact = Get-ChildItem -LiteralPath $packageDir -Filter "ace-browser-*" -Force | + Select-Object -First 1 + if ($legacyBrowserArtifact) { + throw "Legacy browser artifact must not be packaged: $($legacyBrowserArtifact.FullName)" + } + Compress-Archive -Path "$packageDir/*" -DestinationPath "acecode-${{ matrix.id }}.zip" -Force + + $verifyRoot = Join-Path $env:RUNNER_TEMP ("acecode-package-verify-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $verifyRoot | Out-Null + Expand-Archive -LiteralPath "acecode-${{ matrix.id }}.zip" -DestinationPath $verifyRoot + $archivedComputerUseExe = Join-Path $verifyRoot "acecode-computer-use.exe" + if (-not (Test-Path -LiteralPath $archivedComputerUseExe -PathType Leaf) -or + (Get-FileHash -LiteralPath $archivedComputerUseExe -Algorithm SHA256).Hash -ne + (Get-FileHash -LiteralPath $computerUseExe -Algorithm SHA256).Hash) { + throw "Archived Computer Use runtime is missing or does not match the build" + } + Assert-ModelsDevRegistry (Join-Path $verifyRoot "share/acecode/models_dev") + & python scripts/verify_seed_bundle.py ` + --source assets/seed ` + --packaged (Join-Path $verifyRoot "share/acecode/seed") + if ($LASTEXITCODE -ne 0) { + throw "Failed to verify the archived default seed bundle" + } + - name: Verify Windows ARM64 package + if: matrix.id == 'windows-arm64' + shell: pwsh + run: | + $packageDir = "dist/acecode-${{ matrix.id }}" + $expectedExecutables = @( + "acecode.exe", + "acecode-desktop.exe", + "acecode-computer-use.exe" + ) + foreach ($name in $expectedExecutables) { + $path = Join-Path $packageDir $name + if (-not (Test-Path -LiteralPath $path)) { + throw "Missing ARM64 package executable: $path" + } + $headers = (& dumpbin /headers $path | Out-String) + if ($LASTEXITCODE -ne 0 -or $headers -notmatch '(?im)\bAA64 machine\b') { + throw "$name is not an ARM64 PE executable" + } + Write-Host "${name}: ARM64" + } + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }} + path: acecode-${{ matrix.id }}.${{ matrix.archive_extension }} + if-no-files-found: error + - name: Upload Linux self-update archive + if: matrix.id == 'linux-x64' || matrix.id == 'linux-arm64' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-update + path: ${{ steps.linux-update.outputs.path }} + if-no-files-found: error + - name: Upload macOS installer DMG + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-dmg + path: ${{ steps.macos-dmg.outputs.path }} + if-no-files-found: error + - name: Upload macOS self-update archive + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-update + path: ${{ steps.macos-update.outputs.path }} + if-no-files-found: error + - name: Upload PDB + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-pdb + path: build/**/acecode.pdb + if-no-files-found: warn + - name: Upload desktop PDB + if: runner.os == 'Windows' && matrix.build_desktop + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-desktop-pdb + path: build/**/acecode-desktop.pdb + if-no-files-found: warn + - name: Upload debug symbols (Linux) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-debug + path: build/${{ matrix.executable }}.debug + if-no-files-found: warn + - name: Upload desktop debug symbols (Linux) + if: runner.os == 'Linux' && matrix.build_desktop + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-desktop-debug + path: build/acecode-desktop.debug + if-no-files-found: warn + - name: Upload dSYM (macOS) + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-dsym + path: build/${{ matrix.executable }}.dSYM + if-no-files-found: warn + - name: Upload desktop dSYM (macOS) + if: runner.os == 'macOS' && matrix.build_desktop + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-desktop-dsym + path: | + build/ACECode.app.dSYM + build/acecode-daemon.dSYM + if-no-files-found: warn + timeout-minutes: 90 diff --git a/.github/workflows/recover-v1.0.7-platform-assets.yml b/.github/workflows/recover-v1.0.7-platform-assets.yml new file mode 100644 index 000000000..f6bbfac7f --- /dev/null +++ b/.github/workflows/recover-v1.0.7-platform-assets.yml @@ -0,0 +1,919 @@ +name: Recover v1.0.7 macOS x64 assets +'on': + push: + branches: + - claude/recover-v1.0.7-platform-assets +permissions: + contents: read + actions: read +concurrency: + group: recover-v1.0.7-macos-x64 + cancel-in-progress: false +jobs: + build-web-ui: + name: web-dist + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: v1.0.7 + - name: Verify immutable v1.0.7 source + shell: bash + run: | + test "$(git rev-parse HEAD)" = "bb779f1f81e55536e56d21c3e5d66be1b2c1158e" + git diff --exit-code + - name: Check bundled models.dev freshness for release + if: 'true' + shell: bash + run: | + python3 - <<'PY' + import datetime as dt + import json + from pathlib import Path + + manifest_path = Path("assets/models_dev/MANIFEST.json") + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + generated_at = dt.datetime.fromisoformat( + manifest["generated_at"].replace("Z", "+00:00") + ) + except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise SystemExit(f"Invalid {manifest_path}: {exc}") + + if generated_at.tzinfo is None: + raise SystemExit(f"{manifest_path}: generated_at must include a timezone") + now = dt.datetime.now(dt.timezone.utc) + age = now - generated_at.astimezone(dt.timezone.utc) + if age < -dt.timedelta(minutes=5): + raise SystemExit(f"{manifest_path}: generated_at is in the future") + if age > dt.timedelta(days=30): + raise SystemExit( + f"Bundled models.dev registry is {age.days} days old; " + "run scripts/sync_models_dev before creating a release tag" + ) + print(f"Bundled models.dev registry age: {age.days} days") + PY + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.32.1 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: pnpm + cache-dependency-path: web/pnpm-lock.yaml + - name: Build web UI + shell: bash + run: | + cd web + pnpm install --frozen-lockfile + pnpm build + - name: Upload web UI + uses: actions/upload-artifact@v4 + with: + name: web-dist + path: web/dist + if-no-files-found: error + timeout-minutes: 60 + env: + ACE_RECOVERY_REF: refs/tags/v1.0.7 + ACE_RECOVERY_REF_NAME: v1.0.7 + build-and-package: + name: ${{ matrix.id }} + needs: build-web-ui + runs-on: ${{ matrix.os }} + env: + VCPKG_BINARY_SOURCES: clear;default,readwrite + ACE_RECOVERY_REF: refs/tags/v1.0.7 + ACE_RECOVERY_REF_NAME: v1.0.7 + strategy: + fail-fast: false + matrix: + include: + - id: macos-x64 + os: macos-15 + installer_arch: x64 + triplet: x64-osx + executable: acecode + archive_extension: tar.gz + build_desktop: true + cmake_extra_args: -DCMAKE_OSX_ARCHITECTURES=x86_64 -DACECODE_BUILD_DESKTOP=ON + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + ref: v1.0.7 + - name: Verify immutable v1.0.7 source + shell: bash + run: | + test "$(git rev-parse HEAD)" = "bb779f1f81e55536e56d21c3e5d66be1b2c1158e" + git diff --exit-code + - name: Resolve package version + id: package-version + shell: bash + run: | + declared_version="$(awk '$1 == "project(acecode" && $2 == "VERSION" { print $3; exit }' CMakeLists.txt)" + if [ -z "$declared_version" ]; then + echo "::error::Unable to read the ACECode version from CMakeLists.txt" + exit 1 + fi + if [[ "$ACE_RECOVERY_REF" == refs/tags/v* && "${ACE_RECOVERY_REF_NAME#v}" != "$declared_version" ]]; then + echo "::error::Tag $ACE_RECOVERY_REF_NAME does not match CMake version $declared_version" + exit 1 + fi + echo "version=$declared_version" >> "$GITHUB_OUTPUT" + - name: Check macOS release credentials + if: runner.os == 'macOS' + id: macos-release + shell: bash + env: + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_BASE64 }} + MACOS_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + application_missing=() + for name in \ + MACOS_CERTIFICATE_BASE64 \ + MACOS_CERTIFICATE_PASSWORD \ + APPLE_ID \ + APPLE_TEAM_ID \ + APPLE_APP_SPECIFIC_PASSWORD; do + if [ -z "${!name}" ]; then + application_missing+=("$name") + fi + done + + if [ "${#application_missing[@]}" -ne 0 ]; then + if [[ "$ACE_RECOVERY_REF" == refs/tags/v* ]]; then + echo "::error::Tagged macOS application releases require secrets: ${application_missing[*]}" + exit 1 + fi + echo "::notice::macOS signing/notarization disabled; missing: ${application_missing[*]}" + echo "enabled=false" >> "$GITHUB_OUTPUT" + else + echo "enabled=true" >> "$GITHUB_OUTPUT" + fi + + installer_certificate_set=false + installer_password_set=false + if [ -n "$MACOS_INSTALLER_CERTIFICATE_BASE64" ]; then + installer_certificate_set=true + fi + if [ -n "$MACOS_INSTALLER_CERTIFICATE_PASSWORD" ]; then + installer_password_set=true + fi + + if [ "$installer_certificate_set" != "$installer_password_set" ]; then + echo "::error::macOS Installer credentials must configure both MACOS_INSTALLER_CERTIFICATE_BASE64 and MACOS_INSTALLER_CERTIFICATE_PASSWORD" + exit 1 + fi + if [ "$installer_certificate_set" = "true" ]; then + if [ "${#application_missing[@]}" -eq 0 ]; then + echo "pkg_enabled=true" >> "$GITHUB_OUTPUT" + else + echo "::notice::macOS PKG creation disabled because application signing/notarization is disabled" + echo "pkg_enabled=false" >> "$GITHUB_OUTPUT" + fi + else + if [[ "$ACE_RECOVERY_REF" == refs/tags/v* ]]; then + echo "::error::Tagged macOS releases require MACOS_INSTALLER_CERTIFICATE_BASE64 and MACOS_INSTALLER_CERTIFICATE_PASSWORD to build both PKGs" + exit 1 + fi + echo "::notice::macOS PKG creation disabled; Developer ID Installer credentials are not configured" + echo "pkg_enabled=false" >> "$GITHUB_OUTPUT" + fi + - name: Configure MSVC + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: ${{ matrix.msvc_arch }} + - name: Install Linux ARMv7 cross toolchain + if: matrix.id == 'linux-armv7' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y \ + binutils-arm-linux-gnueabihf \ + gcc-arm-linux-gnueabihf \ + g++-arm-linux-gnueabihf \ + qemu-user + - name: Run vcpkg + uses: lukka/run-vcpkg@v11 + - name: Restore vcpkg binary cache + uses: actions/cache@v4 + with: + path: ${{ env.VCPKG_DEFAULT_BINARY_CACHE }} + key: vcpkg-v2-${{ matrix.os }}-${{ matrix.triplet }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json', 'ports/**') + }} + restore-keys: | + vcpkg-v2-${{ matrix.os }}-${{ matrix.triplet }}- + - name: Install Linux desktop dependencies + if: runner.os == 'Linux' && matrix.build_desktop + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libwebkit2gtk-4.1-dev + - name: Download web UI + uses: actions/download-artifact@v4 + with: + name: web-dist + path: web/dist + - name: Configure + shell: bash + run: | + cmake -S . -B build -G Ninja \ + -DCMAKE_BUILD_TYPE=MinSizeRel \ + -DCMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_TARGET_TRIPLET="${{ matrix.triplet }}" \ + -DBUILD_TESTING=OFF \ + ${{ matrix.cmake_extra_args }} + - name: Build + shell: bash + run: cmake --build build --config MinSizeRel --target acecode + - name: Build desktop + if: matrix.build_desktop + shell: bash + run: cmake --build build --config MinSizeRel --target acecode-desktop + - name: Verify Linux ARMv7 executable + if: matrix.id == 'linux-armv7' + shell: bash + run: | + export LC_ALL=C + elf_header="$(arm-linux-gnueabihf-readelf -h build/acecode)" + arm_attributes="$(arm-linux-gnueabihf-readelf -A build/acecode)" + program_headers="$(arm-linux-gnueabihf-readelf -l build/acecode)" + + printf '%s\n' "$elf_header" + printf '%s\n' "$arm_attributes" + + grep -Eq 'Class:[[:space:]]+ELF32' <<<"$elf_header" + grep -Eq 'Machine:[[:space:]]+ARM' <<<"$elf_header" + grep -Eq 'Tag_CPU_arch:[[:space:]]+v7' <<<"$arm_attributes" + grep -Eq 'Tag_ABI_VFP_args:[[:space:]]+VFP registers' <<<"$arm_attributes" + grep -Fq '/lib/ld-linux-armhf.so.3' <<<"$program_headers" + + version_output="$(qemu-arm -L /usr/arm-linux-gnueabihf build/acecode --version)" + printf '%s\n' "$version_output" + grep -Eq '^acecode v[^[:space:]]+$' <<<"$version_output" + - name: Verify bundled resources in macOS app + if: runner.os == 'macOS' && matrix.build_desktop + shell: bash + run: | + registry_dir="build/ACECode.app/Contents/Resources/share/acecode/models_dev" + file_count="$(find "$registry_dir" -type f | wc -l | tr -d '[:space:]')" + if [ "$file_count" != "3" ]; then + echo "Expected exactly 3 models.dev resources in $registry_dir, found $file_count" >&2 + exit 1 + fi + for file in api.json MANIFEST.json LICENSE; do + if [ ! -f "$registry_dir/$file" ] || + ! cmp -s "assets/models_dev/$file" "$registry_dir/$file"; then + echo "Missing or mismatched macOS app models.dev resource: $file" >&2 + exit 1 + fi + done + python3 scripts/verify_seed_bundle.py \ + --source assets/seed \ + --packaged build/ACECode.app/Contents/Resources/share/acecode/seed + - name: Extract debug symbols (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + binutils_prefix="" + if [ "${{ matrix.id }}" = "linux-armv7" ]; then + binutils_prefix="arm-linux-gnueabihf-" + fi + "${binutils_prefix}objcopy" --only-keep-debug \ + "build/${{ matrix.executable }}" \ + "build/${{ matrix.executable }}.debug" + "${binutils_prefix}strip" --strip-unneeded "build/${{ matrix.executable }}" + "${binutils_prefix}objcopy" \ + --add-gnu-debuglink="build/${{ matrix.executable }}.debug" \ + "build/${{ matrix.executable }}" + - name: Extract desktop debug symbols (Linux) + if: runner.os == 'Linux' && matrix.build_desktop + shell: bash + run: | + objcopy --only-keep-debug "build/acecode-desktop" "build/acecode-desktop.debug" + strip --strip-unneeded "build/acecode-desktop" + objcopy --add-gnu-debuglink="build/acecode-desktop.debug" "build/acecode-desktop" + - name: Extract debug symbols (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + dsymutil "build/${{ matrix.executable }}" -o "build/${{ matrix.executable }}.dSYM" + strip "build/${{ matrix.executable }}" + - name: Extract desktop debug symbols (macOS) + if: runner.os == 'macOS' && matrix.build_desktop + shell: bash + run: | + app_exec="build/ACECode.app/Contents/MacOS/ACECode" + app_daemon="build/ACECode.app/Contents/MacOS/acecode-daemon" + if [ ! -f "$app_exec" ]; then + echo "Missing desktop app executable: $app_exec" >&2 + exit 1 + fi + if [ ! -f "$app_daemon" ]; then + echo "Missing desktop app daemon: $app_daemon" >&2 + exit 1 + fi + dsymutil "$app_exec" -o "build/ACECode.app.dSYM" + strip "$app_exec" + dsymutil "$app_daemon" -o "build/acecode-daemon.dSYM" + strip "$app_daemon" + - name: Import Developer ID identities (macOS) + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + id: macos-keychain + shell: bash + env: + MACOS_CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_BASE64 }} + MACOS_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_INSTALLER_CERTIFICATE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + MACOS_CODESIGN_IDENTITY: ${{ vars.MACOS_CODESIGN_IDENTITY }} + MACOS_INSTALLER_SIGNING_IDENTITY: ${{ vars.MACOS_INSTALLER_SIGNING_IDENTITY }} + run: | + set +x + certificate_path="$RUNNER_TEMP/acecode-developer-id.p12" + installer_certificate_path="$RUNNER_TEMP/acecode-developer-id-installer.p12" + keychain_path="$RUNNER_TEMP/acecode-signing.keychain-db" + keychain_password="$(openssl rand -hex 32)" + + printf '%s' "$MACOS_CERTIFICATE_BASE64" | /usr/bin/base64 -D > "$certificate_path" + chmod 600 "$certificate_path" + if [ "${{ steps.macos-release.outputs.pkg_enabled }}" = "true" ]; then + printf '%s' "$MACOS_INSTALLER_CERTIFICATE_BASE64" | /usr/bin/base64 -D > "$installer_certificate_path" + chmod 600 "$installer_certificate_path" + fi + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + + existing_user_keychains=() + while IFS= read -r existing_keychain; do + if [ -n "$existing_keychain" ]; then + existing_user_keychains+=("$existing_keychain") + fi + done < <( + security list-keychains -d user | + sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//' + ) + security list-keychains -d user -s \ + "$keychain_path" \ + "${existing_user_keychains[@]}" + + security import "$certificate_path" \ + -k "$keychain_path" \ + -P "$MACOS_CERTIFICATE_PASSWORD" \ + -f pkcs12 \ + -T /usr/bin/codesign \ + -T /usr/bin/security + if [ "${{ steps.macos-release.outputs.pkg_enabled }}" = "true" ]; then + security import "$installer_certificate_path" \ + -k "$keychain_path" \ + -P "$MACOS_INSTALLER_CERTIFICATE_PASSWORD" \ + -f pkcs12 \ + -T /usr/bin/pkgbuild \ + -T /usr/bin/productbuild \ + -T /usr/bin/security + fi + security set-key-partition-list \ + -S apple-tool:,apple: \ + -s \ + -k "$keychain_password" \ + "$keychain_path" + + identity_record="$(security find-identity -v -p codesigning "$keychain_path" | + awk '/Developer ID Application:/ { print; exit }')" + identity_fingerprint="$(printf '%s\n' "$identity_record" | awk '{ print $2 }')" + identity_name="$(printf '%s\n' "$identity_record" | awk -F '"' '{ print $2 }')" + if [[ ! "$identity_fingerprint" =~ ^[[:xdigit:]]{40}$ ]] || [ -z "$identity_name" ]; then + echo "::error::No Developer ID Application identity found in the imported certificate" + exit 1 + fi + if [ -n "$MACOS_CODESIGN_IDENTITY" ] && [ "$identity_name" != "$MACOS_CODESIGN_IDENTITY" ]; then + echo "::error::Imported signing identity does not match MACOS_CODESIGN_IDENTITY" + exit 1 + fi + if [ -n "$APPLE_TEAM_ID" ] && [[ "$identity_name" != *"($APPLE_TEAM_ID)" ]]; then + echo "::error::Developer ID Application identity does not match APPLE_TEAM_ID" + exit 1 + fi + + if [ "${{ steps.macos-release.outputs.pkg_enabled }}" = "true" ]; then + installer_identity_record="$(security find-identity -v -p basic "$keychain_path" | + awk '/Developer ID Installer:/ { print; exit }')" + installer_identity_fingerprint="$(printf '%s\n' "$installer_identity_record" | awk '{ print $2 }')" + installer_identity_name="$(printf '%s\n' "$installer_identity_record" | awk -F '"' '{ print $2 }')" + if [[ ! "$installer_identity_fingerprint" =~ ^[[:xdigit:]]{40}$ ]] || [ -z "$installer_identity_name" ]; then + echo "::error::No Developer ID Installer identity found in the imported certificate" + exit 1 + fi + if [ -n "$MACOS_INSTALLER_SIGNING_IDENTITY" ] && [ "$installer_identity_name" != "$MACOS_INSTALLER_SIGNING_IDENTITY" ]; then + echo "::error::Imported Installer identity does not match MACOS_INSTALLER_SIGNING_IDENTITY" + exit 1 + fi + if [ -n "$APPLE_TEAM_ID" ] && [[ "$installer_identity_name" != *"($APPLE_TEAM_ID)" ]]; then + echo "::error::Developer ID Installer identity does not match APPLE_TEAM_ID" + exit 1 + fi + echo "installer_identity=$installer_identity_name" >> "$GITHUB_OUTPUT" + fi + + echo "keychain=$keychain_path" >> "$GITHUB_OUTPUT" + # Sign by the certificate fingerprint. codesign can fail to resolve a + # human-readable identity name inside an isolated temporary keychain. + echo "identity=$identity_fingerprint" >> "$GITHUB_OUTPUT" + - name: Sign macOS release payloads + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + shell: bash + run: | + xcrun() { + if [[ "${1:-}" != "stapler" ]]; then + command xcrun "$@" + return $? + fi + local result=0 + for attempt in 1 2 3 4 5 6; do + if command xcrun "$@"; then return 0; else result=$?; fi + if [[ "$result" != "68" || "$attempt" == "6" ]]; then return "$result"; fi + echo "Apple ticket service disconnected (68), retry $attempt/6 in 15 seconds" + sleep 15 + done + return "$result" + } + export -f xcrun + scripts/macos_codesign.sh \ + --identity "${{ steps.macos-keychain.outputs.identity }}" \ + --keychain "${{ steps.macos-keychain.outputs.keychain }}" \ + --binary "build/${{ matrix.executable }}" \ + --binary "build/acecode-computer-use" \ + --app "build/ACECode.app" + - name: Notarize and staple macOS app + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: |- + xcrun() { + if [[ "${1:-}" != "stapler" ]]; then + command xcrun "$@" + return $? + fi + local result=0 + for attempt in 1 2 3 4 5 6; do + if command xcrun "$@"; then return 0; else result=$?; fi + if [[ "$result" != "68" || "$attempt" == "6" ]]; then return "$result"; fi + echo "Apple ticket service disconnected (68), retry $attempt/6 in 15 seconds" + sleep 15 + done + return "$result" + } + export -f xcrun + scripts/macos_notarize_app.sh --app "build/ACECode.app" + - name: Test macOS installer integration + if: runner.os == 'macOS' + shell: bash + run: | + bash tests/scripts/macos_installer_test.sh + python3 tests/scripts/macos_installer_release_test.py + - name: Create current-user macOS PKG + if: runner.os == 'macOS' && steps.macos-release.outputs.pkg_enabled == 'true' + id: macos-pkg + shell: bash + run: | + xcrun() { + if [[ "${1:-}" != "stapler" ]]; then + command xcrun "$@" + return $? + fi + local result=0 + for attempt in 1 2 3 4 5 6; do + if command xcrun "$@"; then return 0; else result=$?; fi + if [[ "$result" != "68" || "$attempt" == "6" ]]; then return "$result"; fi + echo "Apple ticket service disconnected (68), retry $attempt/6 in 15 seconds" + sleep 15 + done + return "$result" + } + export -f xcrun + pkg_path="ACECode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}.pkg" + scripts/macos_create_pkg.sh \ + --app "build/ACECode.app" \ + --output "$pkg_path" \ + --installer-identity "${{ steps.macos-keychain.outputs.installer_identity }}" \ + --keychain "${{ steps.macos-keychain.outputs.keychain }}" + echo "path=$pkg_path" >> "$GITHUB_OUTPUT" + - name: Notarize and validate macOS PKG + if: runner.os == 'macOS' && steps.macos-release.outputs.pkg_enabled == 'true' + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: |- + xcrun() { + if [[ "${1:-}" != "stapler" ]]; then + command xcrun "$@" + return $? + fi + local result=0 + for attempt in 1 2 3 4 5 6; do + if command xcrun "$@"; then return 0; else result=$?; fi + if [[ "$result" != "68" || "$attempt" == "6" ]]; then return "$result"; fi + echo "Apple ticket service disconnected (68), retry $attempt/6 in 15 seconds" + sleep 15 + done + return "$result" + } + export -f xcrun + scripts/macos_notarize_pkg.sh --pkg "${{ steps.macos-pkg.outputs.path }}" + - name: Upload macOS PKG + if: runner.os == 'macOS' && steps.macos-release.outputs.pkg_enabled == 'true' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-pkg + path: ${{ steps.macos-pkg.outputs.path }} + if-no-files-found: error + - name: Build signed and notarized macOS installer DMG + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + id: macos-dmg + shell: bash + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + run: | + xcrun() { + if [[ "${1:-}" != "stapler" ]]; then + command xcrun "$@" + return $? + fi + local result=0 + for attempt in 1 2 3 4 5 6; do + if command xcrun "$@"; then return 0; else result=$?; fi + if [[ "$result" != "68" || "$attempt" == "6" ]]; then return "$result"; fi + echo "Apple ticket service disconnected (68), retry $attempt/6 in 15 seconds" + sleep 15 + done + return "$result" + } + export -f xcrun + dmg_path="ACECode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}.dmg" + bash scripts/macos_create_installer_dmg.sh \ + --app "build/ACECode.app" \ + --output "$dmg_path" \ + --arch "${{ matrix.installer_arch }}" \ + --identity "${{ steps.macos-keychain.outputs.identity }}" \ + --keychain "${{ steps.macos-keychain.outputs.keychain }}" + echo "path=$dmg_path" >> "$GITHUB_OUTPUT" + - name: Create macOS self-update archive + if: runner.os == 'macOS' + id: macos-update + shell: bash + run: | + xcrun() { + if [[ "${1:-}" != "stapler" ]]; then + command xcrun "$@" + return $? + fi + local result=0 + for attempt in 1 2 3 4 5 6; do + if command xcrun "$@"; then return 0; else result=$?; fi + if [[ "$result" != "68" || "$attempt" == "6" ]]; then return "$result"; fi + echo "Apple ticket service disconnected (68), retry $attempt/6 in 15 seconds" + sleep 15 + done + return "$result" + } + export -f xcrun + unsigned_suffix="" + trust_args=() + if [ "${{ steps.macos-release.outputs.enabled }}" = "true" ]; then + trust_args+=(--require-trusted) + else + unsigned_suffix="-unsigned" + fi + update_path="ACECode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}-update${unsigned_suffix}.zip" + scripts/macos_create_update_zip.sh \ + --app "build/ACECode.app" \ + --output "$update_path" \ + "${trust_args[@]}" + echo "path=$update_path" >> "$GITHUB_OUTPUT" + - name: Package (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + package_dir="dist/acecode-${{ matrix.id }}" + mkdir -p "$package_dir" + + validate_models_dev_registry() { + registry_dir="$1" + file_count="$(find "$registry_dir" -type f | wc -l | tr -d '[:space:]')" + if [ "$file_count" != "3" ]; then + echo "Expected exactly 3 models.dev files in $registry_dir, found $file_count" >&2 + exit 1 + fi + for file in api.json MANIFEST.json LICENSE; do + if [ ! -f "$registry_dir/$file" ] || + ! cmp -s "assets/models_dev/$file" "$registry_dir/$file"; then + echo "Missing or mismatched models.dev package file: $registry_dir/$file" >&2 + exit 1 + fi + done + } + + if [ ! -f "build/${{ matrix.executable }}" ]; then + echo "Missing terminal executable: build/${{ matrix.executable }}" >&2 + exit 1 + fi + cp "build/${{ matrix.executable }}" "$package_dir/" + cp README.md README_CN.md "$package_dir/" + cmake --install build \ + --config MinSizeRel \ + --prefix "$PWD/$package_dir" \ + --component models_dev_registry + cmake --install build \ + --config MinSizeRel \ + --prefix "$PWD/$package_dir" \ + --component default_seed_bundle + cmake --install build --config MinSizeRel --prefix "$PWD/$package_dir" --component whatsapp_channel + if [ "$RUNNER_OS" = "macOS" ]; then + cmake --install build --config MinSizeRel --prefix "$PWD/$package_dir" --component computer_use_runtime + test -x "$package_dir/acecode-computer-use" + test -x "build/ACECode.app/Contents/MacOS/acecode-computer-use" + fi + validate_models_dev_registry "$package_dir/share/acecode/models_dev" + python3 scripts/verify_seed_bundle.py \ + --source assets/seed \ + --packaged "$package_dir/share/acecode/seed" + + if [ "${{ matrix.build_desktop }}" = "true" ]; then + if [ "$RUNNER_OS" = "macOS" ]; then + if [ ! -d "build/ACECode.app" ]; then + echo "Missing desktop app bundle: build/ACECode.app" >&2 + exit 1 + fi + cp -r "build/ACECode.app" "$package_dir/" + else + if [ ! -f "build/acecode-desktop" ]; then + echo "Missing desktop executable: build/acecode-desktop" >&2 + exit 1 + fi + cp "build/acecode-desktop" "$package_dir/" + cp "build/acecode-logo.png" "$package_dir/" + fi + fi + legacy="$(find "$package_dir" -maxdepth 1 -iname 'ace-browser-*' -print -quit)" + if [ -n "$legacy" ]; then + echo "Legacy browser artifact must not be packaged: $legacy" >&2 + exit 1 + fi + tar -C dist -czf "acecode-${{ matrix.id }}.tar.gz" "acecode-${{ matrix.id }}" + + verify_root="$(mktemp -d)" + tar -xzf "acecode-${{ matrix.id }}.tar.gz" -C "$verify_root" + extracted_package="$verify_root/acecode-${{ matrix.id }}" + validate_models_dev_registry "$extracted_package/share/acecode/models_dev" + python3 scripts/verify_seed_bundle.py \ + --source assets/seed \ + --packaged "$extracted_package/share/acecode/seed" + if [ "$RUNNER_OS" = "macOS" ]; then + test -x "$extracted_package/acecode-computer-use" + test -x "$extracted_package/ACECode.app/Contents/MacOS/acecode-computer-use" + validate_models_dev_registry \ + "$extracted_package/ACECode.app/Contents/Resources/share/acecode/models_dev" + python3 scripts/verify_seed_bundle.py \ + --source assets/seed \ + --packaged "$extracted_package/ACECode.app/Contents/Resources/share/acecode/seed" + fi + rm -rf -- "$verify_root" + - name: Create Linux self-update archive + if: matrix.id == 'linux-x64' || matrix.id == 'linux-arm64' + id: linux-update + shell: bash + run: | + update_path="acecode-${{ steps.package-version.outputs.version }}-${{ matrix.id }}-update.zip" + bash scripts/linux_create_update_zip.sh \ + --package-dir "dist/acecode-${{ matrix.id }}" \ + --output "$update_path" \ + --expected-version "${{ steps.package-version.outputs.version }}" + echo "path=$update_path" >> "$GITHUB_OUTPUT" + - name: Clean up macOS signing material + if: always() && runner.os == 'macOS' + shell: bash + run: | + set +e + security delete-keychain "$RUNNER_TEMP/acecode-signing.keychain-db" 2>/dev/null + rm -f -- \ + "$RUNNER_TEMP/acecode-developer-id.p12" \ + "$RUNNER_TEMP/acecode-developer-id-installer.p12" + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $packageDir = "dist/acecode-${{ matrix.id }}" + New-Item -ItemType Directory -Force -Path $packageDir | Out-Null + + function Assert-ModelsDevRegistry([string] $RegistryDir) { + if (-not (Test-Path -LiteralPath $RegistryDir -PathType Container)) { + throw "Missing models.dev directory: $RegistryDir" + } + $actualFiles = @(Get-ChildItem -LiteralPath $RegistryDir -File -Recurse) + if ($actualFiles.Count -ne 3) { + throw "Expected exactly 3 models.dev files in ${RegistryDir}, found $($actualFiles.Count)" + } + foreach ($name in @("api.json", "MANIFEST.json", "LICENSE")) { + $source = Join-Path "assets/models_dev" $name + $packaged = Join-Path $RegistryDir $name + if (-not (Test-Path -LiteralPath $packaged -PathType Leaf)) { + throw "Missing models.dev package file: $packaged" + } + $sourceHash = (Get-FileHash -LiteralPath $source -Algorithm SHA256).Hash + $packagedHash = (Get-FileHash -LiteralPath $packaged -Algorithm SHA256).Hash + if ($sourceHash -ne $packagedHash) { + throw "Mismatched models.dev package file: $packaged" + } + } + } + + $terminalExe = "build/${{ matrix.executable }}" + if (-not (Test-Path -LiteralPath $terminalExe)) { + throw "Missing terminal executable: $terminalExe" + } + Copy-Item -LiteralPath $terminalExe -Destination "$packageDir/" + Copy-Item "README.md","README_CN.md" "$packageDir/" + $packageRoot = (Resolve-Path -LiteralPath $packageDir).Path + & cmake --install build --config MinSizeRel --prefix $packageRoot --component models_dev_registry + if ($LASTEXITCODE -ne 0) { + throw "Failed to install the bundled models.dev registry" + } + & cmake --install build --config MinSizeRel --prefix $packageRoot --component default_seed_bundle + if ($LASTEXITCODE -ne 0) { + throw "Failed to install the default seed bundle" + } + & cmake --install build --config MinSizeRel --prefix $packageRoot --component whatsapp_channel + if ($LASTEXITCODE -ne 0) { + throw "Failed to install the WhatsApp channel bridge" + } + & cmake --install build --config MinSizeRel --prefix $packageRoot --component computer_use_runtime + if ($LASTEXITCODE -ne 0) { + throw "Failed to install the Computer Use runtime" + } + $computerUseExe = Join-Path $packageRoot "acecode-computer-use.exe" + if (-not (Test-Path -LiteralPath $computerUseExe -PathType Leaf) -or + (Get-Item -LiteralPath $computerUseExe).Length -eq 0) { + throw "Missing Computer Use runtime: $computerUseExe" + } + Assert-ModelsDevRegistry (Join-Path $packageDir "share/acecode/models_dev") + & python scripts/verify_seed_bundle.py ` + --source assets/seed ` + --packaged (Join-Path $packageDir "share/acecode/seed") + if ($LASTEXITCODE -ne 0) { + throw "Failed to verify the packaged default seed bundle" + } + + if ("${{ matrix.build_desktop }}" -eq "true") { + $desktopCandidates = @( + "build/acecode-desktop.exe", + "build/Release/acecode-desktop.exe", + "build/Debug/acecode-desktop.exe", + "build/RelWithDebInfo/acecode-desktop.exe", + "build/MinSizeRel/acecode-desktop.exe" + ) + $desktopExe = $desktopCandidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + if (-not $desktopExe) { + $desktopExe = Get-ChildItem -Path "build" -Recurse -Filter "acecode-desktop.exe" -File | + Select-Object -ExpandProperty FullName -First 1 + } + if (-not $desktopExe) { + throw "Missing desktop executable: acecode-desktop.exe" + } + Copy-Item -LiteralPath $desktopExe -Destination "$packageDir/" + } + $legacyBrowserArtifact = Get-ChildItem -LiteralPath $packageDir -Filter "ace-browser-*" -Force | + Select-Object -First 1 + if ($legacyBrowserArtifact) { + throw "Legacy browser artifact must not be packaged: $($legacyBrowserArtifact.FullName)" + } + Compress-Archive -Path "$packageDir/*" -DestinationPath "acecode-${{ matrix.id }}.zip" -Force + + $verifyRoot = Join-Path $env:RUNNER_TEMP ("acecode-package-verify-" + [Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $verifyRoot | Out-Null + Expand-Archive -LiteralPath "acecode-${{ matrix.id }}.zip" -DestinationPath $verifyRoot + $archivedComputerUseExe = Join-Path $verifyRoot "acecode-computer-use.exe" + if (-not (Test-Path -LiteralPath $archivedComputerUseExe -PathType Leaf) -or + (Get-FileHash -LiteralPath $archivedComputerUseExe -Algorithm SHA256).Hash -ne + (Get-FileHash -LiteralPath $computerUseExe -Algorithm SHA256).Hash) { + throw "Archived Computer Use runtime is missing or does not match the build" + } + Assert-ModelsDevRegistry (Join-Path $verifyRoot "share/acecode/models_dev") + & python scripts/verify_seed_bundle.py ` + --source assets/seed ` + --packaged (Join-Path $verifyRoot "share/acecode/seed") + if ($LASTEXITCODE -ne 0) { + throw "Failed to verify the archived default seed bundle" + } + - name: Verify Windows ARM64 package + if: matrix.id == 'windows-arm64' + shell: pwsh + run: | + $packageDir = "dist/acecode-${{ matrix.id }}" + $expectedExecutables = @( + "acecode.exe", + "acecode-desktop.exe", + "acecode-computer-use.exe" + ) + foreach ($name in $expectedExecutables) { + $path = Join-Path $packageDir $name + if (-not (Test-Path -LiteralPath $path)) { + throw "Missing ARM64 package executable: $path" + } + $headers = (& dumpbin /headers $path | Out-String) + if ($LASTEXITCODE -ne 0 -or $headers -notmatch '(?im)\bAA64 machine\b') { + throw "$name is not an ARM64 PE executable" + } + Write-Host "${name}: ARM64" + } + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }} + path: acecode-${{ matrix.id }}.${{ matrix.archive_extension }} + if-no-files-found: error + - name: Upload Linux self-update archive + if: matrix.id == 'linux-x64' || matrix.id == 'linux-arm64' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-update + path: ${{ steps.linux-update.outputs.path }} + if-no-files-found: error + - name: Upload macOS installer DMG + if: runner.os == 'macOS' && steps.macos-release.outputs.enabled == 'true' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-dmg + path: ${{ steps.macos-dmg.outputs.path }} + if-no-files-found: error + - name: Upload macOS self-update archive + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-update + path: ${{ steps.macos-update.outputs.path }} + if-no-files-found: error + - name: Upload PDB + if: runner.os == 'Windows' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-pdb + path: build/**/acecode.pdb + if-no-files-found: warn + - name: Upload desktop PDB + if: runner.os == 'Windows' && matrix.build_desktop + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-desktop-pdb + path: build/**/acecode-desktop.pdb + if-no-files-found: warn + - name: Upload debug symbols (Linux) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-debug + path: build/${{ matrix.executable }}.debug + if-no-files-found: warn + - name: Upload desktop debug symbols (Linux) + if: runner.os == 'Linux' && matrix.build_desktop + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-desktop-debug + path: build/acecode-desktop.debug + if-no-files-found: warn + - name: Upload dSYM (macOS) + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-dsym + path: build/${{ matrix.executable }}.dSYM + if-no-files-found: warn + - name: Upload desktop dSYM (macOS) + if: runner.os == 'macOS' && matrix.build_desktop + uses: actions/upload-artifact@v4 + with: + name: acecode-${{ matrix.id }}-desktop-dsym + path: | + build/ACECode.app.dSYM + build/acecode-daemon.dSYM + if-no-files-found: warn + timeout-minutes: 60 diff --git a/assets/desktop_pet/agent_office_pet.html b/assets/desktop_pet/agent_office_pet.html index 5e735c00a..b3c797e61 100644 --- a/assets/desktop_pet/agent_office_pet.html +++ b/assets/desktop_pet/agent_office_pet.html @@ -18,7 +18,8 @@ .ov{position:absolute;inset:0;pointer-events:none;line-height:1.4} .scene canvas.up{position:absolute;inset:0;image-rendering:auto;pointer-events:none} .grip{position:absolute;left:calc(11 / 344 * 100%);top:calc(112 / 252 * 100%);width:16px;height:16px;cursor:nwse-resize;opacity:0;transition:opacity .15s;z-index:3} -.scene:hover .grip{opacity:.95} +/* The resize handle stays hidden (but hit-testable) until the pointer is on it. */ +.grip:hover{opacity:.95} .grip svg{display:block;width:100%;height:100%} .zoom-badge{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);background:#27324f;color:#fff;font-weight:700;border-radius:6px;padding:2px 9px;font-size:13px;pointer-events:none;z-index:4} .bub{position:absolute;left:0;top:0;z-index:2;display:flex;align-items:center;gap:3px;max-width:min(64vw,calc(var(--k) * 150px));background:#fff;color:#1b2540;border:2px solid #27324f;border-radius:6px;padding:1px 6px 2px;font-size:max(10px,calc(var(--k) * 6.2px));font-weight:700;white-space:nowrap;box-shadow:2px 2px 0 rgba(20,24,40,.28)} diff --git a/assets/desktop_pet/classic/README.md b/assets/desktop_pet/classic/README.md index e0ad9a051..4ea450718 100644 --- a/assets/desktop_pet/classic/README.md +++ b/assets/desktop_pet/classic/README.md @@ -2,4 +2,8 @@ Clippy(43 个动作)与 Rover(29 个动作)原始 PNG 精灵图和动作数据来自 [clippyjs/clippy.js](https://github.com/clippyjs/clippy.js),固定提交和 SHA-256 见 `sources.json`。`preview.png` 直接裁取原图的 RestPose 首帧,仅用于设置中的预览。 +## 趴边偷看(clippy/peek.png、rover/peek.png) + +宠物拖到屏幕左/右边缘吸附后改用各自目录下的 `peek.png`:两帧并排(左睁眼、右闭眼),透明背景,画面右缘就是它躲着的那条屏幕边(贴左边时页面水平镜像)。不来自上游项目,2026-10-16 用 Codex 的内置图像生成(codex-cli 0.162)绘制,以本目录精灵图的整张图与 RestPose / Idle 首帧作为风格参考,要求同一角色设计与画风:回形针把上半截钢丝和两只大眼睛从画面右缘探进来,rover 探出脑袋、两只前爪扒着右缘。闭眼帧由同一张图做「只闭眼、其余不变」的编辑,再只把眼睛区域贴回睁眼图,眨眼时其余像素不动。处理:纯绿背景抠成透明并去绿边,两帧同裁,按眼睛大小与原帧一致缩到精灵分辨率(回形针 29×75、rover 41×56 每帧),alpha 只留全透明 / 不透明,颜色映射到对应 `map.png` 实际用到、且不偏绿的调色板颜色,所以和原帧一样是不平滑放大的像素画。换图时要按眼睛重新量缩放,页面里的倍数见 `classic_pet.js` 的 `PEEK` 表。 + 角色由 Microsoft 创作;上游代码许可保存在 `MIT-LICENSE.txt`。ACECode 自有播放器不依赖上游 jQuery 播放器,不播放原始音效。素材随本地程序打包,运行时没有 CDN 请求。 diff --git a/assets/desktop_pet/classic/clippy/peek.png b/assets/desktop_pet/classic/clippy/peek.png new file mode 100644 index 000000000..9a0c54922 Binary files /dev/null and b/assets/desktop_pet/classic/clippy/peek.png differ diff --git a/assets/desktop_pet/classic/rover/peek.png b/assets/desktop_pet/classic/rover/peek.png new file mode 100644 index 000000000..9a382c6ec Binary files /dev/null and b/assets/desktop_pet/classic/rover/peek.png differ diff --git a/assets/desktop_pet/classic_pet.html b/assets/desktop_pet/classic_pet.html index c28d2d78e..b205f13c3 100644 --- a/assets/desktop_pet/classic_pet.html +++ b/assets/desktop_pet/classic_pet.html @@ -21,7 +21,11 @@ box, and Windows clips painting to it, so a shadow running past it is cut off hard. */ /* Card text is the ACECode transcript body text: same family, size and line height (the main window sends its live values with every snapshot, following its font-size setting). */ -.stack{position:absolute;left:0;right:0;bottom:calc(58% - 10px);z-index:3;padding:12px max(4%,16px) 26px;font:var(--pet-font-size)/1.6 var(--pet-font)} +/* In a small window the cards would run past the top edge: --stack-fit (set by fitStack) shrinks + them, down to 9px text, and .pinned holds them at the top edge when even that is too tall. */ +.stack{position:absolute;left:0;right:0;bottom:calc(58% - 10px);z-index:3;padding:calc(12px * var(--stack-fit, 1)) max(4%,16px) calc(26px * var(--stack-fit, 1)); + font:calc(var(--pet-font-size) * var(--stack-fit, 1))/1.6 var(--pet-font)} +.stack.collapsed.pinned{bottom:auto;top:0} .stack.expanded{bottom:auto;top:0;max-height:100%;overflow-y:auto;scrollbar-width:thin} .deck{position:relative;isolation:isolate;display:flex;flex-direction:column;gap:6px} .stack.collapsed .card:not(:first-child){display:none} @@ -34,7 +38,8 @@ .text{flex:1;min-width:0} .head{display:flex;align-items:center;gap:.4em;font-weight:700} .name{min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis} -.body{margin-top:.1em;color:var(--card-muted);overflow:hidden;white-space:nowrap;text-overflow:ellipsis} +/* A block box: on an inline span the ellipsis does nothing and long text runs under the stop button. */ +.body{display:block;margin-top:.1em;color:var(--card-muted);overflow:hidden;white-space:nowrap;text-overflow:ellipsis} .icon{flex:none;width:1.1em;height:1.1em;display:grid;place-items:center} .icon svg{width:100%;height:100%} .spinner{width:.9em;height:.9em;border:2px solid var(--card-button);border-top-color:var(--idle);border-radius:50%;animation:spin .9s linear infinite} @@ -46,8 +51,10 @@ box-shadow:0 1px 3px rgba(0,0,0,.25);display:none;place-items:center;cursor:pointer;padding:0;font:inherit;font-weight:700;line-height:1} .card:hover .dismiss,.card:focus-within .dismiss{display:grid} .stack.collapsed.layers .card .dismiss{display:none} -.grip{position:absolute;left:calc(11 / 344 * 100%);top:calc(112 / 252 * 100%);width:16px;height:16px;cursor:nwse-resize;opacity:0;z-index:2} -.scene:hover .grip,.scene:focus-within .grip{opacity:1} +/* The resize handle sits at the top-left of the visible pet (placed by reportRegions) and stays + hidden, though still hit-testable, until the pointer is on it. */ +.grip{position:absolute;width:16px;height:16px;cursor:nwse-resize;opacity:0;transition:opacity .15s;z-index:2} +.grip:hover{opacity:1} .grip svg{width:100%;height:100%;fill:none;stroke:var(--pet-ink);stroke-width:2} .badge{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);background:var(--pet-ink);color:#fff;padding:3px 8px;border-radius:5px;pointer-events:none} [hidden]{display:none!important} @@ -57,7 +64,7 @@
-
+
diff --git a/assets/desktop_pet/classic_pet.js b/assets/desktop_pet/classic_pet.js index 1bc058e3b..fbeb6f8a5 100644 --- a/assets/desktop_pet/classic_pet.js +++ b/assets/desktop_pet/classic_pet.js @@ -52,14 +52,21 @@ sleep: 'sleep', wake: 'wake', }, }; + // Docked pets peek in from the screen edge. `hidden` canvas px of the art stays past the edge so + // they can lean in without revealing where the drawing is cut. `ratio` is how much larger the peek + // art is drawn than the sprite frames: it is shown at the frame scale divided by it, so the head + // keeps the size it has in every other pose instead of growing at the edge. Whale Girl's art is + // about 1.8x (eye height and headband-to-mouth both measure 1.8x); Clippy's and Rover's are made + // at sprite resolution (their eyes match the frames' eyes) and, like their frames, are scaled + // without smoothing. Their art is narrow, so less of it hides past the edge. + const PEEK = { + whale: { ratio: 1.8, hidden: 9, smooth: true }, + clippy: { ratio: 1, hidden: 4, smooth: false }, + rover: { ratio: 1, hidden: 4, smooth: false }, + }; + const peekStyle = () => PEEK[kind] || PEEK.whale; // Extra hit margin (canvas px) so moving silhouettes stay clickable without per-frame region updates. - // Docked Whale Girl peeks in from the screen edge; PEEK_HIDDEN of the art stays past the edge - // so she can lean in without revealing where the drawing is cut. - // The peek art is drawn about 1.8 times larger than the sprite frames (eye height and headband-to- - // mouth both measure 1.8x), so it is shown at the frame scale divided by that ratio: her head - // keeps the size it has in every other pose instead of nearly doubling at the edge. - const PEEK_ART_RATIO = 1.8, PEEK_HIDDEN = 9; - const MOTION_MARGIN = { float: [0, 4], breathe: [0, 3], shake: [3, 0], wiggle: [8, 0], tilt: [16, 2], peek: [PEEK_HIDDEN + 4, 8] }; + const MOTION_MARGIN = { float: [0, 4], breathe: [0, 3], shake: [3, 0], wiggle: [8, 0], tilt: [16, 2] }; const timing = { fidgetMin: 7000, fidgetMax: 16000, sleepAfter: 5 * 60 * 1000, greeting: 6000 }; // The original agents hold a pose through self-looping branches (Clippy's idle stares ~6s). // Once one picture has been shown this long, branches are skipped so the clip moves on. @@ -68,8 +75,10 @@ let snapshot = { version: 1, follow: true, connected: false, agents: [], offices: [], selected: null, activities: [] }; let kind = '', data = null, sheet = null, profile = null, loadRevision = 0, pendingIntro = null; let animation = '', frameIndex = 0, flipped = false, motionKind = '', images = null, imageKey = '', imageSince = 0; - // Edge docking (Whale Girl only): the peek sheet, which edge, eyes open/closed, lean and bounce. - let peek = null, docked = '', peekEyes = 0, peekLean = 0, peekBounce = '', peekBounceAt = 0; + // Edge docking (pets with peek art): the peek sheet, which edge, eyes open/closed, lean and bounce. + // `windowEdge` is the edge the native window sits on; it becomes `docked` once the pet has peek art, + // so a pet switched in (or still loading) while the window is docked peeks from the same edge. + let peek = null, docked = '', windowEdge = '', peekEyes = 0, peekLean = 0, peekBounce = '', peekBounceAt = 0; let timer = 0, generation = 0, current = null; let base = 'idle', variant = '', queue = [], stateKey = ''; // Session cards: keys already reacted to, cards the user dismissed, and whether the deck is spread out. @@ -77,7 +86,8 @@ let dragging = false, dragDir = 0, dragPauseTimer = 0, dragWatchTimer = 0, dragEndedAt = 0; let hovered = false, hoverOffTimer = 0, hoverReadyAt = 0, lastClickAt = 0; let sleeping = false, lastActivity = performance.now(), fidgetAt = 0, fidgetTimer = 0, lastFidget = ''; - let greetingUntil = 0, greetingTimer = 0, rafId = 0, bodyRegions = [], spriteRect = null, bandCache = new Map(); + let greetingUntil = 0, greetingTimer = 0, rafId = 0, bodyRegions = [], bodyBounds = null, bandCache = new Map(); + let hoverPoint = null; let overlayKey = '', badgeTimer = 0; const pick = list => list[Math.floor(Math.random() * list.length)]; @@ -107,9 +117,10 @@ const [w, h] = data.framesize; const frameScale = Math.min(184 / w, 140 / h); if (peeking()) { - const scale = frameScale / PEEK_ART_RATIO, width = peek.fw * scale, height = peek.fh * scale; + const { ratio, hidden } = peekStyle(); + const scale = frameScale / ratio, width = peek.fw * scale, height = peek.fh * scale; return { peek: true, w: peek.fw, h: peek.fh, scale, width, height, - x: docked === 'right' ? cv.width - width + PEEK_HIDDEN : -PEEK_HIDDEN, y: cv.height - height - 2 }; + x: docked === 'right' ? cv.width - width + hidden : -hidden, y: cv.height - height - 2 }; } const width = w * frameScale, height = h * frameScale; return { w, h, scale: frameScale, width, height, x: (cv.width - width) / 2, y: cv.height - height - 8 }; @@ -124,9 +135,10 @@ else if (kindOfMotion === 'wiggle') o.angle = Math.sin(now / 160) * 0.05; else if (kindOfMotion === 'peek') { // A shy, slow lean in and out; hovering or a waiting session makes her lean in further. - const sway = (1 - Math.cos(now / 1900)) * 3; + // The sway spans two thirds of the hidden strip, so it never stalls against that limit. + const sway = (1 - Math.cos(now / 1900)) * peekStyle().hidden / 3; peekLean += ((hovered ? 12 : base === 'waiting' ? 9 : 0) - peekLean) * 0.12; - o.dx = (docked === 'right' ? -1 : 1) * Math.min(PEEK_HIDDEN, sway + peekLean); + o.dx = (docked === 'right' ? -1 : 1) * Math.min(peekStyle().hidden, sway + peekLean); const since = now - peekBounceAt; if (peekBounce === 'happy' && since < 900) o.dy = -Math.abs(Math.sin(since / 90)) * 6 * (1 - since / 900); else if (peekBounce === 'shake' && since < 700) o.dx += Math.sin(since / 28) * 3 * (1 - since / 700); @@ -142,7 +154,7 @@ target.clearRect(0, 0, cv.width, cv.height); if (g.peek) { target.save(); - target.imageSmoothingEnabled = true; + target.imageSmoothingEnabled = peekStyle().smooth; if (o) target.translate(o.dx, o.dy); // The art peeks from the right edge; mirror it for the left edge. if (docked === 'left') { target.translate(g.x * 2 + g.width, 0); target.scale(-1, 1); } @@ -166,7 +178,6 @@ function render() { if (!sheet || !data) return; const g = geometry(); - spriteRect = g; paint(ctx, g, offsetAt(performance.now(), g)); if (!rafId && continuous()) rafId = requestAnimationFrame(() => { rafId = 0; render(); }); } @@ -188,8 +199,11 @@ } bandCache.set(key, bands); } - const [mx, my] = MOTION_MARGIN[activeMotion()] || [0, 0]; + const motion = activeMotion(); + const [mx, my] = motion === 'peek' ? [peekStyle().hidden + 4, 8] : MOTION_MARGIN[motion] || [0, 0]; bodyRegions = bands.map(([l, t, r, b]) => [Math.max(0, l - mx), Math.max(0, t - my), Math.min(cv.width, r + mx), Math.min(cv.height, b + my)]); + bodyBounds = bands.length ? [Math.min(...bands.map(b => b[0])), Math.min(...bands.map(b => b[1])), + Math.max(...bands.map(b => b[2])), Math.max(...bands.map(b => b[3]))] : null; } function setFrame(frame) { if (!Array.isArray(frame?.images)) return; // frames without images keep the previous picture @@ -201,15 +215,25 @@ function reportRegions() { const r = cv.getBoundingClientRect(); const toWindow = b => [b[0] / cv.width, b[1] / cv.height, b[2] / cv.width, b[3] / cv.height]; - // While hovered the whole sprite box stays hit-testable (as Codex does), so a jump that changes - // the silhouette under a resting pointer cannot flicker the hover on and off. - const g = spriteRect; - const bodies = hovered && g ? [toWindow([g.x - 4, g.y - 6, g.x + g.width + 4, g.y + g.height + 2])] : bodyRegions.map(toWindow); - // The fixed-size native handle uses these same CSS coordinates on both hosts. - bodies.push(normalized($('grip').getBoundingClientRect())); + const bodies = bodyRegions.map(toWindow); + // Keep only a small anchor under a stationary pointer when the animation jumps away. + // The rest of the transparent sprite frame remains click-through during hover. + if (hovered && hoverPoint) bodies.push(toWindow([hoverPoint.x - 3, hoverPoint.y - 3, hoverPoint.x + 3, hoverPoint.y + 3])); + const grip = $('grip'); + grip.hidden = !bodyBounds; + // It follows the animated outline, but holds still under the pointer so it can be grabbed. + // It also stays below the collapsed card stack: that box (with its shadow padding) sits above + // the grip and would swallow the pointer, and macOS treats it as foreground, not the grip. + if (bodyBounds && !grip.matches(':hover')) { + const belowCards = stack.hidden || expanded ? 0 : stack.getBoundingClientRect().bottom; + const top = Math.max(0, belowCards, bodyBounds[1] * r.height / cv.height - grip.offsetHeight); + grip.style.left = Math.max(0, bodyBounds[0] * r.width / cv.width - grip.offsetWidth) + 'px'; + grip.style.top = Math.min(Math.max(0, innerHeight - grip.offsetHeight), top) + 'px'; + } // No card, no rectangle: the empty space above the pet stays click-through. const message = { type: 'overlay', controls: null, - rect: stack.hidden ? null : normalized(stack.getBoundingClientRect()), bubbles: bodies }; + rect: stack.hidden ? null : normalized(stack.getBoundingClientRect()), + grip: grip.hidden ? null : normalized(grip.getBoundingClientRect()), bubbles: bodies }; const key = JSON.stringify(message); if (key !== overlayKey && r.width > 0) { overlayKey = key; action(message); } } @@ -263,7 +287,7 @@ animation = rest.name; flipped = false; motionKind = ''; frameIndex = 0; setFrame(data.animations[rest.name].frames[0]); } - // ---- Edge peek (docked Whale Girl): blink now and then, happy squint on attention ---- + // ---- Edge peek (docked pet): blink now and then, happy squint on attention ---- function setPeekEyes(value) { peekEyes = value; frameIndex = value; updateBands(); render(); reportRegions(); @@ -295,8 +319,9 @@ timer = setTimeout(() => { if (token === generation) playPeek(); }, kind === 'happy' ? 900 : 700); } function setDocked(side, { quiet = false } = {}) { - const next_ = side === 'left' || side === 'right' ? side : ''; - if (next_ === docked || (next_ && !peek)) return; + windowEdge = side === 'left' || side === 'right' ? side : ''; + const next_ = peek ? windowEdge : ''; + if (next_ === docked) return; docked = next_; peekLean = 0; peekBounce = ''; if (!sheet || quiet) return; next(); @@ -415,7 +440,26 @@ stack.classList.toggle('layers-2', !expanded && cards.length > 2); stack.setAttribute('aria-label', cards.length > 1 && !expanded ? `${cards.length} 个会话动态,点击展开` : '会话动态'); deck.replaceChildren(...cards.map(cardElement)); - reportRegions(); + fitStack(); reportRegions(); + } + // The cards keep the transcript font size, but the pet shrinks with the window: below roughly + // 280px the collapsed stack would run past the top edge and be cut off. Shrink it just enough + // to fit above the pet (not below 9px text), else pin it to the top edge. The spread-out list + // scrolls inside the window and keeps the collapsed size. + function fitStack() { + if (expanded) return; + stack.style.removeProperty('--stack-fit'); + stack.classList.remove('pinned'); + if (stack.hidden) return; + const minFit = Math.min(1, 9 / parseFloat(getComputedStyle(stack).fontSize)); + let fit = 1; + for (let pass = 0; pass < 3; pass++) { + const box = stack.getBoundingClientRect(); + if (box.top >= -0.5 || fit <= minFit) break; + fit = Math.max(minFit, fit * box.bottom / box.height); + stack.style.setProperty('--stack-fit', fit.toFixed(3)); + } + if (stack.getBoundingClientRect().top < -0.5) stack.classList.add('pinned'); } function setExpanded(value) { clearTimeout(collapseTimer); @@ -501,8 +545,10 @@ function applyTranscriptFont(font) { const root = document.documentElement.style; const size = String(font?.size || '').trim(), family = String(font?.family || '').trim(); + const before = root.cssText; if (/^\d+(\.\d+)?px$/.test(size)) root.setProperty('--pet-font-size', size); if (family && !/[;{}<>]/.test(family)) root.setProperty('--pet-font', family); + if (root.cssText !== before) { fitStack(); reportRegions(); } } function applySnapshot(value) { if (value?.version !== 1 || !Array.isArray(value.agents) || !Array.isArray(value.offices)) return; @@ -528,14 +574,15 @@ try { await image.decode(); } catch { if (revision === loadRevision) { deck.replaceChildren(cardElement({ key: 'load-failed', status: 'failed', title: '宠物素材加载失败', body: '请关闭后重新开启', local: true })); - stack.hidden = false; reportRegions(); + stack.hidden = false; fitStack(); reportRegions(); } return; } if (revision !== loadRevision) return; kind = value.kind; sheet = image; data = window.ClassicPetAgents[kind]; profile = normalizeProfile(PROFILES[kind]); peek = peekImage ? { image: peekImage, fw: peekImage.naturalWidth / 2, fh: peekImage.naturalHeight } : null; - docked = ''; + docked = peek ? windowEdge : ''; peekLean = 0; peekBounce = ''; + hovered = false; hoverPoint = null; bandCache = new Map(); images = null; base = 'idle'; variant = ''; queue = []; current = null; stateKey = ''; sleeping = false; lastFidget = ''; // A newly shown pet greets instead of celebrating results that were already waiting. @@ -597,16 +644,19 @@ const r = cv.getBoundingClientRect(); return { x: (event.clientX - r.left) * cv.width / r.width, y: (event.clientY - r.top) * cv.height / r.height }; } - function overSprite(event) { - const g = spriteRect; - if (!g || !images?.length) return false; + function overSprite(event, updateAnchor = false) { const p = canvasPoint(event); - return p.x >= g.x - 4 && p.x <= g.x + g.width + 4 && p.y >= g.y - 6 && p.y <= g.y + g.height + 2; + if (bodyRegions.some(([l, t, r, b]) => p.x >= l && p.x < r && p.y >= t && p.y < b)) { + if (updateAnchor) hoverPoint = p; + return true; + } + return Boolean(hovered && hoverPoint && Math.abs(p.x - hoverPoint.x) <= 3 && Math.abs(p.y - hoverPoint.y) <= 3); } function setHovered(value) { clearTimeout(hoverOffTimer); if (hovered === value) return; hovered = value; + if (!value) hoverPoint = null; reportRegions(); if (!value || dragging) return; const now = performance.now(); @@ -634,7 +684,9 @@ } // The native move loop swallows the release; the next free move ends any stale drag pose. if (dragging && !(event.buttons & 1)) endDrag(); - if (overSprite(event)) setHovered(true); else hoverOff(); + if (overSprite(event, true)) { + setHovered(true); reportRegions(); + } else hoverOff(); }); cv.addEventListener('pointerleave', hoverOff); cv.addEventListener('pointerup', event => { @@ -690,7 +742,7 @@ document.addEventListener('visibilitychange', () => { if (document.hidden) { stopPlayback(); cancelAnimationFrame(rafId); rafId = 0; } else if (sheet) next(); }); - window.addEventListener('resize', reportRegions); + window.addEventListener('resize', () => { fitStack(); reportRegions(); }); window.addEventListener('pagehide', () => { stopPlayback(); loadRevision++; for (const id of [collapseTimer, badgeTimer, fidgetTimer, greetingTimer, dragPauseTimer, dragWatchTimer, hoverOffTimer]) clearTimeout(id); @@ -709,7 +761,7 @@ const selected = new URLSearchParams(location.search).get('pet'); if (PROFILES[selected]) void setCharacter({ kind: selected, sprite: selected === 'whale' ? './whale/map.png' : `./classic/${selected}/map.png`, - peek: selected === 'whale' ? './whale/peek.png' : undefined }); + peek: selected === 'whale' ? './whale/peek.png' : `./classic/${selected}/peek.png` }); } reportRegions(); post('ready'); })(); diff --git a/assets/desktop_pet/whale/README.md b/assets/desktop_pet/whale/README.md index aad5caebf..2b26da51d 100644 --- a/assets/desktop_pet/whale/README.md +++ b/assets/desktop_pet/whale/README.md @@ -8,4 +8,4 @@ ## 趴边偷看(peek.png) -鲸鱼娘拖到屏幕左/右边缘吸附后改用 `peek.png`:两帧并排(左睁眼、右闭眼),每帧 178×300,透明背景,画面右缘就是她躲着的那条边(贴左边时页面水平镜像)。不来自上游项目,2026-10-16 用 Codex 的内置图像生成(codex-cli 0.162,`image_generation` 功能)绘制,以本目录 idle / welcome / joy 的首帧作为风格参考,要求同一角色设计与画风:从画面右缘探出半个头、双手扒着边、好奇又害羞地偷看。闭眼帧由同一张图做「只闭眼、其余不变」的编辑,再只把眼睛区域贴回睁眼图,眨眼时其余线条不抖动。纯绿背景抠成透明并去绿边,在她扒着的那条边线处裁掉,两帧同裁同缩放。这张原画比精灵帧大约 1.8 倍,页面按常规帧缩放 ÷ 1.8 绘制(`classic_pet.js` 的 `PEEK_ART_RATIO`),换图时要重新量这个比例。生成用的是 ZipZipPipe 设计的角色形象,沿用上游许可说明。 +鲸鱼娘拖到屏幕左/右边缘吸附后改用 `peek.png`:两帧并排(左睁眼、右闭眼),每帧 178×300,透明背景,画面右缘就是她躲着的那条边(贴左边时页面水平镜像)。不来自上游项目,2026-10-16 用 Codex 的内置图像生成(codex-cli 0.162,`image_generation` 功能)绘制,以本目录 idle / welcome / joy 的首帧作为风格参考,要求同一角色设计与画风:从画面右缘探出半个头、双手扒着边、好奇又害羞地偷看。闭眼帧由同一张图做「只闭眼、其余不变」的编辑,再只把眼睛区域贴回睁眼图,眨眼时其余线条不抖动。纯绿背景抠成透明并去绿边,在她扒着的那条边线处裁掉,两帧同裁同缩放。这张原画比精灵帧大约 1.8 倍,页面按常规帧缩放 ÷ 1.8 绘制(`classic_pet.js` 的 `PEEK.whale.ratio`),换图时要重新量这个比例。生成用的是 ZipZipPipe 设计的角色形象,沿用上游许可说明。 diff --git a/assets/seed/MANIFEST.json b/assets/seed/MANIFEST.json index 4b0beb51d..2ac2c1793 100644 --- a/assets/seed/MANIFEST.json +++ b/assets/seed/MANIFEST.json @@ -1,5 +1,5 @@ { - "bundle_version": "2026-10-15.1", + "bundle_version": "2026-10-16.1", "description": "Versioned default ACECode global Skill, expert, and managed hook seed bundle reconciled at startup.", "skills": [ { @@ -24,7 +24,7 @@ "name": "expert-manager", "source_id": "acecode:expert-manager@2026-08-09", "relative_path": "expert-management/expert-manager", - "skill_md_sha256": "804fbfefb9cfb49a047182b82f1f6b6f42eb3ba1f42abb87e412fcaf24ef20a8" + "skill_md_sha256": "54250804b66e1e5b8658f7c56a3e4cd1fcd175cdfe2a583e1b2b9017ab0c0dc9" }, { "name": "native-mcp", diff --git a/assets/seed/seed.version b/assets/seed/seed.version index 2e7a6e1d0..ed433c0e1 100644 --- a/assets/seed/seed.version +++ b/assets/seed/seed.version @@ -1 +1 @@ -2026-10-15.1 +2026-10-16.1 diff --git a/assets/seed/skills/expert-management/expert-manager/SKILL.md b/assets/seed/skills/expert-management/expert-manager/SKILL.md index 6597412bb..119b9968e 100644 --- a/assets/seed/skills/expert-management/expert-manager/SKILL.md +++ b/assets/seed/skills/expert-management/expert-manager/SKILL.md @@ -34,6 +34,9 @@ description: 创建、导入、修改、校验和打包 ACECode 专家组件; - 如果已有资料足够,直接转化,不重复询问资料里已经明确的信息。 - 高级能力默认保持“继承全局”。只有用户明确要求限制或启用特定 Skill、MCP 服务器、本地工具时,才写入 `expert.json.capabilities`。 +- 采样参数默认继承模型。用户指定时写入 `expert.json.sampling`,支持 + `temperature`、`top_p`、`top_k`、`min_p`;批量定义也接受同名对象。 + 不把采样值写成 Agent 指令或未经解析的 frontmatter。具体范围见包格式文档。 ## 开始前检查 diff --git a/assets/seed/skills/expert-management/expert-manager/references/expert-json-spec.md b/assets/seed/skills/expert-management/expert-manager/references/expert-json-spec.md index 2b191b4af..1ee5d50fa 100644 --- a/assets/seed/skills/expert-management/expert-manager/references/expert-json-spec.md +++ b/assets/seed/skills/expert-management/expert-manager/references/expert-json-spec.md @@ -17,6 +17,27 @@ - 不写 `author`。ACECode 只使用 `displayName` 作为专家对外名称;来源和许可 信息保留在 `NOTICE.md`、`license` 与 `homepage` 中。 +## 采样参数(可选) + +Agent 和 Team 的 `expert.json` 都可包含: + +```json +"sampling": { "temperature": 0.3, "top_p": 0.95, "top_k": 20, "min_p": 0 } +``` + +每个字段独立可选,`null` 表示继承:temperature 为 0 到 2,top_p 大于 0 且不超过 1, +top_k 为 1 到 2147483647 的整数,min_p 为 0 到 1。数值 0 必须保留。 +包内 `agents` 对象项也可声明 `sampling`,覆盖包级默认值。 +团队引用专家时使用各专家的有效预设,团队默认值补充未设置字段。 +子 agent 发起时传入的 `sampling` 具有最高优先级,模型设置作为最终默认值。 + +实际运行仍受所选 provider/protocol 限制。Responses 不支持 top_k/min_p,Anthropic +不支持 min_p 且 temperature 不超过 1。无法生效的设置会在会话启动或切换时被拒绝。 +管理 API 更新时省略 sampling 保留原预设,传 null 或空对象清除全部预设。 +单专家详情返回包含 lead 覆盖后的有效值;显式更新 sampling 时一并清除 lead 的旧覆盖。 +专家团的更新只替换团队默认值,被引用专家的预设保持独立。 +`batch_create.py` 接受同名字段,`validate_expert.py` 校验类型、范围和未知键。 + ## Agent 型必需字段 ```json diff --git a/assets/seed/skills/expert-management/expert-manager/scripts/batch_create.py b/assets/seed/skills/expert-management/expert-manager/scripts/batch_create.py index 1fb1231a0..d99bf6374 100644 --- a/assets/seed/skills/expert-management/expert-manager/scripts/batch_create.py +++ b/assets/seed/skills/expert-management/expert-manager/scripts/batch_create.py @@ -42,6 +42,7 @@ "homepage", "avatar", "stateAvatars", + "sampling", } AGENT_INPUT_KEYS = COMMON_INPUT_KEYS | {"instructions", "capabilities"} TEAM_INPUT_KEYS = COMMON_INPUT_KEYS | {"leadExpert", "memberExperts"} @@ -67,7 +68,7 @@ def quick_prompts(item: dict[str, Any]) -> list[str]: def copy_optional_metadata(item: dict[str, Any], manifest: dict[str, Any]) -> None: - for key in ("license", "homepage"): + for key in ("license", "homepage", "sampling"): if key in item: manifest[key] = item[key] diff --git a/assets/seed/skills/expert-management/expert-manager/scripts/validate_expert.py b/assets/seed/skills/expert-management/expert-manager/scripts/validate_expert.py index af589bf4b..4f622ab1e 100644 --- a/assets/seed/skills/expert-management/expert-manager/scripts/validate_expert.py +++ b/assets/seed/skills/expert-management/expert-manager/scripts/validate_expert.py @@ -13,6 +13,7 @@ import argparse import json +import math import os import re from pathlib import Path @@ -323,6 +324,32 @@ def validate_capabilities( seen.add(normalized) +def validate_sampling(value: Any, result: ValidationResult, label: str = "sampling") -> None: + if value is None: + return + if not isinstance(value, dict): + result.error(f"{label} must be an object or null") + return + fields = {"temperature", "top_p", "top_k", "min_p"} + for key, number in value.items(): + if key not in fields: + result.error(f"{label}: unknown field {key}") + continue + if number is None: + continue + if type(number) not in (int, float) or (type(number) is float and not math.isfinite(number)): + result.error(f"{label}.{key} must be a finite number") + continue + valid = { + "temperature": 0 <= number <= 2, + "top_p": 0 < number <= 1, + "top_k": type(number) is int and 1 <= number <= 2147483647, + "min_p": 0 <= number <= 1, + }[key] + if not valid: + result.error(f"{label}.{key} is outside its supported range") + + def validate_agent( package_root: Path, manifest: dict[str, Any], result: ValidationResult ) -> None: @@ -340,6 +367,7 @@ def validate_agent( elif isinstance(entry, dict): agent_id = localized_text(entry.get("id")) raw_path = localized_text(entry.get("path")) + validate_sampling(entry.get("sampling"), result, f"agents[{index}].sampling") else: result.error(f"expert.json: agents[{index}] must be a path or object") continue @@ -485,6 +513,7 @@ def validate_expert( validate_display_fields(manifest, result) validate_avatar_and_skills(package_root, manifest, result) validate_capabilities(manifest, expert_type, result) + validate_sampling(manifest.get("sampling"), result) if expert_type == "agent": validate_agent(package_root, manifest, result) diff --git a/cmake/acecode_desktop.cmake b/cmake/acecode_desktop.cmake index 6cbf8f24a..f7dfc8cbd 100644 --- a/cmake/acecode_desktop.cmake +++ b/cmake/acecode_desktop.cmake @@ -177,8 +177,10 @@ if(WIN32 OR APPLE) ${CMAKE_SOURCE_DIR}/assets/desktop_pet/classic_pet.js ${CMAKE_SOURCE_DIR}/assets/desktop_pet/classic/clippy/agent.js ${CMAKE_SOURCE_DIR}/assets/desktop_pet/classic/clippy/map.png + ${CMAKE_SOURCE_DIR}/assets/desktop_pet/classic/clippy/peek.png ${CMAKE_SOURCE_DIR}/assets/desktop_pet/classic/rover/agent.js ${CMAKE_SOURCE_DIR}/assets/desktop_pet/classic/rover/map.png + ${CMAKE_SOURCE_DIR}/assets/desktop_pet/classic/rover/peek.png ${CMAKE_SOURCE_DIR}/assets/desktop_pet/whale/agent.js ${CMAKE_SOURCE_DIR}/assets/desktop_pet/whale/map.png ${CMAKE_SOURCE_DIR}/assets/desktop_pet/whale/peek.png diff --git a/docs/agent-browser-performance.md b/docs/agent-browser-performance.md new file mode 100644 index 000000000..04f372793 --- /dev/null +++ b/docs/agent-browser-performance.md @@ -0,0 +1,328 @@ +# Agent Browser 性能改造方案 + +对标项目:[browser-use/jev-ultrafast](https://github.com/browser-use/jev-ultrafast)(2026-09 抓取)。 + +本文只谈**延迟**。正确性与隐私类问题另见文末「附录 B」,不在本方案范围内。 + +--- + +## 一、背景:jev 的「7 秒」是怎么来的 + +jev-ultrafast 的卖点是一句话:一条自然语言目标,Google Flights 上从苏黎世搜到伦敦, +**7.1 秒**,含模型调用、文本生成、浏览器操作和加载等待。 + +拆开看,这 7 秒的构成与直觉相反: + +| 来源 | 占比 | 说明 | +| --- | --- | --- | +| 模型时间 | 绝对主导 | 每步一次模型请求,约 10 余步 | +| 浏览器协议时间 | 零点几秒 | 整个任务 101 次 CDP 调用,持久会话上每次约毫秒级 | + +它自己公布的优化前后对比更说明问题: + +| 指标 | 优化前 | 优化后 | 变化 | +| --- | --- | --- | --- | +| 浏览器协议调用 | 1092 | 101 | 降低 91% | +| 任务耗时中位数 | 9.450 秒 | 7.092 秒 | 降低 25% | + +**把浏览器协议调用砍掉 91%,任务只快了 25%。** 这是本方案最重要的一条背景事实: +jev 的速度主要不来自浏览器代码,而来自它的**模型形态**和**每步只发一次请求**。 + +它快的三个真实原因: + +1. **模型不是通用 LLM。** 决策模型是 TypeSafe 的 Jev,一个约束解码的分类器:输出一个 + 操作枚举(`CLICK` / `TYPE_TEXT` / `SELECT` / `SCROLL_UP` / `SCROLL_DOWN` / `WAIT` / + `DONE` / `BLOCKED`)加一个元素序号。只有需要输入文本时才调一次小而快的文本模型, + 当前 demo 用 `inception/mercury-2.5` 且关闭 reasoning。 +2. **每个决策周期一次网络往返。** 操作头和目标头共享同一份观测状态,投机式扇出, + 一次请求同时拿到「做什么」和「对谁做」。目标头里只包含与该操作兼容的元素。 +3. **步数少,且每步的状态是替换而非累积。** 模型每步只看当前元素表,历史是一份 + 紧凑的决策列表,不是一堆过期页面全文。 + +它明确不做的:默认循环里**没有截图**;结构化状态就是全部输入。截图只给它的可视化 +inspector 用,演示视频走独立的 screencast。 + +### jev 在浏览器侧做的事(值得借的部分) + +即使浏览器侧只值 25%,这 25% 的做法仍然干净,值得参考: + +- **一次快照一次调用。** `jev_ultrafast/snapshot.js` 一个 `Runtime.evaluate` 原子返回 + url、title、viewport、可见文本、动作表、新鲜度标记、逐节点守卫。 +- **只发可见内容。** 元素中心点落在视口外的直接丢弃;正文用 Range 的 rect 判断是否 + 真在屏幕上,上限 6000 字符。屏幕外的正文和页脚不进模型上下文。 +- **动作空间而非元素空间。** 表里每一行都是可执行动作:` update('sampling', { ...form.sampling, [field]: event.target.value })} + placeholder="继承模型" + aria-invalid={!!errors.sampling?.[field]} + aria-describedby={`expert-sampling-${field}-hint`} + className={clsx(inputClass, 'mt-1.5')} + /> + + {errors.sampling?.[field] || range} + + + ))} + +
", "s_f627a3a526dc9da0": "在当前分支开始", "s_f628f6c3e43734b2": "清除筛选后查看全部可用组件。", + "s_f630012cea065bd1": "参数需受所用模型支持。", "s_f69273dbbebfb3a1": "格式化", "s_f699d16db6c37a35": "当前平台不能真正断网:关闭网络访问时只注入离线环境(代理指向无效端口、ssh 桩),不走代理的程序仍能联网。", "s_f6b3aadcf1df1ad2": "允许 AI 控制 {{p0}}", @@ -3062,6 +3070,7 @@ export const sourceCatalogs = { "s_13e0991638226a6d": "Allow the agent to view windows and use the mouse, keyboard, and app controls", "s_13f6a415ac664be2": "Detect the actual model list from this provider", "s_140b07fe3f9f7d48": "Download size:", + "s_1414769bed7c6551": "Sampling parameters (optional)", "s_1417bae3d17d6405": "Collapse images", "s_142cf02f83d4e7ab": "Writing reply", "s_14357c0d6ca270df": "Check that the address is correct, then try again.", @@ -3344,6 +3353,7 @@ export const sourceCatalogs = { "s_2d1d8c1e38956bea": "Back", "s_2d5492fa38ef2f75": "Establishing connection", "s_2d55d67a4923e9fa": "Unsupported goal action", + "s_2d8ff2bb909846ec": "Inherit model", "s_2da0b2a8111d771b": "Open session", "s_2dd59fd0593a8e93": "The path picker is unavailable", "s_2de0f5a9afd47152": "Flask", @@ -3351,6 +3361,7 @@ export const sourceCatalogs = { "s_2df73bf9535cb691": "Dispatched to the current conversation; the opening line will only fill in the input box and will not be sent automatically.", "s_2df7697dbb612878": "agent", "s_2e2fc2c95a2a682c": "No matching command", + "s_2e55fd2728f3d723": "{{p0}}: enter a value in the range {{p1}}, or leave blank to inherit", "s_2e659c0c617332d7": "read isolation active", "s_2e6bfec616ad6945": "Team members", "s_2e7e1c747a435c14": "(Multi)", @@ -3479,6 +3490,7 @@ export const sourceCatalogs = { "s_3a68c45441b531e0": "Archived conversations will not appear in the sidebar, and archive recovery can be canceled at any time.", "s_3a765e0fef6131d5": "The simulator control component is missing. Reinstall the full ACECode package.", "s_3a7c10115641eb4c": "Choose a file on the server filesystem.", + "s_3ac23fa27c7b0894": "Blank fields inherit model settings. Each subtask can override them when started.", "s_3ae030dd220465c9": "Work area usage", "s_3afa87e94673b0d1": "Dispatch failed: {{p0}}", "s_3b6fefc50febace4": "Hour", @@ -3911,6 +3923,7 @@ export const sourceCatalogs = { "s_622de9749cb55343": "Could not load developer settings", "s_624b5799ebd036b0": "No workspace", "s_6258a2c6f9c89143": "Copy all", + "s_62711127c9ee73df": "Greater than 0, at most 1", "s_62731d95bc805803": "Exporting…", "s_6278c2f645ab3982": "Public address", "s_629bb52ce0e5270f": "Added {{p0}} to chat", @@ -4368,6 +4381,7 @@ export const sourceCatalogs = { "s_89ce481a4b23d2a4": "Keyboard shortcuts", "s_89d3e8892ae8fd51": "More experts", "s_89db648162e7cea1": "Connection lost, retrying automatically…", + "s_89e028004391a408": "A positive integer", "s_89e0ebd9d750f2c1": "The current backend does not support image generation. Update ACECode, restart, and try again.", "s_8a32f1c8f96d5905": "Sandbox backend unavailable: {{p0}}", "s_8a687fb42860ccd0": "Reasoning depth: {{p0}}", @@ -5124,6 +5138,7 @@ export const sourceCatalogs = { "s_d0ca08d604551151": "Queue paused", "s_d0f680208ed8796c": "git times out, click to try again", "s_d0fb5bbca8184bdd": "Jump to current question:{{p0}}", + "s_d12291a6d3a755a0": "Team defaults. Each member’s own sampling settings take priority. Blank fields inherit model settings.", "s_d1aeba9123ecd734": "Workspaces folder", "s_d1cd931be84cce03": "trust", "s_d1d044826a45ef65": "Loading failed", @@ -5542,6 +5557,7 @@ export const sourceCatalogs = { "s_f60f6f444d92e1b1": "Usage: /turn ", "s_f627a3a526dc9da0": "Start on current branch", "s_f628f6c3e43734b2": "Clear filters to view all available components.", + "s_f630012cea065bd1": "The selected model must support these parameters.", "s_f69273dbbebfb3a1": "format", "s_f699d16db6c37a35": "This platform cannot fully cut the network: turning network access off only injects an offline environment (proxy pointed at a dead port, ssh stubs); programs that ignore the proxy can still connect.", "s_f6b3aadcf1df1ad2": "Allow AI control of {{p0}}", diff --git a/web/src/lib/composerFileIntake.js b/web/src/lib/composerFileIntake.js index 42a37d5ce..1ffdee171 100644 --- a/web/src/lib/composerFileIntake.js +++ b/web/src/lib/composerFileIntake.js @@ -25,7 +25,7 @@ async function storeLocalFiles(files, win) { for (let offset = 0; offset < bytes.length; offset += 8192) { binary += String.fromCharCode(...bytes.subarray(offset, offset + 8192)); } - payload.push({ name: file.name || 'clipboard-file', data_base64: btoa(binary) }); + payload.push({ name: file.name || 'clipboard_file', data_base64: btoa(binary) }); } return parseNativeFilesystemItemsResult(await win.aceDesktop_storeContextFiles(payload)); } diff --git a/web/src/lib/composerFileTransfer.js b/web/src/lib/composerFileTransfer.js index 3a2ec30b2..56e2e2948 100644 --- a/web/src/lib/composerFileTransfer.js +++ b/web/src/lib/composerFileTransfer.js @@ -1,11 +1,11 @@ const PASTE_IMAGE_NAMES = { - 'image/png': 'pasted-image.png', - 'image/jpeg': 'pasted-image.jpg', - 'image/jpg': 'pasted-image.jpg', - 'image/webp': 'pasted-image.webp', - 'image/gif': 'pasted-image.gif', - 'image/bmp': 'pasted-image.bmp', - 'image/tiff': 'pasted-image.tiff', + 'image/png': 'pasted_image.png', + 'image/jpeg': 'pasted_image.jpg', + 'image/jpg': 'pasted_image.jpg', + 'image/webp': 'pasted_image.webp', + 'image/gif': 'pasted_image.gif', + 'image/bmp': 'pasted_image.bmp', + 'image/tiff': 'pasted_image.tiff', }; const DESKTOP_SOURCE_PATH = Symbol.for('acecode.desktopSourcePath'); @@ -110,8 +110,8 @@ function listToArray(list) { function fallbackPasteName(file, index) { const type = String(file?.type || '').toLowerCase(); if (PASTE_IMAGE_NAMES[type]) return PASTE_IMAGE_NAMES[type]; - const suffix = index > 0 ? `-${index + 1}` : ''; - return `pasted-attachment${suffix}`; + const suffix = index > 0 ? `_${index + 1}` : ''; + return `pasted_attachment${suffix}`; } function ensurePasteFileName(file, index) { diff --git a/web/src/lib/composerFileTransfer.test.js b/web/src/lib/composerFileTransfer.test.js index 69db0e7f0..3f0def33c 100644 --- a/web/src/lib/composerFileTransfer.test.js +++ b/web/src/lib/composerFileTransfer.test.js @@ -81,7 +81,7 @@ run('unnamed pasted images get a stable filename when File is available', () => const files = filesFromClipboardEvent(event); assert.equal(files.length, 1); - assert.equal(files[0].name, 'pasted-image.png'); + assert.equal(files[0].name, 'pasted_image.png'); assert.equal(files[0].type, 'image/png'); }); @@ -154,7 +154,7 @@ const imageClipboard = await readComposerClipboardData({ read: async () => [clip })] }); assert.equal(imageClipboard.files.length, 1); assert.equal(imageClipboard.files[0].type, 'image/png'); -assert.equal(imageClipboard.files[0].name, 'pasted-image.png'); +assert.equal(imageClipboard.files[0].name, 'pasted_image.png'); assert.equal(await imageClipboard.files[0].text(), 'png'); assert.equal(imageClipboard.getData('text/html'), ''); console.log('[pass] context clipboard chooses one image representation without duplicate HTML'); diff --git a/web/src/lib/expertComponents.js b/web/src/lib/expertComponents.js index d3bdfbb6d..22e14429b 100644 --- a/web/src/lib/expertComponents.js +++ b/web/src/lib/expertComponents.js @@ -1,3 +1,12 @@ +import { MODEL_SAMPLING_FIELDS, normalizeSampling, samplingDraftFields, samplingFieldValid } from './modelSettings.js'; + +export const EXPERT_SAMPLING_INPUTS = [ + { field: 'temperature', label: 'Temperature', range: '0–2', min: 0, max: 2, step: '0.01' }, + { field: 'top_p', label: 'Top P', range: '大于 0,最多 1', min: 0, max: 1, step: '0.01' }, + { field: 'top_k', label: 'Top K', range: '正整数', min: 1, max: 2147483647, step: '1' }, + { field: 'min_p', label: 'Min P', range: '0–1', min: 0, max: 1, step: '0.01' }, +]; + export const EXPERT_PRIMARY_TABS = Object.freeze([ Object.freeze({ id: 'agent', label: '专家' }), Object.freeze({ id: 'team', label: '专家团' }), @@ -190,6 +199,7 @@ export function normalizeExperts(value) { created_at: String(item.created_at || ''), updated_at: String(item.updated_at || ''), capabilities: normalizeExpertCapabilities(item.capabilities), + sampling: normalizeSampling(item.sampling, 'expert.sampling'), agents: Array.isArray(item.agents) ? item.agents.map(normalizeAgent).filter(Boolean) : [], references_existing_experts: item.references_existing_experts === true, lead_expert_id: String(item.lead_expert_id || item.lead_agent_id || ''), @@ -283,6 +293,7 @@ export function emptyExpertForm(type = 'agent', initialExpertId = '') { instructions: '', quickPromptsText: '', capabilities: {}, + sampling: samplingDraftFields(), stateAvatars: {}, stateAvatarUrls: {}, selectedExpertIds, @@ -311,6 +322,7 @@ export function expertFormFromDetail(expert) { instructions: normalized.type === 'agent' ? String(lead.instructions || '') : '', quickPromptsText: (normalized.quick_prompts || []).join('\n'), capabilities: normalizeExpertCapabilities(normalized.capabilities), + sampling: samplingDraftFields(normalized.sampling), stateAvatars: normalizeExpertStateAvatars(normalized.state_avatars), stateAvatarUrls: normalizeExpertStateAvatarUrls(normalized.state_avatar_urls), selectedExpertIds, @@ -320,6 +332,14 @@ export function expertFormFromDetail(expert) { export function validateExpertFormFields(form, selectableExperts) { const errors = {}; + const samplingErrors = {}; + for (const { field, label, range } of EXPERT_SAMPLING_INPUTS) { + const raw = String(form?.sampling?.[field] ?? '').trim(); + if (raw && !samplingFieldValid(field, Number(raw))) { + samplingErrors[field] = `${label}:请输入${range}的数值,或留空继承`; + } + } + if (Object.keys(samplingErrors).length) errors.sampling = samplingErrors; if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(String(form?.id || ''))) { errors.id = '无法保存,请重新打开后再试'; } @@ -355,7 +375,8 @@ export function validateExpertFormFields(form, selectableExperts) { } export function validateExpertForm(form) { - return Object.values(validateExpertFormFields(form))[0] || ''; + const first = Object.values(validateExpertFormFields(form))[0]; + return typeof first === 'object' ? (Object.values(first)[0] || '') : (first || ''); } function capabilitiesPayload(form) { @@ -374,6 +395,12 @@ export function expertPayloadFromForm(form) { tags: normalizeStringList(form.tags), expertise: parseLineList(form.expertiseText), quick_prompts: parseLineList(form.quickPromptsText), + ...(Object.prototype.hasOwnProperty.call(form, 'sampling') ? { + sampling: Object.fromEntries(MODEL_SAMPLING_FIELDS.flatMap((field) => { + const raw = String(form.sampling?.[field] ?? '').trim(); + return raw ? [[field, Number(raw)]] : []; + })), + } : {}), ...(stateAvatarsPresent ? { state_avatars: normalizeExpertStateAvatars(form.stateAvatars) } : {}), diff --git a/web/src/lib/expertComponents.test.js b/web/src/lib/expertComponents.test.js index b66141306..2c44447eb 100644 --- a/web/src/lib/expertComponents.test.js +++ b/web/src/lib/expertComponents.test.js @@ -33,6 +33,34 @@ import { validateExpertFormFields, } from './expertComponents.js'; +test('专家采样参数保存与回填保留零值,清空后明确恢复继承', () => { + const form = emptyExpertForm(); + form.displayName = '采样专家'; + form.instructions = '认真分析任务'; + form.sampling = { temperature: '0', top_p: '0.95', top_k: '20', min_p: '0' }; + assert.deepEqual(validateExpertFormFields(form), {}); + const payload = expertPayloadFromForm(form); + assert.deepEqual(payload.sampling, { temperature: 0, top_p: 0.95, top_k: 20, min_p: 0 }); + const loaded = expertFormFromDetail({ ...payload, agents: [payload.lead], lead_agent_id: 'lead' }); + assert.deepEqual(loaded.sampling, form.sampling); + loaded.sampling = { temperature: '', top_p: ' ', top_k: '', min_p: '' }; + assert.deepEqual(expertPayloadFromForm(loaded).sampling, {}); +}); + +test('专家采样字段校验拒绝越界、小数 Top K 和非数字', () => { + const valid = { ...emptyExpertForm(), displayName: '专家', instructions: '分析' }; + for (const [field, value] of [ + ['temperature', '2.1'], ['temperature', 'NaN'], ['temperature', '-1'], + ['top_p', '0'], ['top_p', '1.1'], ['top_k', '1.5'], + ['top_k', '2147483648'], ['min_p', '-0.1'], + ]) { + const errors = validateExpertFormFields({ ...valid, sampling: { [field]: value } }); + assert.ok(errors.sampling?.[field], `${field}=${value}`); + } + const team = { ...emptyExpertForm('team'), sampling: { temperature: '0.5' } }; + assert.deepEqual(expertPayloadFromForm(team).sampling, { temperature: 0.5 }); +}); + function test(name, fn) { try { fn(); @@ -181,6 +209,7 @@ test('expert payload keeps Tags, expertise, prompts and optional scopes separate tags: ['开发', 'OPC-一人公司'], expertise: ['架构设计', '代码质量'], quick_prompts: ['审查当前改动', '给出重构建议'], + sampling: {}, state_avatars: {}, capabilities: { skills: ['code-review'], @@ -366,6 +395,7 @@ test('expert team payload references selected existing experts and one lead', () tags: ['项目质量'], expertise: ['需求拆解', '体验验收'], quick_prompts: ['推进这个需求'], + sampling: {}, state_avatars: {}, lead_expert_id: 'reviewer', member_expert_ids: ['tester'], diff --git a/web/src/lib/iosSimulator.js b/web/src/lib/iosSimulator.js index 3f045db59..4774aaabf 100644 --- a/web/src/lib/iosSimulator.js +++ b/web/src/lib/iosSimulator.js @@ -438,11 +438,11 @@ export function simulatorNeedsSettings({ status = null, message = '' } = {}) { } export function simulatorScreenshotFilename(name = '', date = new Date()) { - const safe = String(name || 'simulator').replace(/[\\/:*?"<>|\s]+/g, '-').replace(/^-+|-+$/g, '') || 'simulator'; + const safe = String(name || 'simulator').replace(/[-\\/:*?"<>|\s]+/g, '_').replace(/^_+|_+$/g, '') || 'simulator'; const pad = (value) => String(value).padStart(2, '0'); const stamp = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` - + `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; - return `${safe}-${stamp}.png`; + + `_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; + return `${safe}_${stamp}.png`; } export function downloadSimulatorBlob(blob, filename, { diff --git a/web/src/lib/iosSimulator.test.js b/web/src/lib/iosSimulator.test.js index fe8be3ea8..ea81021f6 100644 --- a/web/src/lib/iosSimulator.test.js +++ b/web/src/lib/iosSimulator.test.js @@ -336,9 +336,9 @@ run('panel copy helpers', () => { assert.equal(simulatorNeedsSettings({ message: 'device is not booted' }), false); assert.equal( simulatorScreenshotFilename('iPhone 17 Pro', new Date(2026, 9, 16, 8, 5, 9)), - 'iPhone-17-Pro-20261016-080509.png', + 'iPhone_17_Pro_20261016_080509.png', ); - assert.equal(simulatorScreenshotFilename('', new Date(2026, 0, 2, 3, 4, 5)), 'simulator-20260102-030405.png'); + assert.equal(simulatorScreenshotFilename('', new Date(2026, 0, 2, 3, 4, 5)), 'simulator_20260102_030405.png'); }); run('screenshot downloads go through a temporary object URL', () => { diff --git a/web/src/lib/mermaidExport.js b/web/src/lib/mermaidExport.js index ab3a28883..a1754b169 100644 --- a/web/src/lib/mermaidExport.js +++ b/web/src/lib/mermaidExport.js @@ -97,7 +97,7 @@ export function mermaidFamilyFromSource(source) { export function mermaidExportFilename(detail, format) { const family = mermaidFamilyFromSource(detail?.source); const extension = format === 'source' ? 'mmd' : String(format || '').toLowerCase(); - return `mermaid-${family}.${extension || 'txt'}`; + return `mermaid_${family.replace(/-/g, '_')}.${extension || 'txt'}`; } export function mermaidPngDimensions(width, height, { diff --git a/web/src/lib/mermaidExport.test.js b/web/src/lib/mermaidExport.test.js index 2929edfd6..d0cebbd3c 100644 --- a/web/src/lib/mermaidExport.test.js +++ b/web/src/lib/mermaidExport.test.js @@ -113,9 +113,9 @@ await test('Mermaid families produce deterministic export filenames', () => { ]; for (const [source, family] of cases) { assert.equal(mermaidFamilyFromSource(source), family); - assert.equal(mermaidExportFilename({ source }, 'svg'), `mermaid-${family}.svg`); + assert.equal(mermaidExportFilename({ source }, 'svg'), `mermaid_${family}.svg`); } - assert.equal(mermaidExportFilename(detail, 'source'), 'mermaid-flowchart.mmd'); + assert.equal(mermaidExportFilename(detail, 'source'), 'mermaid_flowchart.mmd'); }); await test('PNG dimensions prefer 2x but obey dimension and pixel caps', () => { @@ -131,14 +131,14 @@ await test('PNG dimensions prefer 2x but obey dimension and pixel caps', () => { await test('SVG and source export download exact retained artifacts and revoke URLs', async () => { const svgHarness = downloadHarness(); const svgResult = exportMermaidSvg(detail, { BlobType: Blob, ...svgHarness.options }); - assert.deepEqual(svgResult, { ok: true, filename: 'mermaid-flowchart.svg' }); - assert.equal(svgHarness.anchors[0].download, 'mermaid-flowchart.svg'); + assert.deepEqual(svgResult, { ok: true, filename: 'mermaid_flowchart.svg' }); + assert.equal(svgHarness.anchors[0].download, 'mermaid_flowchart.svg'); assert.equal(await svgHarness.blobs.get(svgHarness.anchors[0].href).text(), detail.svg); assert.deepEqual(svgHarness.revoked, [svgHarness.anchors[0].href]); const sourceHarness = downloadHarness(); const sourceResult = exportMermaidSource(detail, { BlobType: Blob, ...sourceHarness.options }); - assert.deepEqual(sourceResult, { ok: true, filename: 'mermaid-flowchart.mmd' }); + assert.deepEqual(sourceResult, { ok: true, filename: 'mermaid_flowchart.mmd' }); assert.equal(await sourceHarness.blobs.get(sourceHarness.anchors[0].href).text(), detail.source); assert.deepEqual(sourceHarness.revoked, [sourceHarness.anchors[0].href]); }); @@ -198,7 +198,7 @@ await test('PNG export paints the theme, downloads 2x output, and cleans both UR ImageType: FakeImage, ...harness.options, }); - assert.deepEqual(result, { ok: true, filename: 'mermaid-flowchart.png' }); + assert.deepEqual(result, { ok: true, filename: 'mermaid_flowchart.png' }); assert.equal(canvas.width, 320); assert.equal(canvas.height, 160); assert.deepEqual(drawCalls[0], ['fill', '#333333', 0, 0, 320, 160]); diff --git a/web/src/lib/modelSettings.js b/web/src/lib/modelSettings.js index 29b1e14d6..51c7b453e 100644 --- a/web/src/lib/modelSettings.js +++ b/web/src/lib/modelSettings.js @@ -398,7 +398,7 @@ export function normalizeProviderModelQuery(payload, expectedProviderId = '') { }; } -function normalizeSampling(value, label) { +export function normalizeSampling(value, label) { if (value === undefined || value === null) return {}; const object = requireObject(value, label); const output = {}; @@ -415,7 +415,7 @@ function normalizeSampling(value, label) { return output; } -function samplingDraftFields(sampling = {}) { +export function samplingDraftFields(sampling = {}) { return Object.fromEntries(MODEL_SAMPLING_FIELDS.map((field) => [ field, typeof sampling?.[field] === 'number' ? String(sampling[field]) : '', @@ -914,7 +914,7 @@ export function modelSamplingFieldSupport(draft, provider) { return { temperature: true, top_p: true, top_k: true, min_p: true }; } -function samplingFieldValid(field, value, provider) { +export function samplingFieldValid(field, value, provider) { if (!Number.isFinite(value)) return false; switch (field) { case 'temperature': diff --git a/web/src/lib/pastedText.js b/web/src/lib/pastedText.js index ad2a9445a..08434e49a 100644 --- a/web/src/lib/pastedText.js +++ b/web/src/lib/pastedText.js @@ -468,14 +468,14 @@ let lastFileStamp = ''; let fileStampSerial = 0; const pad = (value, width = 2) => String(value).padStart(width, '0'); -// pasted-text-YYYYMMDD-HHMMSS[_s][-k].txt:ASCII、同一页面内唯一。 +// pasted_text_YYYYMMDD_HHMMSS[_s][_k].txt:ASCII、同一页面内唯一。 // 同一秒内的第二次粘贴加 _2、_3…(否则 composerFileIdentity 会把两次相同粘贴去重); // 多段时 index 为 1..n 的段号,单段传 0。一次粘贴的各段须传同一个 now,且从 // index 0 或 1 开始按顺序调用(index <= 1 视为新的一次粘贴)。 export function pastedTextFileName(now = new Date(), index = 0) { const date = now instanceof Date ? now : new Date(now); const stamp = `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` - + `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; + + `_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`; if (index <= 1) { if (stamp === lastFileStamp) fileStampSerial += 1; else { @@ -484,8 +484,8 @@ export function pastedTextFileName(now = new Date(), index = 0) { } } const serial = fileStampSerial > 1 ? `_${fileStampSerial}` : ''; - const chunk = index > 0 ? `-${index}` : ''; - return `pasted-text-${stamp}${serial}${chunk}.txt`; + const chunk = index > 0 ? `_${index}` : ''; + return `pasted_text_${stamp}${serial}${chunk}.txt`; } // 上传请求体里的来源描述,服务端存进附件记录 metadata(origin / pasted_text)。 diff --git a/web/src/lib/pastedText.test.js b/web/src/lib/pastedText.test.js index 193a9cc7d..527c0bd3b 100644 --- a/web/src/lib/pastedText.test.js +++ b/web/src/lib/pastedText.test.js @@ -124,11 +124,11 @@ run('pasted file names are ASCII and unique', () => { const now = new Date(2026, 8, 24, 10, 15, 0); const first = pastedTextFileName(now, 0); const second = pastedTextFileName(now, 0); - assert.equal(first, 'pasted-text-20260924-101500.txt'); + assert.equal(first, 'pasted_text_20260924_101500.txt'); assert.notEqual(first, second); const chunk1 = pastedTextFileName(now, 1); const chunk2 = pastedTextFileName(now, 2); - assert.ok(chunk1.endsWith('-1.txt') && chunk2.endsWith('-2.txt')); + assert.ok(chunk1.endsWith('_1.txt') && chunk2.endsWith('_2.txt')); assert.equal(chunk1.slice(0, -6), chunk2.slice(0, -6)); assert.ok(new Set([first, second, chunk1, chunk2]).size === 4); for (const name of [first, second, chunk1, chunk2]) assert.match(name, /^[\x20-\x7e]+$/); diff --git a/web/src/lib/securityCenter.js b/web/src/lib/securityCenter.js index a65a393df..d95089cbe 100644 --- a/web/src/lib/securityCenter.js +++ b/web/src/lib/securityCenter.js @@ -462,8 +462,8 @@ export function auditSummaryCounts(summary) { // 导出文件名跟着后端的 Content-Disposition 走,拿不到就自己拼一个。 export function auditExportFilename(format, now = new Date()) { const pad = (value) => String(value).padStart(2, '0'); - const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; - return `acecode-audit-${stamp}.${format === 'csv' ? 'csv' : 'jsonl'}`; + const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; + return `acecode_audit_${stamp}.${format === 'csv' ? 'csv' : 'jsonl'}`; } // --------------------------------------------------------------------------- diff --git a/web/src/lib/securityCenter.test.js b/web/src/lib/securityCenter.test.js index 7b41b7f8d..cde74fe9e 100644 --- a/web/src/lib/securityCenter.test.js +++ b/web/src/lib/securityCenter.test.js @@ -194,8 +194,8 @@ import { assert.deepEqual(auditSummaryCounts({ total: 9, by_decision: { allow: 4, allow_session: 1, deny: 2, forbidden: 1, blocked: 1 } }), { total: 9, allowed: 5, denied: 3, blocked: 1 }); assert.deepEqual(auditSummaryCounts(null), { total: 0, allowed: 0, denied: 0, blocked: 0 }); - assert.equal(auditExportFilename('csv', new Date(2026, 8, 17, 9, 5, 3)), 'acecode-audit-20260917-090503.csv'); - assert.equal(auditExportFilename('jsonl', new Date(2026, 0, 1, 0, 0, 0)), 'acecode-audit-20260101-000000.jsonl'); + assert.equal(auditExportFilename('csv', new Date(2026, 8, 17, 9, 5, 3)), 'acecode_audit_20260917_090503.csv'); + assert.equal(auditExportFilename('jsonl', new Date(2026, 0, 1, 0, 0, 0)), 'acecode_audit_20260101_000000.jsonl'); } // 场景:错误码归一。期望:404 → 不支持,401 → 认证,503+AUDIT_UNAVAILABLE 保留,