diff --git a/.github/workflows/architecture.yml b/.github/workflows/architecture.yml index acaf4bc..b062120 100644 --- a/.github/workflows/architecture.yml +++ b/.github/workflows/architecture.yml @@ -22,7 +22,6 @@ jobs: env: PYTHONUTF8: "1" PYTHONDONTWRITEBYTECODE: "1" - CARGO_TARGET_DIR: ${{ runner.temp }}/pebrel-i18n-contract steps: - uses: actions/checkout@v4 with: @@ -63,6 +62,8 @@ jobs: -o "$RUNNER_TEMP/pebrel-contracts/line-budget" "$RUNNER_TEMP/pebrel-contracts/line-budget" - name: Test renderer-independent translation and allocation contracts + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/pebrel-i18n-contract run: cargo test --manifest-path tools/i18n-contract/Cargo.toml --locked - name: Test branding and image compatibility run: >- @@ -70,3 +71,4 @@ jobs: scripts.tests.test_pebrel_branding scripts.tests.test_windows_resources scripts.tests.test_app_icons + scripts.tests.test_ai_session_probe diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml index 482bc3e..3a80e45 100644 --- a/.github/workflows/preview-packages.yml +++ b/.github/workflows/preview-packages.yml @@ -50,6 +50,9 @@ jobs: asset_version: ${{ steps.metadata.outputs.asset_version }} steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Resolve package metadata id: metadata @@ -98,12 +101,19 @@ jobs: run: >- python3 -m unittest scripts.tests.test_preview_release + scripts.tests.test_macos_dmg + scripts.tests.test_ai_session_probe scripts.tests.test_platform_cfg scripts.tests.test_windows_resources scripts.tests.test_pebrel_branding scripts.tests.test_app_icons scripts.tests.test_git_attributes + scripts.tests.test_xplat scripts.conformance.tests.test_harness + scripts.conformance.tests.test_resize + scripts.conformance.tests.test_scrollback + scripts.conformance.tests.test_lifecycle + scripts.conformance.tests.test_posix_lifecycle - name: Compile Python helpers run: python3 -m compileall -q scripts/preview_release.py scripts/conformance @@ -124,6 +134,9 @@ jobs: LINUXDEPLOY_SHA256: c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Install build, package, and headless runtime dependencies run: | @@ -143,6 +156,8 @@ jobs: toolchain: ${{ env.RUST_TOOLCHAIN }} - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - name: Test workspace natively run: cargo test --locked --workspace @@ -187,6 +202,8 @@ jobs: tar -tzf "$archive" "$root/usr/share/applications/io.github.kuddev.pebrel.preview.desktop" - name: Run AppImage conformance under X11 + env: + NEBULA_RESIZE_TRACE: "1" run: | xvfb-run -a -s "-screen 0 1600x1000x24" \ python3 scripts/conformance/run.py \ @@ -195,13 +212,20 @@ jobs: --output dist/conformance/linux-appimage-report.json - name: Run AppImage conformance under Wayland + env: + NEBULA_RESIZE_TRACE: "1" run: | + # Weston headless has no wl_seat. A private X11 backend provides + # input devices while DISPLAY is cleared for the Wayland client. + xvfb-run -a -s "-screen 0 1600x1000x24" bash -euo pipefail <<'WAYLAND_SESSION' runtime_directory="$RUNNER_TEMP/pebrel-wayland-runtime" mkdir -p "$runtime_directory" dist/conformance chmod 0700 "$runtime_directory" XDG_RUNTIME_DIR="$runtime_directory" \ weston \ - --backend=headless-backend.so \ + --backend=x11-backend.so \ + --use-pixman \ + --no-config \ --socket=wayland-pebrel \ --width=1600 \ --height=1000 \ @@ -233,8 +257,11 @@ jobs: --app "dist/Pebrel-v$ASSET_VERSION-linux-x86_64.AppImage" \ --platform linux-x86_64-appimage-wayland \ --output dist/conformance/linux-appimage-wayland-report.json + WAYLAND_SESSION - name: Install and run Debian package conformance under X11 + env: + NEBULA_RESIZE_TRACE: "1" run: | sudo apt-get install -y \ "./dist/Pebrel-v$ASSET_VERSION-linux-x86_64.deb" @@ -289,25 +316,42 @@ jobs: MACOSX_DEPLOYMENT_TARGET: "14.0" steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - name: Verify native runner architecture run: test "$(uname -m)" = "${{ matrix.native_architecture }}" + - name: Test native DMG capacity and mounted contents + run: python3 -m unittest scripts.tests.test_macos_dmg -v + - uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ env.RUST_TOOLCHAIN }} - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - name: Test workspace natively run: cargo test --locked --workspace - - name: Test native shell startup contracts - run: python3 -m unittest scripts.tests.test_shell_integration -v + - name: Test native shell startup and harness process ownership + run: >- + python3 -m unittest scripts.tests.test_shell_integration + scripts.conformance.tests.test_posix_lifecycle -v - name: Test SSH confirmation and password dialogs run: cargo test --locked -p nebula --features gpui-test-support dialog + - name: Release completed test artifacts before building the package + run: | + df -h . "${TMPDIR:-/tmp}" + du -sh target/debug + cargo clean --profile dev --package nebula + df -h . "${TMPDIR:-/tmp}" + - name: Build GPUI release binary run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell @@ -371,6 +415,8 @@ jobs: --force "${signing_args[@]}" - name: Mount final DMG and run conformance + env: + NEBULA_RESIZE_TRACE: "1" run: | dmg="dist/Pebrel-v$ASSET_VERSION-macos-${{ matrix.architecture }}.dmg" mount_point="$RUNNER_TEMP/pebrel-preview-${{ matrix.architecture }}" @@ -432,10 +478,20 @@ jobs: timeout-minutes: 120 steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Test native harness process ownership and completion markers + run: >- + python -m unittest + scripts.conformance.tests.test_lifecycle + scripts.conformance.tests.test_scrollback - uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ env.RUST_TOOLCHAIN }} - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true - name: Test workspace natively run: cargo test --locked --workspace - name: Test SSH confirmation and password dialogs @@ -443,6 +499,8 @@ jobs: - name: Build GPUI comparison binary run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell - name: Run Windows conformance + env: + NEBULA_RESIZE_TRACE: "1" run: >- python scripts/conformance/run.py --app target/release/pebrel.exe --platform windows-x86_64 --output dist/conformance/windows-report.json @@ -465,6 +523,9 @@ jobs: ASSET_VERSION: ${{ needs.prepare.outputs.asset_version }} steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - uses: actions/download-artifact@v4 with: @@ -517,6 +578,9 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" - uses: actions/download-artifact@v4 with: name: Pebrel-v${{ needs.prepare.outputs.asset_version }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..cfa93b5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,625 @@ +name: Pebrel stable release + +run-name: >- + Pebrel ${{ inputs.version || github.ref_name }} stable release + +on: + push: + tags: ["v*.*.*"] + workflow_dispatch: + inputs: + version: + description: Stable Cargo package version to build and publish + required: true + type: string + publish: + description: Create the public GitHub Release after every package check passes + required: true + default: false + type: boolean + windows_only: + description: Build only the missing Windows package, retaining other platform artifacts + type: boolean + default: false + source_commit: + description: Exact previously tested source commit for Windows-only packaging + type: string + default: "" + validated_run: + description: Existing stable run whose Windows native tests passed for source_commit + type: string + default: "" + macos_signing: + description: macOS signing mode; Developer ID also requires notarization secrets + required: true + type: choice + options: [adhoc, developer-id] + default: adhoc + +concurrency: + group: stable-release-${{ github.ref }}-${{ inputs.windows_only || false }} + cancel-in-progress: false + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + RUST_TOOLCHAIN: "1.97.1" + PYTHONUTF8: "1" + MACOS_SIGNING: ${{ inputs.macos_signing || 'adhoc' }} + +jobs: + prepare: + name: Validate stable release inputs + runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.metadata.outputs.version }} + asset_version: ${{ steps.metadata.outputs.asset_version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Resolve and validate package metadata + id: metadata + env: + REQUESTED_VERSION: ${{ inputs.version }} + EVENT_NAME: ${{ github.event_name }} + REF_NAME: ${{ github.ref_name }} + run: | + version="$(python3 scripts/preview_release.py --print-package-version nebula_app/Cargo.toml)" + if [[ -n "$REQUESTED_VERSION" && "$REQUESTED_VERSION" != "$version" ]]; then + echo "requested version $REQUESTED_VERSION does not match Cargo package version $version" >&2 + exit 1 + fi + if [[ "$EVENT_NAME" == push && "$REF_NAME" != "v$version" ]]; then + echo "release tag $REF_NAME does not match Cargo package version $version" >&2 + exit 1 + fi + VERSION="$version" python3 - <<'PY' + import os + from scripts.stable_release import validate_version + validate_version(os.environ["VERSION"]) + if "-" in os.environ["VERSION"] or "+" in os.environ["VERSION"]: + raise SystemExit("stable releases require a plain semver without prerelease or build suffix") + PY + python3 - <<'PY' + from pathlib import Path + from scripts.stable_release import validate_changelog, validate_notes + import subprocess + version = subprocess.check_output( + ["python3", "scripts/preview_release.py", "--print-package-version", "nebula_app/Cargo.toml"], + text=True, + ).strip() + validate_notes(Path(f"docs/release-notes/v{version}.md"), version) + validate_changelog(Path("CHANGELOG.md"), version) + PY + { + echo "version=$version" + echo "asset_version=$version" + } >>"$GITHUB_OUTPUT" + - name: Check architecture and release helper contracts + run: | + python3 scripts/check_architecture.py + python3 scripts/check_platform_cfg.py + python3 -m unittest \ + scripts.tests.test_stable_release \ + scripts.tests.test_preview_release \ + scripts.tests.test_macos_dmg \ + scripts.tests.test_ai_session_probe \ + scripts.tests.test_platform_cfg \ + scripts.tests.test_windows_resources \ + scripts.tests.test_pebrel_branding \ + scripts.tests.test_app_icons \ + scripts.tests.test_git_attributes \ + scripts.tests.test_xplat + bash -n scripts/package-linux.sh scripts/package-macos.sh + python3 -m py_compile scripts/stable_release.py scripts/preview_release.py + + linux: + name: Linux x86_64 stable package + if: ${{ !inputs.windows_only }} + needs: prepare + runs-on: ubuntu-22.04 + timeout-minutes: 120 + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + LINUXDEPLOY_VERSION: 1-alpha-20251107-1 + LINUXDEPLOY_SHA256: c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Install build, package, and headless runtime dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + appstream build-essential cmake curl desktop-file-utils dpkg-dev \ + imagemagick libasound2-dev libdbus-1-dev libegl1-mesa-dev \ + libfontconfig1-dev libfreetype6-dev libgl1-mesa-dev libudev-dev \ + libvulkan1 libwayland-dev libx11-dev libx11-xcb-dev \ + libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \ + libxcb-xinput-dev libxcb-xkb-dev libxcb1-dev libxi-dev \ + libxkbcommon-dev libxkbcommon-x11-dev mesa-vulkan-drivers \ + patchelf pkg-config weston xauth xvfb zsh + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Test workspace and startup contracts + run: | + cargo test --locked --workspace + cargo test --locked -p nebula --bin pebrel --features gpui-shell + python3 -m unittest scripts.tests.test_shell_integration + cargo test --locked -p nebula --features gpui-test-support dialog + cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell:: + - name: Build GPUI release binary + run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + - name: Download pinned linuxdeploy + env: + LINUXDEPLOY: ${{ runner.temp }}/linuxdeploy-x86_64.AppImage + run: | + curl --fail --location --retry 3 --retry-all-errors --show-error \ + --output "$LINUXDEPLOY" \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/$LINUXDEPLOY_VERSION/linuxdeploy-x86_64.AppImage" + echo "$LINUXDEPLOY_SHA256 $LINUXDEPLOY" | sha256sum --check --strict + chmod 0755 "$LINUXDEPLOY" + echo "LINUXDEPLOY=$LINUXDEPLOY" >>"$GITHUB_ENV" + - name: Package stable Linux assets + run: | + SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")" + export SOURCE_DATE_EPOCH + bash scripts/package-linux.sh \ + --channel stable \ + --binary target/release/pebrel \ + --version "$PACKAGE_VERSION" \ + --output-directory dist \ + --linuxdeploy "$LINUXDEPLOY" \ + --force + - name: Run AppImage conformance under X11 + env: + NEBULA_RESIZE_TRACE: "1" + run: | + xvfb-run -a -s "-screen 0 1600x1000x24" \ + python3 scripts/conformance/run.py \ + --app "dist/Pebrel-v$PACKAGE_VERSION-linux-x64.AppImage" \ + --platform linux-x86_64-appimage \ + --output dist/conformance/linux-appimage-report.json + - name: Run AppImage conformance under Wayland + env: + NEBULA_RESIZE_TRACE: "1" + run: | + xvfb-run -a -s "-screen 0 1600x1000x24" bash -euo pipefail <<'WAYLAND_SESSION' + runtime_directory="$RUNNER_TEMP/pebrel-wayland-runtime" + mkdir -p "$runtime_directory" dist/conformance + chmod 0700 "$runtime_directory" + XDG_RUNTIME_DIR="$runtime_directory" weston --backend=x11-backend.so \ + --use-pixman --no-config --socket=wayland-pebrel --width=1600 --height=1000 \ + --idle-time=0 --log=dist/conformance/weston.log & + weston_pid=$! + trap 'kill "$weston_pid" >/dev/null 2>&1 || true; wait "$weston_pid" >/dev/null 2>&1 || true' EXIT + for _ in $(seq 1 100); do + [[ -S "$runtime_directory/wayland-pebrel" ]] && break + kill -0 "$weston_pid" >/dev/null 2>&1 || exit 1 + sleep 0.1 + done + test -S "$runtime_directory/wayland-pebrel" + DISPLAY='' XDG_RUNTIME_DIR="$runtime_directory" WAYLAND_DISPLAY=wayland-pebrel \ + python3 scripts/conformance/run.py \ + --app "dist/Pebrel-v$PACKAGE_VERSION-linux-x64.AppImage" \ + --platform linux-x86_64-appimage-wayland \ + --output dist/conformance/linux-appimage-wayland-report.json + WAYLAND_SESSION + - name: Install and run Debian conformance + env: + NEBULA_RESIZE_TRACE: "1" + run: | + sudo apt-get install -y "./dist/Pebrel-v$PACKAGE_VERSION-linux-x64.deb" + test "$(dpkg-query -W -f='${Status}' pebrel)" = "install ok installed" + xvfb-run -a -s "-screen 0 1600x1000x24" \ + python3 scripts/conformance/run.py --app /usr/bin/pebrel \ + --platform linux-x86_64-deb --output dist/conformance/linux-deb-report.json + - name: Upload Linux stable assets + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-package-linux-x64 + path: | + dist/*.AppImage + dist/*.deb + dist/*.tar.gz + if-no-files-found: error + retention-days: 14 + - name: Upload Linux conformance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-evidence-linux-x64 + path: dist/conformance + if-no-files-found: warn + retention-days: 14 + + macos: + name: macOS ${{ matrix.architecture }} stable package + if: ${{ !inputs.windows_only }} + needs: prepare + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + architecture: aarch64 + native_architecture: arm64 + asset_architecture: arm64 + - runner: macos-15-intel + architecture: x86_64 + native_architecture: x86_64 + asset_architecture: x64 + runs-on: ${{ matrix.runner }} + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + MACOSX_DEPLOYMENT_TARGET: "14.0" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Verify native runner architecture + run: test "$(uname -m)" = "${{ matrix.native_architecture }}" + - name: Test native DMG capacity and mounted contents + run: python3 -m unittest scripts.tests.test_macos_dmg -v + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Test workspace and native startup contracts + run: | + cargo test --locked --workspace + cargo test --locked -p nebula --bin pebrel --features gpui-shell + python3 -m unittest scripts.tests.test_shell_integration scripts.conformance.tests.test_posix_lifecycle + cargo test --locked -p nebula --features gpui-test-support dialog + cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell:: + - name: Build GPUI release binary + run: cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + - name: Import Developer ID signing credentials + if: ${{ env.MACOS_SIGNING == 'developer-id' }} + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + export SIGNING_DIRECTORY="$RUNNER_TEMP/pebrel-signing" + mkdir -m 0700 "$SIGNING_DIRECTORY" + python3 - <<'PY' + import base64 + import os + from pathlib import Path + required = ["APPLE_CERTIFICATE_BASE64", "APPLE_CERTIFICATE_PASSWORD", "APPLE_SIGNING_IDENTITY", + "APPLE_API_KEY_BASE64", "APPLE_API_KEY_ID", "APPLE_API_ISSUER"] + if any(not os.environ.get(name) for name in required): + raise SystemExit("missing required Developer ID signing secret") + root = Path(os.environ["SIGNING_DIRECTORY"]) + for name, filename in [("APPLE_CERTIFICATE_BASE64", "certificate.p12"), ("APPLE_API_KEY_BASE64", "AuthKey.p8")]: + path = root / filename + path.write_bytes(base64.b64decode(os.environ[name], validate=True)) + path.chmod(0o600) + PY + keychain="$SIGNING_DIRECTORY/release.keychain-db" + password="$(openssl rand -hex 32)" + echo "::add-mask::$password" + security create-keychain -p "$password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$password" "$keychain" + security import "$SIGNING_DIRECTORY/certificate.p12" -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$password" "$keychain" + xcrun notarytool store-credentials pebrel-release --keychain "$keychain" \ + --key "$SIGNING_DIRECTORY/AuthKey.p8" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" + echo "PEBREL_SIGNING_KEYCHAIN=$keychain" >>"$GITHUB_ENV" + - name: Package stable macOS DMG + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + signing_args=() + if [[ "$MACOS_SIGNING" == developer-id ]]; then + test -n "$PEBREL_SIGNING_KEYCHAIN" + signing_args=(--sign-identity "$APPLE_SIGNING_IDENTITY" --notary-profile pebrel-release --signing-keychain "$PEBREL_SIGNING_KEYCHAIN") + fi + bash scripts/package-macos.sh \ + --channel stable \ + --binary target/release/pebrel \ + --version "$PACKAGE_VERSION" \ + --architecture "${{ matrix.architecture }}" \ + --build-number "$GITHUB_RUN_NUMBER" \ + --output-directory dist --force "${signing_args[@]}" + - name: Mount DMG and run conformance + env: + NEBULA_RESIZE_TRACE: "1" + run: | + dmg="dist/Pebrel-v$PACKAGE_VERSION-macos-${{ matrix.asset_architecture }}.dmg" + mount_point="$RUNNER_TEMP/pebrel-stable-${{ matrix.architecture }}" + mkdir -p "$mount_point" + hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_point" + trap 'hdiutil detach "$mount_point" >/dev/null 2>&1 || hdiutil detach "$mount_point" -force >/dev/null 2>&1 || true' EXIT + app="$mount_point/Pebrel.app" + codesign --verify --deep --strict --verbose=2 "$app" + plutil -lint "$app/Contents/Info.plist" + test "$(plutil -extract CFBundleIdentifier raw -o - "$app/Contents/Info.plist")" = "io.github.kuddev.pebrel" + test "$(lipo -archs "$app/Contents/MacOS/pebrel")" = "${{ matrix.native_architecture }}" + python3 scripts/conformance/run.py --app "$app" --platform "macos-${{ matrix.architecture }}" \ + --output "dist/conformance/macos-${{ matrix.architecture }}-report.json" + python3 scripts/conformance/macos_launch.py --app "$app" \ + --output "dist/conformance/macos-${{ matrix.architecture }}-launch.json" + - name: Remove temporary signing credentials + if: ${{ always() && env.MACOS_SIGNING == 'developer-id' }} + run: | + if [[ -f "$RUNNER_TEMP/pebrel-signing/release.keychain-db" ]]; then + security delete-keychain "$RUNNER_TEMP/pebrel-signing/release.keychain-db" + fi + rm -rf -- "$RUNNER_TEMP/pebrel-signing" + - name: Upload macOS stable asset + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-package-macos-${{ matrix.asset_architecture }} + path: dist/*.dmg + if-no-files-found: error + retention-days: 14 + - name: Upload macOS conformance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-evidence-macos-${{ matrix.architecture }} + path: dist/conformance + if-no-files-found: warn + retention-days: 14 + + windows: + name: Windows x64 stable package + needs: prepare + runs-on: windows-2022 + timeout-minutes: 120 + permissions: + contents: read + actions: read + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@v4 + - name: Retain runtime preparation tool for the selected source + shell: pwsh + run: | + Copy-Item scripts/prepare-windows-runtime.ps1 "$env:RUNNER_TEMP/prepare-windows-runtime.ps1" + New-Item -ItemType Directory -Path "$env:RUNNER_TEMP/runtime-tests" -Force | Out-Null + Copy-Item scripts/tests/windows-runtime.tests.ps1 "$env:RUNNER_TEMP/runtime-tests/windows-runtime.tests.ps1" + - name: Verify the previous Windows test result + if: ${{ inputs.source_commit != '' }} + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + SELECTED_COMMIT: ${{ inputs.source_commit }} + VALIDATED_RUN: ${{ inputs.validated_run }} + WINDOWS_ONLY: ${{ inputs.windows_only }} + run: | + if ($env:WINDOWS_ONLY -ne 'true' -or $env:SELECTED_COMMIT -notmatch '^[0-9a-f]{40}$' -or $env:VALIDATED_RUN -notmatch '^[0-9]+$') { + throw 'Selecting old source requires Windows-only mode, an exact commit, and a validated run.' + } + $headers = @{ Authorization = "Bearer $env:GH_TOKEN"; Accept = 'application/vnd.github+json'; 'X-GitHub-Api-Version' = '2022-11-28' } + $run = Invoke-RestMethod -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/runs/$env:VALIDATED_RUN" -Headers $headers -ErrorAction Stop + if ($run.head_sha -ne $env:SELECTED_COMMIT) { throw 'Validated run source differs.' } + $jobs = Invoke-RestMethod -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/actions/runs/$env:VALIDATED_RUN/jobs?per_page=100" -Headers $headers -ErrorAction Stop + $windows = @($jobs.jobs | Where-Object name -eq 'Windows x64 stable package') + $tests = @($windows.steps | Where-Object { $_.name -eq 'Test workspace and native harness' -and $_.conclusion -eq 'success' }) + if ($windows.Count -ne 1 -or $tests.Count -ne 1) { throw 'Prior Windows native tests did not pass.' } + - uses: actions/checkout@v4 + if: ${{ inputs.source_commit != '' }} + with: + ref: ${{ inputs.source_commit }} + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + - name: Prepare pinned Windows console runtime + shell: pwsh + run: | + & "$env:RUNNER_TEMP/prepare-windows-runtime.ps1" -Destination assets/windows/conhost + & "$env:RUNNER_TEMP/runtime-tests/windows-runtime.tests.ps1" -ArchivePath (Join-Path ([IO.Path]::GetTempPath()) 'pebrel-conpty-source-v1.5.0.zip') + - name: Test workspace and native harness + if: ${{ inputs.source_commit == '' }} + shell: pwsh + run: | + python -m unittest scripts.conformance.tests.test_lifecycle scripts.conformance.tests.test_scrollback + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test --locked --workspace + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test --locked -p nebula --bin pebrel --features gpui-shell + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test --locked -p nebula --features gpui-test-support dialog + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell:: + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Build GPUI release binary + shell: pwsh + run: | + cargo build --locked --release -p nebula_hook --bin pebrel-hook + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo build --locked --release -p nebula --bin pebrel --features gpui-shell + exit $LASTEXITCODE + - name: Retain built Windows runtime before packaging + uses: actions/upload-artifact@v4 + with: + name: stable-runtime-windows-x64 + path: | + target/release/pebrel.exe + target/release/pebrel-hook.exe + target/release/conpty.dll + target/release/OpenConsole.exe + if-no-files-found: error + retention-days: 30 + - name: Test Windows packaging scripts + shell: pwsh + run: | + ./scripts/tests/installer.tests.ps1 -TargetDirectory target + ./scripts/tests/package-release.tests.ps1 -TargetDirectory target + - name: Package ZIP and installer + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + ./scripts/package-release.ps1 -Version $env:PACKAGE_VERSION -PackageBrand Pebrel -OutputDirectory dist -TargetDirectory target -Force + ./scripts/build-installer.ps1 -Version $env:PACKAGE_VERSION -PackageBrand Pebrel -OutputDirectory dist -TargetDirectory target -Force + $canonical = Join-Path (Resolve-Path dist) "Pebrel-v$env:PACKAGE_VERSION-windows-x64-setup.exe" + $legacy = Join-Path (Resolve-Path dist) "NebulaTerminal-$env:PACKAGE_VERSION-windows-x64-setup.exe" + Copy-Item -LiteralPath $canonical -Destination $legacy -Force + $canonicalHash = (Get-FileHash -LiteralPath $canonical -Algorithm SHA256).Hash + $legacyHash = (Get-FileHash -LiteralPath $legacy -Algorithm SHA256).Hash + if ($canonicalHash -ne $legacyHash) { throw 'legacy installer alias differs from canonical installer' } + - name: Run Windows conformance + shell: pwsh + env: + NEBULA_RESIZE_TRACE: "1" + SELECTED_COMMIT: ${{ inputs.source_commit || github.sha }} + run: | + $actual = git rev-parse HEAD + if ($actual -ne $env:SELECTED_COMMIT) { throw 'Checked-out source differs from the requested build.' } + $env:GITHUB_SHA = $actual + python scripts/conformance/run.py --app target/release/pebrel.exe --platform windows-x86_64 --output dist/conformance/windows-report.json + exit $LASTEXITCODE + - name: Upload Windows stable assets + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-package-windows-x64 + path: | + dist/Pebrel-v${{ needs.prepare.outputs.version }}-windows-x64.zip + dist/Pebrel-v${{ needs.prepare.outputs.version }}-windows-x64-setup.exe + dist/NebulaTerminal-${{ needs.prepare.outputs.version }}-windows-x64-setup.exe + if-no-files-found: error + retention-days: 14 + - name: Upload Windows conformance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: stable-evidence-windows-x64 + path: dist/conformance + if-no-files-found: warn + retention-days: 14 + + aggregate: + name: Verify stable assets and upload complete set + if: ${{ !inputs.windows_only }} + needs: [prepare, linux, macos, windows] + runs-on: ubuntu-22.04 + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: actions/download-artifact@v4 + with: + pattern: stable-package-* + merge-multiple: true + path: release-dist + - uses: actions/download-artifact@v4 + with: + pattern: stable-evidence-* + merge-multiple: true + path: stable-evidence + - name: Validate conformance evidence and stable manifest + env: + SOURCE_COMMIT: ${{ github.sha }} + run: | + python3 - <<'PY' + import os + from pathlib import Path + from scripts.preview_release import validate_evidence + validate_evidence(Path("stable-evidence"), os.environ["SOURCE_COMMIT"]) + PY + python3 scripts/stable_release.py release-dist \ + --version "$PACKAGE_VERSION" \ + --mark-platform-previews \ + --notes "docs/release-notes/v$PACKAGE_VERSION.md" \ + --changelog CHANGELOG.md \ + --write-checksums release-dist/SHA256SUMS \ + --write-notes release-dist/RELEASE_NOTES.md + - name: Upload complete stable release set + uses: actions/upload-artifact@v4 + with: + name: Pebrel-v${{ needs.prepare.outputs.version }} + path: release-dist + if-no-files-found: error + retention-days: 30 + + publish: + name: Publish verified GitHub Release + if: ${{ !inputs.windows_only && (github.event_name == 'push' || inputs.publish == true) }} + needs: [prepare, aggregate] + runs-on: ubuntu-22.04 + permissions: + contents: write + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - uses: actions/download-artifact@v4 + with: + name: Pebrel-v${{ needs.prepare.outputs.version }} + path: release-dist + - name: Create public stable Release + run: | + tag="v$PACKAGE_VERSION" + if gh release view "$tag" >/dev/null 2>&1; then + echo "refusing to replace existing Release: $tag" >&2 + exit 1 + fi + existing_tag_ref="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha' 2>/dev/null || true)" + existing_tag_commit="" + if [[ -n "$existing_tag_ref" ]]; then + read -r existing_tag_type existing_tag_object <<<"$existing_tag_ref" + if [[ "$existing_tag_type" == tag ]]; then + existing_tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$existing_tag_object" --jq .object.sha)" + else + existing_tag_commit="$existing_tag_object" + fi + fi + if [[ -n "$existing_tag_commit" && "$existing_tag_commit" != "$GITHUB_SHA" ]]; then + echo "tag $tag already points to $existing_tag_commit, expected $GITHUB_SHA" >&2 + exit 1 + fi + gh release create "$tag" \ + release-dist/*.AppImage release-dist/*.deb release-dist/*.dmg \ + release-dist/*.tar.gz release-dist/*.zip release-dist/*-setup.exe \ + release-dist/SHA256SUMS \ + --target "$GITHUB_SHA" --title "Pebrel $PACKAGE_VERSION" \ + --notes-file release-dist/RELEASE_NOTES.md + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" >"$RUNNER_TEMP/stable-release.json" + tag_ref="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha')" + read -r tag_type tag_object <<<"$tag_ref" + if [[ "$tag_type" == tag ]]; then + tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/git/tags/$tag_object" --jq .object.sha)" + else + tag_commit="$tag_object" + fi + python3 scripts/stable_release.py release-dist --version "$PACKAGE_VERSION" \ + --verify-release "$RUNNER_TEMP/stable-release.json" \ + --commit "$GITHUB_SHA" --tag-commit "$tag_commit" diff --git a/CHANGELOG.md b/CHANGELOG.md index a3f5b19..8a5ec73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,89 +9,101 @@ Every release entry is provided in English and Simplified Chinese. ### English #### Added -- Clipboard images can be pasted into local, WSL, and SSH terminal sessions as PNG file paths. WSL paths are translated and SSH images are uploaded before insertion; failures are visible, and late results are discarded after input submission, interruption, or a session change. -- Added an application icon picker with 25 color palettes and light/dark previews. -- Added an opt-in terminal network proxy setting. On Windows, new local sessions inherit the system HTTP/HTTPS proxy when enabled; existing sessions keep their environment. -- Added per-host SSH proxy and jump-host options, with separate proxy credentials and a connection-route preview. -- Added an answer reader for captured Claude Code and Codex responses, with Markdown, formulas, source-text mode, and local image previews. -- Added UI language choices for Traditional Chinese, French, German, Spanish, Brazilian Portuguese, Italian, Russian, Japanese, and Korean alongside English and Simplified Chinese. Initial coverage includes navigation, common actions, appearance, and network controls; untranslated text falls back to English. -- Added a settings search that opens the matching section and a confirmed Restore defaults action that backs up preferences while retaining saved hosts and other user data. -- Added the Antigravity product icon to terminal tabs and the sidebar, including its common launcher aliases, while retaining its original colors in light and dark themes. -- Recognized binary terminal confirmations can show Allow and Deny actions in a notification. The action is rejected if the prompt, session, or input state has changed, and is available only once for the captured request. + +- Added Linux x64 Preview packages in AppImage, DEB, and portable tar.gz formats, plus separate macOS Preview DMGs for Apple Silicon and Intel, alongside the stable Windows installer and ZIP. RPM packages are not included. macOS packages use ad-hoc signing and may require Open Anyway in System Settings > Privacy & Security on first launch. Addresses [#87](https://github.com/Kuddev/nebula/issues/87). +- Added clipboard image paste for local, WSL, and SSH terminals. Pebrel saves the image as a PNG and inserts a path that the session can access; SSH images are uploaded first. Attachment thumbnails and images in a CLI's conversation still depend on that CLI. +- Added a reader for captured Claude Code and Codex answers, with Markdown, formulas, original text, and local image previews. +- Added separate SOCKS5/HTTP proxy and jump-host settings for each SSH host, with saved proxy credentials and a connection-route preview. Addresses [#90](https://github.com/Kuddev/nebula/issues/90). +- Added an optional Windows system-proxy setting for newly opened local terminals. It is off by default and does not change existing sessions. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#94](https://github.com/Kuddev/pebrel/pull/94). +- Added nine UI language choices alongside English and Simplified Chinese: Traditional Chinese, French, German, Spanish, Brazilian Portuguese, Italian, Russian, Japanese, and Korean. Untranslated text falls back to English. +- Added 25 application icon palettes with light and dark previews. Titanium is now the default icon. +- Added settings search and a confirmed Restore defaults action. Preferences are backed up before resetting, and saved hosts and other user data are retained. +- Added Allow and Deny actions to notifications for recognized terminal confirmation prompts. Actions stop being available when the prompt or session changes, and each request can be handled only once. #### Fixed -- `Ctrl+C` copies and clears a terminal selection, and still sends an interrupt when no selection is present. Text inputs keep their own copy behavior. -- `Ctrl+Backspace` deletes a word in the supported legacy and kitty keyboard paths, including the managed PowerShell prompt. -- Running tab indicators keep animating when another window has focus; hidden or minimized windows stop requesting animation frames. -- File-name search discards results from an old root, updates after creates, renames and deletes, and handles canonical file-watcher paths without losing the visible directory root. -- Multiline confirmation text is measured with wrapping so longer messages fit the shared confirmation dialog. -- Improved Antigravity activity and permission detection so old spinner output and ordinary prose mentioning approval do not keep an idle session marked as running or waiting for permission. -- Closing the last regular window and then quitting no longer replaces saved tabs and AI session identities with an empty snapshot. Closing all tabs explicitly still starts an empty workspace next time, and failed session writes are retried. -- SSH startup now waits for PTY and shell confirmation before showing Ready, supports cancellation during connection setup, and stops stalled startup stages within a bounded timeout. Unexpected disconnects retain the pane and its failure state; explicit shell exits still close normally. -- Duplicated WSL tabs retain the current guest directory and selected distribution. Duplicated SSH tabs retain the known remote working directory and quote it when starting the new remote shell. -- Fixed Windows native SSH sessions consuming a remote application's first device-attributes response during local console startup, which could leave the application waiting with a blank screen. -- Modified Enter and ASCII shortcuts retain their negotiated keyboard-protocol modifiers, including Shift+Enter, Ctrl+Enter, and Alt+V. Keyboard-mode queries now report the active flags after a protocol reset. -- Runtime CLI responses and event subscriptions stop cleanly when the program reading their output closes its pipe, instead of opening a panic dialog. Other output errors are still reported. + +- Fixed reopening the workspace restoring only the tab for Codex sessions without a hook-reported conversation ID. On WSL and Linux, Pebrel can now identify the current conversation and save it before window close or the application's Quit action. Native Windows and macOS sessions continue to rely on hook-reported IDs. +- Fixed quitting after closing the last regular window replacing saved tabs with an empty workspace. Deliberately closing every tab still starts an empty workspace next time. +- Fixed `Ctrl+C` ignoring selected terminal text. It now copies and clears the selection; without a selection, it still interrupts the running command. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#93](https://github.com/Kuddev/pebrel/pull/93). +- Fixed `Ctrl+Backspace` deleting only one character in supported terminal input modes and the managed PowerShell prompt. It now deletes a word. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#92](https://github.com/Kuddev/pebrel/pull/92). +- Fixed `Shift+Enter` being treated as plain Enter in negotiated multiline input modes, and Antigravity continuing to show a busy indicator after an answer finished. `Ctrl+Enter`, `Alt+V`, and other modified keys also retain their negotiated modifiers. Addresses [#95](https://github.com/Kuddev/nebula/issues/95). Contributed in part by [@821869798](https://github.com/821869798) in [#96](https://github.com/Kuddev/pebrel/pull/96). +- Fixed Windows SSH sessions losing an application's first terminal-capability response during startup, which could leave a full-screen command-line application waiting on a blank screen. +- Fixed duplicated WSL and SSH tabs losing their known working directory. WSL tabs also retain the selected distribution and launch settings. +- Fixed file search showing results from a previously opened directory or missing newly created, renamed, and deleted files. +- Fixed long confirmation messages being clipped by the dialog instead of wrapping to fit. +- Fixed the Runtime CLI showing an error dialog when the program receiving its output closed the pipe. It now finishes cleanly in that case. #### Improved -- Made Titanium the default application icon and adjusted small-size proportions, prompt strokes, and transparent edges. -- Unified the application, command, executable, helper, package, and managed integration names under Pebrel. New local sessions expose `PEBREL_*` environment variables alongside legacy aliases. -- The Windows installer migrates registered `Nebula Terminal` installation directories to a sibling `Pebrel` directory, updates its shortcuts and PATH entry, and preserves custom directory names and unknown files. Cleanup failures are reported and can be retried. -- Existing application data is copied into the Pebrel data directory without overwriting newer files or removing the old directory. New configuration names take precedence, while old environment variables and `require 'nebula'` Lua configurations remain compatible. -- The project repository is now `Kuddev/pebrel`. Existing `Kuddev/nebula` repository and Git URLs redirect to it; historical release asset filenames remain unchanged. -- File-name indexing applies incremental updates and recovers from watcher queue overflow with a rescan; editing a file's contents alone no longer rebuilds its name entry. -- Improved terminal formula parsing and rendering for streamed agent output, while preserving the original text when a formula cannot be rendered. -- Made the settings navigation more compact, kept Application first, labeled terminal options more clearly, and added matching navigation icons. Appearance settings now show font, cursor, interface, and background controls in groups with expandable help. -- Reduced the intensity of hover and selected backgrounds in light themes. -- Settings now appears as a selectable, closable item in the top tab layout. Switching tab layouts while Settings is open preserves the sidebar state, and closing the last terminal tab keeps Settings available until it is closed. -- SSH editing now offers common usernames alongside recent entries, keeps manual usernames available, and makes host icons and pin controls clearer. Connection screens show completed stages and failures more accurately. -- Terminal completion lists now scroll to reveal keyboard selections, support wheel scrolling and hover feedback, and clear stale list positions when the query changes. -- Completion, bell, and Agent attention notifications now follow their source pane, including after a tab moves to another window. Clicking a notification focuses that pane, and activity in one pane no longer suppresses another pane's notification. + +- Improved the upgrade from Nebula to Pebrel: the application, commands, installer, and managed shortcuts use the new name. The installer migrates the standard old installation directory while preserving custom locations; configuration migration keeps old data and does not overwrite newer files. +- Improved continuity after the repository rename. Old `Kuddev/nebula` repository and Git links redirect to `Kuddev/pebrel`, and historical download filenames remain available. +- Improved package naming to use `Pebrel-v--` consistently. A byte-identical Windows installer with the old Nebula filename remains available for existing automatic-update clients. +- Improved SSH connection feedback: startup can be cancelled, stalled connection stages time out, and an unexpected disconnect leaves the terminal and error visible. This improves the disappearing-window and stuck-connection behavior. Addresses [#89](https://github.com/Kuddev/nebula/issues/89). +- Improved streamed formula recognition and layout, while preserving the original text when a formula cannot be displayed. KaTeX syntax support remains partial. Addresses [#84](https://github.com/Kuddev/nebula/issues/84). +- Improved notifications so clicking one focuses the terminal that raised it, even after moving its tab to another window. Activity in a different terminal no longer suppresses that notification. +- Improved activity indicators so they continue animating when another application has focus; hidden and minimized windows stop requesting animation frames. +- Improved settings with clearer groups, compact navigation, and a normal closable Settings tab. Light-theme selection and hover colors are less intense, and SSH editing makes common usernames and host controls easier to find. +- Improved completion lists with wheel scrolling, visible keyboard selections, and hover feedback. Changing the query clears the old list position. ### 中文 #### 新增 -- 新增把剪贴板图片转换为 PNG 文件路径后粘贴到本地、WSL 与 SSH 终端会话;WSL 会转换路径,SSH 会先上传图片。失败会明确提示,提交输入、中断或会话变化后到达的结果会被丢弃。 -- 新增应用图标选择器,提供 25 款配色及浅色、深色背景预览。 -- 新增默认关闭的终端网络代理设置。Windows 下启用后,新建本地会话继承系统 HTTP/HTTPS 代理;现有会话保留原有环境。 -- SSH 主机新增独立的代理与跳板配置,支持单独保存代理凭据并预览连接路线。 -- 为已捕获的 Claude Code 和 Codex 回答新增阅读视图,支持 Markdown、公式、原文模式和本地图片预览。 -- 在英语和简体中文之外,界面语言新增繁体中文、法语、德语、西班牙语、巴西葡萄牙语、意大利语、俄语、日语及韩语。初版覆盖导航、常用操作、外观与网络控件,未翻译文案回退英文。 -- 新增可跳转到匹配分区的设置搜索,以及需要确认的“恢复默认”操作;重置前备份偏好设置,并保留已保存主机等用户数据。 -- 终端标签与侧栏新增 Antigravity 产品图标,支持常见启动别名,并在浅色和深色主题中保留图标原色。 -- 可识别的终端二选一确认提示可在通知中显示允许与拒绝操作;提示、会话或输入状态变化后会拒绝过期操作,每个捕获的请求只能处理一次。 + +- 新增 Linux x64 Preview 的 AppImage、DEB 和 tar.gz 便携包,以及分别适用于 Apple Silicon 和 Intel 的 macOS Preview DMG;Windows 提供正式版安装器与 ZIP 便携包。本次不包含 RPM。macOS 包采用临时签名,首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。对应 [#87](https://github.com/Kuddev/nebula/issues/87)。 +- 新增本地、WSL 和 SSH 终端的剪贴板图片粘贴:将图片保存成 PNG,再插入当前会话可以访问的路径,SSH 会先上传图片。输入框缩略图和发送后的图片显示仍取决于所用 CLI。 +- 新增 Claude Code 和 Codex 回答阅读器,支持阅读已捕获回答的 Markdown、公式、原文和本地图片。 +- 新增每台 SSH 主机独立的 SOCKS5/HTTP 代理与跳板设置,可保存代理凭据并预览连接路线。对应 [#90](https://github.com/Kuddev/nebula/issues/90)。 +- 新增让新建本地终端使用 Windows 系统代理的选项,默认关闭,不影响已经打开的会话。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#94](https://github.com/Kuddev/pebrel/pull/94) 中贡献。 +- 新增繁体中文、法语、德语、西班牙语、巴西葡萄牙语、意大利语、俄语、日语和韩语界面选项,加上原有英语和简体中文共十一种语言;尚未翻译的内容显示英文。 +- 新增 25 款应用图标配色,提供浅色和深色预览,并将钛银设为默认图标。 +- 新增设置搜索和需要确认的“恢复默认”操作;重置前备份偏好设置,保留已保存主机等用户数据。 +- 新增可识别终端确认提示的通知操作,可直接选择允许或拒绝;提示或会话变化后操作失效,同一请求只能处理一次。 #### 修复 -- 终端中 `Ctrl+C` 会复制并清除选区,没有选区时仍发送中断;文本输入框保留自身的复制行为。 -- `Ctrl+Backspace` 在支持的传统和 kitty 键盘路径中按词删除,并适用于受管理的 PowerShell 提示符。 -- 其他窗口获得焦点后,运行中的标签指示器继续播放动画;窗口隐藏或最小化时停止请求动画帧。 -- 文件名搜索丢弃旧目录的结果,在创建、重命名和删除后更新,并正确处理文件监听器返回的规范路径,保留用户看到的目录根。 -- 多行确认文案按换行后的尺寸测量,使较长消息能完整放入共享确认框。 -- 改进 Antigravity 的活动与权限提示识别,避免历史转圈输出或普通正文中提到审批时,把空闲会话误标为运行中或等待授权。 -- 关闭最后一个普通窗口后再退出,不再用空快照覆盖已保存标签与 AI 会话身份;明确关闭全部标签后,下次仍从空工作区开始,保存失败会重试。 -- SSH 启动现在等待 PTY 与 shell 确认后才显示就绪,连接准备期间可取消,停滞的启动阶段会在限定时间内结束;意外断连会保留 pane 与失败状态,明确退出 shell 时仍正常关闭。 -- 复制 WSL 标签时保留当前来宾目录与所选发行版;复制 SSH 标签时保留已知远端工作目录,并在启动新远端 shell 时正确引用该路径。 -- 修复 Windows 原生 SSH 会话在本地控制台启动时消耗远端应用首个设备属性应答的问题;该问题可能导致远端应用停留在空白画面等待应答。 -- 带修饰键的回车与 ASCII 快捷键会保留协商键盘协议中的修饰信息,包括 Shift+Enter、Ctrl+Enter 和 Alt+V;协议重置后的键盘模式查询现在返回实际生效的标志。 -- 读取输出的程序关闭管道后,Runtime CLI 响应与事件订阅会正常结束,不再弹出 panic 对话框;其他输出错误仍会报告。 + +- 修复部分 Codex 会话重开应用后只恢复标签、没有接续对话的问题。在 WSL 和 Linux 中,即使 hook 未上报 ID,也能识别当前对话,并在关闭窗口或通过应用内“退出”操作结束前保存;原生 Windows 和 macOS 会话仍依赖 hook 上报的 ID。 +- 修复关闭最后一个普通窗口后再退出,已保存的标签被空工作区覆盖的问题;主动关闭全部标签时,下次仍从空工作区开始。 +- 修复选中终端文字后按 `Ctrl+C` 没有复制的问题:现在会复制并清除选区,没有选区时仍中断当前命令。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#93](https://github.com/Kuddev/pebrel/pull/93) 中贡献。 +- 修复受支持的终端输入模式和受管理 PowerShell 提示符中,`Ctrl+Backspace` 只删除一个字符的问题,现在可按词删除。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#92](https://github.com/Kuddev/pebrel/pull/92) 中贡献。 +- 修复已协商多行输入模式下 `Shift+Enter` 被当成普通回车,以及 Antigravity 回答结束后仍一直转圈的问题;`Ctrl+Enter`、`Alt+V` 等按键也会保留协商协议要求的修饰信息。对应 [#95](https://github.com/Kuddev/nebula/issues/95)。其中部分修复由 [@821869798](https://github.com/821869798) 在 [#96](https://github.com/Kuddev/pebrel/pull/96) 中贡献。 +- 修复 Windows SSH 会话启动时丢失应用首个终端能力应答,可能使全屏命令行程序一直停在空白画面的问题。 +- 修复复制 WSL 和 SSH 标签后丢失已知工作目录的问题;WSL 同时保留所选发行版与启动设置。 +- 修复文件搜索残留上一个目录的结果,以及新建、重命名或删除文件后结果未及时更新的问题。 +- 修复较长确认消息被对话框截断的问题,现在会换行并适应内容高度。 +- 修复接收输出的程序关闭管道后,Runtime CLI 弹出错误窗口的问题;这种情况现在会正常结束。 #### 改进 -- 将钛银设为默认应用图标,并调整小尺寸比例、提示符笔画与透明边缘。 -- 应用、命令、可执行文件、辅助程序、安装包与受管理的集成统一使用 Pebrel 名称。新建本地会话同时提供 `PEBREL_*` 环境变量和旧版兼容别名。 -- Windows 安装器会把已注册的 `Nebula Terminal` 安装目录迁到同级 `Pebrel`,更新其快捷方式与 PATH 项,并保留自定义目录名称和未知文件;清理失败时会明确报告并支持重试。 -- 既有应用数据会复制到 Pebrel 数据目录,不覆盖较新的文件,也不删除旧目录。新配置名称优先,旧环境变量与 `require 'nebula'` Lua 配置继续兼容。 -- 项目仓库现为 `Kuddev/pebrel`,已有的 `Kuddev/nebula` 仓库及 Git 地址会重定向到新地址;历史发布资产文件名保持不变。 -- 文件名索引采用增量更新,监听队列溢出时通过重新扫描恢复;仅编辑文件内容不再重建对应名称条目。 -- 改进流式 Agent 输出中的终端公式解析与绘制,无法渲染时保留原始文本。 -- 设置导航更紧凑,“应用”保持首项,终端选项名称更清晰,并配有含义匹配的图标;外观设置按字体、光标、界面和背景分组,提供可展开的详细说明。 -- 降低浅色主题中悬停与选中背景的色彩强度。 -- 顶部标签布局中,设置现在作为可选择、可关闭的标签显示;打开设置时切换标签布局会保留侧栏状态,关闭最后一个终端标签后仍可继续使用设置,直到关闭设置页。 -- SSH 编辑器在最近使用项之外提供常用用户名,保留手动输入,并改进主机图标与置顶控件;连接界面更准确地显示已完成阶段和失败状态。 -- 终端补全列表现在会滚动到键盘选中项,支持滚轮浏览与悬停反馈,并在查询变化后清除过期列表位置。 -- 命令完成、响铃和 Agent 待处理通知现在跟随来源 pane,即使标签已移动到另一个窗口;点击通知会聚焦来源 pane,一个 pane 的活动不再抑制另一个 pane 的通知。 + +- 改进 Nebula 到 Pebrel 的升级体验:应用、命令、安装器和受管理的快捷方式统一使用新名称。安装器会迁移标准旧安装目录并保留自定义位置;配置迁移保留旧数据,不覆盖较新的文件。 +- 改进仓库改名后的访问衔接:旧 `Kuddev/nebula` 仓库与 Git 链接会跳转到 `Kuddev/pebrel`,历史下载文件名继续保留。 +- 改进安装包命名,统一采用 `Pebrel-v<版本>-<系统>-<架构>`;同时提供与正式安装器字节一致的旧 Nebula 文件名资产,保证旧版自动更新客户端仍能找到安装包。 +- 改进 SSH 连接反馈:连接准备期间可以取消,停滞阶段会超时结束,意外断连后保留终端并显示错误。本次处理了窗口消失和连接一直卡住的相关表现。对应 [#89](https://github.com/Kuddev/nebula/issues/89)。 +- 改进连续输出时的公式识别与排版,无法显示的公式保留原文;KaTeX 语法支持仍有范围限制。对应 [#84](https://github.com/Kuddev/nebula/issues/84)。 +- 改进通知定位:点击通知会回到发出通知的终端,即使其标签已移动到另一个窗口;其他终端的活动不再抑制这条通知。 +- 改进运行状态指示器,切到其他应用后仍继续播放动画,窗口隐藏或最小化时停止请求动画帧。 +- 改进设置的分组、导航和标签体验,设置页可像普通标签一样关闭;降低浅色主题的选中与悬停颜色强度,SSH 编辑器中的常用用户名和主机控件也更容易找到。 +- 改进补全列表,支持滚轮浏览、自动显示键盘选中项和悬停反馈;查询变化后清除旧列表位置。 ### Contributors -- [@Sakyvo](https://github.com/Sakyvo): terminal network proxy, conditional `Ctrl+C` copy, and word deletion with `Ctrl+Backspace` / 终端网络代理、`Ctrl+C` 按选区复制及 `Ctrl+Backspace` 按词删除。 + +@Sakyvo avatar@821869798 avatar + +**[@Sakyvo](https://github.com/Sakyvo)** - Added the optional Windows terminal proxy, selection-aware `Ctrl+C`, and word deletion with `Ctrl+Backspace`. / 新增可选的 Windows 终端代理、按选区处理的 `Ctrl+C`,以及 `Ctrl+Backspace` 按词删除。([#94](https://github.com/Kuddev/pebrel/pull/94)、[#93](https://github.com/Kuddev/pebrel/pull/93)、[#92](https://github.com/Kuddev/pebrel/pull/92)) + +**[@821869798](https://github.com/821869798)** - Contributed `Shift+Enter` multiline input and Antigravity completion and permission-state detection. / 贡献 `Shift+Enter` 多行输入,以及 Antigravity 回答结束与权限状态识别。([#96](https://github.com/Kuddev/pebrel/pull/96)) + +--- + +**SHA256** + +- `Pebrel-v1.6.0-windows-x64.zip`: `d57f9c61988ca950da1088415717137b85e022475e046d611f3595b48e66ce2d` +- `Pebrel-v1.6.0-windows-x64-setup.exe`: `369d5f93f34d7f90bde91b51a4f66259d1f277619f7e2b618fb6e03d449f605f` +- `NebulaTerminal-1.6.0-windows-x64-setup.exe`: `369d5f93f34d7f90bde91b51a4f66259d1f277619f7e2b618fb6e03d449f605f` +- `Pebrel-v1.6.0-linux-x64-preview.AppImage`: `91e44fef5f56b3bc5f840a603cbcbf1a8186c4fa042ea012a47b11855cc305cd` +- `Pebrel-v1.6.0-linux-x64-preview.deb`: `f3f4f6292f5460dd7ed626d9a5d8279ae89f7cce1bd21c90db7acd4164eef57a` +- `Pebrel-v1.6.0-linux-x64-preview.tar.gz`: `0e56fb2a01d8cce71e4cda8290f8edde1868d37e588cdbf443bc42dee18dd22b` +- `Pebrel-v1.6.0-macos-arm64-preview.dmg`: `1b747113b2d6f868f2547a365d72a0ec4a2d045900708f2f7c18315d2691cf74` +- `Pebrel-v1.6.0-macos-x64-preview.dmg`: `1bf53ef3dc957a8e3bac89b5a516f4c1153651c48eb58e912180f3ab48a46548` ## 1.5.0 - 2026-09-01 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..290a2f3 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,23 @@ +# Code of Conduct + +Pebrel welcomes contributors and users with different backgrounds and levels of experience. + +## Participation + +- Treat other people with respect. Discuss code and ideas without personal attacks. +- Give constructive feedback and make space for questions and differing perspectives. +- Do not harass, threaten, discriminate against, or publish private information about others. +- Keep issues, pull requests, and project discussions relevant and free of spam. + +## Reporting + +Report conduct concerns privately to **fickleheartedkeys@163.com**. Include links +and enough context for the maintainers to review the situation. Do not include +unrelated private information. + +Maintainers may remove inappropriate content or restrict participation when needed +to protect project discussions. Reports will be handled with respect for the +privacy of those involved. + +This policy applies to the project's issues, pull requests, discussions, and other +spaces administered by the Pebrel maintainers. diff --git a/Cargo.lock b/Cargo.lock index 095b3e0..6e484e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1414,7 +1414,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "gpui_util", "indexmap", @@ -2024,7 +2024,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "proc-macro2", "quote", @@ -3159,7 +3159,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "accesskit", "anyhow", @@ -3245,7 +3245,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/Kuddev/gpui-component?rev=fa3763c681dba5b3ea028395148488e1281d478d#fa3763c681dba5b3ea028395148488e1281d478d" +source = "git+https://github.com/Kuddev/gpui-component?rev=2dd896281102c7d5a0137ac7c754886dc0d5e1fc#2dd896281102c7d5a0137ac7c754886dc0d5e1fc" dependencies = [ "aho-corasick", "anyhow", @@ -3328,7 +3328,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=fa3763c681dba5b3ea028395148488e1281d478d#fa3763c681dba5b3ea028395148488e1281d478d" +source = "git+https://github.com/Kuddev/gpui-component?rev=2dd896281102c7d5a0137ac7c754886dc0d5e1fc#2dd896281102c7d5a0137ac7c754886dc0d5e1fc" dependencies = [ "anyhow", "gpui", @@ -3342,7 +3342,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/Kuddev/gpui-component?rev=fa3763c681dba5b3ea028395148488e1281d478d#fa3763c681dba5b3ea028395148488e1281d478d" +source = "git+https://github.com/Kuddev/gpui-component?rev=2dd896281102c7d5a0137ac7c754886dc0d5e1fc#2dd896281102c7d5a0137ac7c754886dc0d5e1fc" dependencies = [ "proc-macro2", "quote", @@ -3352,7 +3352,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "accesskit", "accesskit_unix", @@ -3404,7 +3404,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "accesskit", "accesskit_macos", @@ -3447,12 +3447,13 @@ dependencies = [ "smallvec", "strum 0.27.2", "uuid", + "zed-font-kit", ] [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3463,7 +3464,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "console_error_panic_hook", "gpui", @@ -3476,7 +3477,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "schemars", "serde", @@ -3486,7 +3487,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "anyhow", "log", @@ -3496,7 +3497,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3519,7 +3520,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "anyhow", "bytemuck", @@ -3549,7 +3550,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "accesskit", "accesskit_windows", @@ -3845,7 +3846,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "anyhow", "async-compression", @@ -4807,7 +4808,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "anyhow", "bindgen", @@ -6279,7 +6280,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "collections", "serde", @@ -7178,7 +7179,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "derive_refineable", ] @@ -7773,7 +7774,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "async-task", "backtrace", @@ -8636,7 +8637,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "heapless", "log", @@ -9961,7 +9962,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "perf", "quote", @@ -11972,7 +11973,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "anyhow", "chrono", @@ -11989,7 +11990,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" dependencies = [ "tracing", "tracing-subscriber", @@ -12000,7 +12001,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/Kuddev/zed?rev=eb8e1c8b5502b7007465fbbc465f4a736fa39210#eb8e1c8b5502b7007465fbbc465f4a736fa39210" +source = "git+https://github.com/Kuddev/zed?rev=8a1a62727bcb2ead7352e32e7e0d2e919a943535#8a1a62727bcb2ead7352e32e7e0d2e919a943535" [[package]] name = "zune-core" diff --git a/Cargo.toml b/Cargo.toml index 629b173..454a063 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,8 @@ toml = "0.9.11" toml_edit = "0.24.0" # GPUI v1.16.1 把平台启动入口拆到独立 crate。它必须与 `gpui` 使用完全相同 # 的 Git URL 和 rev,否则 Cargo 会把同名类型解析成两套互不兼容的 crate。 -gpui_platform = { git = "https://github.com/Kuddev/zed", rev = "eb8e1c8b5502b7007465fbbc465f4a736fa39210", version = "=0.1.0" } +# Without font-kit, the macOS platform uses NoopTextSystem and renders no text. +gpui_platform = { git = "https://github.com/Kuddev/zed", rev = "8a1a62727bcb2ead7352e32e7e0d2e919a943535", version = "=0.1.0", features = ["font-kit"] } # TODO: Drop this pin and return to the crates.io release once the fix ships there. [patch.crates-io] @@ -96,6 +97,18 @@ winit = { path = "third_party/winit-0.30.13" } # @ fa3763c681dba5b3ea028395148488e1281d478d # (c20dbef8 基线 + 下游虚拟窗口测试支持) # +# 当前窗口生命周期修补(2026-09-06,保留以上上游/组件基线): +# Kuddev/zed nebula-v1.16.1-virtual-window-handles-20260906 +# @ 8a1a62727bcb2ead7352e32e7e0d2e919a943535 +# (TestWindow 对不存在的原生窗口/显示句柄返回 Unavailable;真实窗口路径不变) +# Kuddev/gpui-component nebula-v1.16.1-virtual-window-handles-20260906 +# @ 2dd896281102c7d5a0137ac7c754886dc0d5e1fc +# (五个 Zed 依赖同步到上面的同一提交,避免两套 GPUI 类型) +# 原因:macOS 密码输入的原生 content-type 适配已处理 Err,但虚拟窗口此前 +# 直接 panic,阻止完整对话框回归测试。X11 checked MapWindow 还会把事件留在 +# XCB 内部队列而使 fd 不可读;延后 drain 并唤醒 calloop,才能启动首次刷新。 +# 该问题由原样 Xvfb/libxcb 与 CI AppImage 重现;上游包含这些合同后撤销薄修补。 +# # 所有 Zed 依赖统一使用 Kuddev URL:Cargo 的 source identity 包含 URL,应用 # 与 component 内部若混用官方/自有 URL,即使 SHA 相同也会解析成两套 gpui # 类型。新增 Zed/component 补丁必须从固定基线开独立分支、逐条提交并更新 @@ -161,6 +174,6 @@ winit = { path = "third_party/winit-0.30.13" } # 上游提供等价的下游 headless 测试支持后撤销本补丁。 # # 当前精确引用已包含补丁 1-3 与 6;Input 补丁 4-5 仍保持独立,尚未带入本次升级。 -gpui = { git = "https://github.com/Kuddev/zed", rev = "eb8e1c8b5502b7007465fbbc465f4a736fa39210", version = "=0.2.2" } -gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "fa3763c681dba5b3ea028395148488e1281d478d", version = "=0.5.2" } -gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "fa3763c681dba5b3ea028395148488e1281d478d", version = "=0.5.1" } +gpui = { git = "https://github.com/Kuddev/zed", rev = "8a1a62727bcb2ead7352e32e7e0d2e919a943535", version = "=0.2.2" } +gpui-component = { git = "https://github.com/Kuddev/gpui-component", rev = "2dd896281102c7d5a0137ac7c754886dc0d5e1fc", version = "=0.5.2" } +gpui-component-assets = { git = "https://github.com/Kuddev/gpui-component", rev = "2dd896281102c7d5a0137ac7c754886dc0d5e1fc", version = "=0.5.1" } diff --git a/INSTALL.md b/INSTALL.md index a31eabf..e9dcdf8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -7,21 +7,20 @@ application at startup. ## Linux Preview -Cross-platform Preview runs provide three Linux x86_64 assets. They are test -builds rather than stable releases. +The 1.6.0 release provides three Linux x64 Preview packages. - Debian/Ubuntu: install - `Pebrel-v-preview.-linux-x86_64.deb` with - `sudo apt install ./Pebrel-v-preview.-linux-x86_64.deb`. - Remove it with `sudo apt remove pebrel-preview`. + `Pebrel-v-linux-x64-preview.deb` with + `sudo apt install ./Pebrel-v-linux-x64-preview.deb`. + Remove it with `sudo apt remove pebrel`. - AppImage: make - `Pebrel-v-preview.-linux-x86_64.AppImage` executable and + `Pebrel-v-linux-x64-preview.AppImage` executable and run it directly. It does not register itself with the system package manager. - Portable archive: extract the `tar.gz` and run its `AppRun` launcher. Keep the AppDir layout intact so bundled libraries are found correctly. -The initial Preview target is Linux x86_64 with glibc 2.35 or newer. The -Preview workflow is configured to build on Ubuntu 22.04 and run the same +The Linux target is x86_64 with glibc 2.35 or newer. The +release workflow builds on Ubuntu 22.04 and runs the same Runtime API conformance suite against the final AppImage under X11 and Wayland, and against the installed Debian package under X11. @@ -35,15 +34,15 @@ the current connection instead; Pebrel does not silently claim to save it. Download the DMG matching the Mac architecture: -- `macos-aarch64.dmg` for Apple Silicon Macs. -- `macos-x86_64.dmg` for Intel Macs. +- `Pebrel-v-macos-arm64-preview.dmg` for Apple Silicon Macs. +- `Pebrel-v-macos-x64-preview.dmg` for Intel Macs. -Open the DMG and drag **Pebrel Preview** into Applications. These -Preview builds default to ad-hoc signing, without Apple notarization. For a +Open the DMG and drag **Pebrel** into Applications. The 1.6.0 +packages use ad-hoc signing, without Apple notarization. For a download you have verified and trust, macOS may require **System Settings → Privacy & Security → Open Anyway** after an initial launch is blocked. Do not disable Gatekeeper globally. Developer ID/notarized builds are explicitly -identified in their Preview notes; that mode requires the maintainer's Apple +identified in their release notes; that mode requires the maintainer's Apple credentials and fails rather than falling back to ad-hoc signing. The workflow builds both architectures on native macOS 15 runners. It checks @@ -57,16 +56,21 @@ a UTF-8 locale and starts from the home directory when launched from `/`, while an explicit working directory is preserved. Both platforms use the embedded Maple Mono terminal font without requiring a system font installation. -Preview is not full Windows feature parity: tray/close-to-background residency, +Linux/macOS do not yet provide full Windows feature parity: tray/close-to-background residency, global quick-terminal hotkeys, automatic update installation, and automatic local AI-hook configuration are not enabled on Linux/macOS. Native IME, display scaling, notification permissions, and interactive SSH/SFTP still need native user testing. The release procedure and acceptance checklist are in [`docs/preview-release-checklist.md`](docs/preview-release-checklist.md). +Earlier CI Preview archives may contain `-preview.` and use `x86_64` / +`aarch64` filenames; those older builds use the Debian package name +`pebrel-preview` and the macOS bundle name **Pebrel Preview**. The 1.6.0 platform +Preview packages use `pebrel` and **Pebrel**, as described above. + ## Windows installer (recommended) -1. Download `Pebrel--windows-x64-setup.exe` from the +1. Download `Pebrel-v-windows-x64-setup.exe` from the [Releases](https://github.com/Kuddev/pebrel/releases/latest) page. 2. Follow the wizard to choose the installation directory and optional desktop or Windows sign-in shortcuts. The default per-user installation does not @@ -121,8 +125,10 @@ Keep the extracted directory structure intact: ## Build from source -Requirements: Windows 10 1809+ / 11 and [rustup](https://rustup.rs). The -repository pins Rust 1.97.1 in `rust-toolchain.toml`. +Install [rustup](https://rustup.rs) and the build dependencies for your platform. +The repository pins Rust 1.97.1 in `rust-toolchain.toml`. Linux dependency packages +are listed in `.github/workflows/release.yml`; macOS requires Xcode command-line +tools. Windows requires Windows 10 1809+ / 11 and a supported Rust linker toolchain. ```powershell git clone https://github.com/Kuddev/pebrel diff --git a/README.md b/README.md index 5525d8b..fc9cf6b 100644 --- a/README.md +++ b/README.md @@ -1,513 +1,186 @@

- Pebrel — 钛银 / Titanium + Pebrel Titanium icon

Pebrel

-Repository / 仓库:[Kuddev/pebrel](https://github.com/Kuddev/pebrel)。 -Existing `Kuddev/nebula` repository and Git links redirect here. -旧 `Kuddev/nebula` 仓库与 Git 链接会自动跳转到新地址。 - -Contributing / 参与贡献:[Guide / 指南](CONTRIBUTING.md) · [Architecture / 架构](docs/architecture.md) · [Engineering contracts / 工程规范](docs/project-constraints.md) -

- A GPU-accelerated terminal for Windows that keeps your sessions alive — close the window, your claude conversation survives.
- 一款 GPU 加速的 Windows 终端:关闭窗口不杀会话,重新打开,你的 claude 对话原样回来。 + A GPU-accelerated terminal, SSH workspace, and home for your AI CLI sessions.

- Rust - OpenGL - Windows - PowerShell - License + Built with Rust and GPUI for Windows, macOS, and Linux.
+ Split terminals · SSH & SFTP · Claude Code & Codex workflows · Native document reader

- Stars - Forks - Last commit - linux.do + Rust + OpenGL ES 2.0+ + Windows 10 and 11 + macOS 14+ + Linux glibc 2.35+ + PowerShell + GPL-3.0 license

- English · 简体中文 + Stars + Forks + Last commit + linux.do

- Pebrel horizontal title-bar tabs and translucent background -

- -

- Claude Code running in Pebrel -

- -

- - Pebrel with OpenCode, Claude Code, and Codex in split panes + English · 简体中文

--- -## English - -### ✨ About - -Pebrel (formerly Nebula) is a terminal emulator for Windows, built in Rust on a -GPU-accelerated rendering core and designed around one idea: **your terminal -sessions are too valuable to die with a window**. It pairs a tmux-style -resident session model with a glass UI, an AI-CLI-aware sidebar, and a shell -experience that works without extra setup. - -### 🚀 Features - -**Sessions that survive** - -- **Session residency** — closing the window *detaches* instead of killing: - every PTY (your running `claude`, builds, SSH sessions) keeps running in a - resident process. Launch Pebrel again and the window re-attaches — same - processes, same scrollback, mid-conversation. -- **Cold session restore** — if the resident process is gone (reboot, crash), - the next launch still restores your tab layout and per-tab working - directories from a continuously autosaved snapshot, with a crash-loop - breaker. -- **Single instance** — a second launch hands over to the running instance - instead of piling up windows. - -**Built for AI-CLI workflows** -

- - AI-aware sidebar + Terminal tabs in Pebrel

-- **Real brand marks in the sidebar** — a tab running `claude` shows the - actual Anthropic starburst; `codex` shows the OpenAI blossom, tinted to the - theme. Other programs get Nerd Font icons (`gemini`, `copilot`, `git`, - `vim`, `cargo`, …). -- **Live turn state, wired to the source** — Pebrel installs Claude Code - hooks (and Codex notify) pointing at a bundled bridge - (`pebrel-hook.exe`, dependency-free): prompt submitted → spinner; turn - finished → dot + toast; Claude needs your input → toast carrying the actual - message text. Delivered over a local named pipe, with no shell integration - required. -- **Click-to-focus notifications** — every toast knows which pane raised it: - click one and Pebrel comes to the foreground, switches to that tab and - focuses that split. -- **Zero setup, self-healing** — the hook entries install on first boot and - re-install themselves if a config switcher rewrites the file (a watcher on - the config directory re-applies them). Scoped by environment: claude - running in any other terminal is untouched. `pebrel setup-ai --remove` - undoes everything. -- **Plays nice with existing notifiers** — codex has a single notify slot; - Pebrel wraps it (`--chain`) instead of stealing it, so a pre-existing - notifier keeps firing. -- **Fallback signals** — OSC 133 command tracking + BEL still cover every - other CLI: long builds toast on completion, with their duration. -- **Native SSH sessions** — saved hosts open directly through Pebrel's Rust SSH - transport: no wrapper shell and no external console window. Host aliases, - usernames, ports and identity files are resolved from `~/.ssh/config`; - authentication supports standard private keys and certificates, encrypted-key - passphrases, Windows Credential Manager passwords, and keyboard-interactive/MFA. - Accepted host keys use the standard `known_hosts` store, and additional tabs - reuse an already authenticated - connection to the same `user@host:port` for a faster second shell. -- **Built-in SFTP transfers** — open a remote file drawer from any saved SSH - host and reuse its authenticated connection. Browse or type remote paths, - filter entries, upload and download files or folders, create and rename - folders, recursively delete, follow symlink targets, and cancel transfers - with visible progress and errors. -- **AI-aware over SSH** — remote Hook envelopes can return through a - per-channel, randomly authenticated private OSC bridge. Pebrel validates the - channel token, replaces any remote pane identifier with the local pane, and - routes the event through the existing sidebar and Windows notification path. - The `pebrel ssh` compatibility command remains available for forwarding, - query and explicit-command forms that need the system SSH client. -

- Pebrel native SSH session + Claude Code running in Pebrel

-**Performance & correctness** - -- **Instrumented startup** — the boot path is fully traceable - (`PEBREL_BOOT_TRACE=1`); no shell profile is loaded and history loads - lazily. -- **Modern ConPTY host** — ships the side-by-side ConPTY host for correct - resize behavior, with its startup handshake (DA1) pre-primed so a new tab - doesn't stall on that round-trip. -- **Coalesced resizing** — interactive drags resize the grid only; the PTY - learns its final size once, so full-screen TUIs don't smear redraws into - scrollback. - -**Shell experience** - -- **Inline ghost-text completions** — fish-style dim suggestions from command - history and filesystem paths; accept with `→` or `Tab`. -- **Persistent indexed history** — commands stored as JSONL under - `%APPDATA%\Pebrel`, shared across sessions, with prefix hints. -- **Powerline prompt built in** — themed gradient prompt with git branch and - clock, for PowerShell and Git Bash, no plugins to install. -- **Quality-of-life fixes** — unquoted `cd D:/Program Files` just works, bare - `$env:KEY=value` assignments are auto-quoted, `ls` gets colors and - clickable OSC 8 hyperlinks. - -**Markdown that renders like GitHub** -

- Pebrel rendering its own README: centred logo, badge row, and an embedded screenshot + OpenCode, Claude Code, and Codex in split panes

-- **Markdown/GFM document tabs** — open Markdown, JSON, and plain-text files - directly from the file drawer with headings, lists, tables, code blocks, - links, quotes, word wrapping, and virtualized scrolling. -- **Images from anywhere** — remote `http(s)` URLs, `data:` URIs, absolute - paths, and paths relative to the document all resolve, so badge rows, logos, - and embedded screenshots land exactly where the file puts them. The - screenshot above is Pebrel rendering its own README. -- **The HTML that real READMEs use** — `

` centres its - content, several `` badges in one paragraph stay on a single row instead - of breaking into separate blocks, `
` breaks in document order, and - `` link badges survive as clickable images. -- **CJK-correct soft wraps** — CommonMark soft line breaks join as spaces, but - adjacent CJK characters join directly, so Chinese paragraphs don't pick up - the stray gaps other viewers insert. +## One Workspace -**Documents & native mathematics** +Pebrel (formerly Nebula) brings local shells, remote hosts, files, and AI command-line +tools into a native desktop workspace. Arrange terminals in tabs and splits, follow +each agent's activity, and read its output without leaving the application. + +### Terminals and Sessions + +- Sidebar or top tabs, draggable split panes, and per-pane working directories. +- Saved workspace layouts and optional AI conversation restoration. On WSL and + Linux, Codex session metadata can supply an ID when its hook has not reported one. +- On Windows, optional background residency keeps running sessions alive when you + close the window. Restoring a conversation after the process exits is a separate + feature and requires a supported CLI and a usable session identity. +- History and path completions, configurable keybindings, and integrated shell prompts. + +### SSH and Files + +- Saved hosts, SSH config aliases, proxy and jump-host options, private-key and + keyboard-interactive authentication, and host-key verification. +- SFTP browsing, uploads and downloads, folder transfers, progress, and cancellation. +- Local file browsing and Git actions alongside your terminals. +- Duplicated WSL and SSH tabs retain their known working directory.

- Native mathematics rendered in a Pebrel Markdown document + Native SSH session

-- **Native TeX mathematics** — inline `$...$` and display `$$...$$` formulas - are parsed and laid out entirely in Rust, then drawn through cached Latin - Modern Math glyphs and GPU rule quads. Fractions, radicals, scripts, limits, - integrals, matrices, scalable delimiters, Greek letters, and common symbols - render without a WebView, JavaScript runtime, or external TeX process. -- **Bounded document rendering** — oversized formulas fit the reading column, - long prose and failed formulas wrap, and parser, layout, cache, atlas, and - per-frame work all have explicit limits. See the - [math rendering fixture](docs/math-rendering-test.md) for the screenshot and - regression set. +### AI CLI Workflows -**Interface** +- Claude Code, Codex, and other recognized CLIs have their own icons and activity + states. Supported hook events provide more precise progress and attention signals. +- Notifications follow their source pane; clicking one returns you to that terminal. +- Captured Claude Code and Codex answers open in a reader with Markdown, formulas, + source text, and local image previews. +- Clipboard images are saved as PNG files and their paths inserted into local, + WSL, or SSH sessions. Inline terminal images require the CLI to emit the supported + OSC 1337 protocol; image attachment previews depend on the CLI itself.

- - Themes + AI activity sidebar

-- **Glass chrome & seven themes** — Pebrel plus three matched light/dark - pairs: Silver Light / Steel Dark, Limestone / Coal Dark, Linen Light / - Moss Dark. One skin system drives chrome, prompt and dialogs; every theme - persists across restarts. -- **Tabs & splits** — sidebar tabs with drag-to-reorder and drag-to-dock into - splits; unfocused panes dim instead of growing borders. -- **Files, Git and SFTP drawers** — browse local or remote files without - leaving the terminal. The Git drawer can stage, commit, pull fast-forward - updates, and push. -- **Quick terminal** — global Ctrl+` drops a Quake-style - terminal from the top edge. -- **Settings tab & command palette** — settings opens as a normal reusable tab, - with themes, language, background image/opacity, shell choice and completion - behavior; all persisted. -- **Inline images** — OSC 1337 image protocol support, lazily anchored to - scrollback rows. +### A Native, Configurable Interface -### 📥 Install +- GPU-accelerated GPUI interface, light and dark themes, backgrounds, and opacity controls. +- Application icon palettes and eleven UI language choices with English fallback + for untranslated text. +- Searchable settings, a command palette, and Lua configuration with validation and + live reload. Existing TOML configuration remains supported. +- Markdown document tabs and native mathematical typesetting without a WebView. -Pebrel embeds **Maple Mono Normal NF CN** for terminal text, powerline symbols, -and icons. The Windows installer also offers system font installation. -The font is licensed under SIL OFL 1.1. +

+ Native formula rendering +

-**Windows packages** are listed on -[Releases](https://github.com/Kuddev/pebrel/releases/latest). Pebrel 1.6 uses -`Pebrel--windows-x64-setup.exe` for installation and -`Pebrel-v-windows-x64.zip` for portable use. Extract the ZIP and run -`pebrel.exe`; keep `runtime/`, `docs/`, `skills/`, and `licenses/` in place. -Older releases retain their original asset names. See the -[installation guide](INSTALL.md) for upgrade and configuration migration details. +

+ Application themes +

-**From source** +## Download -```powershell +Choose a package from the **[latest release](https://github.com/Kuddev/pebrel/releases/latest)**. + +| System | Architecture | Packages | +| --- | --- | --- | +| Windows 10 1809+ / 11 | x64 | Installer `.exe`, portable `.zip` | +| Linux Preview, glibc 2.35+ | x64 | `.AppImage`, `.deb`, portable `.tar.gz` | +| macOS Preview, deployment target 14+ | Apple Silicon / arm64 | `.dmg` | +| macOS Preview, deployment target 14+ | Intel / x64 | `.dmg` | + +Package names follow `Pebrel-v--`. Windows offers +stable installers ending in `-setup.exe` and portable ZIPs. Linux and macOS are +Preview builds, marked `-preview` before the extension. A legacy-named Windows +installer is also supplied for older automatic-update clients. + +On Windows, run the installer or extract the ZIP and launch `pebrel.exe`, keeping +the bundled directories together. On Linux, install the DEB or make the AppImage +executable. On macOS, open the matching DMG and drag Pebrel into Applications. +Ad-hoc-signed macOS builds may require **Open Anyway** in **System Settings > +Privacy & Security** on first launch. Native macOS CI runs on macOS 15; the +deployment target alone does not establish validation on every older OS version. + +Windows currently also provides tray residency, the global quick-terminal hotkey, +automatic local AI-hook setup, and automatic update installation. These integrations +are not yet available on Linux or macOS. See [installation details](INSTALL.md) for +platform requirements and upgrading an existing Nebula installation. + +## Configure + +```sh +pebrel config init --language en-US +pebrel config check +``` + +The generated Lua configuration uses `require 'pebrel'` and +`pebrel.config_builder()`. Invalid reloads retain the last valid configuration. +See the [Lua configuration guide](docs/lua-configuration.md) for settings and examples. + +## Build + +With the pinned Rust toolchain and the platform dependencies installed: + +```sh cargo build --release --locked -p nebula --bin pebrel --features gpui-shell ``` -### Lua Configuration +The internal Cargo package is still named `nebula`; the application and command +are `pebrel`. GPUI is the product interface. The older renderer is available only +through the explicit `legacy-shell` feature. -Pebrel ships a vendored Lua 5.4 runtime with `require 'pebrel'` and -`pebrel.config_builder()`. Generate an annotated template with -`pebrel config init --language system|zh-CN|en-US`, then validate it without -opening the GUI using `pebrel config check`. Module `require` paths participate -in live reload; invalid edits keep the last-known-good configuration active. -Existing TOML files remain supported. See the -[Lua configuration guide](docs/lua-configuration.md) for APIs, discovery order, -Windows/Linux paths, arrays, modules, and reload behavior. +## Contact -### 🧩 Tech Stack +Email: [fickleheartedkeys@163.com](mailto:fickleheartedkeys@163.com) -| Layer | Tech | -| --- | --- | -| Language | Rust (2024 edition) | -| Rendering | OpenGL / OpenGL ES 2.0+, custom glyph & UI quad renderers | -| Terminal core | GPU-resident grid + VTE escape-sequence parsing, on an Alacritty-derived foundation | -| Session model | Resident mux process, loopback attach protocol | -| Shell integration | PowerShell + PSReadLine, Git Bash; OSC 7/8/9/133/1337 | -| Document math | pulldown-cmark + pulldown-latex + OpenType MATH (Latin Modern Math) | -| Fonts | Maple Mono Normal NF CN (Nerd Font glyphs, CJK-aware) | +## Acknowledgements -### 📦 Requirements +Pebrel builds on [Alacritty](https://github.com/alacritty/alacritty), +[GPUI](https://github.com/zed-industries/zed), and +[gpui-component](https://github.com/longbridge/gpui-component). Terminal text uses +Maple Mono, and native formulas use Latin Modern Math. Upstream copyright and +license notices are preserved in `THIRD-PARTY-NOTICES` and `licenses/`. -- At least OpenGL ES 2.0 -- Windows 10 (1809+) / 11 with ConPTY support +## Community -### Contact - -Questions and feedback: [fickleheartedkeys@163.com](mailto:fickleheartedkeys@163.com) - -### 🙏 Acknowledgements - -Pebrel's terminal core — the grid, the VTE escape-sequence parsing, and the PTY -plumbing — is derived from -**[Alacritty](https://github.com/alacritty/alacritty)**, a masterpiece of -terminal engineering. Sincere thanks to its maintainers and contributors; -Pebrel would not exist without their work. - -Pebrel's shell is built on **[GPUI](https://github.com/zed-industries/zed)** — the -GPU-accelerated UI framework developed by the [Zed](https://zed.dev) team — together with -**[gpui-component](https://github.com/longbridge/gpui-component)** from -[Longbridge](https://github.com/longbridge), which supplies the widget library. Both are -Apache-2.0 licensed and are used through lightly patched forks pinned in the workspace -manifest. Thank you for releasing this work as open source — Pebrel's interface would not -look the way it does without it. - -### 📜 License - -Released under the [GNU General Public License v3.0](LICENSE). - -Parts of Pebrel are derived from upstream open-source projects; their original -license and copyright notices are preserved in -[THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES). The work as a whole is distributed -under GPL-3.0. - ---- - -## 简体中文 - -### ✨ 简介 - -Pebrel(原名 Nebula)是一款 Windows 上的终端模拟器,以 Rust 编写,构建在 GPU 加速渲染 -内核之上,并围绕一个理念设计:**终端会话太宝贵,不该随窗口一起消失**。它 -把 tmux 式的常驻会话模型、玻璃质感界面、AI CLI 感知侧边栏和无需额外配置 -的 shell 体验组合在一起。 - -### 🚀 功能特性 - -**会话永生** - -- **会话驻留** — 关闭窗口是*分离*而不是杀死:所有 PTY(正在跑的 - `claude`、构建、SSH)继续在常驻进程里运行。再次启动 Pebrel,窗口原样接回 - —— 同样的进程、同样的回滚缓冲、对话进行到一半也不丢。 -- **冷恢复** — 常驻进程不在了(重启、崩溃)也没关系:下次启动从持续自动保 - 存的快照恢复标签布局和每个标签的工作目录,并带崩溃循环保护。 -- **单实例** — 重复启动会交棒给运行中的实例,不会堆一堆窗口。 - -**为 AI CLI 工作流而生** - -

- - AI 感知侧边栏 -

- -- **侧栏显示真品牌标识** — 跑 `claude` 的标签显示 Anthropic 珊瑚星芒, - `codex` 显示 OpenAI 花结(贴图渲染、跟随主题染色);其余程序用 - Nerd Font 图标(`gemini`、`copilot`、`git`、`vim`、`cargo`……)。 -- **回合状态实时直连** — Pebrel 自动安装 Claude Code hooks(及 Codex - notify),指向随包的桥接器 `pebrel-hook.exe`(无第三方依赖): - 提交 prompt → 转圈;回合完成 → 圆点 + 通知;claude 要你确认 → 通知里 - 带消息原文。经本地命名管道传递,无需任何 shell 集成。 -- **通知点击直达** — 每条 toast 都知道自己来自哪个 pane:点一下,Pebrel - 前置、切到那个标签、聚焦那个分屏。 -- **零配置、自愈合** — hook 条目首次启动自动写入;被配置切换工具覆盖后 - 会自动补回(配置目录上有监视器重新写入)。作用域由环境变量限定:其他 - 终端里的 claude 完全不受影响。`pebrel setup-ai --remove` 一键撤销。 -- **不抢占已有 notifier** — codex 只有一个 notify 槽位,Pebrel 用 - `--chain` 包装而非顶掉:原有通知程序照常触发。 -- **兜底信号** — OSC 133 命令跟踪 + BEL 覆盖其余所有 CLI:长构建完成也 - 弹通知,并带耗时。 -- **原生 SSH 会话** — 保存的主机现在直接通过 Pebrel 的 Rust SSH 传输连接, - 不再经过包装 Shell,也不会弹出外部控制台窗口。主机别名、用户名、端口和 - IdentityFile 会从 `~/.ssh/config` 解析;认证覆盖标准私钥与证书、加密密钥 - 口令、Windows 凭据管理器密码以及 keyboard-interactive/MFA。主机密钥写入 - 标准 `known_hosts`;同一 `user@host:port` 再开标签页会复用已认证连接,让第二个 - Shell 更快出现。 -- **内置 SFTP 传输** — 从已保存 SSH 主机即可打开远端文件抽屉,并复用已认证 - 连接。支持浏览或手动输入远端路径、筛选、上传/下载文件与文件夹、新建与重命名 - 文件夹、递归删除、跟随符号链接目标,以及带进度、取消和错误提示的后台传输。 -- **SSH 里也 AI 感知** — 远端 Hook 信封可通过每通道随机认证的私有 OSC 桥 - 返回。Pebrel 会校验通道令牌,用本地 Pane 身份覆盖远端字段,再复用现有侧栏 - 状态和 Windows 通知链路。需要端口转发、配置查询或显式远程命令时,仍可使用 - `pebrel ssh` 兼容命令交给系统 SSH 客户端处理。 - -

- Pebrel 原生 SSH 会话 -

- -**性能与正确性** - -- **启动全程可观测** — 启动路径全程打点(`PEBREL_BOOT_TRACE=1`),不加载 - shell profile,历史惰性加载。 -- **现代 ConPTY 宿主** — 内置 side-by-side ConPTY 宿主保证 resize 行为正确, - 并预热其启动握手(DA1),让新标签不必卡在这一次往返上。 -- **合并式 resize** — 拖动窗口时只调整网格,PTY 在拖动结束后一次性获知最终 - 尺寸,全屏 TUI 不会把重绘刷进历史。 - -**Shell 体验** - -- **行内幽灵补全** — fish 风格暗色建议,来自命令历史与文件路径, - `→` 或 `Tab` 接受。 -- **持久化索引历史** — 命令以 JSONL 存于 `%APPDATA%\Pebrel`,跨会话共享, - 提供前缀提示。 -- **内置 powerline 提示符** — 主题化渐变提示符,含 git 分支与时钟, - PowerShell 与 Git Bash 皆可用,无需安装任何插件。 -- **顺手的小修正** — 不带引号的 `cd D:/Program Files` 直接可用,裸 - `$env:KEY=value` 自动加引号,`ls` 带颜色和可点击的 OSC 8 超链接。 - -**Markdown 解析:照着 GitHub 的样子显示** - -

- Pebrel 正在渲染自己的 README:居中 logo、徽章横排、内嵌截图 -

- -- **Markdown/GFM 文档标签页** — 可以从文件抽屉直接打开 Markdown、JSON 和 - 纯文本文件,原生显示标题、列表、表格、代码块、链接、引用、自动换行和虚拟滚动。 -- **图片来源不挑** — 远程 `http(s)` 链接、`data:` URI、绝对路径,以及相对文档 - 自身位置的路径都能解析,徽章行、logo 和内嵌截图都落在文件让它们出现的地方。 - 上面这张图就是 Pebrel 在渲染自己的 README。 -- **真实 README 会用到的那些 HTML** — `

` 居中生效,同一段里 - 的多枚 `` 徽章横排显示而不会被拆成若干块,`
` 按文档顺序断行, - `` 形式的链接徽章不会整个丢失。 -- **中文软换行不留空隙** — CommonMark 的软换行按规范并作空格,但相邻中日韩 - 字符直接相连,中文段落不会出现别的阅读器常见的那些多余空格。 - -**文档与原生数学公式** - -

- Pebrel Markdown 文档中的原生数学公式 -

- -- **原生 TeX 数学排版** — 行内 `$...$` 与块级 `$$...$$` 公式全部由 Rust - 解析和布局,再使用缓存的 Latin Modern Math 字形与 GPU 规则线 quad 绘制。 - 分式、根式、上下标、极限、积分、矩阵、伸缩括号、希腊字母和常用符号均不经过 - WebView、JavaScript 运行时或外部 TeX 进程。 -- **有界文档渲染** — 超宽公式会收敛到阅读列,长文本和失败公式会继续换行; - 解析、布局、缓存、字形图集和每帧工作量均有明确上限。截图与回归内容见 - [数学渲染测试文档](docs/math-rendering-test.md)。 - -**界面** - -

- - 七套主题 -

- -- **玻璃质感与七套主题** — Pebrel + 三对明暗成组主题:Silver Light / - Steel Dark、Limestone / Coal Dark、Linen Light / Moss Dark。一套皮肤 - 系统驱动 chrome、提示符与弹窗,主题选择跨重启持久化。 -- **标签与分屏** — 侧边栏标签支持拖拽排序、拖入终端区四方位分屏;非焦点 - 面板压暗而非描边。 -- **文件、Git 与 SFTP 抽屉** — 不离开终端即可浏览本地或远端文件;Git 抽屉 - 支持暂存、提交、仅快进拉取和推送。 -- **快速终端** — 全局 Ctrl+` 从屏幕顶部滑出 - Quake 式终端。 -- **设置标签页与命令面板** — 设置像普通标签页一样打开和复用,支持主题、 - 跟随系统/简体中文/English、背景图/透明度、Shell 与补全行为,并跨重启持久化。 -- **行内图片** — 支持 OSC 1337 图片协议,惰性锚定到回滚行。 - -### 📥 安装 - -Pebrel 内嵌 **Maple Mono Normal NF CN**,用于终端文字、powerline 符号与图标。 -Windows 安装器同时提供可选的系统字体安装。字体使用 SIL OFL 1.1 许可。 - -**Windows 安装包**见 -[Releases](https://github.com/Kuddev/pebrel/releases/latest)。Pebrel 1.6 的安装器命名为 -`Pebrel--windows-x64-setup.exe`,便携包命名为 -`Pebrel-v-windows-x64.zip`。解压 ZIP 后运行 `pebrel.exe`,保留 -`runtime/`、`docs/`、`skills/` 与 `licenses/` 目录。旧版本资产保留原文件名。 -升级与配置迁移详见[安装指南](INSTALL.md)。 - -**从源码构建** - -```powershell -cargo build --release --locked -p nebula --bin pebrel --features gpui-shell -``` - -### Lua 配置 - -Pebrel 内置 Lua 5.4,使用 `require 'pebrel'` 与 -`pebrel.config_builder()`。运行 -`pebrel config init --language system|zh-CN|en-US` 可生成带完整注释的模板, -`pebrel config check` 可在不启动 GUI 的情况下检查语法与字段。通过 `require` -载入的模块会参与热重载;配置错误时继续使用上一份有效配置。已有 TOML 仍兼容。 -路径优先级、Windows/Linux 位置、数组、模块与重载说明见 -[Lua 配置指南](docs/lua-configuration.md)。 - -### 🧩 技术栈 - -| 层级 | 技术 | -| --- | --- | -| 语言 | Rust(2024 edition) | -| 渲染 | OpenGL / OpenGL ES 2.0+,自定义字形与 UI 四边形渲染器 | -| 终端内核 | GPU 常驻网格 + VTE 转义序列解析,基于 Alacritty 衍生的地基 | -| 会话模型 | 常驻 mux 进程,环回 attach 协议 | -| Shell 集成 | PowerShell + PSReadLine、Git Bash;OSC 7/8/9/133/1337 | -| 文档数学 | pulldown-cmark + pulldown-latex + OpenType MATH(Latin Modern Math) | -| 字体 | Maple Mono Normal NF CN(Nerd Font 图标,支持 CJK) | - -### 📦 环境要求 - -- 至少 OpenGL ES 2.0 -- Windows 10(1809+)/ 11,需 ConPTY 支持 - -### 联系方式 - -问题与反馈:[fickleheartedkeys@163.com](mailto:fickleheartedkeys@163.com) - -### 🙏 致谢 - -Pebrel 的终端内核——网格、VTE 转义序列解析与 PTY 管路——衍生自 -**[Alacritty](https://github.com/alacritty/alacritty)**,终端工程的杰作。 -衷心感谢它的维护者与贡献者们;没有他们的工作就没有 Pebrel。 - -Pebrel 的界面层构建在 **[GPUI](https://github.com/zed-industries/zed)** 之上——由 -[Zed](https://zed.dev) 团队开发的 GPU 加速 UI 框架——并使用 -[Longbridge](https://github.com/longbridge) 的 -**[gpui-component](https://github.com/longbridge/gpui-component)** 作为组件库。两者均以 -Apache-2.0 许可发布,Pebrel 通过带少量补丁的 fork 使用,版本在 workspace 清单中精确锁定。 -感谢把这些工作开源——没有它,Pebrel 现在的界面不会是这个样子。 - -### 📜 许可证 - -基于 [GNU 通用公共许可证 v3.0(GPL-3.0)](LICENSE) 发布。 - -Pebrel 的部分代码衍生自上游开源项目,其原始许可与版权声明保留于 -[THIRD-PARTY-NOTICES](THIRD-PARTY-NOTICES)。整体作品以 GPL-3.0 分发。 - ---- - -## 🔗 友情链接 / Community - -- **[linux.do](https://linux.do)** — 新的理想型社区 / a thriving developer community. - ---- +- **[linux.do](https://linux.do)** - A thriving developer community. ## ⭐ Star History - + - - - Star History Chart + + + Star History Chart diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..5a55465 --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,171 @@ +

+ Pebrel 钛银图标 +

+ +

Pebrel

+ +

+ GPU 加速终端、SSH 工作区,以及你的 AI CLI 会话空间。 +

+ +

+ 使用 Rust 与 GPUI 构建,面向 Windows、macOS 和 Linux。
+ 分屏终端 · SSH 与 SFTP · Claude Code 与 Codex 工作流 · 原生文档阅读 +

+ +

+ Rust + OpenGL ES 2.0+ + Windows 10 和 11 + macOS 14+ + Linux glibc 2.35+ + PowerShell + GPL-3.0 许可证 +

+ +

+ Stars + Forks + 最近提交 + linux.do +

+ +

+ English · 简体中文 +

+ +--- + +

+ Pebrel 终端标签 +

+ +

+ Pebrel 中运行的 Claude Code +

+ +

+ OpenCode、Claude Code 与 Codex 分屏工作流 +

+ +## 一个工作区 + +Pebrel(原名 Nebula)把本地 Shell、远程主机、文件和 AI 命令行工具放进同一个原生桌面工作区。 +通过标签和分屏组织终端,关注各个 Agent 的活动,并直接在应用中阅读输出。 + +### 终端与会话 + +- 侧栏或顶部标签、可拖拽分屏,以及各窗格独立的工作目录。 +- 保存工作区布局,可选恢复 AI 对话。在 WSL 与 Linux 中,Codex 的 hook 未上报 ID 时, + 可从当前会话元数据补全身份。 +- Windows 下可选择后台驻留,让关窗后的会话继续运行。进程退出后重新接续已保存的对话 + 是另一项功能,需要 CLI 支持恢复,并且有可用的会话身份。 +- 历史记录与路径补全、自定义快捷键,以及集成的 Shell 提示符。 + +### SSH 与文件 + +- 保存主机、读取 SSH 配置别名、设置代理与跳板,支持私钥和交互式认证,并验证主机密钥。 +- SFTP 文件浏览、上传下载、文件夹传输、进度显示与取消。 +- 在终端旁浏览本地文件并执行 Git 操作。 +- 复制 WSL 与 SSH 标签时保留已知工作目录。 + +

+ 原生 SSH 会话 +

+ +### AI CLI 工作流 + +- Claude Code、Codex 等可识别的 CLI 显示各自图标与活动状态;受支持的 hook 事件提供更准确的进度和待处理信号。 +- 通知跟随来源窗格,点击即可返回对应终端。 +- 已捕获的 Claude Code 与 Codex 回答可在阅读器中打开,支持 Markdown、公式、原文和本地图片预览。 +- 剪贴板图片会保存为 PNG,并把路径插入本地、WSL 或 SSH 会话。终端内直接显示图片需要 CLI + 输出受支持的 OSC 1337 协议;附件缩略图取决于 CLI 自身。 + +

+ AI 活动侧栏 +

+ +### 原生、可配置的界面 + +- GPU 加速的 GPUI 界面、浅色与深色主题、背景及不透明度设置。 +- 应用图标配色与十一种界面语言,未翻译内容回退英文。 +- 可搜索的设置、命令面板,以及支持检查和热重载的 Lua 配置;已有 TOML 配置仍可使用。 +- Markdown 文档标签与原生数学公式排版,不依赖 WebView。 + +

+ 原生公式排版 +

+ +

+ 应用主题 +

+ +## 下载 + +从 **[最新发布](https://github.com/Kuddev/pebrel/releases/latest)** 选择对应安装包。 + +| 系统 | 架构 | 安装包 | +| --- | --- | --- | +| Windows 10 1809+ / 11 | x64 | `.exe` 安装器、`.zip` 便携包 | +| Linux Preview,glibc 2.35+ | x64 | `.AppImage`、`.deb`、`.tar.gz` 便携包 | +| macOS Preview,部署目标为 14+ | Apple Silicon / arm64 | `.dmg` | +| macOS Preview,部署目标为 14+ | Intel / x64 | `.dmg` | + +安装包统一采用 `Pebrel-v<版本>-<系统>-<架构>` 命名,Windows 安装器以 `-setup.exe` 结尾。 +Windows 为正式版,Linux 与 macOS 文件名在扩展名前带有 `-preview` 标记。 +同时提供旧名称的兼容安装器,供旧版自动更新客户端使用。 + +Windows 可运行安装器,或解压 ZIP 后启动 `pebrel.exe`,保留随包目录结构。 +Linux 可安装 DEB,或为 AppImage 添加执行权限。macOS 打开对应 DMG,把 Pebrel 拖入“应用程序”。 +采用临时签名的 macOS 包首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。 +原生 macOS CI 运行在 macOS 15 上,部署目标并不代表每个较早系统版本都已通过运行验证。 + +系统托盘驻留、全局快速终端热键、自动配置本地 AI hook 和自动安装更新目前由 Windows 提供, +Linux 与 macOS 尚未提供这些集成。平台要求和旧版 Nebula 升级步骤见[安装说明](INSTALL.md)。 + +## 配置 + +```sh +pebrel config init --language zh-CN +pebrel config check +``` + +生成的 Lua 配置使用 `require 'pebrel'` 与 `pebrel.config_builder()`。 +重载失败时继续使用上一份有效配置。设置与示例见 [Lua 配置指南](docs/lua-configuration.md)。 + +## 构建 + +安装项目固定的 Rust 工具链与对应平台依赖后运行: + +```sh +cargo build --release --locked -p nebula --bin pebrel --features gpui-shell +``` + +内部 Cargo 包名仍为 `nebula`,应用与命令名为 `pebrel`。GPUI 是正式产品界面, +旧渲染器仅在显式启用 `legacy-shell` 时使用。 + +## 联系方式 + +邮箱:[fickleheartedkeys@163.com](mailto:fickleheartedkeys@163.com) + +## 致谢 + +Pebrel 基于 [Alacritty](https://github.com/alacritty/alacritty)、 +[GPUI](https://github.com/zed-industries/zed) 与 +[gpui-component](https://github.com/longbridge/gpui-component) 构建。 +终端使用 Maple Mono 字体,原生公式使用 Latin Modern Math。 +上游版权与许可证声明保留在 `THIRD-PARTY-NOTICES` 和 `licenses/` 中。 + +## 友情链接 + +- **[linux.do](https://linux.do)** - 新的理想型社区。 + +## ⭐ Star History + + + + + + Star History Chart + + diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2f2bccf --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,25 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report suspected security vulnerabilities privately to +**fickleheartedkeys@163.com** before posting technical details in a public issue. + +Include the affected Pebrel version, operating system, reproduction steps, expected +and observed behavior, and the potential impact. A small reproduction using test +data is preferable to logs containing real credentials or private conversations. + +Do not send passwords, access tokens, private keys, or unrelated personal data. +Use public issues for ordinary bugs that do not expose a security vulnerability. + +## Versions + +Use the latest stable release when checking whether a problem is still present. +Mention if it also affects a Preview build or the current `main` branch. +Older releases remain available for reference; availability does not imply +that every older version receives fixes. + +## Follow-up + +The maintainers will review the report and coordinate any necessary fix and public +disclosure. This policy does not promise a response deadline or a bug bounty. diff --git a/architecture/file-budgets.txt b/architecture/file-budgets.txt index 25d6d35..c36ada7 100644 --- a/architecture/file-budgets.txt +++ b/architecture/file-budgets.txt @@ -19,4 +19,4 @@ nebula_app/src/event.rs 3594 nebula_app/src/gpui_shell/terminal/view.rs 2416 nebula_app/src/gpui_shell/workspace.rs 4798 nebula_app/src/window_context.rs 3735 -nebula_terminal/src/term/mod.rs 2716 +nebula_terminal/src/term/mod.rs 2713 diff --git a/docs/architecture-decisions.md b/docs/architecture-decisions.md index 039317a..b08246e 100644 --- a/docs/architecture-decisions.md +++ b/docs/architecture-decisions.md @@ -102,7 +102,34 @@ unrelated feature's growth. Remote approval/enforcement is not implied by this l - **Revisit condition:** Remove compatibility readers only after support for old clients and persisted configurations is explicitly retired. -## ADR-0004 - Molecular diagrams and file hover previews +## ADR-0004 - Current conversation identity at workspace save + +- **Status:** Implemented for the maintainer-reported restore defect, 2026-09-07; + validation is part of the 1.6.0 release candidate checks. +- **Context:** A Codex pane can identify its foreground program without receiving + a session ID from the CLI hook. Saving only the program restores the tab but + cannot construct an exact conversation resume command. +- **Decision:** Keep hook identities authoritative. For WSL and Linux, match the + process environment to both the Pebrel instance and pane, then read only the + first metadata record of open Codex rollout files. Accept one main conversation + whose filename and metadata agree. Do not choose by working directory or time. + Native Windows and macOS keep the existing hook path. +- **Lifecycle:** Queries use the existing background executor, a two-second time + limit and bounded output. Results carry the foreground command generation. + Window close and the application Quit action allow up to three seconds before + saving and stopping panes; the UI thread remains responsive. A refreshed + inferred identity is required at close. Hook identities are not replaced. +- **Persistence:** Reuse the existing optional `source` and `session_id` fields; + there is no new file format or dependency. A missing or ambiguous ID cannot + launch a different conversation as a fallback. Existing autosave and the final + operating-system shutdown callback save the identities already available. +- **Validation:** File save/load and exact resume commands, primary-thread + metadata selection, WSL user arguments, pane/instance isolation, output limits, + stale-result rejection, and a subprocess deadline have focused regressions. +- **Revisit condition:** Replace the procfs fallback when a supported CLI session + identity API covers these launches consistently. + +## ADR-0005 - Molecular diagrams and file hover previews - **Status:** Requested in the current working session, 2026-09-08; implementation and validation in progress, pending normal review. @@ -133,7 +160,7 @@ unrelated feature's growth. Remote approval/enforcement is not implied by this l - **Revisit condition:** Remove or replace an adapter when upstream support covers the same behavior, or measured parsing/rendering cost exceeds the bounded use. -## ADR-0005 — Remote file workflows and persistent host organization +## ADR-0006 — Remote file workflows and persistent host organization - **Status:** Implemented in the working tree on 2026-09-08 for the maintainer's requested workflows. Native compilation and focused regression checks passed; @@ -187,7 +214,7 @@ unrelated feature's growth. Remote approval/enforcement is not implied by this l 记录重大取舍而非每次小修复;事实与测试能推翻旧决定。规范误伤、安全修复与旧预算冲突时, 先记录问题和最小修订,维护者审查后更新合同;不能将“只减不增”变成拒绝纠正规范的理由。 -## ADR-0006 — Editable documents, reusable layouts and pane drop targets +## ADR-0007 — Editable documents, reusable layouts and pane drop targets - **Status:** Implemented in the working tree, 2026-09-08, for the requested editor, recipe and quick-terminal workflows; pending normal maintainer review. @@ -227,7 +254,7 @@ unrelated feature's growth. Remote approval/enforcement is not implied by this l with a deliberate replay contract. Replace the preview block adapter when the pinned TextView exposes an equivalent public heading/navigation API. -## ADR-0007 — Formula bitmap admission and preview view lifetime +## ADR-0008 — Formula bitmap admission and preview view lifetime - **Status:** Implemented for the maintainer's memory reduction request, 2026-09-09; validation and normal review tracked separately. @@ -255,7 +282,7 @@ unrelated feature's growth. Remote approval/enforcement is not implied by this l are enabled/profiled; add viewport eviction only with preserved selection and measured parsed-view accounting. No 50 MB process-wide guarantee is implied. -## ADR-0008 — Optional in-app AI message toasts +## ADR-0009 — Optional in-app AI message toasts - **Status:** Requested by the maintainer, 2026-09-11; implemented in the working tree, with native validation pending. diff --git a/docs/release-notes/v1.6.0.md b/docs/release-notes/v1.6.0.md index d1b3b52..0710588 100644 --- a/docs/release-notes/v1.6.0.md +++ b/docs/release-notes/v1.6.0.md @@ -3,93 +3,98 @@ ## English ### Added -- Clipboard images can be pasted into local, WSL, and SSH terminal sessions as PNG file paths. WSL paths are translated and SSH images are uploaded before insertion; failures are visible, and late results are discarded after input submission, interruption, or a session change. -- Added an application icon picker with 25 color palettes and light/dark previews. -- Added an opt-in terminal network proxy setting. On Windows, new local sessions inherit the system HTTP/HTTPS proxy when enabled; existing sessions keep their environment. -- Added per-host SSH proxy and jump-host options, with separate proxy credentials and a connection-route preview. -- Added an answer reader for captured Claude Code and Codex responses, with Markdown, formulas, source-text mode, and local image previews. -- Added UI language choices for Traditional Chinese, French, German, Spanish, Brazilian Portuguese, Italian, Russian, Japanese, and Korean alongside English and Simplified Chinese. Initial coverage includes navigation, common actions, appearance, and network controls; untranslated text falls back to English. -- Added a settings search that opens the matching section and a confirmed Restore defaults action that backs up preferences while retaining saved hosts and other user data. -- Added the Antigravity product icon to terminal tabs and the sidebar, including its common launcher aliases, while retaining its original colors in light and dark themes. -- Recognized binary terminal confirmations can show Allow and Deny actions in a notification. The action is rejected if the prompt, session, or input state has changed, and is available only once for the captured request. + +- Added Linux x64 Preview packages in AppImage, DEB, and portable tar.gz formats, plus separate macOS Preview DMGs for Apple Silicon and Intel, alongside the stable Windows installer and ZIP. RPM packages are not included. macOS packages use ad-hoc signing and may require Open Anyway in System Settings > Privacy & Security on first launch. Addresses [#87](https://github.com/Kuddev/nebula/issues/87). +- Added clipboard image paste for local, WSL, and SSH terminals. Pebrel saves the image as a PNG and inserts a path that the session can access; SSH images are uploaded first. Attachment thumbnails and images in a CLI's conversation still depend on that CLI. +- Added a reader for captured Claude Code and Codex answers, with Markdown, formulas, original text, and local image previews. +- Added separate SOCKS5/HTTP proxy and jump-host settings for each SSH host, with saved proxy credentials and a connection-route preview. Addresses [#90](https://github.com/Kuddev/nebula/issues/90). +- Added an optional Windows system-proxy setting for newly opened local terminals. It is off by default and does not change existing sessions. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#94](https://github.com/Kuddev/pebrel/pull/94). +- Added nine UI language choices alongside English and Simplified Chinese: Traditional Chinese, French, German, Spanish, Brazilian Portuguese, Italian, Russian, Japanese, and Korean. Untranslated text falls back to English. +- Added 25 application icon palettes with light and dark previews. Titanium is now the default icon. +- Added settings search and a confirmed Restore defaults action. Preferences are backed up before resetting, and saved hosts and other user data are retained. +- Added Allow and Deny actions to notifications for recognized terminal confirmation prompts. Actions stop being available when the prompt or session changes, and each request can be handled only once. ### Fixed -- `Ctrl+C` copies and clears a terminal selection, and still sends an interrupt when no selection is present. Text inputs keep their own copy behavior. -- `Ctrl+Backspace` deletes a word in the supported legacy and kitty keyboard paths, including the managed PowerShell prompt. -- Running tab indicators keep animating when another window has focus; hidden or minimized windows stop requesting animation frames. -- File-name search discards results from an old root, updates after creates, renames and deletes, and handles canonical file-watcher paths without losing the visible directory root. -- Multiline confirmation text is measured with wrapping so longer messages fit the shared confirmation dialog. -- Improved Antigravity activity and permission detection so old spinner output and ordinary prose mentioning approval do not keep an idle session marked as running or waiting for permission. -- Closing the last regular window and then quitting no longer replaces saved tabs and AI session identities with an empty snapshot. Closing all tabs explicitly still starts an empty workspace next time, and failed session writes are retried. -- SSH startup now waits for PTY and shell confirmation before showing Ready, supports cancellation during connection setup, and stops stalled startup stages within a bounded timeout. Unexpected disconnects retain the pane and its failure state; explicit shell exits still close normally. -- Duplicated WSL tabs retain the current guest directory and selected distribution. Duplicated SSH tabs retain the known remote working directory and quote it when starting the new remote shell. -- Fixed Windows native SSH sessions consuming a remote application's first device-attributes response during local console startup, which could leave the application waiting with a blank screen. -- Modified Enter and ASCII shortcuts retain their negotiated keyboard-protocol modifiers, including Shift+Enter, Ctrl+Enter, and Alt+V. Keyboard-mode queries now report the active flags after a protocol reset. -- Runtime CLI responses and event subscriptions stop cleanly when the program reading their output closes its pipe, instead of opening a panic dialog. Other output errors are still reported. + +- Fixed reopening the workspace restoring only the tab for Codex sessions without a hook-reported conversation ID. On WSL and Linux, Pebrel can now identify the current conversation and save it before window close or the application's Quit action. Native Windows and macOS sessions continue to rely on hook-reported IDs. +- Fixed quitting after closing the last regular window replacing saved tabs with an empty workspace. Deliberately closing every tab still starts an empty workspace next time. +- Fixed `Ctrl+C` ignoring selected terminal text. It now copies and clears the selection; without a selection, it still interrupts the running command. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#93](https://github.com/Kuddev/pebrel/pull/93). +- Fixed `Ctrl+Backspace` deleting only one character in supported terminal input modes and the managed PowerShell prompt. It now deletes a word. Contributed by [@Sakyvo](https://github.com/Sakyvo) in [#92](https://github.com/Kuddev/pebrel/pull/92). +- Fixed `Shift+Enter` being treated as plain Enter in negotiated multiline input modes, and Antigravity continuing to show a busy indicator after an answer finished. `Ctrl+Enter`, `Alt+V`, and other modified keys also retain their negotiated modifiers. Addresses [#95](https://github.com/Kuddev/nebula/issues/95). Contributed in part by [@821869798](https://github.com/821869798) in [#96](https://github.com/Kuddev/pebrel/pull/96). +- Fixed Windows SSH sessions losing an application's first terminal-capability response during startup, which could leave a full-screen command-line application waiting on a blank screen. +- Fixed duplicated WSL and SSH tabs losing their known working directory. WSL tabs also retain the selected distribution and launch settings. +- Fixed file search showing results from a previously opened directory or missing newly created, renamed, and deleted files. +- Fixed long confirmation messages being clipped by the dialog instead of wrapping to fit. +- Fixed the Runtime CLI showing an error dialog when the program receiving its output closed the pipe. It now finishes cleanly in that case. ### Improved -- Made Titanium the default application icon and adjusted small-size proportions, prompt strokes, and transparent edges. -- Unified the application, command, executable, helper, package, and managed integration names under Pebrel. New local sessions expose `PEBREL_*` environment variables alongside legacy aliases. -- The Windows installer migrates registered `Nebula Terminal` installation directories to a sibling `Pebrel` directory, updates its shortcuts and PATH entry, and preserves custom directory names and unknown files. Cleanup failures are reported and can be retried. -- Existing application data is copied into the Pebrel data directory without overwriting newer files or removing the old directory. New configuration names take precedence, while old environment variables and `require 'nebula'` Lua configurations remain compatible. -- The project repository is now `Kuddev/pebrel`. Existing `Kuddev/nebula` repository and Git URLs redirect to it; historical release asset filenames remain unchanged. -- File-name indexing applies incremental updates and recovers from watcher queue overflow with a rescan; editing a file's contents alone no longer rebuilds its name entry. -- Improved terminal formula parsing and rendering for streamed agent output, while preserving the original text when a formula cannot be rendered. -- Made the settings navigation more compact, kept Application first, labeled terminal options more clearly, and added matching navigation icons. Appearance settings now show font, cursor, interface, and background controls in groups with expandable help. -- Reduced the intensity of hover and selected backgrounds in light themes. -- Settings now appears as a selectable, closable item in the top tab layout. Switching tab layouts while Settings is open preserves the sidebar state, and closing the last terminal tab keeps Settings available until it is closed. -- SSH editing now offers common usernames alongside recent entries, keeps manual usernames available, and makes host icons and pin controls clearer. Connection screens show completed stages and failures more accurately. -- Terminal completion lists now scroll to reveal keyboard selections, support wheel scrolling and hover feedback, and clear stale list positions when the query changes. -- Completion, bell, and Agent attention notifications now follow their source pane, including after a tab moves to another window. Clicking a notification focuses that pane, and activity in one pane no longer suppresses another pane's notification. + +- Improved the upgrade from Nebula to Pebrel: the application, commands, installer, and managed shortcuts use the new name. The installer migrates the standard old installation directory while preserving custom locations; configuration migration keeps old data and does not overwrite newer files. +- Improved continuity after the repository rename. Old `Kuddev/nebula` repository and Git links redirect to `Kuddev/pebrel`, and historical download filenames remain available. +- Improved package naming to use `Pebrel-v--` consistently. A byte-identical Windows installer with the old Nebula filename remains available for existing automatic-update clients. +- Improved SSH connection feedback: startup can be cancelled, stalled connection stages time out, and an unexpected disconnect leaves the terminal and error visible. This improves the disappearing-window and stuck-connection behavior. Addresses [#89](https://github.com/Kuddev/nebula/issues/89). +- Improved streamed formula recognition and layout, while preserving the original text when a formula cannot be displayed. KaTeX syntax support remains partial. Addresses [#84](https://github.com/Kuddev/nebula/issues/84). +- Improved notifications so clicking one focuses the terminal that raised it, even after moving its tab to another window. Activity in a different terminal no longer suppresses that notification. +- Improved activity indicators so they continue animating when another application has focus; hidden and minimized windows stop requesting animation frames. +- Improved settings with clearer groups, compact navigation, and a normal closable Settings tab. Light-theme selection and hover colors are less intense, and SSH editing makes common usernames and host controls easier to find. +- Improved completion lists with wheel scrolling, visible keyboard selections, and hover feedback. Changing the query clears the old list position. ## 中文 ### 新增 -- 新增把剪贴板图片转换为 PNG 文件路径后粘贴到本地、WSL 与 SSH 终端会话;WSL 会转换路径,SSH 会先上传图片。失败会明确提示,提交输入、中断或会话变化后到达的结果会被丢弃。 -- 新增应用图标选择器,提供 25 款配色及浅色、深色背景预览。 -- 新增默认关闭的终端网络代理设置。Windows 下启用后,新建本地会话继承系统 HTTP/HTTPS 代理;现有会话保留原有环境。 -- SSH 主机新增独立的代理与跳板配置,支持单独保存代理凭据并预览连接路线。 -- 为已捕获的 Claude Code 和 Codex 回答新增阅读视图,支持 Markdown、公式、原文模式和本地图片预览。 -- 在英语和简体中文之外,界面语言新增繁体中文、法语、德语、西班牙语、巴西葡萄牙语、意大利语、俄语、日语及韩语。初版覆盖导航、常用操作、外观与网络控件,未翻译文案回退英文。 -- 新增可跳转到匹配分区的设置搜索,以及需要确认的“恢复默认”操作;重置前备份偏好设置,并保留已保存主机等用户数据。 -- 终端标签与侧栏新增 Antigravity 产品图标,支持常见启动别名,并在浅色和深色主题中保留图标原色。 -- 可识别的终端二选一确认提示可在通知中显示允许与拒绝操作;提示、会话或输入状态变化后会拒绝过期操作,每个捕获的请求只能处理一次。 + +- 新增 Linux x64 Preview 的 AppImage、DEB 和 tar.gz 便携包,以及分别适用于 Apple Silicon 和 Intel 的 macOS Preview DMG;Windows 提供正式版安装器与 ZIP 便携包。本次不包含 RPM。macOS 包采用临时签名,首次启动可能需要在“系统设置 > 隐私与安全性”中选择“仍要打开”。对应 [#87](https://github.com/Kuddev/nebula/issues/87)。 +- 新增本地、WSL 和 SSH 终端的剪贴板图片粘贴:将图片保存成 PNG,再插入当前会话可以访问的路径,SSH 会先上传图片。输入框缩略图和发送后的图片显示仍取决于所用 CLI。 +- 新增 Claude Code 和 Codex 回答阅读器,支持阅读已捕获回答的 Markdown、公式、原文和本地图片。 +- 新增每台 SSH 主机独立的 SOCKS5/HTTP 代理与跳板设置,可保存代理凭据并预览连接路线。对应 [#90](https://github.com/Kuddev/nebula/issues/90)。 +- 新增让新建本地终端使用 Windows 系统代理的选项,默认关闭,不影响已经打开的会话。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#94](https://github.com/Kuddev/pebrel/pull/94) 中贡献。 +- 新增繁体中文、法语、德语、西班牙语、巴西葡萄牙语、意大利语、俄语、日语和韩语界面选项,加上原有英语和简体中文共十一种语言;尚未翻译的内容显示英文。 +- 新增 25 款应用图标配色,提供浅色和深色预览,并将钛银设为默认图标。 +- 新增设置搜索和需要确认的“恢复默认”操作;重置前备份偏好设置,保留已保存主机等用户数据。 +- 新增可识别终端确认提示的通知操作,可直接选择允许或拒绝;提示或会话变化后操作失效,同一请求只能处理一次。 ### 修复 -- 终端中 `Ctrl+C` 会复制并清除选区,没有选区时仍发送中断;文本输入框保留自身的复制行为。 -- `Ctrl+Backspace` 在支持的传统和 kitty 键盘路径中按词删除,并适用于受管理的 PowerShell 提示符。 -- 其他窗口获得焦点后,运行中的标签指示器继续播放动画;窗口隐藏或最小化时停止请求动画帧。 -- 文件名搜索丢弃旧目录的结果,在创建、重命名和删除后更新,并正确处理文件监听器返回的规范路径,保留用户看到的目录根。 -- 多行确认文案按换行后的尺寸测量,使较长消息能完整放入共享确认框。 -- 改进 Antigravity 的活动与权限提示识别,避免历史转圈输出或普通正文中提到审批时,把空闲会话误标为运行中或等待授权。 -- 关闭最后一个普通窗口后再退出,不再用空快照覆盖已保存标签与 AI 会话身份;明确关闭全部标签后,下次仍从空工作区开始,保存失败会重试。 -- SSH 启动现在等待 PTY 与 shell 确认后才显示就绪,连接准备期间可取消,停滞的启动阶段会在限定时间内结束;意外断连会保留 pane 与失败状态,明确退出 shell 时仍正常关闭。 -- 复制 WSL 标签时保留当前来宾目录与所选发行版;复制 SSH 标签时保留已知远端工作目录,并在启动新远端 shell 时正确引用该路径。 -- 修复 Windows 原生 SSH 会话在本地控制台启动时消耗远端应用首个设备属性应答的问题;该问题可能导致远端应用停留在空白画面等待应答。 -- 带修饰键的回车与 ASCII 快捷键会保留协商键盘协议中的修饰信息,包括 Shift+Enter、Ctrl+Enter 和 Alt+V;协议重置后的键盘模式查询现在返回实际生效的标志。 -- 读取输出的程序关闭管道后,Runtime CLI 响应与事件订阅会正常结束,不再弹出 panic 对话框;其他输出错误仍会报告。 + +- 修复部分 Codex 会话重开应用后只恢复标签、没有接续对话的问题。在 WSL 和 Linux 中,即使 hook 未上报 ID,也能识别当前对话,并在关闭窗口或通过应用内“退出”操作结束前保存;原生 Windows 和 macOS 会话仍依赖 hook 上报的 ID。 +- 修复关闭最后一个普通窗口后再退出,已保存的标签被空工作区覆盖的问题;主动关闭全部标签时,下次仍从空工作区开始。 +- 修复选中终端文字后按 `Ctrl+C` 没有复制的问题:现在会复制并清除选区,没有选区时仍中断当前命令。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#93](https://github.com/Kuddev/pebrel/pull/93) 中贡献。 +- 修复受支持的终端输入模式和受管理 PowerShell 提示符中,`Ctrl+Backspace` 只删除一个字符的问题,现在可按词删除。由 [@Sakyvo](https://github.com/Sakyvo) 在 [#92](https://github.com/Kuddev/pebrel/pull/92) 中贡献。 +- 修复已协商多行输入模式下 `Shift+Enter` 被当成普通回车,以及 Antigravity 回答结束后仍一直转圈的问题;`Ctrl+Enter`、`Alt+V` 等按键也会保留协商协议要求的修饰信息。对应 [#95](https://github.com/Kuddev/nebula/issues/95)。其中部分修复由 [@821869798](https://github.com/821869798) 在 [#96](https://github.com/Kuddev/pebrel/pull/96) 中贡献。 +- 修复 Windows SSH 会话启动时丢失应用首个终端能力应答,可能使全屏命令行程序一直停在空白画面的问题。 +- 修复复制 WSL 和 SSH 标签后丢失已知工作目录的问题;WSL 同时保留所选发行版与启动设置。 +- 修复文件搜索残留上一个目录的结果,以及新建、重命名或删除文件后结果未及时更新的问题。 +- 修复较长确认消息被对话框截断的问题,现在会换行并适应内容高度。 +- 修复接收输出的程序关闭管道后,Runtime CLI 弹出错误窗口的问题;这种情况现在会正常结束。 ### 改进 -- 将钛银设为默认应用图标,并调整小尺寸比例、提示符笔画与透明边缘。 -- 应用、命令、可执行文件、辅助程序、安装包与受管理的集成统一使用 Pebrel 名称。新建本地会话同时提供 `PEBREL_*` 环境变量和旧版兼容别名。 -- Windows 安装器会把已注册的 `Nebula Terminal` 安装目录迁到同级 `Pebrel`,更新其快捷方式与 PATH 项,并保留自定义目录名称和未知文件;清理失败时会明确报告并支持重试。 -- 既有应用数据会复制到 Pebrel 数据目录,不覆盖较新的文件,也不删除旧目录。新配置名称优先,旧环境变量与 `require 'nebula'` Lua 配置继续兼容。 -- 项目仓库现为 `Kuddev/pebrel`,已有的 `Kuddev/nebula` 仓库及 Git 地址会重定向到新地址;历史发布资产文件名保持不变。 -- 文件名索引采用增量更新,监听队列溢出时通过重新扫描恢复;仅编辑文件内容不再重建对应名称条目。 -- 改进流式 Agent 输出中的终端公式解析与绘制,无法渲染时保留原始文本。 -- 设置导航更紧凑,“应用”保持首项,终端选项名称更清晰,并配有含义匹配的图标;外观设置按字体、光标、界面和背景分组,提供可展开的详细说明。 -- 降低浅色主题中悬停与选中背景的色彩强度。 -- 顶部标签布局中,设置现在作为可选择、可关闭的标签显示;打开设置时切换标签布局会保留侧栏状态,关闭最后一个终端标签后仍可继续使用设置,直到关闭设置页。 -- SSH 编辑器在最近使用项之外提供常用用户名,保留手动输入,并改进主机图标与置顶控件;连接界面更准确地显示已完成阶段和失败状态。 -- 终端补全列表现在会滚动到键盘选中项,支持滚轮浏览与悬停反馈,并在查询变化后清除过期列表位置。 -- 命令完成、响铃和 Agent 待处理通知现在跟随来源 pane,即使标签已移动到另一个窗口;点击通知会聚焦来源 pane,一个 pane 的活动不再抑制另一个 pane 的通知。 + +- 改进 Nebula 到 Pebrel 的升级体验:应用、命令、安装器和受管理的快捷方式统一使用新名称。安装器会迁移标准旧安装目录并保留自定义位置;配置迁移保留旧数据,不覆盖较新的文件。 +- 改进仓库改名后的访问衔接:旧 `Kuddev/nebula` 仓库与 Git 链接会跳转到 `Kuddev/pebrel`,历史下载文件名继续保留。 +- 改进安装包命名,统一采用 `Pebrel-v<版本>-<系统>-<架构>`;同时提供与正式安装器字节一致的旧 Nebula 文件名资产,保证旧版自动更新客户端仍能找到安装包。 +- 改进 SSH 连接反馈:连接准备期间可以取消,停滞阶段会超时结束,意外断连后保留终端并显示错误。本次处理了窗口消失和连接一直卡住的相关表现。对应 [#89](https://github.com/Kuddev/nebula/issues/89)。 +- 改进连续输出时的公式识别与排版,无法显示的公式保留原文;KaTeX 语法支持仍有范围限制。对应 [#84](https://github.com/Kuddev/nebula/issues/84)。 +- 改进通知定位:点击通知会回到发出通知的终端,即使其标签已移动到另一个窗口;其他终端的活动不再抑制这条通知。 +- 改进运行状态指示器,切到其他应用后仍继续播放动画,窗口隐藏或最小化时停止请求动画帧。 +- 改进设置的分组、导航和标签体验,设置页可像普通标签一样关闭;降低浅色主题的选中与悬停颜色强度,SSH 编辑器中的常用用户名和主机控件也更容易找到。 +- 改进补全列表,支持滚轮浏览、自动显示键盘选中项和悬停反馈;查询变化后清除旧列表位置。 ## Contributors -- [@Sakyvo](https://github.com/Sakyvo): terminal network proxy, conditional `Ctrl+C` copy, and word deletion with `Ctrl+Backspace` / 终端网络代理、`Ctrl+C` 按选区复制及 `Ctrl+Backspace` 按词删除。 -## SHA256 +@Sakyvo avatar@821869798 avatar -```text -5ADE2DB18BFCAB653240AE7B93D507587087C31D1FC684B331121426AE23915B Pebrel-v1.6.0-windows-x64.zip -400672F52E5D37965983C37293E07286B52BDD74F8D8462432349C8C646EE2CA Pebrel-1.6.0-windows-x64-setup.exe -``` +**[@Sakyvo](https://github.com/Sakyvo)** - Added the optional Windows terminal proxy, selection-aware `Ctrl+C`, and word deletion with `Ctrl+Backspace`. / 新增可选的 Windows 终端代理、按选区处理的 `Ctrl+C`,以及 `Ctrl+Backspace` 按词删除。([#94](https://github.com/Kuddev/pebrel/pull/94)、[#93](https://github.com/Kuddev/pebrel/pull/93)、[#92](https://github.com/Kuddev/pebrel/pull/92)) + +**[@821869798](https://github.com/821869798)** - Contributed `Shift+Enter` multiline input and Antigravity completion and permission-state detection. / 贡献 `Shift+Enter` 多行输入,以及 Antigravity 回答结束与权限状态识别。([#96](https://github.com/Kuddev/pebrel/pull/96)) + +--- + +**SHA256** + +- `Pebrel-v1.6.0-windows-x64.zip`: `d57f9c61988ca950da1088415717137b85e022475e046d611f3595b48e66ce2d` +- `Pebrel-v1.6.0-windows-x64-setup.exe`: `369d5f93f34d7f90bde91b51a4f66259d1f277619f7e2b618fb6e03d449f605f` +- `NebulaTerminal-1.6.0-windows-x64-setup.exe`: `369d5f93f34d7f90bde91b51a4f66259d1f277619f7e2b618fb6e03d449f605f` +- `Pebrel-v1.6.0-linux-x64-preview.AppImage`: `91e44fef5f56b3bc5f840a603cbcbf1a8186c4fa042ea012a47b11855cc305cd` +- `Pebrel-v1.6.0-linux-x64-preview.deb`: `f3f4f6292f5460dd7ed626d9a5d8279ae89f7cce1bd21c90db7acd4164eef57a` +- `Pebrel-v1.6.0-linux-x64-preview.tar.gz`: `0e56fb2a01d8cce71e4cda8290f8edde1868d37e588cdbf443bc42dee18dd22b` +- `Pebrel-v1.6.0-macos-arm64-preview.dmg`: `1b747113b2d6f868f2547a365d72a0ec4a2d045900708f2f7c18315d2691cf74` +- `Pebrel-v1.6.0-macos-x64-preview.dmg`: `1bf53ef3dc957a8e3bac89b5a516f4c1153651c48eb58e912180f3ab48a46548` diff --git a/nebula_app/src/agent_env.rs b/nebula_app/src/agent_env.rs index 1ed612b..0fe8039 100644 --- a/nebula_app/src/agent_env.rs +++ b/nebula_app/src/agent_env.rs @@ -64,7 +64,7 @@ const PATH_ENV: &str = "PATH"; /// [`crate::shell_detect::wsl_cwd_report_env`] 也会往里追加条目,本函数以 /// 环境表里的现值为基准合并,从而与调用顺序无关地保住两边的条目。 pub fn apply(env: &mut HashMap, pane_id: impl Display) { - let aliases = crate::brand::environment_aliases( + let aliases = crate::platform::environment::environment_aliases( env.iter().map(|(name, value)| (name.into(), value.into())).collect(), ); for (name, value) in aliases { diff --git a/nebula_app/src/brand.rs b/nebula_app/src/brand.rs index 71a7513..3ea586a 100644 --- a/nebula_app/src/brand.rs +++ b/nebula_app/src/brand.rs @@ -1,52 +1,6 @@ pub const NAME: &str = "Pebrel"; pub const DESCRIPTION: &str = "Pebrel — a GPU-accelerated terminal emulator"; -/// Must run before any threads start: environment mutation is process-global. -pub unsafe fn import_environment_aliases() { - for (name, value) in environment_aliases(std::env::vars_os().collect()) { - unsafe { std::env::set_var(name, value) }; - } -} - -pub(crate) fn environment_aliases( - variables: Vec<(std::ffi::OsString, std::ffi::OsString)>, -) -> Vec<(String, std::ffi::OsString)> { - let mut aliases = Vec::new(); - for (name, value) in &variables { - let Some(name) = name.to_str() else { continue }; - let canonical = if cfg!(windows) { name.to_ascii_uppercase() } else { name.to_owned() }; - let name = canonical.as_str(); - if let Some(suffix) = name.strip_prefix("PEBREL_") { - if configuration_override(suffix) && value.is_empty() { - continue; - } - aliases.push((format!("NEBULA_{suffix}"), value.clone())); - } else if let Some(suffix) = name.strip_prefix("NEBULA_") { - if configuration_override(suffix) && value.is_empty() { - continue; - } - let current = format!("PEBREL_{suffix}"); - if !variables.iter().any(|(name, value)| { - name.to_str().is_some_and(|name| { - let matches = if cfg!(windows) { - name.eq_ignore_ascii_case(¤t) - } else { - name == current - }; - matches && !(configuration_override(suffix) && value.is_empty()) - }) - }) { - aliases.push((current, value.clone())); - } - } - } - aliases -} - -fn configuration_override(suffix: &str) -> bool { - matches!(suffix, "CONFIG_DIR" | "CONFIG_FILE" | "GPUI_CONFIG") -} - #[cfg(test)] mod tests { use clap::CommandFactory; @@ -61,54 +15,4 @@ mod tests { assert_eq!(crate::config::window::Identity::default().title, super::NAME); assert_eq!(crate::config::window::DEFAULT_CLASS, "Pebrel"); } - - #[test] - fn environment_aliases_preserve_legacy_inputs_and_prefer_explicit_pebrel_values() { - let aliases = super::environment_aliases( - [ - ("NEBULA_CONFIG_DIR", "legacy"), - ("PEBREL_CONFIG_DIR", "current"), - ("NEBULA_PANE_REMOTE", "1"), - ("OTHER_TOOL", "unchanged"), - ] - .into_iter() - .map(|(name, value)| (name.into(), value.into())) - .collect(), - ); - assert_eq!( - aliases, - vec![ - ("NEBULA_CONFIG_DIR".to_owned(), "current".into()), - ("PEBREL_PANE_REMOTE".to_owned(), "1".into()), - ] - ); - } - - #[test] - fn empty_configuration_overrides_fall_back_without_reopening_an_empty_hook_scope() { - let aliases = super::environment_aliases( - [ - ("PEBREL_CONFIG_DIR", ""), - ("NEBULA_CONFIG_DIR", "legacy-config"), - ("PEBREL_CONFIG_FILE", ""), - ("NEBULA_CONFIG_FILE", "legacy.toml"), - ("PEBREL_GPUI_CONFIG", ""), - ("NEBULA_GPUI_CONFIG", "legacy-gpui.toml"), - ("PEBREL_NOTIFY_PIPE", ""), - ("NEBULA_NOTIFY_PIPE", "stale-pipe"), - ] - .into_iter() - .map(|(name, value)| (name.into(), value.into())) - .collect(), - ); - assert_eq!( - aliases, - vec![ - ("PEBREL_CONFIG_DIR".to_owned(), "legacy-config".into()), - ("PEBREL_CONFIG_FILE".to_owned(), "legacy.toml".into()), - ("PEBREL_GPUI_CONFIG".to_owned(), "legacy-gpui.toml".into()), - ("NEBULA_NOTIFY_PIPE".to_owned(), "".into()), - ] - ); - } } diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index 437a9a5..1955790 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -226,6 +226,29 @@ impl Element for TerminalElement { let hitbox = window.insert_hitbox(bounds, HitboxBehavior::Normal); let view = self.view.read(cx); + // Bounded, opt-in evidence that a real layout reached this pane. PTY + // resize traces alone cannot distinguish missing prepaint from a + // viewport which still matches the spawn grid. + static TRACE_ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + static TRACE_SAMPLES: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + if *TRACE_ENABLED.get_or_init(|| std::env::var_os("NEBULA_RESIZE_TRACE").is_some()) + && TRACE_SAMPLES.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < 256 + { + crate::gpui_shell::try_write_stderr(format_args!( + "[nebula:resize-trace] prepaint pane={} origin=({:.2},{:.2}) content={:.2}x{:.2} cell={:.2}x{:.2} scale={:.2} viewport={}x{}", + view.pane_id, + bounds.origin.x.as_f32(), + bounds.origin.y.as_f32(), + bounds.size.width.as_f32(), + bounds.size.height.as_f32(), + cell_width.as_f32(), + line_height.as_f32(), + scale, + view.grid_cols(), + view.grid_rows(), + )); + } TermLayout { cell_width, line_height, diff --git a/nebula_app/src/gpui_shell/terminal/keymap.rs b/nebula_app/src/gpui_shell/terminal/keymap.rs index af0adb7..655ee79 100644 --- a/nebula_app/src/gpui_shell/terminal/keymap.rs +++ b/nebula_app/src/gpui_shell/terminal/keymap.rs @@ -3,6 +3,9 @@ //! 只编码控制键与带修饰组合;普通可打印字符(含中文 IME 提交文本)走 //! `EntityInputHandler::replace_text_in_range`,避免同一按键被编码两次。 +#[cfg(windows)] +mod win32; + use gpui::Keystroke; use nebula_terminal::term::TermMode; @@ -90,161 +93,6 @@ fn use_win32_input_mode(mode: &TermMode) -> bool { crate::input::terminal_input::use_win32_input_mode(*mode) } -/// 无修饰的字母/数字/空格必须交给 IME / `TranslateMessage`,不能编进 PTY。 -/// -/// GPUI 的 Windows 后端:`on_key_down` 一旦 `stop_propagation`,就不会再 -/// `TranslateMessage`。IME 组字(微软拼音)是 TranslateMessage 喂进去的; -/// 把 `n`/`i` 编成 KEY_EVENT_RECORD 等于把拼音当英文写进 shell,中文永远 -/// 起不来。旧壳对应合同是 `keyboard.rs`:`ime.preedit()` 期间直接 return。 -#[cfg(windows)] -fn win32_encodes_keystroke(ks: &Keystroke) -> bool { - if ks.modifiers.control || ks.modifiers.alt || ks.modifiers.platform { - return true; - } - let key = ks.key.as_str(); - if key == "space" { - return false; - } - let mut chars = key.chars(); - match (chars.next(), chars.next()) { - (Some(c), None) if c.is_ascii_alphanumeric() => false, - _ => true, - } -} - -/// GPUI 键名 → Win32 虚拟键码。 -/// -/// 旧壳从 winit fork 的 `RawKeyEventInfo` 直接拿到系统报的 VK;GPUI 的 -/// `Keystroke` 只有键名,所以这里按名字反查。表只覆盖**编码器会处理的键** -/// (控制键、方向、功能键)——可打印字符在 GPUI 走 IME 管道,不经这里。 -#[cfg(windows)] -fn virtual_key_of(key: &str) -> Option { - use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ - VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_HOME, VK_INSERT, VK_LEFT, - VK_NEXT, VK_PRIOR, VK_RETURN, VK_RIGHT, VK_SPACE, VK_TAB, VK_UP, - }; - - let vk = match key { - "escape" => VK_ESCAPE, - "enter" => VK_RETURN, - "tab" => VK_TAB, - "backspace" => VK_BACK, - "space" => VK_SPACE, - "up" => VK_UP, - "down" => VK_DOWN, - "left" => VK_LEFT, - "right" => VK_RIGHT, - "home" => VK_HOME, - "end" => VK_END, - "insert" => VK_INSERT, - "delete" => VK_DELETE, - "pageup" => VK_PRIOR, - "pagedown" => VK_NEXT, - // F1..F24 在 VK 表里连号。 - key if key.starts_with('f') => { - let index: u16 = key[1..].parse().ok()?; - if !(1..=24).contains(&index) { - return None; - } - VK_F1 + index - 1 - }, - // 单个字符(Ctrl+C 一族):ASCII 字母数字的 VK 就是它的大写码点。 - key => { - let mut chars = key.chars(); - let (Some(c), None) = (chars.next(), chars.next()) else { return None }; - if !c.is_ascii_alphanumeric() { - return None; - } - c.to_ascii_uppercase() as u16 - }, - }; - Some(vk) -} - -/// 控制键必须携带真实 `KEY_EVENT_RECORD` 的字符值(Esc=0x1B、Enter=0x0D、 -/// Tab=0x09、Backspace=0x08):OpenConsole 1.22 的 VT 翻译层会丢弃 uChar=0 -/// 的 VK_ESCAPE,于是读字节流的那类应用(Claude Code)收不到 Esc。修饰键与 -/// 功能键保持 0,与真实键盘一致。逐条同旧壳 `control_char_fallback`。 -#[cfg(windows)] -fn unicode_char_of(ks: &Keystroke) -> u16 { - // 平台已经判出文本的(含 Ctrl 变体)以它为准,与 WM_CHAR 语义一致。 - // `key_char` 若是 NUL,当作没文本:真实键盘的 Esc 不会写出 U+0000。 - if let Some(text) = ks.key_char.as_deref() { - let mut units = text.encode_utf16(); - if let (Some(first), None) = (units.next(), units.next()) { - if first != 0 { - return first; - } - } - } - match ks.key.as_str() { - "escape" => 0x1b, - "enter" => b'\r' as u16, - "tab" => b'\t' as u16, - // 真实控制台的 Ctrl+Backspace 记录携带 Uc=DEL(0x7f,WM_CHAR 语义), - // PSReadLine 的原生 Ctrl+Backspace=BackwardKillWord 绑定按此匹配。 - // 给 0x08 会跌进 KeyChar 分发被当成 Ctrl+H——只删一个字符。 - "backspace" => { - if ks.modifiers.control { - 0x7f - } else { - 0x08 - } - }, - "space" => b' ' as u16, - _ => 0, - } -} - -/// 一条 ConPTY Win32 input 记录:`CSI Vk;Sc;Uc;Kd;Cs;Rc_`。 -/// -/// 认不出 VK 的键返回 `None`,调用方回落到传统 VT 编码——宁可少一条记录, -/// 也不要编一个 Vk=0 的假记录,那会让子进程读到一个不存在的键。 -#[cfg(windows)] -fn win32_input_record(ks: &Keystroke, key_down: bool) -> Option> { - use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW}; - - const SHIFT_PRESSED: u32 = 0x0010; - const LEFT_ALT_PRESSED: u32 = 0x0002; - const LEFT_CTRL_PRESSED: u32 = 0x0008; - - let vk = virtual_key_of(ks.key.as_str())?; - // 扫描码问系统要,不硬编码:非 US 布局与笔记本键盘上这张表并不通用。 - let scan_code = unsafe { MapVirtualKeyW(u32::from(vk), MAPVK_VK_TO_VSC) }; - let mut control_key_state = 0u32; - if ks.modifiers.shift { - control_key_state |= SHIFT_PRESSED; - } - if ks.modifiers.alt { - control_key_state |= LEFT_ALT_PRESSED; - } - if ks.modifiers.control { - control_key_state |= LEFT_CTRL_PRESSED; - } - let key_down = u8::from(key_down); - Some( - format!( - "\x1b[{};{};{};{key_down};{};1_", - vk, - scan_code, - unicode_char_of(ks), - control_key_state - ) - .into_bytes(), - ) -} - -/// GPUI 的终端视图只接到 key-down。旧壳对 9001 会再写一条 Kd=0 的抬起 -/// (`keyboard.rs` 的 `key_release` + `escape_carries_its_control_character_both_directions`)。 -/// 真实键盘也是 down+up:Codex 按 VK 看按下就够了,Claude Code / Ink 吃的是 -/// OpenConsole 翻译出的字节流,缺抬起时 Esc 常常一个字节都到不了。 -#[cfg(windows)] -fn win32_press_and_release(ks: &Keystroke) -> Option> { - let mut sequence = win32_input_record(ks, true)?; - sequence.extend(win32_input_record(ks, false)?); - Some(sequence) -} - /// 子进程是否请求过 kitty 键盘协议(三位标志任一)。kitty 是线上合同, /// 压过 DECSET 9001——口径同旧壳 `input/terminal_input.rs`。 fn kitty_keyboard_active(mode: &TermMode) -> bool { @@ -382,8 +230,8 @@ pub fn encode(ks: &Keystroke, mode: &TermMode) -> Option> { // OpenConsole 的翻译层反译成 KEY_EVENT_RECORD,而它会丢掉 uChar=0 的 // VK_ESCAPE——读字节流的应用(Claude Code)因此收不到 Esc。 #[cfg(windows)] - if use_win32_input_mode(mode) && win32_encodes_keystroke(ks) { - if let Some(record) = win32_press_and_release(ks) { + if use_win32_input_mode(mode) && win32::win32_encodes_keystroke(ks) { + if let Some(record) = win32::win32_press_and_release(ks) { return Some(record); } } @@ -561,10 +409,10 @@ mod tests { #[cfg(windows)] #[test] fn unknown_keys_fall_back_instead_of_forging_a_record() { - assert_eq!(virtual_key_of("f99"), None); - assert_eq!(virtual_key_of("capslock"), None); + assert_eq!(win32::virtual_key_of("f99"), None); + assert_eq!(win32::virtual_key_of("capslock"), None); // 方向键在 win32 模式下仍要出记录(它们有确定的 VK)。 - assert!(win32_input_record(&keystroke("up"), true).is_some()); + assert!(win32::win32_input_record(&keystroke("up"), true).is_some()); } /// 9001 记录路径与真实控制台同构:Ctrl+Backspace 的记录 Uc=DEL(0x7f), @@ -576,7 +424,7 @@ mod tests { let mut ks = keystroke("backspace"); ks.modifiers.control = true; assert_eq!( - win32_press_and_release(&ks), + win32::win32_press_and_release(&ks), Some(b"\x1b[8;14;127;1;8;1_\x1b[8;14;127;0;8;1_".to_vec()) ); } @@ -696,6 +544,7 @@ mod tests { (false, false, false, None, 13, 0), (true, false, false, None, 13, 16), (true, false, false, Some("\r"), 13, 16), + (false, true, false, None, 10, 8), (false, true, false, Some("\n"), 10, 8), (false, false, true, Some("\r"), 13, 2), (true, true, false, Some("\n"), 10, 24), @@ -742,6 +591,18 @@ mod tests { } } + #[test] + fn ctrl_j_keeps_its_newline_identity_across_protocol_negotiation() { + let mut key = keystroke("j"); + key.modifiers.control = true; + assert_eq!(encode(&key, &TermMode::default()), Some(b"\n".to_vec())); + assert!(!preserves_enter_modifiers(&key, &TermMode::default())); + for mode in [pi_keyboard_mode(), pi_keyboard_mode() | TermMode::WIN32_INPUT_MODE] { + assert_eq!(encode(&key, &mode), Some(b"\x1b[106;5u".to_vec())); + assert!(!preserves_enter_modifiers(&key, &mode)); + } + } + fn pi_keyboard_mode() -> TermMode { TermMode::DISAMBIGUATE_ESC_CODES | TermMode::REPORT_EVENT_TYPES @@ -867,6 +728,48 @@ mod tests { } } + #[test] + fn antigravity_startup_queries_applied_flags_and_preserves_newline_chords() { + use nebula_terminal::term::Config; + use nebula_terminal::vte::ansi::Processor; + + let size = super::super::session::GridSize { columns: 80, screen_lines: 24 }; + let recorder = KeyboardReplyRecorder::default(); + let mut term = nebula_terminal::Term::new( + Config { kitty_keyboard: true, ..Config::default() }, + &size, + recorder.clone(), + ); + let mut parser: Processor = Processor::new(); + // Captured from AGY 1.1.7's outer ConPTY stream before authentication. + // The host's 9001 mode must not override the later Kitty negotiation. + parser.advance(&mut term, b"\x1b[?9001h\x1b[=0;1u\x1b[=1;1u\x1b[?u"); + assert!(term.mode().contains(TermMode::WIN32_INPUT_MODE)); + assert_eq!( + *term.mode() & TermMode::KITTY_KEYBOARD_PROTOCOL, + TermMode::DISAMBIGUATE_ESC_CODES + ); + for (name, shift, control, expected) in [ + ("enter", false, false, "\r"), + ("enter", true, false, "\x1b[13;2u"), + ("enter", false, true, "\x1b[13;5u"), + ("j", false, true, "\x1b[106;5u"), + ] { + let mut key = keystroke(name); + key.modifiers.shift = shift; + key.modifiers.control = control; + assert_eq!(encode(&key, term.mode()), Some(expected.as_bytes().to_vec())); + } + + // A later reset must restore the legacy path without a stale reply. + parser.advance(&mut term, b"\x1b[=0;1u\x1b[?u\x1b[?9001l"); + assert_eq!(recorder.0.borrow().as_slice(), ["\x1b[?1u", "\x1b[?0u"]); + assert_eq!(encode(&keystroke("enter"), term.mode()), Some(b"\r".to_vec())); + let mut ctrl_j = keystroke("j"); + ctrl_j.modifiers.control = true; + assert_eq!(encode(&ctrl_j, term.mode()), Some(b"\n".to_vec())); + } + #[test] fn codex_keyboard_negotiation_preserves_enter_chords_until_reset() { use nebula_terminal::term::Config; diff --git a/nebula_app/src/gpui_shell/terminal/keymap/win32.rs b/nebula_app/src/gpui_shell/terminal/keymap/win32.rs new file mode 100644 index 0000000..f9a73dd --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/keymap/win32.rs @@ -0,0 +1,158 @@ +//! GPUI key facts adapted to ConPTY Win32 input records. + +use gpui::Keystroke; + +/// 无修饰的字母/数字/空格必须交给 IME / `TranslateMessage`,不能编进 PTY。 +/// +/// GPUI 的 Windows 后端:`on_key_down` 一旦 `stop_propagation`,就不会再 +/// `TranslateMessage`。IME 组字(微软拼音)是 TranslateMessage 喂进去的; +/// 把 `n`/`i` 编成 KEY_EVENT_RECORD 等于把拼音当英文写进 shell,中文永远 +/// 起不来。旧壳对应合同是 `keyboard.rs`:`ime.preedit()` 期间直接 return。 +pub(super) fn win32_encodes_keystroke(ks: &Keystroke) -> bool { + if ks.modifiers.control || ks.modifiers.alt || ks.modifiers.platform { + return true; + } + let key = ks.key.as_str(); + if key == "space" { + return false; + } + let mut chars = key.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) if c.is_ascii_alphanumeric() => false, + _ => true, + } +} + +/// GPUI 键名 → Win32 虚拟键码。 +/// +/// 旧壳从 winit fork 的 `RawKeyEventInfo` 直接拿到系统报的 VK;GPUI 的 +/// `Keystroke` 只有键名,所以这里按名字反查。表只覆盖**编码器会处理的键** +/// (控制键、方向、功能键)——可打印字符在 GPUI 走 IME 管道,不经这里。 +pub(super) fn virtual_key_of(key: &str) -> Option { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_HOME, VK_INSERT, VK_LEFT, + VK_NEXT, VK_PRIOR, VK_RETURN, VK_RIGHT, VK_SPACE, VK_TAB, VK_UP, + }; + + let vk = match key { + "escape" => VK_ESCAPE, + "enter" => VK_RETURN, + "tab" => VK_TAB, + "backspace" => VK_BACK, + "space" => VK_SPACE, + "up" => VK_UP, + "down" => VK_DOWN, + "left" => VK_LEFT, + "right" => VK_RIGHT, + "home" => VK_HOME, + "end" => VK_END, + "insert" => VK_INSERT, + "delete" => VK_DELETE, + "pageup" => VK_PRIOR, + "pagedown" => VK_NEXT, + // F1..F24 在 VK 表里连号。 + key if key.starts_with('f') => { + let index: u16 = key[1..].parse().ok()?; + if !(1..=24).contains(&index) { + return None; + } + VK_F1 + index - 1 + }, + // 单个字符(Ctrl+C 一族):ASCII 字母数字的 VK 就是它的大写码点。 + key => { + let mut chars = key.chars(); + let (Some(c), None) = (chars.next(), chars.next()) else { return None }; + if !c.is_ascii_alphanumeric() { + return None; + } + c.to_ascii_uppercase() as u16 + }, + }; + Some(vk) +} + +/// 控制键必须携带真实 `KEY_EVENT_RECORD` 的字符值(Esc=0x1B、Enter=0x0D、 +/// Tab=0x09、Backspace=0x08):OpenConsole 1.22 的 VT 翻译层会丢弃 uChar=0 +/// 的 VK_ESCAPE,于是读字节流的那类应用(Claude Code)收不到 Esc。修饰键与 +/// 功能键保持 0,与真实键盘一致。逐条同旧壳 `control_char_fallback`。 +fn unicode_char_of(ks: &Keystroke, scan_code: u32) -> u16 { + // 平台已经判出文本的(含 Ctrl 变体)以它为准,与 WM_CHAR 语义一致。 + // `key_char` 若是 NUL,当作没文本:真实键盘的 Esc 不会写出 U+0000。 + if let Some(text) = ks.key_char.as_deref() { + let mut units = text.encode_utf16(); + if let (Some(first), None) = (units.next(), units.next()) { + if first != 0 { + return first; + } + } + } + match ks.key.as_str() { + "escape" => 0x1b, + "enter" => crate::platform::keyboard::enter_character( + scan_code, + ks.modifiers.shift, + ks.modifiers.control, + ks.modifiers.alt, + ), + "tab" => b'\t' as u16, + // 真实控制台的 Ctrl+Backspace 记录携带 Uc=DEL(0x7f,WM_CHAR 语义), + // PSReadLine 的原生 Ctrl+Backspace=BackwardKillWord 绑定按此匹配。 + // 给 0x08 会跌进 KeyChar 分发被当成 Ctrl+H——只删一个字符。 + "backspace" => { + if ks.modifiers.control { + 0x7f + } else { + 0x08 + } + }, + "space" => b' ' as u16, + _ => 0, + } +} + +/// 一条 ConPTY Win32 input 记录:`CSI Vk;Sc;Uc;Kd;Cs;Rc_`。 +/// +/// 认不出 VK 的键返回 `None`,调用方回落到传统 VT 编码——宁可少一条记录, +/// 也不要编一个 Vk=0 的假记录,那会让子进程读到一个不存在的键。 +pub(super) fn win32_input_record(ks: &Keystroke, key_down: bool) -> Option> { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{MAPVK_VK_TO_VSC, MapVirtualKeyW}; + + const SHIFT_PRESSED: u32 = 0x0010; + const LEFT_ALT_PRESSED: u32 = 0x0002; + const LEFT_CTRL_PRESSED: u32 = 0x0008; + + let vk = virtual_key_of(ks.key.as_str())?; + // 扫描码问系统要,不硬编码:非 US 布局与笔记本键盘上这张表并不通用。 + let scan_code = unsafe { MapVirtualKeyW(u32::from(vk), MAPVK_VK_TO_VSC) }; + let mut control_key_state = 0u32; + if ks.modifiers.shift { + control_key_state |= SHIFT_PRESSED; + } + if ks.modifiers.alt { + control_key_state |= LEFT_ALT_PRESSED; + } + if ks.modifiers.control { + control_key_state |= LEFT_CTRL_PRESSED; + } + let key_down = u8::from(key_down); + Some( + format!( + "\x1b[{};{};{};{key_down};{};1_", + vk, + scan_code, + unicode_char_of(ks, scan_code), + control_key_state + ) + .into_bytes(), + ) +} + +/// GPUI 的终端视图只接到 key-down。旧壳对 9001 会再写一条 Kd=0 的抬起 +/// (`keyboard.rs` 的 `key_release` + `escape_carries_its_control_character_both_directions`)。 +/// 真实键盘也是 down+up:Codex 按 VK 看按下就够了,Claude Code / Ink 吃的是 +/// OpenConsole 翻译出的字节流,缺抬起时 Esc 常常一个字节都到不了。 +pub(super) fn win32_press_and_release(ks: &Keystroke) -> Option> { + let mut sequence = win32_input_record(ks, true)?; + sequence.extend(win32_input_record(ks, false)?); + Some(sequence) +} diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 4619d4c..fa7ea52 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -358,6 +358,7 @@ pub struct TerminalView { /// rollout probe runs in the background and uses this generation to reject /// a result from a previous foreground command. pub(super) ai_session_probe_pending: bool, + pub(super) ai_session_from_probe: bool, pub(super) ai_session_probe_epoch: u64, pub(super) last_ai_session_probe: Option, error: Option, @@ -809,6 +810,7 @@ impl TerminalView { ssh_connect_last_step: std::time::Instant::now(), ai_session: None, ai_session_probe_pending: false, + ai_session_from_probe: false, ai_session_probe_epoch: 0, last_ai_session_probe: None, error, @@ -973,53 +975,7 @@ impl TerminalView { Err(error) => log::warn!("terminal image dropped: {error}"), } }, - TermEvent::CommandStart => { - self.answers.begin_command(); - // 程序身份来自 Enter 时捕获的完整命令行;直接启动和 - // npx/node/uvx 等包装启动都会归一成 Agent slug。它是 WSL - // 看不见来宾进程时的主通道,hook 信封仍是更准的覆盖层。 - let identity = - crate::ai_agents::AgentKind::parse_command(&self.suggest.last_committed) - .map(|agent| agent.slug().to_owned()) - .or_else(|| crate::display::extract_program(&self.suggest.last_committed)); - // A probe belongs to one foreground command. Invalidate it - // before replacing the command identity so a slow WSL result - // from Codex A cannot become the identity of Codex B. - self.invalidate_ai_session_probe(); - if identity != self.running_program { - self.running_program = identity; - self.ai_session = None; - cx.emit(TerminalViewEvent::TitleChanged); - } - if self - .running_program - .as_deref() - .and_then(crate::ai_agents::AgentKind::parse) - .is_some() - && !self.agent_hook_seen - { - self.agent_status_source = crate::ai_agents::AgentStatusSource::Process; - self.agent_status_rule = None; - } - self.mark_command_running(); - self.probe_missing_codex_session(cx); - // 首个词就是一个交互式 shell(`cmd`、`wsl`、裸 `bash`):133;C - // 是真的,但这条「命令」其实是一个新提示符,133;D 永远不会来 - // ——那个 shell 接管了终端,而我们的集成不在它里面。立刻按「已被 - // 进程树反证」处理,转圈不必等 3 秒节流窗口。 - // - // 这不是把状态钉死:后续对账双向纠正,真在那个 shell 里跑起活儿 - // 会多出一个子进程,进程树看得见,状态会被拉回运行中。 - if crate::process_tree::is_interactive_shell_command(&self.suggest.last_committed) { - self.command_running_disproved = true; - } - if let Some(run) = &mut self.active_run - && run.phase == crate::runtime_api::RuntimeRunPhase::Submitted - { - run.phase = crate::runtime_api::RuntimeRunPhase::Started; - } - cx.notify(); - }, + TermEvent::CommandStart => self.on_command_start(cx), // 退出码接到「上一条命令失败」的未读徽章上(⚠ 三角);`Some(0)` 与 // `None`(没带退出码的 133;D)都算过关。记在下面那道 barrier 之后: // 属于上一轮/初始化的那个边沿不能改写徽章。 diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index 144b95b..ad157e1 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -51,6 +51,49 @@ impl TerminalView { ) } + pub(super) fn on_command_start(&mut self, cx: &mut Context) { + self.answers.begin_command(); + // 程序身份来自 Enter 时捕获的完整命令行;直接启动和 + // npx/node/uvx 等包装启动都会归一成 Agent slug。它是 WSL + // 看不见来宾进程时的主通道,hook 信封仍是更准的覆盖层。 + let identity = crate::ai_agents::AgentKind::parse_command(&self.suggest.last_committed) + .map(|agent| agent.slug().to_owned()) + .or_else(|| crate::display::extract_program(&self.suggest.last_committed)); + // A probe belongs to one foreground command. Invalidate it + // before replacing the command identity so a slow WSL result + // from Codex A cannot become the identity of Codex B. + self.invalidate_ai_session_probe(); + if identity != self.running_program { + self.running_program = identity; + self.ai_session = None; + cx.emit(TerminalViewEvent::TitleChanged); + } + if self.running_program.as_deref().and_then(crate::ai_agents::AgentKind::parse).is_some() + && !self.agent_hook_seen + { + self.agent_status_source = crate::ai_agents::AgentStatusSource::Process; + self.agent_status_rule = None; + } + self.mark_command_running(); + self.probe_missing_codex_session(cx); + // 首个词就是一个交互式 shell(`cmd`、`wsl`、裸 `bash`):133;C + // 是真的,但这条「命令」其实是一个新提示符,133;D 永远不会来 + // ——那个 shell 接管了终端,而我们的集成不在它里面。立刻按「已被 + // 进程树反证」处理,转圈不必等 3 秒节流窗口。 + // + // 这不是把状态钉死:后续对账双向纠正,真在那个 shell 里跑起活儿 + // 会多出一个子进程,进程树看得见,状态会被拉回运行中。 + if crate::process_tree::is_interactive_shell_command(&self.suggest.last_committed) { + self.command_running_disproved = true; + } + if let Some(run) = &mut self.active_run + && run.phase == crate::runtime_api::RuntimeRunPhase::Submitted + { + run.phase = crate::runtime_api::RuntimeRunPhase::Started; + } + cx.notify(); + } + pub(super) fn invalidate_ai_session_probe(&mut self) { self.ai_session_probe_epoch = self.ai_session_probe_epoch.wrapping_add(1); self.ai_session_probe_pending = false; @@ -91,6 +134,7 @@ impl TerminalView { self.running_program.take().is_some() || self.ai_session.take().is_some(); self.invalidate_ai_session_probe(); self.primary_agent_pid = None; + self.ai_session_from_probe = false; self.agent_status = crate::ai_agents::AgentStatus::Unknown; self.agent_status_source = crate::ai_agents::AgentStatusSource::Unknown; self.agent_status_rule = None; @@ -657,14 +701,24 @@ impl TerminalView { cx.notify(); } - /// Fill a missing Codex identity from the active rollout file. Hook events - /// remain authoritative; this only repairs panes whose Codex process was - /// already running before the hook was installed or before Nebula saw its - /// first event. The blocking probe is isolated on GPUI's background - /// executor and its result is accepted only for the same foreground epoch. + pub(crate) fn prepare_ai_session_save(&mut self, cx: &mut Context) { + if self.ai_session_from_probe { + self.ai_session = None; + } + self.last_ai_session_probe = None; + self.probe_missing_codex_session(cx); + } + + pub(crate) fn ai_session_save_pending(&self) -> bool { + self.ai_session_probe_pending + } + + /// Read the active conversation metadata when its hook has not reported an ID. + /// File and process operations run on the background executor. Only results + /// for the same foreground command are applied; hook identities take priority. pub(super) fn probe_missing_codex_session(&mut self, cx: &mut Context) { if self.exited.is_some() - || self.ai_session.is_some() + || (self.agent_hook_seen && !self.ai_session_from_probe && self.ai_session.is_some()) || self.ai_session_probe_pending || !self .running_program @@ -674,10 +728,10 @@ impl TerminalView { { return; } - if self - .last_ai_session_probe - .is_some_and(|at| at.elapsed() < std::time::Duration::from_secs(2)) - { + if self.last_ai_session_probe.is_some_and(|at| { + at.elapsed() + < std::time::Duration::from_secs(if self.ai_session.is_some() { 10 } else { 2 }) + }) { return; } let Some(exec_context) = self.exec_context.clone() else { return }; @@ -691,21 +745,26 @@ impl TerminalView { cx.spawn(async move |this, cx| { let session_id = work.await; let _ = this.update(cx, |view, cx| { + if view.ai_session_probe_epoch == epoch { + view.ai_session_probe_pending = false; + } if !probe_result_is_current( view.ai_session_probe_epoch, epoch, - view.ai_session.is_some(), + view.agent_hook_seen + && !view.ai_session_from_probe + && view.ai_session.is_some(), view.running_program.as_deref(), ) { return; } - view.ai_session_probe_pending = false; if let Some(session_id) = session_id { log::debug!("agent session identity from active rollout: pane={pane_id}"); view.ai_session = Some(crate::display::AiSessionIdentity { source: "codex".to_owned(), session_id, }); + view.ai_session_from_probe = true; cx.emit(TerminalViewEvent::TitleChanged); cx.notify(); } @@ -852,6 +911,7 @@ impl TerminalView { self.agent_runtime_submit_pending = false; } if from_primary_agent && let Some(id) = event.session_id.as_deref() { + self.ai_session_from_probe = false; self.ai_session = Some(crate::display::AiSessionIdentity { source: event.source.clone(), session_id: id.to_owned(), diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index ceb026d..e62b255 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -46,6 +46,7 @@ use gpui_component::notification::Notification; use nebula_split::{DIVIDER_GAP, HIT_SLOP, RemoveOutcome, SplitDirection, SplitNav, SplitTree}; mod agents; +mod closing; mod command_manager; mod keyboard_bindings; use keyboard_bindings::custom_workspace_binding; @@ -1064,6 +1065,7 @@ pub struct NebulaWorkspace { spinner_visible: std::cell::Cell, /// 系统关闭按钮可能连续送来多次 should-close;确认框在场时只保留一份。 window_close_confirm_open: bool, + window_close_pending: bool, /// `keep_session` 关窗后 HWND 已隐藏、PTY 仍在;托盘 / mux ATTACH 用来捞回。 window_hidden: bool, /// 开窗时记下,mux `tab.new` 需要从 pump 拿到 `&mut Window`。 @@ -1298,6 +1300,7 @@ impl NebulaWorkspace { spinner_frame_pending: std::cell::Cell::new(false), spinner_visible: std::cell::Cell::new(false), window_close_confirm_open: false, + window_close_pending: false, window_hidden: false, window_handle: window.window_handle(), runtime_window_id, @@ -1923,75 +1926,6 @@ impl NebulaWorkspace { ); } - /// GPUI 的 should-close 回调必须同步返回:无繁忙进程时直接允许系统关闭; - /// 有繁忙进程时先返回 false,再由对话框确认回调显式移除窗口。 - fn should_close_window(&mut self, window: &mut Window, cx: &mut Context) -> bool { - #[cfg(windows)] - windowing::quick_terminal_bounds_changed(self.runtime_window_id, window, cx); - let persist_session = self.window_role == windowing::WindowRole::Regular; - if persist_session && self.keep_session_on_close(window, cx) { - return false; - } - if self.guard_file_window_close(window, cx) { - return false; - } - self.close_window_after_documents(window, cx) - } - - fn close_window_after_documents( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> bool { - let persist_session = self.window_role == windowing::WindowRole::Regular; - let Some(process) = self.busy_process_in_window(cx) else { - if persist_session { - self.save_clean_window_session(cx); - } - return true; - }; - if self.window_close_confirm_open { - return false; - } - self.window_close_confirm_open = true; - - let body: SharedString = format!("{process} 仍在运行,关闭窗口会中止它。").into(); - let confirm_workspace = cx.entity().downgrade(); - let close_workspace = confirm_workspace.clone(); - window.open_dialog(cx, move |dialog, window, _cx| { - let confirm_workspace = confirm_workspace.clone(); - let close_workspace = close_workspace.clone(); - confirm_dialog( - dialog, - window, - "关闭窗口?", - body.clone(), - "关闭", - "取消", - ButtonVariant::Danger, - ) - .on_ok(move |_, window, cx| { - let _ = confirm_workspace.update(cx, |workspace, cx| { - if persist_session { - workspace.save_clean_window_session(cx); - } - workspace.window_close_confirm_open = false; - // `remove_window` 是确认后的最终动作,不会重新触发 - // should-close,从而避免再次弹出同一确认框。 - window.remove_window(); - }); - true - }) - .on_close(move |_, _, cx| { - let _ = close_workspace.update(cx, |workspace, cx| { - workspace.window_close_confirm_open = false; - cx.notify(); - }); - }) - }); - false - } - fn request_close_pane( &mut self, tab_ix: usize, diff --git a/nebula_app/src/gpui_shell/workspace/closing.rs b/nebula_app/src/gpui_shell/workspace/closing.rs new file mode 100644 index 0000000..4cfa872 --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/closing.rs @@ -0,0 +1,132 @@ +use super::*; + +impl NebulaWorkspace { + /// GPUI 的 should-close 回调必须同步返回:无繁忙进程时直接允许系统关闭; + /// 有繁忙进程时先返回 false,再由对话框确认回调显式移除窗口。 + pub(super) fn should_close_window( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + #[cfg(windows)] + windowing::quick_terminal_bounds_changed(self.runtime_window_id, window, cx); + if self.window_close_pending { + return false; + } + let persist_session = self.window_role == windowing::WindowRole::Regular; + if persist_session && self.keep_session_on_close(window, cx) { + return false; + } + if self.guard_file_window_close(window, cx) { + return false; + } + self.close_window_after_documents(window, cx) + } + + /// Continue after document save/discard confirmation. A true result lets + /// the caller remove the window; false means cancellation or an async close. + pub(super) fn close_window_after_documents( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let persist_session = self.window_role == windowing::WindowRole::Regular; + let Some(process) = self.busy_process_in_window(cx) else { + if persist_session { + self.finish_close_window(window, cx); + return false; + } + return true; + }; + if self.window_close_confirm_open { + return false; + } + self.window_close_confirm_open = true; + + let body: SharedString = format!("{process} 仍在运行,关闭窗口会中止它。").into(); + let confirm_workspace = cx.entity().downgrade(); + let close_workspace = confirm_workspace.clone(); + window.open_dialog(cx, move |dialog, window, _cx| { + let confirm_workspace = confirm_workspace.clone(); + let close_workspace = close_workspace.clone(); + confirm_dialog( + dialog, + window, + "关闭窗口?", + body.clone(), + "关闭", + "取消", + ButtonVariant::Danger, + ) + .on_ok(move |_, window, cx| { + let _ = confirm_workspace.update(cx, |workspace, cx| { + if persist_session { + workspace.finish_close_window(window, cx); + } + workspace.window_close_confirm_open = false; + // `remove_window` 是确认后的最终动作,不会重新触发 + // should-close,从而避免再次弹出同一确认框。 + if !persist_session { + window.remove_window(); + } + }); + true + }) + .on_close(move |_, _, cx| { + let _ = close_workspace.update(cx, |workspace, cx| { + workspace.window_close_confirm_open = false; + cx.notify(); + }); + }) + }); + false + } + + fn finish_close_window(&mut self, _window: &mut Window, cx: &mut Context) { + self.window_close_pending = true; + let panes = self.prepare_session_save(cx); + let handle = self.window_handle; + cx.spawn(async move |this, cx| { + wait_for_session_ids(&panes, cx).await; + let _ = handle.update(cx, |_, window, cx| { + let _ = this.update(cx, |workspace, cx| { + workspace.save_clean_window_session(cx); + window.remove_window(); + }); + }); + }) + .detach(); + } + + pub(super) fn prepare_session_save( + &mut self, + cx: &mut Context, + ) -> Vec> { + let panes = self + .tabs + .iter() + .filter_map(|tab| match tab { + WorkspaceTab::Terminal { panes, .. } => Some(panes), + _ => None, + }) + .flatten() + .map(|pane| pane.view.clone()) + .collect::>(); + for pane in &panes { + pane.update(cx, |view, cx| view.prepare_ai_session_save(cx)); + } + panes + } +} + +pub(super) async fn wait_for_session_ids(panes: &[Entity], cx: &mut gpui::AsyncApp) { + let deadline = std::time::Instant::now() + Duration::from_secs(3); + loop { + let pending = + cx.update(|cx| panes.iter().any(|pane| pane.read(cx).ai_session_save_pending())); + if !pending || std::time::Instant::now() >= deadline { + break; + } + cx.background_executor().timer(Duration::from_millis(25)).await; + } +} diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index b6f2d87..3cb2442 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -129,6 +129,7 @@ pub(crate) struct WindowRegistry { entries: Vec, runtime_hub: crate::runtime_api::RuntimeHub, session_persistence: SessionPersistence, + quit_pending: bool, #[cfg(windows)] quick_terminal: Option, #[cfg(windows)] @@ -204,6 +205,7 @@ pub(crate) fn initialize(cx: &mut App, runtime_hub: crate::runtime_api::RuntimeH entries: Vec::new(), runtime_hub, session_persistence: SessionPersistence::default(), + quit_pending: false, #[cfg(windows)] quick_terminal: None, #[cfg(windows)] @@ -293,10 +295,24 @@ pub(super) fn open_recipe_window(session: crate::session::Session, cx: &mut App) fn workspace_window_options(cx: &mut App, focus: bool, role: WindowRole) -> WindowOptions { match role { WindowRole::Regular => { - let bounds = Bounds::centered(None, size(px(1080.0), px(720.0)), cx); + let preferred = size(px(1080.0), px(720.0)); + let bounds = cx.primary_display().map_or_else( + || Bounds::centered(None, preferred, cx), + |display| { + let visible = display.visible_bounds(); + let fitted = preferred.min(&visible.size); + Bounds::new( + point( + visible.origin.x + (visible.size.width - fitted.width) / 2.0, + visible.origin.y + (visible.size.height - fitted.height) / 2.0, + ), + fitted, + ) + }, + ); WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), - window_min_size: Some(size(px(760.0), px(540.0))), + window_min_size: Some(size(px(760.0), px(540.0)).min(&bounds.size)), titlebar: Some(TitleBar::title_bar_options()), app_id: Some("pebrel".to_owned()), window_background: crate::gpui_shell::wallpaper::initial_background_appearance(), @@ -1260,6 +1276,27 @@ pub(super) fn save_current_window_session( } pub(crate) fn quit_all(cx: &mut App) { + if cx.global::().quit_pending { + return; + } + cx.global_mut::().quit_pending = true; + let entries = cx.global::().entries.clone(); + let panes = entries + .iter() + .filter(|entry| entry.role == WindowRole::Regular) + .filter_map(|entry| { + entry.workspace.update(cx, |workspace, cx| workspace.prepare_session_save(cx)).ok() + }) + .flatten() + .collect::>(); + cx.spawn(async move |cx| { + super::closing::wait_for_session_ids(&panes, cx).await; + cx.update(finish_quit_all); + }) + .detach(); +} + +fn finish_quit_all(cx: &mut App) { save_combined_session(cx, true); prune_entries(cx); let entries = cx.global::().entries.clone(); diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 7d54501..864983c 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -164,7 +164,7 @@ use crate::polling::{IoListener, ipc}; fn main() -> Result<(), Box> { // No worker threads exist yet; import the new override names for legacy readers. - unsafe { brand::import_environment_aliases() }; + unsafe { platform::environment::import_environment_aliases() }; // OpenSSH AskPass reuses the GUI executable as a credential helper. It // must exit before CLI parsing or window initialization because ssh passes // the human-readable prompt as an argument, not as a Nebula subcommand. @@ -186,8 +186,7 @@ fn main() -> Result<(), Box> { // Load command line options. let options = Options::new(); if let Err(error) = nebula_settings::migrate_legacy_data() { - #[cfg(windows)] - panic::report_startup_error(&error, options.subcommands.is_none()); + platform::startup::report_error(&error, options.subcommands.is_none()); return Err(error.into()); } #[cfg(windows)] diff --git a/nebula_app/src/notify.rs b/nebula_app/src/notify.rs index 571c0a7..522b66e 100644 --- a/nebula_app/src/notify.rs +++ b/nebula_app/src/notify.rs @@ -48,7 +48,8 @@ pub fn init_proxy(proxy: EventLoopProxy) { let _ = PROXY.set(proxy); } -type ToastActivation = Arc; +pub use crate::platform::notifications::notify_test; +use crate::platform::notifications::{ToastActivation, toast_clickable}; #[cfg(feature = "gpui-shell")] static GPUI_ACTIVATION: OnceLock> = @@ -270,85 +271,6 @@ fn spawn_toast(title: String, body: String, activation: Option) } } -/// [`toast`], optionally wired for click-to-focus: activating the banner (or -/// its Action Center entry) surfaces `window` and, when a pane is named, its -/// tab. Uses the in-process WinRT Activated handler — no COM server, no -/// protocol registration. The one trade-off: clicks after Pebrel exited do -/// nothing, which is exactly right (there is nothing left to focus). -#[cfg(windows)] -fn toast_clickable(title: &str, body: &str, activation: Option) { - use tauri_winrt_notification::{IconCrop, Toast}; - - // Attribute the toast to the Pebrel AUMID so it reads "Pebrel" instead of - // "Windows PowerShell". One registry write, cached per process. - win::ensure_aumid(); - - let mut toast = Toast::new(win::AUMID) - .title(title) - .text1(body) - .duration(tauri_winrt_notification::Duration::Short); - // Belt and braces: besides the AUMID IconUri (which some Windows builds - // cache stale), embed the logo per-toast as appLogoOverride so the banner - // always carries the Pebrel mark next to the message. - if let Some(icon) = win::icon_path() { - toast = toast.icon(&icon, IconCrop::Square, crate::brand::NAME); - } - if let Some(activation) = activation { - toast = toast.on_activated(move |_action| { - activation(); - Ok(()) - }); - } - - match toast.show() { - Ok(()) => log::debug!("notify: toast shown"), - Err(err) => log::warn!("notify: toast failed: {err}"), - } -} - -#[cfg(any(target_os = "linux", target_os = "macos"))] -fn toast_clickable(title: &str, body: &str, activation: Option) { - #[cfg(target_os = "macos")] - { - if objc2_foundation::NSBundle::mainBundle().bundleIdentifier().is_none() { - log::warn!("System notifications require a registered Pebrel application bundle"); - return; - } - crate::platform::notifications::prepare(); - } - let mut notification = notify_rust::Notification::new(); - notification.appname(crate::brand::NAME).summary(title).body(body); - if activation.is_some() { - let language = crate::i18n::LanguagePreference::from( - nebula_settings::RuntimeSettings::load().language, - ) - .resolved(); - notification.action("default", language.text(crate::i18n::Message::CommonOpen)); - } - match notification.show() { - Ok(handle) => { - if let Some(activation) = activation { - let result = handle.wait_for_response(move |response: ¬ify_rust::NotificationResponse| { - if response.is_default_action() - || matches!(response, notify_rust::NotificationResponse::Action(action) if action == "default") - { - activation(); - } - }); - if let Err(error) = result { - log::warn!("notify: activation listener failed: {error}"); - } - } - }, - Err(error) => log::warn!("notify: toast failed: {error}"), - } -} - -#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] -fn toast_clickable(title: &str, body: &str, _activation: Option) { - crate::platform::notifications::show(title, body); -} - #[cfg(test)] mod delivery_tests { use super::*; @@ -457,167 +379,3 @@ mod delivery_tests { pane_activation(); } } - -/// `pebrel notify-test` entrypoint: run the full toast pipeline synchronously -/// (registration + show), printing per-step diagnostics to the console. Skips -/// the focus policy and throttle on purpose — the tester is looking at the -/// screen. Returns a process exit code. -#[cfg(windows)] -pub fn notify_test() -> i32 { - println!("[1/2] Registering AUMID '{}' ...", win::AUMID); - match win::register_aumid() { - Ok(()) => { - println!(r" OK (HKCU\Software\Classes\AppUserModelId\{})", win::AUMID); - match win::icon_path() { - Some(path) => println!(" icon: {}", path.display()), - None => println!(" icon: unavailable (banner will have no logo)"), - } - }, - Err(err) => { - eprintln!(" FAILED: {err}"); - return 1; - }, - } - - println!("[2/2] Showing toast ..."); - let mut toast = tauri_winrt_notification::Toast::new(win::AUMID) - .title(crate::brand::NAME) - .text1("通知链路正常:pebrel notify-test") - .duration(tauri_winrt_notification::Duration::Short); - if let Some(icon) = win::icon_path() { - toast = toast.icon(&icon, tauri_winrt_notification::IconCrop::Square, crate::brand::NAME); - } - match toast.show() { - Ok(()) => { - println!(" OK — a toast should be on screen now."); - println!(); - println!("If nothing appeared, check Windows Settings > System > Notifications:"); - println!( - "the global toggle, Do Not Disturb / Focus Assist, and the {} entry.", - crate::brand::NAME - ); - 0 - }, - Err(err) => { - eprintln!(" FAILED: {err}"); - 1 - }, - } -} - -#[cfg(not(windows))] -pub fn notify_test() -> i32 { - println!("notify-test: system toasts are only implemented on Windows."); - 0 -} - -/// Windows-only: the Pebrel AppUserModelID and its registration. -/// -/// A WinRT toast must be attributed to an AUMID that Windows can resolve to -/// an app identity, or it silently refuses to show. For an unpackaged app the -/// documented lightweight route is a registry key — -/// `HKCU\Software\Classes\AppUserModelId\` with a `DisplayName` value -/// (what Microsoft's own ToastNotificationManagerCompat writes). Per-user, no -/// admin rights, no COM, no Start-menu shortcut, and idempotent: rewriting -/// the same value is a cheap no-op, so a broken key self-heals on next run. -#[cfg(windows)] -mod win { - use std::path::PathBuf; - use std::sync::Mutex; - - use windows_sys::Win32::Foundation::ERROR_SUCCESS; - use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, REG_SZ, RegSetKeyValueW}; - - /// AppUserModelID for Pebrel. Toast notifications fire under this identity - /// so the system shows "Pebrel" instead of "PowerShell" / "cmd.exe". - pub const AUMID: &str = "com.pebrel.terminal"; - - /// Ensure the AUMID is registered. Best-effort, cached per process: the - /// write itself is a few syscalls, there is just no point repeating them - /// for every toast. - pub fn ensure_aumid() { - static REGISTERED: Mutex> = Mutex::new(None); - let variant = crate::app_icon::selected(); - let mut registered = REGISTERED.lock().unwrap_or_else(|error| error.into_inner()); - if *registered != Some(variant) { - if let Err(err) = register_icon(variant) { - log::warn!("notify: AUMID registration failed (toast may not appear): {err}"); - } else { - *registered = Some(variant); - } - } - } - - /// Write `DisplayName` + `IconUri` under the AUMID key. `RegSetKeyValueW` - /// creates the missing subkey chain itself. The icon is best-effort: a - /// failed write only costs the logo, never the toast. - pub fn register_aumid() -> Result<(), String> { - register_icon(crate::app_icon::selected()) - } - - fn register_icon(variant: nebula_settings::AppIconName) -> Result<(), String> { - let subkey = format!(r"Software\Classes\AppUserModelId\{AUMID}"); - set_reg_sz(&subkey, "DisplayName", crate::brand::NAME)?; - match ensure_icon_file(variant) { - Some(icon) => set_reg_sz(&subkey, "IconUri", &icon.display().to_string())?, - None => return Err("could not materialize the notification icon".into()), - } - Ok(()) - } - - /// The materialized icon path, for diagnostics (`pebrel notify-test`). - pub fn icon_path() -> Option { - ensure_icon_file(crate::app_icon::selected()) - } - - fn ensure_icon_file(variant: nebula_settings::AppIconName) -> Option { - let bytes = crate::app_icon::png(variant, 256)?; - let directory = crate::platform::dirs::data_dir(); - // Windows caches the attribution icon separately from the body logo. - // A content-addressed URI invalidates both caches after artwork changes. - use sha2::{Digest as _, Sha256}; - let digest = Sha256::digest(bytes.as_ref()); - let fingerprint = digest[..12].iter().map(|byte| format!("{byte:02x}")).collect::(); - let path = - directory.join(format!("pebrel-toast-{}-{fingerprint}.png", variant.settings_value())); - let stale = std::fs::read(&path).ok().as_deref() != Some(bytes.as_ref()); - if stale { - std::fs::create_dir_all(directory).ok()?; - crate::atomic_file::write(&path, bytes.as_ref()).ok()?; - } - Some(path) - } - - /// Set one REG_SZ value under HKCU\`subkey`, creating the key as needed. - fn set_reg_sz(subkey: &str, name: &str, data: &str) -> Result<(), String> { - let subkey_w = to_wide(subkey); - let name_w = to_wide(name); - let data_w = to_wide(data); - let data_bytes = (data_w.len() * std::mem::size_of::()) as u32; - - // SAFETY: every pointer references a live, NUL-terminated UTF-16 - // buffer owned by this frame; RegSetKeyValueW copies the data before - // returning and creates intermediate keys as needed. - let status = unsafe { - RegSetKeyValueW( - HKEY_CURRENT_USER, - subkey_w.as_ptr(), - name_w.as_ptr(), - REG_SZ, - data_w.as_ptr().cast(), - data_bytes, - ) - }; - - if status == ERROR_SUCCESS { - Ok(()) - } else { - Err(format!("RegSetKeyValueW({name}) failed with status {status}")) - } - } - - /// NUL-terminated UTF-16 for Win32 wide-string APIs. - fn to_wide(s: &str) -> Vec { - s.encode_utf16().chain(Some(0)).collect() - } -} diff --git a/nebula_app/src/platform/ai_session_identity.rs b/nebula_app/src/platform/ai_session_identity.rs index 7cdc85e..31fc9e7 100644 --- a/nebula_app/src/platform/ai_session_identity.rs +++ b/nebula_app/src/platform/ai_session_identity.rs @@ -1,12 +1,13 @@ -//! Recover an AI CLI session identity when the CLI hook was not present. +//! Find the current Codex conversation when no session ID was reported by its hook. //! //! Codex keeps the active rollout open, and its process environment carries -//! the pane id injected by the PTY launcher. That gives us an exact binding -//! between a pane and a rollout without guessing from cwd or modification -//! time. The probe is intentionally small and is only called by a background +//! the pane ID supplied by the PTY launcher. This links a pane to its rollout +//! without relying on working-directory matches or file modification +//! time. Only the first metadata record is read, without conversation content. +//! The lookup is bounded and is only called by a background //! task; UI code must never scan `/proc` or start `wsl.exe` synchronously. -use std::io::{self, BufRead as _, Read}; +use std::io::{self, Read, Seek as _}; use std::path::Path; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; @@ -21,7 +22,7 @@ pane="$1" instance="$2" for proc in /proc/[0-9]*; do pid=${proc##*/} - comm=$(cat "$proc/comm" 2>/dev/null || true) + IFS= read -r comm < "$proc/comm" 2>/dev/null || continue case "$comm" in codex|codex.exe|codex-*) ;; *) continue ;; @@ -44,7 +45,7 @@ for proc in /proc/[0-9]*; do link=$(readlink "$fd" 2>/dev/null || true) case "$link" in "$codex_root"/sessions/*/rollout-*.jsonl) - first=$(head -n 1 "$fd" 2>/dev/null || true) + first=$(head -c 65536 "$fd" 2>/dev/null | head -n 1) [ -n "$first" ] || continue printf '%s\t%s\t%s\t%s\n' "$pid" "${fd##*/}" "$link" "$first" ;; @@ -64,11 +65,9 @@ struct ProbeRecord { /// Probe the active Codex rollout for one pane. /// /// WSL panes are inspected inside the guest because Windows Toolhelp cannot -/// see Linux descendants or their `/proc//fd` handles. Unix hosts use -/// the same exact environment/fd contract directly. Windows-native Codex is -/// deliberately left to the hook path: there is no safe, dependency-free -/// equivalent of `/proc//fd` for proving which open rollout belongs to a -/// pane. +/// see Linux processes or their open session files. Linux hosts use the same +/// process metadata directly. Native Windows and macOS sessions continue to +/// use hook-reported identities; this fallback requires Linux procfs. pub(crate) fn probe_codex_session( pane_id: u64, exec_context: Option<&crate::runtime_exec::PaneExecContext>, @@ -207,35 +206,22 @@ fn wsl_probe_args( args } -/// Run the guest probe without allowing a broken WSL instance to hold a -/// background executor worker forever. stdout is drained on a helper thread -/// so a noisy or malformed process cannot deadlock on a full pipe; the result -/// is capped before it reaches the parser. +/// Bound the guest lookup by time and output size. A temporary file avoids a +/// reader thread surviving when a descendant keeps the output handle open. fn run_probe_command(mut command: Command) -> Option { - command.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()); + let mut output = tempfile::tempfile().ok()?; + command.stdin(Stdio::null()).stdout(output.try_clone().ok()?).stderr(Stdio::null()); let mut child = command.spawn().ok()?; - let Some(stdout) = child.stdout.take() else { - let _ = child.kill(); - let _ = child.wait(); - return None; - }; - let (sender, receiver) = std::sync::mpsc::sync_channel(1); - let reader = std::thread::Builder::new().name("pebrel-ai-session-probe-read".to_owned()).spawn( - move || { - let result = read_bounded(stdout, MAX_PROBE_OUTPUT); - let _ = sender.send(result); - }, - ); - if reader.is_err() { - let _ = child.kill(); - let _ = child.wait(); - return None; - } let deadline = Instant::now() + PROBE_TIMEOUT; let status = loop { match child.try_wait() { Ok(Some(status)) => break status, - Ok(None) if Instant::now() < deadline => { + Ok(None) + if Instant::now() < deadline + && output + .metadata() + .is_ok_and(|meta| meta.len() <= MAX_PROBE_OUTPUT as u64) => + { std::thread::sleep(Duration::from_millis(10)); }, Ok(None) => { @@ -250,12 +236,8 @@ fn run_probe_command(mut command: Command) -> Option { }, } }; - // A descendant can inherit stdout after wsl.exe itself exits. Waiting on - // the reader join handle here would make a successful probe unbounded, so - // use the same deadline for the bounded reader result and abandon it if - // the pipe never closes. - let output = - receiver.recv_timeout(deadline.saturating_duration_since(Instant::now())).ok()?.ok()?; + output.rewind().ok()?; + let output = read_bounded(output, MAX_PROBE_OUTPUT).ok()?; status.success().then(|| parse_probe_records(&String::from_utf8_lossy(&output)))? } @@ -277,9 +259,13 @@ fn read_bounded(mut reader: impl Read, limit: usize) -> io::Result> { #[cfg(unix)] fn probe_local_proc(pane_id: &str, instance: &str) -> Option { + let deadline = Instant::now() + PROBE_TIMEOUT; let mut output = String::new(); let mut records = 0; for entry in std::fs::read_dir("/proc").ok()?.flatten() { + if Instant::now() >= deadline { + return None; + } let name = entry.file_name(); let Some(pid) = name.to_str().and_then(|name| name.parse::().ok()) else { continue; @@ -319,12 +305,9 @@ fn probe_local_proc(pane_id: &str, instance: &str) -> Option { let _ = writeln!(output, "{pid}\t{fd_number}\t{}\t{first_line}", link.display()); records += 1; if records >= MAX_PROBE_RECORDS || output.len() >= MAX_PROBE_OUTPUT { - break; + return None; } } - if records >= MAX_PROBE_RECORDS || output.len() >= MAX_PROBE_OUTPUT { - break; - } } parse_probe_records(&output) } @@ -341,7 +324,7 @@ fn environment_has_value(environment: &[u8], key: &[u8], expected: &[u8]) -> boo prefix.push(b'='); environment .split(|byte| *byte == 0) - .any(|item| item.strip_prefix(&prefix).is_some_and(|value| value == expected)) + .any(|item| item.strip_prefix(prefix.as_slice()).is_some_and(|value| value == expected)) } #[cfg(unix)] @@ -351,7 +334,7 @@ fn environment_value<'a>(environment: &'a [u8], key: &[u8]) -> Option<&'a [u8]> prefix.push(b'='); environment .split(|byte| *byte == 0) - .find_map(|item| item.strip_prefix(&prefix)) + .find_map(|item| item.strip_prefix(prefix.as_slice())) .filter(|value| !value.is_empty()) } @@ -384,7 +367,7 @@ fn is_rollout_link(link: &Path, codex_home: &Path) -> bool { #[cfg(unix)] fn read_first_line(path: &Path) -> std::io::Result { - use std::io::BufReader; + use std::io::{BufRead as _, BufReader}; let file = std::fs::File::open(path)?; let mut line = String::new(); BufReader::new(file).take(MAX_PROBE_LINE as u64).read_line(&mut line)?; @@ -401,6 +384,22 @@ mod tests { const ROOT_ID: &str = "01a079fa-4a9b-7d93-8a4a-4a7a9edaf247"; const GUARDIAN_ID: &str = "01a079f7-7e36-7232-882b-f06d3bde9df8"; + #[cfg(unix)] + #[test] + fn guest_lookup_returns_within_its_deadline() { + let mut command = Command::new("sh"); + command.args(["-c", "exec sleep 10"]); + let started = Instant::now(); + assert_eq!(run_probe_command(command), None); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn oversized_output_is_rejected_instead_of_selecting_a_partial_candidate() { + assert_eq!(read_bounded(&b"1234"[..], 4).unwrap(), b"1234"); + assert!(read_bounded(&b"12345"[..], 4).is_err()); + } + #[test] fn selects_cli_user_rollout_and_rejects_guardian_thread() { let output = format!( diff --git a/nebula_app/src/platform/environment.rs b/nebula_app/src/platform/environment.rs new file mode 100644 index 0000000..c25eb74 --- /dev/null +++ b/nebula_app/src/platform/environment.rs @@ -0,0 +1,100 @@ +//! Startup compatibility for platform environment variable names. + +/// Must run before any threads start: environment mutation is process-global. +pub unsafe fn import_environment_aliases() { + for (name, value) in environment_aliases(std::env::vars_os().collect()) { + unsafe { std::env::set_var(name, value) }; + } +} + +pub(crate) fn environment_aliases( + variables: Vec<(std::ffi::OsString, std::ffi::OsString)>, +) -> Vec<(String, std::ffi::OsString)> { + let mut aliases = Vec::new(); + for (name, value) in &variables { + let Some(name) = name.to_str() else { continue }; + let canonical = if cfg!(windows) { name.to_ascii_uppercase() } else { name.to_owned() }; + let name = canonical.as_str(); + if let Some(suffix) = name.strip_prefix("PEBREL_") { + if configuration_override(suffix) && value.is_empty() { + continue; + } + aliases.push((format!("NEBULA_{suffix}"), value.clone())); + } else if let Some(suffix) = name.strip_prefix("NEBULA_") { + if configuration_override(suffix) && value.is_empty() { + continue; + } + let current = format!("PEBREL_{suffix}"); + if !variables.iter().any(|(name, value)| { + name.to_str().is_some_and(|name| { + let matches = if cfg!(windows) { + name.eq_ignore_ascii_case(¤t) + } else { + name == current + }; + matches && !(configuration_override(suffix) && value.is_empty()) + }) + }) { + aliases.push((current, value.clone())); + } + } + } + aliases +} + +fn configuration_override(suffix: &str) -> bool { + matches!(suffix, "CONFIG_DIR" | "CONFIG_FILE" | "GPUI_CONFIG") +} + +#[cfg(test)] +mod tests { + #[test] + fn environment_aliases_preserve_legacy_inputs_and_prefer_explicit_pebrel_values() { + let aliases = super::environment_aliases( + [ + ("NEBULA_CONFIG_DIR", "legacy"), + ("PEBREL_CONFIG_DIR", "current"), + ("NEBULA_PANE_REMOTE", "1"), + ("OTHER_TOOL", "unchanged"), + ] + .into_iter() + .map(|(name, value)| (name.into(), value.into())) + .collect(), + ); + assert_eq!( + aliases, + vec![ + ("NEBULA_CONFIG_DIR".to_owned(), "current".into()), + ("PEBREL_PANE_REMOTE".to_owned(), "1".into()), + ] + ); + } + + #[test] + fn empty_configuration_overrides_fall_back_without_reopening_an_empty_hook_scope() { + let aliases = super::environment_aliases( + [ + ("PEBREL_CONFIG_DIR", ""), + ("NEBULA_CONFIG_DIR", "legacy-config"), + ("PEBREL_CONFIG_FILE", ""), + ("NEBULA_CONFIG_FILE", "legacy.toml"), + ("PEBREL_GPUI_CONFIG", ""), + ("NEBULA_GPUI_CONFIG", "legacy-gpui.toml"), + ("PEBREL_NOTIFY_PIPE", ""), + ("NEBULA_NOTIFY_PIPE", "stale-pipe"), + ] + .into_iter() + .map(|(name, value)| (name.into(), value.into())) + .collect(), + ); + assert_eq!( + aliases, + vec![ + ("PEBREL_CONFIG_DIR".to_owned(), "legacy-config".into()), + ("PEBREL_CONFIG_FILE".to_owned(), "legacy.toml".into()), + ("PEBREL_GPUI_CONFIG".to_owned(), "legacy-gpui.toml".into()), + ("NEBULA_NOTIFY_PIPE".to_owned(), "".into()), + ] + ); + } +} diff --git a/nebula_app/src/platform/keyboard.rs b/nebula_app/src/platform/keyboard.rs new file mode 100644 index 0000000..917c1b3 --- /dev/null +++ b/nebula_app/src/platform/keyboard.rs @@ -0,0 +1,29 @@ +//! Native character data discarded by the GPUI keyboard adapter. + +/// Recover Return's native character without changing keyboard composition state. +/// GPUI filters control text out of `key_char`, including Ctrl+Enter's LF. +pub(crate) fn enter_character(scan_code: u32, shift: bool, control: bool, alt: bool) -> u16 { + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + ToUnicode, VK_CONTROL, VK_MENU, VK_RETURN, VK_SHIFT, + }; + + let mut state = [0_u8; 256]; + state[VK_SHIFT as usize] = if shift { 0x80 } else { 0 }; + state[VK_CONTROL as usize] = if control { 0x80 } else { 0 }; + state[VK_MENU as usize] = if alt { 0x80 } else { 0 }; + let mut text = [0_u16; 2]; + // SAFETY: both buffers have the lengths required by ToUnicode. Flag 0x4 + // prevents dead-key state mutation; 0x1 selects menu translation semantics. + let count = unsafe { + ToUnicode( + u32::from(VK_RETURN), + scan_code, + state.as_ptr(), + text.as_mut_ptr(), + text.len() as i32, + 0x5, + ) + }; + // Zero is a valid absence of text. Never fabricate a partial UTF-16 unit. + if count == 1 { text[0] } else { 0 } +} diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index 7005508..5c7f2e7 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -13,11 +13,14 @@ pub(crate) mod ai_session_identity; pub mod capabilities; pub mod credentials; pub mod dirs; +pub(crate) mod environment; pub(crate) mod file_drag; pub mod file_manager; pub(crate) mod file_preview; #[cfg(feature = "gpui-shell")] pub mod fonts; +#[cfg(all(windows, feature = "gpui-shell"))] +pub(crate) mod keyboard; pub mod notifications; pub mod shell; pub mod shell_integration; diff --git a/nebula_app/src/platform/notifications.rs b/nebula_app/src/platform/notifications.rs index ec76078..0d006bb 100644 --- a/nebula_app/src/platform/notifications.rs +++ b/nebula_app/src/platform/notifications.rs @@ -1,3 +1,8 @@ +//! Native notification registration, delivery, and activation callbacks. +//! Attention policy and throttling belong to `crate::notify`. + +pub(crate) type ToastActivation = std::sync::Arc; + #[cfg(target_os = "macos")] static MACOS_READY: std::sync::OnceLock = std::sync::OnceLock::new(); @@ -34,3 +39,240 @@ pub fn show(title: &str, body: &str) { } }); } + +/// [`toast`], optionally wired for click-to-focus: activating the banner (or +/// its Action Center entry) surfaces `window` and, when a pane is named, its +/// tab. Uses the in-process WinRT Activated handler — no COM server, no +/// protocol registration. The one trade-off: clicks after Pebrel exited do +/// nothing, which is exactly right (there is nothing left to focus). +#[cfg(windows)] +pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option) { + use tauri_winrt_notification::{IconCrop, Toast}; + + // Attribute the toast to the Pebrel AUMID so it reads "Pebrel" instead of + // "Windows PowerShell". One registry write, cached per process. + win::ensure_aumid(); + + let mut toast = Toast::new(win::AUMID) + .title(title) + .text1(body) + .duration(tauri_winrt_notification::Duration::Short); + // Belt and braces: besides the AUMID IconUri (which some Windows builds + // cache stale), embed the logo per-toast as appLogoOverride so the banner + // always carries the Pebrel mark next to the message. + if let Some(icon) = win::icon_path() { + toast = toast.icon(&icon, IconCrop::Square, crate::brand::NAME); + } + if let Some(activation) = activation { + toast = toast.on_activated(move |_action| { + activation(); + Ok(()) + }); + } + + match toast.show() { + Ok(()) => log::debug!("notify: toast shown"), + Err(err) => log::warn!("notify: toast failed: {err}"), + } +} + +#[cfg(any(target_os = "linux", target_os = "macos"))] +pub(crate) fn toast_clickable(title: &str, body: &str, activation: Option) { + #[cfg(target_os = "macos")] + { + if objc2_foundation::NSBundle::mainBundle().bundleIdentifier().is_none() { + log::warn!("System notifications require a registered Pebrel application bundle"); + return; + } + crate::platform::notifications::prepare(); + } + let mut notification = notify_rust::Notification::new(); + notification.appname(crate::brand::NAME).summary(title).body(body); + if activation.is_some() { + let language = crate::i18n::LanguagePreference::from( + nebula_settings::RuntimeSettings::load().language, + ) + .resolved(); + notification.action("default", language.text(crate::i18n::Message::CommonOpen)); + } + match notification.show() { + Ok(handle) => { + if let Some(activation) = activation { + let result = handle.wait_for_response(move |response: ¬ify_rust::NotificationResponse| { + if response.is_default_action() + || matches!(response, notify_rust::NotificationResponse::Action(action) if action == "default") + { + activation(); + } + }); + if let Err(error) = result { + log::warn!("notify: activation listener failed: {error}"); + } + } + }, + Err(error) => log::warn!("notify: toast failed: {error}"), + } +} + +#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] +pub(crate) fn toast_clickable(title: &str, body: &str, _activation: Option) { + crate::platform::notifications::show(title, body); +} + +/// `pebrel notify-test` entrypoint: run the full toast pipeline synchronously +/// (registration + show), printing per-step diagnostics to the console. Skips +/// the focus policy and throttle on purpose — the tester is looking at the +/// screen. Returns a process exit code. +#[cfg(windows)] +pub fn notify_test() -> i32 { + println!("[1/2] Registering AUMID '{}' ...", win::AUMID); + match win::register_aumid() { + Ok(()) => { + println!(r" OK (HKCU\Software\Classes\AppUserModelId\{})", win::AUMID); + match win::icon_path() { + Some(path) => println!(" icon: {}", path.display()), + None => println!(" icon: unavailable (banner will have no logo)"), + } + }, + Err(err) => { + eprintln!(" FAILED: {err}"); + return 1; + }, + } + + println!("[2/2] Showing toast ..."); + let mut toast = tauri_winrt_notification::Toast::new(win::AUMID) + .title(crate::brand::NAME) + .text1("通知链路正常:pebrel notify-test") + .duration(tauri_winrt_notification::Duration::Short); + if let Some(icon) = win::icon_path() { + toast = toast.icon(&icon, tauri_winrt_notification::IconCrop::Square, crate::brand::NAME); + } + match toast.show() { + Ok(()) => { + println!(" OK — a toast should be on screen now."); + println!(); + println!("If nothing appeared, check Windows Settings > System > Notifications:"); + println!( + "the global toggle, Do Not Disturb / Focus Assist, and the {} entry.", + crate::brand::NAME + ); + 0 + }, + Err(err) => { + eprintln!(" FAILED: {err}"); + 1 + }, + } +} + +#[cfg(not(windows))] +pub fn notify_test() -> i32 { + println!("notify-test: system toasts are only implemented on Windows."); + 0 +} + +/// Windows-only: the Pebrel AppUserModelID and its registration. +/// +/// A WinRT toast must be attributed to an AUMID that Windows can resolve to +/// an app identity, or it silently refuses to show. For an unpackaged app the +/// documented lightweight route is a registry key — +/// `HKCU\Software\Classes\AppUserModelId\` with a `DisplayName` value +/// (what Microsoft's own ToastNotificationManagerCompat writes). Per-user, no +/// admin rights, no COM, no Start-menu shortcut, and idempotent: rewriting +/// the same value is a cheap no-op, so a broken key self-heals on next run. +#[cfg(windows)] +mod win { + use std::path::PathBuf; + use std::sync::Mutex; + + use windows_sys::Win32::Foundation::ERROR_SUCCESS; + use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, REG_SZ, RegSetKeyValueW}; + + /// AppUserModelID for Pebrel. Toast notifications fire under this identity + /// so the system shows "Pebrel" instead of "PowerShell" / "cmd.exe". + pub const AUMID: &str = "com.pebrel.terminal"; + + /// Ensure the AUMID is registered. Best-effort, cached per process: the + /// write itself is a few syscalls, there is just no point repeating them + /// for every toast. + pub fn ensure_aumid() { + static REGISTERED: Mutex> = Mutex::new(None); + let variant = crate::app_icon::selected(); + let mut registered = REGISTERED.lock().unwrap_or_else(|error| error.into_inner()); + if *registered != Some(variant) { + if let Err(err) = register_icon(variant) { + log::warn!("notify: AUMID registration failed (toast may not appear): {err}"); + } else { + *registered = Some(variant); + } + } + } + + /// Write `DisplayName` + `IconUri` under the AUMID key. `RegSetKeyValueW` + /// creates the missing subkey chain itself. The icon is best-effort: a + /// failed write only costs the logo, never the toast. + pub fn register_aumid() -> Result<(), String> { + register_icon(crate::app_icon::selected()) + } + + fn register_icon(variant: nebula_settings::AppIconName) -> Result<(), String> { + let subkey = format!(r"Software\Classes\AppUserModelId\{AUMID}"); + set_reg_sz(&subkey, "DisplayName", crate::brand::NAME)?; + match ensure_icon_file(variant) { + Some(icon) => set_reg_sz(&subkey, "IconUri", &icon.display().to_string())?, + None => log::debug!("notify: toast icon not materialized; banner shows no logo"), + } + Ok(()) + } + + /// The materialized icon path, for diagnostics (`pebrel notify-test`). + pub fn icon_path() -> Option { + ensure_icon_file(crate::app_icon::selected()) + } + + fn ensure_icon_file(variant: nebula_settings::AppIconName) -> Option { + let bytes = crate::app_icon::png(variant, 256)?; + let directory = crate::platform::dirs::data_dir(); + let path = directory.join(format!("toast_icon-{}.png", variant.settings_value())); + let stale = std::fs::read(&path).ok().as_deref() != Some(bytes.as_ref()); + if stale { + std::fs::create_dir_all(directory).ok()?; + std::fs::write(&path, bytes).ok()?; + } + Some(path) + } + + /// Set one REG_SZ value under HKCU\`subkey`, creating the key as needed. + fn set_reg_sz(subkey: &str, name: &str, data: &str) -> Result<(), String> { + let subkey_w = to_wide(subkey); + let name_w = to_wide(name); + let data_w = to_wide(data); + let data_bytes = (data_w.len() * std::mem::size_of::()) as u32; + + // SAFETY: every pointer references a live, NUL-terminated UTF-16 + // buffer owned by this frame; RegSetKeyValueW copies the data before + // returning and creates intermediate keys as needed. + let status = unsafe { + RegSetKeyValueW( + HKEY_CURRENT_USER, + subkey_w.as_ptr(), + name_w.as_ptr(), + REG_SZ, + data_w.as_ptr().cast(), + data_bytes, + ) + }; + + if status == ERROR_SUCCESS { + Ok(()) + } else { + Err(format!("RegSetKeyValueW({name}) failed with status {status}")) + } + } + + /// NUL-terminated UTF-16 for Win32 wide-string APIs. + fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(Some(0)).collect() + } +} diff --git a/nebula_app/src/platform/startup.rs b/nebula_app/src/platform/startup.rs index 60da486..06a5e15 100644 --- a/nebula_app/src/platform/startup.rs +++ b/nebula_app/src/platform/startup.rs @@ -1,3 +1,11 @@ +/// Surface a pre-logger failure for a GUI launch; the caller also returns it on stderr. +pub(crate) fn report_error(error: &dyn std::fmt::Display, gui_launch: bool) { + #[cfg(windows)] + crate::panic::report_startup_error(error, gui_launch); + #[cfg(not(windows))] + let _ = (error, gui_launch); +} + pub fn prepare_gui() { #[cfg(target_os = "macos")] { diff --git a/nebula_app/src/process_tree.rs b/nebula_app/src/process_tree.rs index 40510f9..7824066 100644 --- a/nebula_app/src/process_tree.rs +++ b/nebula_app/src/process_tree.rs @@ -118,7 +118,12 @@ fn is_stateless_process(executable: &str) -> bool { return true; } let stem = name.strip_suffix(".exe").unwrap_or(&name); - INTERACTIVE_SHELLS.contains(&stem) || stem == "git" + // POSIX login shells can expose argv[0] as `-bash`/`-zsh` in ps output. + // Apply this convention only to a known shell, never to the git/plumbing + // exemptions or Windows executable names. Command parsing stays separate. + let login_shell = !name.ends_with(".exe") + && stem.strip_prefix('-').is_some_and(|shell| INTERACTIVE_SHELLS.contains(&shell)); + INTERACTIVE_SHELLS.contains(&stem) || login_shell || stem == "git" } /// Parse the stable three-column `ps` output used by Unix snapshots. The @@ -269,7 +274,11 @@ pub fn descendants(root_pid: u32) -> Result, String> { /// First non-stateless process under `root_pid` (the pane's shell), or `None` /// when the whole tree is safe to kill. The name is used in the confirm modal. pub fn busy_child(root_pid: u32) -> Option { - descendants(root_pid).ok()?.into_iter().skip(1).find_map(|process| { + busy_descendant(descendants(root_pid).ok()?) +} + +fn busy_descendant(processes: Vec) -> Option { + processes.into_iter().skip(1).find_map(|process| { (!is_stateless_process(&process.executable)).then_some(process.executable) }) } @@ -431,10 +440,39 @@ mod tests { use std::collections::HashMap; use super::{ - display_name, is_interactive_shell_command, parse_ps_line, resolve_agent_ancestor, - resolve_within_tree, + ProcessEntry, busy_descendant, display_name, is_interactive_shell_command, + is_stateless_process, parse_ps_line, resolve_agent_ancestor, resolve_within_tree, }; + #[test] + fn login_shell_process_names_are_stateless_without_exempting_other_programs() { + for name in ["-bash", "-zsh", "-sh", "-fish", "/bin/-bash"] { + assert!(is_stateless_process(name), "{name}"); + } + for name in ["-vim", "-python", "-git", "--bash", "-bash.exe", "-bash script.sh"] { + assert!(!is_stateless_process(name), "{name}"); + } + assert!(!is_interactive_shell_command("-bash")); + } + + #[test] + fn login_shell_nodes_do_not_hide_a_busy_descendant() { + let entries = |commands: &[&str]| { + commands + .iter() + .enumerate() + .map(|(depth, command)| ProcessEntry { + pid: depth as u32 + 1, + parent_pid: depth as u32, + executable: (*command).to_owned(), + depth: depth as u32, + }) + .collect() + }; + assert_eq!(busy_descendant(entries(&["login", "-bash"])), None); + assert_eq!(busy_descendant(entries(&["login", "-bash", "vim"])), Some("vim".to_owned())); + } + #[test] fn unix_process_rows_keep_the_complete_command_column() { assert_eq!( diff --git a/nebula_app/src/session.rs b/nebula_app/src/session.rs index 0c913f8..a246e9d 100644 --- a/nebula_app/src/session.rs +++ b/nebula_app/src/session.rs @@ -66,7 +66,7 @@ pub enum SplitAxis { } /// 快照瞬间仍在一个 pane 前台运行的 AI CLI 对话。冷恢复用它接续会话: -/// 身份来自 hook 直报(claude `session_id` / codex `thread-id`),缺 id 的 +/// 身份来自 hook 直报或当前 Codex 会话元数据,缺 id 的 /// claude 退化成 `--continue`(按 cwd 找最近对话,恰好匹配恢复语义)。 /// /// 只存「安全启动描述」——来源名 + id,不存正文、不存启动参数原文。 @@ -515,6 +515,35 @@ mod tests { assert_eq!(cwds, ["a", "b", "c"]); } + #[test] + fn codex_conversations_survive_file_save_and_resume_by_exact_id() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("workspace.json"); + let ids = ["01a079fa-4a9b-7d93-8a4a-4a7a9edaf247", "01a079f7-7e36-7232-882b-f06d3bde9df8"]; + let tabs = ids + .iter() + .map(|id| { + let mut tab = TabSession::single("/home/user/project".into(), None, None); + tab.layout = Some(LayoutSession::Pane { + cwd: "/home/user/project".into(), + agent: Some(AgentSession { + source: "codex".into(), + session_id: Some((*id).into()), + }), + }); + tab + }) + .collect(); + save_to(&path, &Session::new(0, tabs)).unwrap(); + let restored = load_from(&path).unwrap(); + for (tab, id) in restored.tabs.iter().zip(ids) { + let Some(LayoutSession::Pane { agent: Some(agent), .. }) = &tab.layout else { + panic!("saved pane lost its conversation identity"); + }; + assert_eq!(agent.resume_command(), Some(format!("codex resume {id}"))); + } + } + #[test] fn workspace_files_round_trip_through_save_to_and_load_from() { let dir = std::env::temp_dir().join("nebula-session-test"); diff --git a/nebula_app/src/shell_detect.rs b/nebula_app/src/shell_detect.rs index f314db7..e8ba914 100644 --- a/nebula_app/src/shell_detect.rs +++ b/nebula_app/src/shell_detect.rs @@ -510,6 +510,9 @@ pub fn wsl_launch_user<'a>(program: &str, args: &'a [String]) -> Option<&'a str> return None; } for (index, arg) in args.iter().enumerate() { + if matches!(arg.as_str(), "--" | "--exec" | "-e") { + break; + } if matches!(arg.as_str(), "-u" | "--user") { return args.get(index + 1).map(String::as_str).filter(|user| !user.is_empty()); } @@ -806,6 +809,10 @@ mod tests { assert_eq!(super::wsl_launch_user("wsl.exe", &owned(&["--user", "hello"])), Some("hello")); assert_eq!(super::wsl_launch_user("wsl.exe", &owned(&["--user=hello"])), Some("hello")); assert_eq!(super::wsl_launch_user("wsl.exe", &owned(&["--user"])), None); + assert_eq!( + super::wsl_launch_user("wsl.exe", &owned(&["--exec", "tool", "--user", "remote"])), + None + ); assert_eq!(super::wsl_launch_user("pwsh.exe", &owned(&["--user", "hello"])), None); } diff --git a/nebula_app/src/update_check.rs b/nebula_app/src/update_check.rs index 2d73aa9..d6ad5ff 100644 --- a/nebula_app/src/update_check.rs +++ b/nebula_app/src/update_check.rs @@ -310,8 +310,9 @@ fn parse_latest_release(bytes: &[u8]) -> Result { Ok(LatestRelease { version, asset }) } -pub(crate) fn windows_x64_installer_names(version: &str) -> [String; 2] { +pub(crate) fn windows_x64_installer_names(version: &str) -> [String; 3] { [ + format!("Pebrel-v{version}-windows-x64-setup.exe"), format!("Pebrel-{version}-windows-x64-setup.exe"), format!("NebulaTerminal-{version}-windows-x64-setup.exe"), ] @@ -484,8 +485,8 @@ mod tests { #[test] fn installer_selection_prefers_pebrel_regardless_of_asset_order() { - let [pebrel, legacy] = windows_x64_installer_names("1.6.0"); - for names in [[&legacy, &pebrel], [&pebrel, &legacy]] { + let [pebrel, interim, legacy] = windows_x64_installer_names("1.6.0"); + for names in [[&legacy, &interim, &pebrel], [&pebrel, &legacy, &interim]] { let assets = names.map(|name| release_asset(name)).into_iter().collect(); let selected = select_windows_x64_installer("1.6.0", "", assets).unwrap(); assert_eq!(selected.name, pebrel); @@ -495,7 +496,7 @@ mod tests { #[test] fn installer_selection_accepts_legacy_only_releases_and_body_checksums() { - let [_, legacy] = windows_x64_installer_names("1.6.0"); + let [_, _, legacy] = windows_x64_installer_names("1.6.0"); let mut asset = release_asset(&legacy); asset.digest = None; let hash = "b".repeat(64); diff --git a/nebula_terminal/src/term/keyboard.rs b/nebula_terminal/src/term/keyboard.rs index 435d352..92b61d3 100644 --- a/nebula_terminal/src/term/keyboard.rs +++ b/nebula_terminal/src/term/keyboard.rs @@ -1,7 +1,9 @@ -//! Conversion between terminal flags and the keyboard protocol reported to applications. +//! Keyboard protocol flag conversion and application to the active terminal mode. -use super::TermMode; -use crate::vte::ansi::KeyboardModes; +use log::trace; + +use super::{Term, TermMode}; +use crate::vte::ansi::{KeyboardModes, KeyboardModesApplyBehavior}; const KEYBOARD_FLAGS: [(KeyboardModes, TermMode); 5] = [ (KeyboardModes::DISAMBIGUATE_ESC_CODES, TermMode::DISAMBIGUATE_ESC_CODES), @@ -30,3 +32,18 @@ impl From for KeyboardModes { mode } } + +impl Term { + #[inline] + pub(super) fn set_keyboard_mode(&mut self, mode: TermMode, apply: KeyboardModesApplyBehavior) { + let active_mode = self.mode & TermMode::KITTY_KEYBOARD_PROTOCOL; + self.mode &= !TermMode::KITTY_KEYBOARD_PROTOCOL; + let new_mode = match apply { + KeyboardModesApplyBehavior::Replace => mode, + KeyboardModesApplyBehavior::Union => active_mode.union(mode), + KeyboardModesApplyBehavior::Difference => active_mode.difference(mode), + }; + trace!("Setting keyboard mode to {new_mode:?}"); + self.mode |= new_mode; + } +} diff --git a/nebula_terminal/src/term/keyboard_contract_tests.rs b/nebula_terminal/src/term/keyboard_contract_tests.rs new file mode 100644 index 0000000..55ef751 --- /dev/null +++ b/nebula_terminal/src/term/keyboard_contract_tests.rs @@ -0,0 +1,105 @@ +use super::*; + +use crate::term::test::TermSize; +use crate::vte::ansi::Processor; + +#[derive(Clone, Default)] +struct Replies(std::rc::Rc>>); + +impl EventListener for Replies { + fn send_event(&self, event: Event) { + if let Event::PtyWrite(text) = event { + self.0.borrow_mut().push(text); + } + } +} + +fn assert_steps(steps: &[(&[u8], u8)]) { + let replies = Replies::default(); + let mut term = Term::new( + Config { kitty_keyboard: true, ..Config::default() }, + &TermSize::new(80, 24), + replies.clone(), + ); + let mut parser: Processor = Processor::new(); + for &(input, expected) in steps { + parser.advance(&mut term, input); + parser.advance(&mut term, b"\x1b[?u"); + assert_eq!( + std::mem::take(&mut *replies.0.borrow_mut()), + [format!("\x1b[?{expected}u")], + "input={input:?}" + ); + assert_eq!( + *term.mode() & TermMode::KITTY_KEYBOARD_PROTOCOL, + TermMode::from(KeyboardModes::from_bits(expected).unwrap()), + "input={input:?}" + ); + } +} + +#[test] +fn keyboard_contract_set_without_push_survives_nested_modes() { + assert_steps(&[ + (b"\x1b[=5u", 5), + (b"\x1b[>1u", 1), + (b"\x1b[=7u", 7), + (b"\x1b[>3u", 3), + (b"\x1b[1u\x1b[=7u", 7), + (b"\x1b[?1049l", 5), + (b"\x1b[?1049h", 7), + (b"\x1b[?1049l", 5), + ]); +} + +#[test] +fn keyboard_contract_union_and_difference_persist_after_nested_pop() { + assert_steps(&[ + (b"\x1b[>1u", 1), + (b"\x1b[=2;2u", 3), + (b"\x1b[>8u", 8), + (b"\x1b[8u", 8), + (b"\x1b[1u"); + } + assert!(term.keyboard_mode_stack.len() <= KEYBOARD_MODE_STACK_MAX_DEPTH); + assert_eq!(term.title_stack, [Some("saved title".into())]); +} + +#[test] +fn keyboard_contract_reset_clears_both_screens() { + assert_steps(&[ + (b"\x1b[=5u", 5), + (b"\x1b[?1049h\x1b[>3u", 3), + (b"\x1bc", 0), + (b"\x1b[?1049h", 0), + (b"\x1b[?1049l", 0), + ]); +} diff --git a/nebula_terminal/src/term/mod.rs b/nebula_terminal/src/term/mod.rs index a9148f0..7f8d51d 100644 --- a/nebula_terminal/src/term/mod.rs +++ b/nebula_terminal/src/term/mod.rs @@ -31,6 +31,8 @@ pub mod cell; pub mod color; mod damage; mod keyboard; +#[cfg(test)] +mod keyboard_contract_tests; mod renderable; pub mod search; @@ -1248,19 +1250,6 @@ impl Term { Point::new(self.grid.cursor.point.line.0 as usize, self.grid.cursor.point.column); self.damage.damage_point(point); } - - #[inline] - fn set_keyboard_mode(&mut self, mode: TermMode, apply: KeyboardModesApplyBehavior) { - let active_mode = self.mode & TermMode::KITTY_KEYBOARD_PROTOCOL; - self.mode &= !TermMode::KITTY_KEYBOARD_PROTOCOL; - let new_mode = match apply { - KeyboardModesApplyBehavior::Replace => mode, - KeyboardModesApplyBehavior::Union => active_mode.union(mode), - KeyboardModesApplyBehavior::Difference => active_mode.difference(mode), - }; - trace!("Setting keyboard mode to {new_mode:?}"); - self.mode |= new_mode; - } } impl Dimensions for Term { @@ -1523,7 +1512,7 @@ impl Handler for Term { trace!("Pushing `{mode:?}` keyboard mode into the stack"); if self.keyboard_mode_stack.len() >= KEYBOARD_MODE_STACK_MAX_DEPTH { - let removed = self.title_stack.remove(0); + let removed = self.keyboard_mode_stack.remove(0); trace!( "Removing '{removed:?}' from bottom of keyboard mode stack that exceeds its \ maximum depth" @@ -1556,6 +1545,14 @@ impl Handler for Term { } self.set_keyboard_mode(mode.into(), apply); + // CSI = changes the current frame, including the implicit base frame. + // Nested applications and screen switches must restore the changed flags. + let active = KeyboardModes::from(self.mode); + if let Some(frame) = self.keyboard_mode_stack.last_mut() { + *frame = active; + } else { + self.keyboard_mode_stack.push(active); + } } #[inline] diff --git a/packaging/linux/io.github.kuddev.pebrel.desktop b/packaging/linux/io.github.kuddev.pebrel.desktop new file mode 100644 index 0000000..8eec56b --- /dev/null +++ b/packaging/linux/io.github.kuddev.pebrel.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Pebrel +GenericName=Terminal Emulator +Comment=GPU-accelerated terminal for local and remote workflows +Exec=pebrel +Icon=io.github.kuddev.pebrel +Terminal=false +Categories=System;TerminalEmulator; +Keywords=terminal;shell;prompt;ssh; +StartupNotify=true +StartupWMClass=Pebrel diff --git a/packaging/linux/io.github.kuddev.pebrel.metainfo.xml b/packaging/linux/io.github.kuddev.pebrel.metainfo.xml new file mode 100644 index 0000000..fdf2263 --- /dev/null +++ b/packaging/linux/io.github.kuddev.pebrel.metainfo.xml @@ -0,0 +1,26 @@ + + + io.github.kuddev.pebrel + Pebrel + GPU-accelerated terminal for local and remote workflows + CC0-1.0 + GPL-3.0-or-later + Kuddev + +

+ Pebrel combines GPU-accelerated terminal rendering, local and + SSH sessions, file workflows, and command-line agent integrations. +

+
+ io.github.kuddev.pebrel.desktop + + pebrel + + + System + TerminalEmulator + + https://github.com/Kuddev/pebrel + https://github.com/Kuddev/pebrel/issues + +
diff --git a/scripts/build-installer.ps1 b/scripts/build-installer.ps1 index b29f3b3..2d3040c 100644 --- a/scripts/build-installer.ps1 +++ b/scripts/build-installer.ps1 @@ -57,7 +57,7 @@ if ([string]::IsNullOrWhiteSpace($TargetDirectory)) { } $cargoTargetRoot = [System.IO.Path]::GetFullPath($TargetDirectory) $targetRoot = Join-Path $cargoTargetRoot $Configuration -$setupPath = Join-Path $outputRoot "$PackageBrand-$Version-windows-x64-setup.exe" +$setupPath = Join-Path $outputRoot "$PackageBrand-v$Version-windows-x64-setup.exe" $requiredFiles = @( (Join-Path $targetRoot 'pebrel.exe'), @@ -65,6 +65,7 @@ $requiredFiles = @( (Join-Path $targetRoot 'conpty.dll'), (Join-Path $targetRoot 'OpenConsole.exe'), (Join-Path $repo 'README.md'), + (Join-Path $repo 'README.zh-CN.md'), (Join-Path $repo 'CHANGELOG.md'), (Join-Path $repo 'INSTALL.md'), (Join-Path $repo 'docs\lua-configuration.md'), diff --git a/scripts/conformance/cases/resize.py b/scripts/conformance/cases/resize.py index 4fa0c1f..e4ec1d1 100644 --- a/scripts/conformance/cases/resize.py +++ b/scripts/conformance/cases/resize.py @@ -1,8 +1,65 @@ from __future__ import annotations +import json +import os + from conformance.harness import ConformanceContext, require +def _linux_terminal_size(pid: int) -> dict[str, int]: + import fcntl + import struct + import termios + + # Read the shell's actual PTY geometry without relying on COLUMNS or the + # command-substitution stdout used by tput. O_NOCTTY preserves ownership. + fd = os.open(f"/proc/{pid}/fd/0", os.O_RDONLY | os.O_NOCTTY | os.O_CLOEXEC) + try: + packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, bytes(8)) + rows, columns, _, _ = struct.unpack("HHHH", packed) + return {"rows": rows, "columns": columns} + finally: + os.close(fd) + + +def _write_failure_diagnostics( + ctx: ConformanceContext, + source: int, + sibling: int, + measurements: dict[str, int], + error: Exception, +) -> None: + details: dict[str, object] = { + "error": str(error), + "shell": ctx.shell, + "measurements": measurements, + "panes": {}, + } + try: + details["snapshot"] = ctx.api("runtime.snapshot", timeout=2.0) + except Exception as diagnostic_error: + details["snapshot_error"] = str(diagnostic_error) + panes: dict[str, object] = {} + for pane_id in (source, sibling): + params = {"window_id": ctx.window_id, "pane_id": pane_id} + pane: dict[str, object] = {} + try: + pane["read"] = ctx.api("pane.read", {**params, "lines": 40}, timeout=2.0) + except Exception as diagnostic_error: + pane["read_error"] = str(diagnostic_error) + if ctx.platform_family == "linux": + try: + processes = ctx.api("pane.procs", params, timeout=2.0) + pane["kernel_terminal_size"] = _linux_terminal_size(int(processes["root_pid"])) + except Exception as diagnostic_error: + pane["kernel_terminal_size_error"] = str(diagnostic_error) + panes[str(pane_id)] = pane + details["panes"] = panes + (ctx.artifact_dir / "resize-failure.json").write_text( + json.dumps(details, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + def run(ctx: ConformanceContext) -> dict[str, object]: source = ctx.pane_id split = ctx.api( @@ -14,16 +71,21 @@ def run(ctx: ConformanceContext) -> dict[str, object]: }, ) sibling = int(split["action"]["pane_id"]) + measurements: dict[str, int] = {} try: source_before = ctx.measure_columns(source) + measurements["source_columns_before"] = source_before sibling_before = ctx.measure_columns(sibling) + measurements["sibling_columns_before"] = sibling_before resized = ctx.api( "pane.resize", {"window_id": ctx.window_id, "pane_id": source, "ratio": 0.70}, ) ratio = float(resized["action"]["ratio"]) source_after = ctx.measure_columns(source) + measurements["source_columns_after"] = source_after sibling_after = ctx.measure_columns(sibling) + measurements["sibling_columns_after"] = sibling_after require(abs(ratio - 0.70) < 0.001, f"runtime reported wrong split ratio: {ratio}") require( source_after > source_before, @@ -45,6 +107,14 @@ def run(ctx: ConformanceContext) -> dict[str, object]: "sibling_columns_before": sibling_before, "sibling_columns_after": sibling_after, } + except Exception as error: + # Capture only after a strict assertion fails. Extra observations must + # not turn into an implicit wait that makes a resize race disappear. + try: + _write_failure_diagnostics(ctx, source, sibling, measurements, error) + except Exception as diagnostic_error: + print(f"[resize] could not retain diagnostics: {diagnostic_error}", flush=True) + raise finally: ctx.best_effort_api("pane.close", {"window_id": ctx.window_id, "pane_id": sibling}) ctx.best_effort_api("window.focus", {"window_id": ctx.window_id, "pane_id": source}) diff --git a/scripts/conformance/harness.py b/scripts/conformance/harness.py index 33146e7..d6b0180 100644 --- a/scripts/conformance/harness.py +++ b/scripts/conformance/harness.py @@ -2,6 +2,8 @@ from __future__ import annotations +import ctypes +from ctypes import wintypes import fnmatch import glob import json @@ -9,8 +11,10 @@ import os import plistlib import re import shutil +import signal import socket import subprocess +import sys import tempfile import time import zipfile @@ -22,6 +26,15 @@ PROTOCOL_VERSION = 1 MAX_RESPONSE_BYTES = 32 * 1024 * 1024 DEFAULT_STARTUP_TIMEOUT = 20.0 +# The Windows gate cannot start the app until its parent assigns the exact +# process HANDLE to a job. -I -S disables user/site startup hooks in this gate. +_WINDOWS_LAUNCH_GATE = ( + "import subprocess, sys\n" + "if sys.stdin.buffer.read(1) != b'G':\n" + " raise SystemExit(125)\n" + "raise SystemExit(subprocess.call(sys.argv[1:], stdin=subprocess.DEVNULL))\n" +) + class ConformanceError(RuntimeError): """A test precondition or behavioral assertion failed.""" @@ -248,6 +261,173 @@ class RuntimeClient: return response +class _WindowsJob: + """Own one harness launch and its descendants, including orphaned shells. + + Killing only Nebula can leave ConPTY children holding the test directory. + Job membership is established before the gated app launch, inherited by + descendants, and retained after the parent exits. No machine-wide process + enumeration or process-name matching is used. The job kills its members if the + harness itself exits unexpectedly and Windows closes its handle. + """ + + def __init__(self) -> None: + class BasicLimits(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_int64), + ("PerJobUserTimeLimit", ctypes.c_int64), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + class ExtendedLimits(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", BasicLimits), + ("IoInfo", ctypes.c_uint64 * 6), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + class Accounting(ctypes.Structure): + _fields_ = [ + ("TotalUserTime", ctypes.c_int64), + ("TotalKernelTime", ctypes.c_int64), + ("ThisPeriodTotalUserTime", ctypes.c_int64), + ("ThisPeriodTotalKernelTime", ctypes.c_int64), + ("TotalPageFaultCount", wintypes.DWORD), + ("TotalProcesses", wintypes.DWORD), + ("ActiveProcesses", wintypes.DWORD), + ("TotalTerminatedProcesses", wintypes.DWORD), + ] + + self._accounting = Accounting + self._api = ctypes.WinDLL("kernel32", use_last_error=True) + self._api.CreateJobObjectW.argtypes = (ctypes.c_void_p, wintypes.LPCWSTR) + self._api.CreateJobObjectW.restype = wintypes.HANDLE + self._api.SetInformationJobObject.argtypes = ( + wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, + ) + self._api.SetInformationJobObject.restype = wintypes.BOOL + self._api.AssignProcessToJobObject.argtypes = (wintypes.HANDLE, wintypes.HANDLE) + self._api.AssignProcessToJobObject.restype = wintypes.BOOL + self._api.TerminateJobObject.argtypes = (wintypes.HANDLE, wintypes.UINT) + self._api.TerminateJobObject.restype = wintypes.BOOL + self._api.QueryInformationJobObject.argtypes = ( + wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ) + self._api.QueryInformationJobObject.restype = wintypes.BOOL + self._api.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD) + self._api.OpenProcess.restype = wintypes.HANDLE + self._api.IsProcessInJob.argtypes = ( + wintypes.HANDLE, wintypes.HANDLE, ctypes.POINTER(wintypes.BOOL), + ) + self._api.IsProcessInJob.restype = wintypes.BOOL + self._api.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD) + self._api.WaitForSingleObject.restype = wintypes.DWORD + self._api.CloseHandle.argtypes = (wintypes.HANDLE,) + self._api.CloseHandle.restype = wintypes.BOOL + self._process_handles: list[int] = [] + self._handle = self._api.CreateJobObjectW(None, None) + if not self._handle: + raise ctypes.WinError(ctypes.get_last_error()) + limits = ExtendedLimits() + limits.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE + if not self._api.SetInformationJobObject( + self._handle, 9, ctypes.byref(limits), ctypes.sizeof(limits) + ): + error = ctypes.WinError(ctypes.get_last_error()) + self.close() + raise error + + def assign(self, process: subprocess.Popen[bytes]) -> None: + # CPython retains the native process handle on Windows. Using that + # handle avoids opening a PID that could have been recycled. + if not self._api.AssignProcessToJobObject(self._handle, int(process._handle)): + raise ctypes.WinError(ctypes.get_last_error()) + + def terminate(self, timeout: float = 5.0) -> None: + deadline = time.monotonic() + timeout + self._capture_process_handles() + if not self._api.TerminateJobObject(self._handle, 1): + raise ctypes.WinError(ctypes.get_last_error()) + self._capture_process_handles() + while True: + accounting = self._accounting() + if not self._api.QueryInformationJobObject( + self._handle, 1, ctypes.byref(accounting), ctypes.sizeof(accounting), None + ): + raise ctypes.WinError(ctypes.get_last_error()) + if accounting.ActiveProcesses == 0: + break + if time.monotonic() >= deadline: + raise ConformanceError( + f"Windows launch job still has {accounting.ActiveProcesses} " + f"processes after {timeout:.1f}s of cleanup" + ) + time.sleep(0.01) + # ActiveProcesses can reach zero before final process teardown closes + # directory handles. Wait on the retained identities, not just that + # accounting value, including when the launch parent exited earlier. + for handle in self._process_handles: + remaining_ms = max(0, int((deadline - time.monotonic()) * 1000)) + result = self._api.WaitForSingleObject(handle, remaining_ms) + if result == 0xFFFFFFFF: + raise ctypes.WinError(ctypes.get_last_error()) + if result != 0: + raise ConformanceError(f"Windows job member did not exit within {timeout:.1f}s") + + def _capture_process_handles(self) -> None: + capacity = 16 + while True: + buffer = ctypes.create_string_buffer(8 + ctypes.sizeof(ctypes.c_size_t) * capacity) + if self._api.QueryInformationJobObject( + self._handle, 3, buffer, ctypes.sizeof(buffer), None + ): + count = wintypes.DWORD.from_buffer(buffer, 4).value + pids = (ctypes.c_size_t * count).from_buffer(buffer, 8) + break + error = ctypes.get_last_error() + if error != 234: # ERROR_MORE_DATA: members grew beyond the buffer. + raise ctypes.WinError(error) + capacity = max(capacity * 2, wintypes.DWORD.from_buffer(buffer).value) + for pid in pids: + # These are only candidates from this job, never a machine-wide + # process tree. Verify membership after opening to reject PID reuse. + handle = self._api.OpenProcess(0x00101000, False, pid) + if not handle: + error = ctypes.get_last_error() + if error == 87: # The process exited before its handle was opened. + continue + raise ctypes.WinError(error) + member = wintypes.BOOL() + if not self._api.IsProcessInJob(handle, self._handle, ctypes.byref(member)): + error = ctypes.WinError(ctypes.get_last_error()) + self._api.CloseHandle(handle) + raise error + if member.value: + self._process_handles.append(handle) + elif not self._api.CloseHandle(handle): + raise ctypes.WinError(ctypes.get_last_error()) + + def close(self) -> None: + if self._handle is not None: + if not self._api.CloseHandle(self._handle): + raise ctypes.WinError(ctypes.get_last_error()) + self._handle = None + while self._process_handles: + if not self._api.CloseHandle(self._process_handles[-1]): + raise ctypes.WinError(ctypes.get_last_error()) + self._process_handles.pop() + + class ConformanceContext: def __init__( self, @@ -266,6 +446,7 @@ class ConformanceContext: self.artifact_dir = artifact_dir.resolve() self.startup_timeout = startup_timeout self.process: subprocess.Popen[bytes] | None = None + self._windows_job: _WindowsJob | None = None self.client: RuntimeClient | None = None self.description: dict[str, Any] = {} self.startup_ms = 0 @@ -306,7 +487,10 @@ class ConformanceContext: ) def start(self, *, explicit_working_directory: bool = True) -> None: - require(self.process is None, "Pebrel is already running in this context") + require( + self.process is None and self._windows_job is None, + "Pebrel is already running in this context", + ) self._launch_number += 1 log_path = self.artifact_dir / f"pebrel-{self._launch_number}.log" self._log_handle = log_path.open("wb") @@ -321,14 +505,7 @@ class ConformanceContext: command.extend(["--working-directory", os.fspath(self.work_dir)]) started = time.monotonic() try: - self.process = subprocess.Popen( - command, - cwd=self.work_dir, - env=env, - stdin=subprocess.DEVNULL, - stdout=self._log_handle, - stderr=subprocess.STDOUT, - ) + self.process = self._spawn_process(command, env) except OSError as error: self._close_log() raise ConformanceError(f"could not launch {self.app.executable}: {error}") from error @@ -338,8 +515,7 @@ class ConformanceContext: while time.monotonic() < deadline: if self.process.poll() is not None: code = self.process.returncode - self.process = None - self._close_log() + self.stop(force=True) raise ConformanceError(f"Nebula exited during startup with code {code}") if self.port_file.is_file(): try: @@ -351,6 +527,13 @@ class ConformanceContext: ) snapshot = snapshot_response.get("result") require(isinstance(snapshot, dict), "runtime snapshot is not an object") + if os.name == "posix": + runtime_pid = snapshot.get("process_id") + require( + type(runtime_pid) is int and runtime_pid > 0 + and os.getpgid(runtime_pid) == self.process.pid, + "runtime process does not belong to this launch", + ) windows = snapshot.get("windows") or [] ready = any( any( @@ -370,21 +553,76 @@ class ConformanceContext: self.stop(force=True) raise ConformanceError(f"Nebula runtime did not become ready: {last_error}") - def stop(self, *, force: bool) -> None: - process = self.process - self.process = None - self.client = None - if process is not None and process.poll() is None: - if force: - process.kill() - else: - process.terminate() + def _spawn_process(self, command: list[str], env: dict[str, str]) -> subprocess.Popen[bytes]: + options = { + "cwd": self.work_dir, + "env": env, + "stdout": self._log_handle, + "stderr": subprocess.STDOUT, + } + if os.name != "nt": + return subprocess.Popen( + command, stdin=subprocess.DEVNULL, start_new_session=True, **options, + ) + job = _WindowsJob() + process = None + try: + process = subprocess.Popen( + [sys.executable, "-I", "-S", "-c", _WINDOWS_LAUNCH_GATE, *command], + stdin=subprocess.PIPE, **options, + ) + job.assign(process) + assert process.stdin is not None + with process.stdin: + process.stdin.write(b"G") + except BaseException: try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=5) - self._close_log() + # Before assignment the gate has no child; after assignment + # the job owns anything it could have started. + if process is not None: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + if process.stdin is not None: + process.stdin.close() + job.terminate() + finally: + job.close() + raise + self._windows_job = job + return process + + def stop(self, *, force: bool, timeout: float = 5.0) -> None: + process = self.process + try: + if self._windows_job is not None: + deadline = time.monotonic() + timeout + self._windows_job.terminate(timeout) + if process is not None: + process.wait(timeout=max(0, deadline - time.monotonic())) + self._windows_job.close() + elif process is not None: + # An AppImage launcher forks the application. Its private + # group remains ours even after the launcher itself exits. + try: + os.killpg(process.pid, signal.SIGKILL if force else signal.SIGTERM) + except ProcessLookupError: + pass + try: + process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=timeout) + finally: + self._close_log() + # Preserve ownership if cleanup fails, so a caller can retry and the + # failure is not replaced by a silent temporary-directory exclusion. + self.process = None + self._windows_job = None + self.client = None def restart(self) -> int: self.stop(force=True) @@ -613,20 +851,24 @@ class ConformanceContext: def scrollback_command(self, lines: int, marker: str) -> str: shell = self.shell + require(len(marker) >= 2, "completion marker must contain at least two characters") + # The command is echoed before its output arrives. Only execution may + # assemble the complete token that wait_for_line uses as completion. + completion = self.marker_command(marker[:-1], marker[-1]) if shell in {"powershell", "pwsh"}: - return f"1..{lines} | ForEach-Object {{ 'NEBULA_SCROLL_' + $_ }}; '{marker}'" + return f"1..{lines} | ForEach-Object {{ 'NEBULA_SCROLL_' + $_ }}; {completion}" if shell == "cmd": - return f"for /L %i in (1,1,{lines}) do @echo NEBULA_SCROLL_%i & echo {marker}" + return f"(for /L %i in (1,1,{lines}) do @echo NEBULA_SCROLL_%i) & {completion}" if shell == "fish": - return f"for i in (seq 1 {lines}); echo NEBULA_SCROLL_$i; end; echo {marker}" + return f"for i in (seq 1 {lines}); echo NEBULA_SCROLL_$i; end; {completion}" if shell == "nu": return ( f"1..{lines} | each {{ |i| print $'NEBULA_SCROLL_($i)' }}; " - f"print '{marker}'" + f"{completion}" ) return ( f"i=1; while [ \"$i\" -le {lines} ]; do printf 'NEBULA_SCROLL_%s\\n' \"$i\"; " - f"i=$((i + 1)); done; printf '%s\\n' '{marker}'" + f"i=$((i + 1)); done; {completion}" ) def ensure_single_tab(self) -> None: @@ -664,6 +906,7 @@ class ConformanceContext: require(self.process is not None, "Nebula process is not running") process = self.process started = time.monotonic() + deadline = started + timeout try: self.api("window.close", {"window_id": self.window_id}) except ApiFailure: @@ -673,12 +916,10 @@ class ConformanceContext: # response is observed. The owned process exit is authoritative. pass try: - process.wait(timeout=timeout) + process.wait(timeout=max(0, deadline - time.monotonic())) except subprocess.TimeoutExpired as error: raise ConformanceError(f"window.close did not exit within {timeout:.1f}s") from error - self.process = None - self.client = None - self._close_log() + self.stop(force=True, timeout=max(0, deadline - time.monotonic())) if process.returncode != 0: raise ConformanceError(f"Nebula exited with code {process.returncode}") return round((time.monotonic() - started) * 1000) diff --git a/scripts/conformance/macos_launch.py b/scripts/conformance/macos_launch.py index 26c58d7..c222969 100644 --- a/scripts/conformance/macos_launch.py +++ b/scripts/conformance/macos_launch.py @@ -23,6 +23,25 @@ if str(SCRIPTS) not in sys.path: from conformance.harness import ConformanceContext, ResolvedApp, RuntimeClient, require +def verify_rendered_text(ctx: ConformanceContext, probe: Path, log: BinaryIO) -> None: + marker = "PEBREL TEXT READY" + # Clear the echoed command before printing the marker; only rendered output + # can satisfy the screenshot observation. + ctx.prompt("printf '\\033[2J\\033[H%s%s\\n' 'PEBREL TEXT ' 'READY'") + ctx.wait_for_line(re.compile(r"^" + re.escape(marker) + r"$")) + screenshot = ctx.artifact_dir / "installed-app.png" + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + require(capture_screenshot(screenshot, log) == "captured", "installed app screenshot is unavailable") + result = subprocess.run([str(probe), str(screenshot), marker], + stdout=log, stderr=subprocess.STDOUT, timeout=15, check=False) + require(result.returncode in (0, 1), "screenshot text recognition failed") + if result.returncode == 0: + return + time.sleep(0.1) + raise RuntimeError("installed app did not render the terminal text marker") + + def capture_screenshot(destination: Path, log: BinaryIO) -> str: try: capture = subprocess.run(["/usr/sbin/screencapture", "-x", str(destination)], @@ -72,6 +91,11 @@ def main() -> int: launcher = None with (logs / "launchservices.log").open("wb") as log: try: + text_probe = root / "macos-text" + subprocess.run([ + "/usr/bin/xcrun", "swiftc", str(SCRIPTS / "conformance/macos_text.swift"), + "-module-cache-path", str(root / "swift-cache"), "-o", str(text_probe), + ], stdout=log, stderr=subprocess.STDOUT, timeout=90, check=True) launcher = subprocess.Popen( ["/usr/bin/open", "-n", "-W", "-a", str(installed), "--env", f"PEBREL_CONFIG_DIR={ctx.config_dir}", @@ -103,7 +127,8 @@ def main() -> int: ctx.wait_for_line(re.compile(r"NEBULA_GUI_LOCALE=[^\r\n]*UTF-?8_END", re.IGNORECASE)) ctx.prompt('printf "NEBULA_GUI_CWD=%s_END\\n" "$PWD"') ctx.wait_for_line(re.compile(r"NEBULA_GUI_CWD=" + re.escape(str(Path.home())) + r"_END")) - report["screenshot"] = capture_screenshot(logs / "installed-app.png", log) + verify_rendered_text(ctx, text_probe, log) + report.update(screenshot="captured", rendered_text=True) for window in ctx.snapshot().get("windows", []): ctx.api("window.close", {"window_id": window["id"]}) deadline = time.monotonic() + 5 diff --git a/scripts/conformance/macos_text.swift b/scripts/conformance/macos_text.swift new file mode 100644 index 0000000..14d0fd4 --- /dev/null +++ b/scripts/conformance/macos_text.swift @@ -0,0 +1,26 @@ +import Foundation +import Vision + +guard CommandLine.arguments.count == 3 else { + fputs("usage: macos-text IMAGE EXPECTED_TEXT\n", stderr) + exit(2) +} + +do { + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.usesLanguageCorrection = false + request.recognitionLanguages = ["en-US"] + let handler = VNImageRequestHandler(url: URL(fileURLWithPath: CommandLine.arguments[1])) + try handler.perform([request]) + let expected = CommandLine.arguments[2].uppercased() + let found = (request.results ?? []).contains { observation in + observation.topCandidates(1).contains { candidate in + candidate.string.uppercased().contains(expected) + } + } + exit(found ? 0 : 1) +} catch { + fputs("screenshot text recognition failed: \(error)\n", stderr) + exit(2) +} diff --git a/scripts/conformance/tests/test_lifecycle.py b/scripts/conformance/tests/test_lifecycle.py new file mode 100644 index 0000000..8e268a5 --- /dev/null +++ b/scripts/conformance/tests/test_lifecycle.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import ctypes +from ctypes import wintypes +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +SCRIPTS_DIR = Path(__file__).resolve().parents[2] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from conformance.harness import ConformanceContext, ConformanceError + + +REAL_POPEN = subprocess.Popen +FIXTURE = """ +from pathlib import Path +import os, subprocess, sys, time + +root = Path(sys.argv[1]) +role = sys.argv[2] +if role == 'grandchild': + time.sleep(60) +else: + next_role = 'child' if role == 'parent' else 'grandchild' + child = subprocess.Popen( + [sys.executable, '-I', '-S', __file__, str(root), next_role], + stdin=subprocess.DEVNULL, + ) + (root / (next_role + '.pid')).write_text(str(child.pid)) + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + if role == 'parent' and (root / 'exit').exists(): + code = root / 'exit-code' + raise SystemExit(int(code.read_text()) if code.exists() else 0) + time.sleep(0.02) +""" + + +class OwnedProcessHandle: + """Retain fixture process identity; never terminate by an unowned PID lookup.""" + + def __init__(self, pid: int) -> None: + self.api = ctypes.WinDLL("kernel32", use_last_error=True) + self.api.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD) + self.api.OpenProcess.restype = wintypes.HANDLE + self.api.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD) + self.api.WaitForSingleObject.restype = wintypes.DWORD + self.api.TerminateProcess.argtypes = (wintypes.HANDLE, wintypes.UINT) + self.api.TerminateProcess.restype = wintypes.BOOL + self.api.CloseHandle.argtypes = (wintypes.HANDLE,) + self.api.CloseHandle.restype = wintypes.BOOL + self.handle = self.api.OpenProcess(0x00100001, False, pid) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + + def exited(self) -> bool: + return self.api.WaitForSingleObject(self.handle, 0) == 0 + + def close(self) -> None: + if self.handle is None: + return + try: + if not self.exited(): + terminated = self.api.TerminateProcess(self.handle, 1) + error = ctypes.WinError(ctypes.get_last_error()) if not terminated else None + # A job may already be terminating this process. ERROR_ACCESS_DENIED + # is benign only when the retained handle then proves it exited. + if self.api.WaitForSingleObject(self.handle, 5000) != 0: + if error is not None: + raise error + raise AssertionError("owned fixture did not terminate") + finally: + self.api.CloseHandle(self.handle) + self.handle = None + + +@unittest.skipUnless(os.name == "nt", "Windows Job Object process containment") +class WindowsLifecycleTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="nebula-lifecycle-test-") + self.root = Path(self.temporary.name) + self.fixture = self.root / "fixture.py" + self.fixture.write_text(FIXTURE, encoding="utf-8") + self.app = SimpleNamespace(executable=self.root / "fixture-app.exe") + self.context = ConformanceContext( + self.app, "windows-test", self.root / "config", self.root / "work", + self.root / "artifacts", startup_timeout=5, + ) + self.context.prepare() + self.context.port_file.write_text("1 fixture-token", encoding="utf-8") + self.parents: list[subprocess.Popen] = [] + self.handles: list[OwnedProcessHandle] = [] + self.launches: list[Path] = [] + self.unrelated: subprocess.Popen | None = None + self.addCleanup(self.cleanup_owned_processes) + + def launch_fixture(command, **kwargs): + # Keep any platform launch gate intact; replace only the app under + # test with an inert private Python tree, never a GUI executable. + command = list(command) + index = command.index(os.fspath(self.app.executable)) + launch = self.root / f"launch-{len(self.launches) + 1}" + launch.mkdir() + self.launches.append(launch) + command[index:] = [ + sys.executable, "-I", "-S", os.fspath(self.fixture), + os.fspath(launch), "parent", + ] + process = REAL_POPEN(command, **kwargs) + self.parents.append(process) + return process + + client = SimpleNamespace(request=lambda *args, **kwargs: { + "result": {"windows": [{"tabs": [{"kind": "shell", "panes": [{}]}]}]}, + }) + self.launch_patch = patch("conformance.harness.subprocess.Popen", side_effect=launch_fixture) + self.client_patch = patch("conformance.harness.RuntimeClient.from_port_file", return_value=client) + self.launch_patch.start() + self.client_patch.start() + self.addCleanup(self.launch_patch.stop) + self.addCleanup(self.client_patch.stop) + + def cleanup_owned_processes(self) -> None: + failures = [] + try: + self.context.stop(force=True) + except Exception as error: + failures.append(error) + # Regressions must not leave even their failing fixtures running, and + # cleanup errors must remain visible without skipping later fixtures. + for handle in self.handles: + try: + handle.close() + except Exception as error: + failures.append(error) + for process in [*self.parents, self.unrelated]: + try: + if process is not None: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + except Exception as error: + failures.append(error) + try: + self.temporary.cleanup() + except Exception as error: + failures.append(error) + if failures: + raise ExceptionGroup("owned fixture cleanup failed", failures) + + def capture_descendants(self) -> list[OwnedProcessHandle]: + captured = [] + for name in ("child", "grandchild"): + path = self.launches[-1] / f"{name}.pid" + deadline = time.monotonic() + 5 + while not path.is_file() and time.monotonic() < deadline: + time.sleep(0.02) + self.assertTrue(path.is_file(), f"fixture never created {name}") + handle = OwnedProcessHandle(int(path.read_text())) + self.handles.append(handle) + captured.append(handle) + return captured + + def test_force_stop_reaps_descendants_but_not_an_unrelated_process(self) -> None: + self.unrelated = REAL_POPEN( + [sys.executable, "-I", "-S", "-c", "import time; time.sleep(60)"], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + self.context.start() + descendants = self.capture_descendants() + self.context.stop(force=True) + self.assertTrue(all(process.exited() for process in descendants)) + self.assertIsNone(self.unrelated.poll()) + self.context.work_dir.rmdir() + + def test_stop_still_reaps_children_after_the_parent_has_exited(self) -> None: + self.context.start() + descendants = self.capture_descendants() + (self.launches[-1] / "exit").touch() + self.context.process.wait(timeout=5) + self.context.stop(force=True) + self.assertTrue(all(process.exited() for process in descendants)) + + def test_restart_reaps_the_previous_launch_before_starting_another(self) -> None: + self.context.start() + previous = self.capture_descendants() + self.context.restart() + current = self.capture_descendants() + self.assertTrue(all(process.exited() for process in previous)) + self.assertTrue(all(not process.exited() for process in current)) + + def test_normal_window_close_also_reaps_leftover_descendants(self) -> None: + self.context.start() + descendants = self.capture_descendants() + with patch.object( + self.context, "api", side_effect=lambda *args, **kwargs: (self.launches[-1] / "exit").touch() + ): + self.context.close_and_wait() + self.assertTrue(all(process.exited() for process in descendants)) + + def test_window_close_preserves_the_application_exit_code(self) -> None: + self.context.start() + descendants = self.capture_descendants() + (self.launches[-1] / "exit-code").write_text("7") + with patch.object( + self.context, "api", + side_effect=lambda *args, **kwargs: (self.launches[-1] / "exit").touch(), + ): + with self.assertRaisesRegex(ConformanceError, "Nebula exited with code 7"): + self.context.close_and_wait() + self.assertTrue(all(process.exited() for process in descendants)) + + def test_assignment_failure_does_not_start_an_uncontained_application(self) -> None: + with patch("conformance.harness._WindowsJob.assign", side_effect=OSError("cannot assign")): + with self.assertRaisesRegex(ConformanceError, "cannot assign"): + self.context.start() + self.assertIsNone(self.context.process) + self.assertTrue(all(process.poll() is not None for process in self.parents)) + self.assertFalse((self.launches[-1] / "child.pid").exists()) + + def test_cleanup_errors_propagate_and_retain_ownership_for_retry(self) -> None: + self.context.start() + descendants = self.capture_descendants() + process, job = self.context.process, self.context._windows_job + with patch.object(job, "terminate", side_effect=OSError("cleanup unavailable")): + with self.assertRaisesRegex(OSError, "cleanup unavailable"): + self.context.stop(force=True) + self.assertIs(self.context.process, process) + self.assertIs(self.context._windows_job, job) + self.context.stop(force=True) + self.assertTrue(all(process.exited() for process in descendants)) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/conformance/tests/test_posix_lifecycle.py b/scripts/conformance/tests/test_posix_lifecycle.py new file mode 100644 index 0000000..8cd3cb8 --- /dev/null +++ b/scripts/conformance/tests/test_posix_lifecycle.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import os +from pathlib import Path +import select +import signal +import subprocess +import sys +import tempfile +import time +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +SCRIPTS_DIR = Path(__file__).resolve().parents[2] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from conformance.harness import ConformanceContext, ConformanceError + + +REAL_POPEN = subprocess.Popen +FIXTURE = """ +from pathlib import Path +import os, subprocess, sys, time + +root = Path(sys.argv[1]) +role = sys.argv[2] +if role != 'grandchild': + next_role = 'child' if role == 'parent' else 'grandchild' + subprocess.Popen([sys.executable, '-I', '-S', __file__, str(root), next_role]) +(root / (role + '.pid')).write_text(str(os.getpid())) +while True: + if role == 'parent' and (root / 'exit').exists(): + code = root / 'exit-code' + raise SystemExit(int(code.read_text()) if code.exists() else 0) + time.sleep(0.01) +""" + + +@unittest.skipUnless(os.name == "posix", "POSIX launch process groups") +class PosixLifecycleTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="nebula-posix-lifecycle-") + self.root = Path(self.temporary.name) + self.fixture = self.root / "fixture.py" + self.fixture.write_text(FIXTURE, encoding="utf-8") + self.app = SimpleNamespace(executable=self.root / "fixture-app") + self.context = ConformanceContext( + self.app, "linux-test", self.root / "config", self.root / "work", + self.root / "artifacts", startup_timeout=2, + ) + self.context.prepare() + self.context.port_file.write_text("1 fixture-token", encoding="utf-8") + self.parents: list[subprocess.Popen] = [] + self.launches: list[Path] = [] + self.descendants: list[int] = [] + self.unrelated: subprocess.Popen | None = None + self.addCleanup(self.cleanup_owned_processes) + + def launch_fixture(command, **kwargs): + launch = self.root / f"launch-{len(self.launches) + 1}" + launch.mkdir() + self.launches.append(launch) + # All fixture descendants inherit this pipe. EOF proves they have + # actually exited even when their former parent is already reaped. + kwargs["stdout"] = subprocess.PIPE + process = REAL_POPEN([ + sys.executable, "-I", "-S", os.fspath(self.fixture), + os.fspath(launch), "parent", + ], **kwargs) + self.parents.append(process) + return process + + self.client = SimpleNamespace(request=lambda *args, **kwargs: { + "result": { + "process_id": self.parents[-1].pid, + "windows": [{"tabs": [{"kind": "shell", "panes": [{}]}]}], + }, + }) + self.launch_patch = patch("conformance.harness.subprocess.Popen", side_effect=launch_fixture) + self.client_patch = patch( + "conformance.harness.RuntimeClient.from_port_file", return_value=self.client, + ) + self.launch_patch.start() + self.client_patch.start() + self.addCleanup(self.launch_patch.stop) + self.addCleanup(self.client_patch.stop) + + def capture_descendants(self) -> None: + for role in ("child", "grandchild"): + path = self.launches[-1] / f"{role}.pid" + deadline = time.monotonic() + 5 + while not path.is_file() and time.monotonic() < deadline: + time.sleep(0.01) + self.assertTrue(path.is_file(), f"fixture never created {role}") + self.descendants.append(int(path.read_text())) + + def assert_tree_exited(self, process: subprocess.Popen) -> None: + self.assertTrue(select.select([process.stdout], [], [], 2)[0], "launch descendants survived") + self.assertEqual(os.read(process.stdout.fileno(), 65536), b"") + + def cleanup_owned_processes(self) -> None: + self.context.stop(force=True) + # Keep the negative fixture bounded when running against the old harness. + for launch in self.launches: + for role in ("child", "grandchild"): + path = launch / f"{role}.pid" + if path.is_file() and path.read_text(): + self.descendants.append(int(path.read_text())) + for pid in set(self.descendants): + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + for process in [*self.parents, self.unrelated]: + if process is not None: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + if process.stdout is not None: + process.stdout.close() + self.temporary.cleanup() + + def test_force_stop_terminates_the_launch_but_not_an_unrelated_process(self) -> None: + self.unrelated = REAL_POPEN( + [sys.executable, "-I", "-S", "-c", "import time; time.sleep(60)"], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + self.context.start() + self.capture_descendants() + self.context.stop(force=True) + self.assert_tree_exited(self.parents[-1]) + self.assertIsNone(self.unrelated.poll()) + + def test_stop_terminates_children_after_the_wrapper_has_exited(self) -> None: + self.context.start() + self.capture_descendants() + (self.launches[-1] / "exit").touch() + self.context.process.wait(timeout=5) + self.context.stop(force=True) + self.assert_tree_exited(self.parents[-1]) + + def test_restart_terminates_the_previous_launch(self) -> None: + self.context.start() + self.capture_descendants() + self.context.restart() + self.capture_descendants() + self.assert_tree_exited(self.parents[0]) + self.assertFalse(select.select([self.parents[1].stdout], [], [], 0)[0]) + + def test_normal_close_terminates_leftover_children_and_preserves_exit_code(self) -> None: + for code in (0, 7): + with self.subTest(code=code): + self.context.start() + self.capture_descendants() + (self.launches[-1] / "exit-code").write_text(str(code)) + with patch.object( + self.context, "api", + side_effect=lambda *args, **kwargs: (self.launches[-1] / "exit").touch(), + ): + if code: + with self.assertRaisesRegex(ConformanceError, "Nebula exited with code 7"): + self.context.close_and_wait() + else: + self.context.close_and_wait() + self.assert_tree_exited(self.parents[-1]) + + def test_start_rejects_an_endpoint_from_another_launch(self) -> None: + self.context.startup_timeout = 0.15 + self.client.request = lambda *args, **kwargs: { + "result": { + "process_id": os.getpid(), + "windows": [{"tabs": [{"kind": "shell", "panes": [{}]}]}], + }, + } + with self.assertRaisesRegex(ConformanceError, "runtime process does not belong to this launch"): + self.context.start() + self.assert_tree_exited(self.parents[-1]) + + def test_start_accepts_the_application_beneath_a_wrapper(self) -> None: + self.client.request = lambda *args, **kwargs: { + "result": { + "process_id": int((self.launches[-1] / "child.pid").read_text()), + "windows": [{"tabs": [{"kind": "shell", "panes": [{}]}]}], + }, + } + self.context.start() + self.capture_descendants() + self.context.stop(force=True) + self.assert_tree_exited(self.parents[-1]) + + def test_cleanup_failure_retains_the_owned_group_for_retry(self) -> None: + self.context.start() + self.capture_descendants() + process = self.context.process + with patch("conformance.harness.os.killpg", side_effect=OSError("cleanup unavailable")): + with self.assertRaisesRegex(OSError, "cleanup unavailable"): + self.context.stop(force=True) + self.assertIs(self.context.process, process) + self.context.stop(force=True) + self.assert_tree_exited(process) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/conformance/tests/test_resize.py b/scripts/conformance/tests/test_resize.py new file mode 100644 index 0000000..c066cf6 --- /dev/null +++ b/scripts/conformance/tests/test_resize.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +SCRIPTS_DIR = Path(__file__).resolve().parents[2] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from conformance.cases import resize +from conformance.harness import ConformanceError + + +class ResizeContext: + window_id = 1 + pane_id = 10 + shell = "bash" + platform_family = "macos" + + def __init__(self, artifact_dir: Path, columns: list[int]) -> None: + self.artifact_dir = artifact_dir + self.columns = iter(columns) + self.cleanup: list[str] = [] + + def api(self, method: str, params=None, **kwargs): + if method == "pane.split": + return {"action": {"pane_id": 11}} + if method == "pane.resize": + return {"action": {"ratio": params["ratio"]}} + if method == "runtime.snapshot": + return {"windows": [{"id": self.window_id}]} + if method == "pane.read": + return {"text": "NEBULA_COLS_3_116"} + raise AssertionError(method) + + def measure_columns(self, pane_id: int) -> int: + return next(self.columns) + + def best_effort_api(self, method: str, params) -> None: + self.cleanup.append(method) + + +class ResizeDiagnosticsTests(unittest.TestCase): + def test_growing_and_shrinking_panes_pass_without_diagnostic_sampling(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), [57, 56, 80, 33]) + with patch.object(resize, "_write_failure_diagnostics") as diagnose: + result = resize.run(ctx) + self.assertTrue(result["pty_columns_changed"]) + diagnose.assert_not_called() + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + def test_unchanged_spawn_width_still_fails_and_preserves_all_measurements(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), [116, 116, 116, 116]) + with self.assertRaisesRegex(ConformanceError, "70% pane did not grow: 116 -> 116"): + resize.run(ctx) + details = json.loads((Path(directory) / "resize-failure.json").read_text()) + self.assertEqual(details["measurements"], { + "source_columns_before": 116, + "sibling_columns_before": 116, + "source_columns_after": 116, + "sibling_columns_after": 116, + }) + self.assertEqual(set(details["panes"]), {"10", "11"}) + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + def test_sibling_and_total_assertions_remain_strict(self) -> None: + for columns, message in [ + ([57, 56, 80, 56], "30% pane did not shrink"), + ([57, 56, 85, 15], "PTY column total changed"), + ]: + with self.subTest(columns=columns), tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), columns) + with self.assertRaisesRegex(ConformanceError, message): + resize.run(ctx) + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + def test_diagnostic_failure_cannot_replace_the_resize_failure(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ctx = ResizeContext(Path(directory), [116] * 4) + with patch.object(resize, "_write_failure_diagnostics", side_effect=OSError("read-only")), \ + patch("builtins.print"): + with self.assertRaisesRegex(ConformanceError, "70% pane did not grow"): + resize.run(ctx) + self.assertEqual(ctx.cleanup, ["pane.close", "window.focus"]) + + @unittest.skipUnless(sys.platform == "linux", "reads the Linux process PTY") + def test_kernel_probe_follows_pty_geometry_even_with_stale_columns_environment(self) -> None: + import fcntl + import struct + import termios + + master, slave = os.openpty() + child = None + try: + child = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(30)"], + stdin=slave, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env={**os.environ, "COLUMNS": "116"}, + ) + for columns in (57, 82): + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 30, columns, 0, 0)) + self.assertEqual( + resize._linux_terminal_size(child.pid), + {"rows": 30, "columns": columns}, + ) + finally: + if child is not None: + child.terminate() + child.wait(timeout=5) + os.close(master) + os.close(slave) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/conformance/tests/test_scrollback.py b/scripts/conformance/tests/test_scrollback.py new file mode 100644 index 0000000..b43f5df --- /dev/null +++ b/scripts/conformance/tests/test_scrollback.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import unittest +from unittest.mock import patch + +SCRIPTS_DIR = Path(__file__).resolve().parents[2] +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from conformance.harness import ConformanceContext + + +SHELLS = ("powershell", "pwsh", "cmd", "fish", "nu", "sh", "bash", "dash", "zsh") +MARKER = "NEBULA_SCROLL_DONE" + + +def context_for(shell: str) -> ConformanceContext: + context = object.__new__(ConformanceContext) + context.shell = shell + context.pane_id = 1 + return context + + +def available_shells() -> list[tuple[str, list[str]]]: + commands = { + "powershell": ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command"], + "pwsh": ["pwsh", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command"], + "cmd": ["cmd.exe", "/d", "/s", "/c"], + "fish": ["fish", "--no-config", "-c"], + "nu": ["nu", "--no-config-file", "-c"], + "sh": ["sh", "-c"], + "bash": ["bash", "--noprofile", "--norc", "-c"], + "dash": ["dash", "-c"], + "zsh": ["zsh", "-f", "-c"], + } + result = [] + for shell, command in commands.items(): + # Do not discover Windows interoperability launchers from a POSIX run. + if os.name != "nt" and shell in {"powershell", "cmd"}: + continue + executable = shutil.which(command[0]) + if ( + executable and os.name == "nt" and shell == "bash" + and Path(executable).parent.name.lower() in {"system32", "sysnative", "windowsapps"} + ): + # Windows' bash.exe is the WSL launcher, with different command + # argument parsing. The POSIX run tests Bash directly. + continue + if executable: + result.append((shell, [executable, *command[1:]])) + return result + + +class ScrollbackCompletionTests(unittest.TestCase): + def test_complete_marker_is_absent_from_every_shell_command(self) -> None: + for shell in SHELLS: + with self.subTest(shell=shell): + command = context_for(shell).scrollback_command(600, MARKER) + self.assertNotIn(MARKER, command) + + def test_command_echo_cannot_complete_the_output_wait(self) -> None: + for shell in SHELLS: + with self.subTest(shell=shell): + context = context_for(shell) + command = context.scrollback_command(600, MARKER) + observations = [ + {"text": f"prompt> {command}\nNEBULA_SCROLL_1"}, + {"text": "NEBULA_SCROLL_310\nNEBULA_SCROLL_311"}, + {"text": f"NEBULA_SCROLL_600\n{MARKER}"}, + ] + with patch.object(context, "read", side_effect=observations), patch( + "conformance.harness.time.sleep" + ): + _, completed = context.wait_for_line(re.compile(MARKER), timeout=1) + self.assertEqual(completed, observations[-1]) + + def test_real_shell_emits_completion_once_after_all_generated_rows(self) -> None: + shells = available_shells() + if not shells: + self.skipTest("No supported shell is installed") + for shell, invocation in shells: + with self.subTest(shell=shell): + command = context_for(shell).scrollback_command(6, MARKER) + result = subprocess.run( + [*invocation, command], + stdin=subprocess.DEVNULL, + capture_output=True, + text=True, + encoding="utf-8", + timeout=10, + check=True, + ) + self.assertEqual( + result.stdout.splitlines(), + [*(f"NEBULA_SCROLL_{index}" for index in range(1, 7)), MARKER], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/installer.iss b/scripts/installer.iss index d5b9951..c2e09e4 100644 --- a/scripts/installer.iss +++ b/scripts/installer.iss @@ -50,7 +50,7 @@ LicenseFile={#RepoRoot}\LICENSE SetupIconFile={#RepoRoot}\nebula_app\windows\nebula.ico UninstallDisplayIcon={app}\pebrel.exe OutputDir={#RepoRoot}\dist -OutputBaseFilename={#PackageBrand}-{#AppVersion}-windows-x64-setup +OutputBaseFilename={#PackageBrand}-v{#AppVersion}-windows-x64-setup Compression=lzma2/max SolidCompression=yes WizardStyle=modern @@ -98,6 +98,7 @@ Name: "autostart"; Description: "{cm:AutoStart}"; GroupDescription: "{cm:Additio [Files] Source: "{#BuildRoot}\pebrel.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "{#RepoRoot}\README.md"; DestDir: "{app}"; Flags: ignoreversion +Source: "{#RepoRoot}\README.zh-CN.md"; DestDir: "{app}"; Flags: ignoreversion Source: "{#BuildRoot}\pebrel-hook.exe"; DestDir: "{app}\runtime"; Flags: ignoreversion Source: "{#BuildRoot}\conpty.dll"; DestDir: "{app}\runtime"; Flags: ignoreversion Source: "{#BuildRoot}\OpenConsole.exe"; DestDir: "{app}\runtime"; Flags: ignoreversion diff --git a/scripts/macos_dmg.py b/scripts/macos_dmg.py new file mode 100644 index 0000000..68c631d --- /dev/null +++ b/scripts/macos_dmg.py @@ -0,0 +1,39 @@ +"""Size a DMG for logical file contents, including sparse/copied binaries.""" + +import argparse +import os +import stat +from pathlib import Path + + +MIB = 1024 * 1024 +BLOCK = 4096 + + +def image_size_mib(source: Path) -> int: + if source.is_symlink() or not source.is_dir(): + raise ValueError(f"DMG source must be a directory: {source}") + total = 0 + + def fail(error): + raise error + + # Count every directory entry without following /Applications or deduplicating + # hard links: the destination copy may materialize each logical file. + for directory, directories, files in os.walk(source, onerror=fail): + for name in directories + files: + entry = (Path(directory) / name).lstat() + if not (stat.S_ISREG(entry.st_mode) or stat.S_ISDIR(entry.st_mode) + or stat.S_ISLNK(entry.st_mode)): + raise ValueError(f"Unsupported DMG input: {Path(directory) / name}") + total += ((entry.st_size + BLOCK - 1) // BLOCK + 1) * BLOCK + + # Allow 25% growth plus 32 MiB for the filesystem, catalog and journal. + return max(64, (total * 5 + 4 * MIB - 1) // (4 * MIB) + 32) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path) + args = parser.parse_args() + print(image_size_mib(args.source)) diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh index 9ae6c5a..66ace7a 100644 --- a/scripts/package-linux.sh +++ b/scripts/package-linux.sh @@ -5,18 +5,22 @@ export LC_ALL=C.UTF-8 usage() { cat <<'EOF' -Usage: scripts/package-linux.sh --binary PATH --version VERSION --preview-id ID +Usage: scripts/package-linux.sh --binary PATH --version VERSION + [--channel preview --preview-id ID|--channel stable] [--output-directory DIR] [--linuxdeploy PATH] [--force] -Builds a Linux x86_64 Preview AppImage, portable tar.gz, and Debian package -from one fresh Pebrel release binary. linuxdeploy must be supplied explicitly -or through LINUXDEPLOY; the CI workflow downloads a pinned, SHA-verified copy. +Builds a Linux x86_64 AppImage, portable tar.gz, and Debian package from one +fresh Pebrel release binary. Preview packages require --preview-id; stable +packages use formal Pebrel metadata and x64 asset names. linuxdeploy must be +supplied explicitly or through LINUXDEPLOY; CI downloads a pinned, +SHA-verified copy. EOF } binary="" version="" preview_id="" +channel="preview" output_directory="dist" linuxdeploy="${LINUXDEPLOY:-}" force=0 @@ -35,6 +39,10 @@ while [[ $# -gt 0 ]]; do preview_id="${2:-}" shift 2 ;; + --channel) + channel="${2:-}" + shift 2 + ;; --output-directory) output_directory="${2:-}" shift 2 @@ -59,7 +67,19 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -z "$binary" || -z "$version" || -z "$preview_id" || -z "$linuxdeploy" ]]; then +if [[ "$channel" != "preview" && "$channel" != "stable" ]]; then + echo "unsupported package channel: $channel (expected preview or stable)" >&2 + exit 2 +fi +if [[ "$channel" == "preview" && -z "$preview_id" ]]; then + echo "Preview packages require --preview-id" >&2 + exit 2 +fi +if [[ "$channel" == "stable" && -n "$preview_id" ]]; then + echo "stable packages must not include --preview-id" >&2 + exit 2 +fi +if [[ -z "$binary" || -z "$version" || -z "$linuxdeploy" ]]; then usage >&2 exit 2 fi @@ -67,7 +87,7 @@ if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then echo "invalid Cargo package version: $version" >&2 exit 2 fi -if [[ ! "$preview_id" =~ ^[0-9A-Za-z][0-9A-Za-z.-]{0,31}$ ]]; then +if [[ -n "$preview_id" && ! "$preview_id" =~ ^[0-9A-Za-z][0-9A-Za-z.-]{0,31}$ ]]; then echo "invalid Preview id: $preview_id" >&2 exit 2 fi @@ -100,11 +120,28 @@ linuxdeploy="$(cd "$(dirname "$linuxdeploy")" && pwd -P)/$(basename "$linuxdeplo mkdir -p "$output_directory" output_directory="$(cd "$output_directory" && pwd -P)" -desktop_source="$repo/packaging/linux/io.github.kuddev.pebrel.preview.desktop" -metainfo_source="$repo/packaging/linux/io.github.kuddev.pebrel.preview.metainfo.xml" +if [[ "$channel" == "stable" ]]; then + desktop_source="$repo/packaging/linux/io.github.kuddev.pebrel.desktop" + metainfo_source="$repo/packaging/linux/io.github.kuddev.pebrel.metainfo.xml" + desktop_id="io.github.kuddev.pebrel" + package_name="pebrel" + package_description="Pebrel GPU-accelerated terminal" + package_note="" + asset_architecture="x64" + release="$version" +else + desktop_source="$repo/packaging/linux/io.github.kuddev.pebrel.preview.desktop" + metainfo_source="$repo/packaging/linux/io.github.kuddev.pebrel.preview.metainfo.xml" + desktop_id="io.github.kuddev.pebrel.preview" + package_name="pebrel-preview" + package_description="Pebrel cross-platform Preview" + package_note=" This package is a Preview build and is not a stable release." + asset_architecture="x86_64" + release="$version-preview.$preview_id" +fi icon_source="$repo/extra/logo/nebula.png" for required in "$desktop_source" "$metainfo_source" "$icon_source" \ - "$repo/README.md" "$repo/CHANGELOG.md" "$repo/INSTALL.md" \ + "$repo/README.md" "$repo/README.zh-CN.md" "$repo/CHANGELOG.md" "$repo/INSTALL.md" \ "$repo/LICENSE" "$repo/THIRD-PARTY-NOTICES" \ "$repo/licenses/LICENSE-LUA" "$repo/licenses/LICENSE-MLUA" \ "$repo/licenses/LICENSE-LATIN-MODERN-MATH" \ @@ -153,10 +190,9 @@ if dpkg --compare-versions "$required_glibc" gt "2.35"; then exit 1 fi -release="$version-preview.$preview_id" -appimage_path="$output_directory/Pebrel-v$release-linux-x86_64.AppImage" -tar_path="$output_directory/Pebrel-v$release-linux-x86_64.tar.gz" -deb_path="$output_directory/Pebrel-v$release-linux-x86_64.deb" +appimage_path="$output_directory/Pebrel-v$release-linux-$asset_architecture.AppImage" +tar_path="$output_directory/Pebrel-v$release-linux-$asset_architecture.tar.gz" +deb_path="$output_directory/Pebrel-v$release-linux-$asset_architecture.deb" outputs=("$appimage_path" "$tar_path" "$deb_path") for output in "${outputs[@]}"; do if [[ -e "$output" && $force -ne 1 ]]; then @@ -178,37 +214,38 @@ trap cleanup EXIT common_root="$work/common" appdir="$work/Pebrel.AppDir" -deb_root="$work/debian/pebrel-preview" +deb_root="$work/debian/$package_name" mkdir -p \ "$common_root/usr/bin" \ "$common_root/usr/share/applications" \ "$common_root/usr/share/icons/hicolor/256x256/apps" \ "$common_root/usr/share/metainfo" \ - "$common_root/usr/share/doc/pebrel-preview/licenses" \ + "$common_root/usr/share/doc/$package_name/licenses" \ "$common_root/usr/share/bash-completion/completions" \ "$common_root/usr/share/fish/vendor_completions.d" \ "$common_root/usr/share/zsh/vendor-completions" install -m 0755 "$binary" "$common_root/usr/bin/pebrel" install -m 0644 "$desktop_source" \ - "$common_root/usr/share/applications/io.github.kuddev.pebrel.preview.desktop" + "$common_root/usr/share/applications/$desktop_id.desktop" install -m 0644 "$metainfo_source" \ - "$common_root/usr/share/metainfo/io.github.kuddev.pebrel.preview.metainfo.xml" + "$common_root/usr/share/metainfo/$desktop_id.metainfo.xml" convert "$icon_source" -resize 256x256 \ - "$common_root/usr/share/icons/hicolor/256x256/apps/io.github.kuddev.pebrel.preview.png" -install -m 0644 "$repo/README.md" "$common_root/usr/share/doc/pebrel-preview/README.md" -install -m 0644 "$repo/CHANGELOG.md" "$common_root/usr/share/doc/pebrel-preview/CHANGELOG.md" -install -m 0644 "$repo/INSTALL.md" "$common_root/usr/share/doc/pebrel-preview/INSTALL.md" -install -m 0644 "$repo/LICENSE" "$common_root/usr/share/doc/pebrel-preview/licenses/LICENSE" -install -m 0644 "$repo/LICENSE" "$common_root/usr/share/doc/pebrel-preview/copyright" + "$common_root/usr/share/icons/hicolor/256x256/apps/$desktop_id.png" +install -m 0644 "$repo/README.md" "$common_root/usr/share/doc/$package_name/README.md" +install -m 0644 "$repo/README.zh-CN.md" "$common_root/usr/share/doc/$package_name/README.zh-CN.md" +install -m 0644 "$repo/CHANGELOG.md" "$common_root/usr/share/doc/$package_name/CHANGELOG.md" +install -m 0644 "$repo/INSTALL.md" "$common_root/usr/share/doc/$package_name/INSTALL.md" +install -m 0644 "$repo/LICENSE" "$common_root/usr/share/doc/$package_name/licenses/LICENSE" +install -m 0644 "$repo/LICENSE" "$common_root/usr/share/doc/$package_name/copyright" install -m 0644 "$repo/THIRD-PARTY-NOTICES" \ - "$common_root/usr/share/doc/pebrel-preview/licenses/THIRD-PARTY-NOTICES" + "$common_root/usr/share/doc/$package_name/licenses/THIRD-PARTY-NOTICES" install -m 0644 "$repo/licenses/LICENSE-LUA" \ - "$common_root/usr/share/doc/pebrel-preview/licenses/LICENSE-LUA" + "$common_root/usr/share/doc/$package_name/licenses/LICENSE-LUA" install -m 0644 "$repo/licenses/LICENSE-MLUA" \ - "$common_root/usr/share/doc/pebrel-preview/licenses/LICENSE-MLUA" + "$common_root/usr/share/doc/$package_name/licenses/LICENSE-MLUA" install -m 0644 "$repo/licenses/LICENSE-LATIN-MODERN-MATH" \ - "$common_root/usr/share/doc/pebrel-preview/licenses/LICENSE-LATIN-MODERN-MATH" + "$common_root/usr/share/doc/$package_name/licenses/LICENSE-LATIN-MODERN-MATH" install -m 0644 "$repo/extra/completions/pebrel.bash" \ "$common_root/usr/share/bash-completion/completions/pebrel" install -m 0644 "$repo/extra/completions/pebrel.fish" \ @@ -217,9 +254,9 @@ install -m 0644 "$repo/extra/completions/_pebrel" \ "$common_root/usr/share/zsh/vendor-completions/_pebrel" desktop-file-validate \ - "$common_root/usr/share/applications/io.github.kuddev.pebrel.preview.desktop" + "$common_root/usr/share/applications/$desktop_id.desktop" appstreamcli validate --no-net \ - "$common_root/usr/share/metainfo/io.github.kuddev.pebrel.preview.metainfo.xml" + "$common_root/usr/share/metainfo/$desktop_id.metainfo.xml" cp -a "$common_root/." "$appdir/" tool_output="$work/linuxdeploy-output" @@ -232,8 +269,8 @@ mkdir -p "$tool_output" "$linuxdeploy" \ --appdir "$appdir" \ --executable "$appdir/usr/bin/pebrel" \ - --desktop-file "$appdir/usr/share/applications/io.github.kuddev.pebrel.preview.desktop" \ - --icon-file "$appdir/usr/share/icons/hicolor/256x256/apps/io.github.kuddev.pebrel.preview.png" \ + --desktop-file "$appdir/usr/share/applications/$desktop_id.desktop" \ + --icon-file "$appdir/usr/share/icons/hicolor/256x256/apps/$desktop_id.png" \ --output appimage ) mapfile -t generated_appimages < <(find "$tool_output" -maxdepth 1 -type f -name '*.AppImage' -print) @@ -255,7 +292,7 @@ if [[ ! "$source_date_epoch" =~ ^[0-9]+$ ]]; then echo "SOURCE_DATE_EPOCH must be an integer" >&2 exit 1 fi -archive_root="Pebrel-v$release-linux-x86_64" +archive_root="Pebrel-v$release-linux-$asset_architecture" tar \ --sort=name \ --mtime="@$source_date_epoch" \ @@ -269,15 +306,15 @@ tar \ mkdir -p "$deb_root" cp -a "$common_root/." "$deb_root/" cat >"$work/debian/control" < Standards-Version: 4.6.2 -Package: pebrel-preview +Package: $package_name Architecture: any -Description: Pebrel cross-platform Preview +Description: $package_description GPU-accelerated terminal for local and remote workflows. EOF dependency_line="$( @@ -291,9 +328,13 @@ fi dependencies="${dependency_line#shlibs:Depends=}" installed_size="$(du -sk "$deb_root/usr" | awk '{print $1}')" mkdir -p "$deb_root/DEBIAN" +expected_deb_version="$version" +if [[ "$channel" == "preview" ]]; then + expected_deb_version="$version~preview.$preview_id" +fi cat >"$deb_root/DEBIAN/control" <&2 exit 1 fi @@ -318,7 +359,7 @@ if [[ "$(dpkg-deb --field "$deb_path" Architecture)" != "amd64" ]]; then echo "Debian package architecture verification failed" >&2 exit 1 fi -if [[ "$(dpkg-deb --field "$deb_path" Version)" != "$version~preview.$preview_id" ]]; then +if [[ "$(dpkg-deb --field "$deb_path" Version)" != "$expected_deb_version" ]]; then echo "Debian package version verification failed" >&2 exit 1 fi diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh index 5719bdb..a01068b 100644 --- a/scripts/package-macos.sh +++ b/scripts/package-macos.sh @@ -5,12 +5,15 @@ export LC_ALL=en_US.UTF-8 usage() { cat <<'EOF' -Usage: scripts/package-macos.sh --binary PATH --version VERSION --preview-id ID +Usage: scripts/package-macos.sh --binary PATH --version VERSION + [--channel preview --preview-id ID|--channel stable] --architecture aarch64|x86_64 --build-number NUMBER [--output-directory DIR] [--force] [--sign-identity ID --notary-profile PROFILE [--signing-keychain PATH]] -Builds an ad-hoc-signed Pebrel Preview .app and packages it in a DMG. +Builds an ad-hoc-signed Pebrel .app and packages it in a DMG. Preview +packages require --preview-id; stable packages use formal bundle metadata and +arm64/x64 asset names. Developer ID mode requires both signing and notarization; failures never silently downgrade to an ad-hoc signature. EOF @@ -19,6 +22,7 @@ EOF binary="" version="" preview_id="" +channel="preview" architecture="" build_number="" output_directory="dist" @@ -41,6 +45,10 @@ while [[ $# -gt 0 ]]; do preview_id="${2:-}" shift 2 ;; + --channel) + channel="${2:-}" + shift 2 + ;; --architecture) architecture="${2:-}" shift 2 @@ -79,7 +87,19 @@ if [[ -n "$sign_identity" || -n "$notary_profile" || -n "$signing_keychain" ]]; fi fi -if [[ -z "$binary" || -z "$version" || -z "$preview_id" || \ +if [[ "$channel" != "preview" && "$channel" != "stable" ]]; then + echo "unsupported package channel: $channel (expected preview or stable)" >&2 + exit 2 +fi +if [[ "$channel" == "preview" && -z "$preview_id" ]]; then + echo "Preview packages require --preview-id" >&2 + exit 2 +fi +if [[ "$channel" == "stable" && -n "$preview_id" ]]; then + echo "stable packages must not include --preview-id" >&2 + exit 2 +fi +if [[ -z "$binary" || -z "$version" || \ -z "$architecture" || -z "$build_number" ]]; then usage >&2 exit 2 @@ -88,7 +108,7 @@ if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]; then echo "invalid Cargo package version: $version" >&2 exit 2 fi -if [[ ! "$preview_id" =~ ^[0-9A-Za-z][0-9A-Za-z.-]{0,31}$ ]]; then +if [[ -n "$preview_id" && ! "$preview_id" =~ ^[0-9A-Za-z][0-9A-Za-z.-]{0,31}$ ]]; then echo "invalid Preview id: $preview_id" >&2 exit 2 fi @@ -101,7 +121,7 @@ if [[ ! "$build_number" =~ ^[1-9][0-9]*$ ]]; then exit 2 fi if [[ "$(uname -s)" != "Darwin" ]]; then - echo "macOS Preview packages must be built on a native macOS runner" >&2 + echo "macOS packages must be built on a native macOS runner" >&2 exit 1 fi @@ -134,6 +154,7 @@ output_directory="$(cd "$output_directory" && pwd -P)" plist_source="$repo/packaging/macos/Info.plist" icon_source="$repo/extra/logo/nebula.png" for required in "$plist_source" "$icon_source" "$repo/README.md" \ + "$repo/README.zh-CN.md" \ "$repo/CHANGELOG.md" "$repo/INSTALL.md" "$repo/LICENSE" \ "$repo/THIRD-PARTY-NOTICES" "$repo/licenses/LICENSE-LUA" \ "$repo/licenses/LICENSE-MLUA" "$repo/licenses/LICENSE-LATIN-MODERN-MATH"; do @@ -165,8 +186,24 @@ while IFS= read -r dependency; do esac done < <(otool -L "$binary" | tail -n +2 | awk '{print $1}') -release="$version-preview.$preview_id" -dmg_path="$output_directory/Pebrel-v$release-macos-$architecture.dmg" +if [[ "$channel" == "stable" ]]; then + release="$version" + bundle_name="Pebrel" + bundle_id="io.github.kuddev.pebrel" + if [[ "$architecture" == "aarch64" ]]; then + asset_architecture="arm64" + else + asset_architecture="x64" + fi + volume_name="Pebrel" +else + release="$version-preview.$preview_id" + bundle_name="Pebrel Preview" + bundle_id="io.github.kuddev.pebrel.preview" + asset_architecture="$architecture" + volume_name="Pebrel Preview" +fi +dmg_path="$output_directory/Pebrel-v$release-macos-$asset_architecture.dmg" if [[ -e "$dmg_path" && $force -ne 1 ]]; then echo "package already exists: $dmg_path (pass --force to replace it)" >&2 exit 1 @@ -184,7 +221,7 @@ cleanup() { trap cleanup EXIT stage="$work/dmg-root" -app="$stage/Pebrel Preview.app" +app="$stage/$bundle_name.app" contents="$app/Contents" resources="$contents/Resources" mkdir -p "$contents/MacOS" "$resources/docs" "$resources/licenses" @@ -193,7 +230,10 @@ install -m 0644 "$plist_source" "$contents/Info.plist" plutil -replace CFBundleShortVersionString -string "$version" "$contents/Info.plist" plutil -replace CFBundleVersion -string "$build_number" "$contents/Info.plist" plutil -replace CFBundleGetInfoString -string \ - "Pebrel $version Preview $preview_id" "$contents/Info.plist" + "$bundle_name $version" "$contents/Info.plist" +plutil -replace CFBundleDisplayName -string "$bundle_name" "$contents/Info.plist" +plutil -replace CFBundleName -string "$bundle_name" "$contents/Info.plist" +plutil -replace CFBundleIdentifier -string "$bundle_id" "$contents/Info.plist" plutil -lint "$contents/Info.plist" iconset="$work/pebrel.iconset" @@ -215,6 +255,7 @@ EOF iconutil -c icns "$iconset" -o "$resources/pebrel.icns" install -m 0644 "$repo/README.md" "$resources/docs/README.md" +install -m 0644 "$repo/README.zh-CN.md" "$resources/docs/README.zh-CN.md" install -m 0644 "$repo/CHANGELOG.md" "$resources/docs/CHANGELOG.md" install -m 0644 "$repo/INSTALL.md" "$resources/docs/INSTALL.md" install -m 0644 "$repo/LICENSE" "$resources/licenses/LICENSE" @@ -236,7 +277,7 @@ else fi codesign --verify --deep --strict --verbose=2 "$app" if [[ "$(plutil -extract CFBundleIdentifier raw -o - "$contents/Info.plist")" != \ - "io.github.kuddev.pebrel.preview" ]]; then + "$bundle_id" ]]; then echo "application bundle identifier verification failed" >&2 exit 1 fi @@ -246,12 +287,24 @@ if [[ "$(lipo -archs "$contents/MacOS/pebrel")" != "$expected_uname" ]]; then fi ln -s /Applications "$stage/Applications" -hdiutil create \ - -volname "Pebrel Preview" \ +df -h "$output_directory" "$work" +du -sh "$stage" +size_mib="$(python3 "$script_directory/macos_dmg.py" "$stage")" +echo "DMG filesystem capacity: ${size_mib} MiB" +if hdiutil create \ + -volname "$volume_name" \ + -fs HFS+ \ + -size "${size_mib}m" \ -srcfolder "$stage" \ -ov \ -format UDZO \ - "$dmg_path" + "$dmg_path"; then + : +else + create_status=$? + df -h "$output_directory" "$work" + exit "$create_status" +fi hdiutil verify "$dmg_path" if [[ -n "$sign_identity" ]]; then codesign --force --sign "$sign_identity" --timestamp ${keychain_args[@]+"${keychain_args[@]}"} "$dmg_path" diff --git a/scripts/package-release.ps1 b/scripts/package-release.ps1 index ca30b18..400d7b8 100644 --- a/scripts/package-release.ps1 +++ b/scripts/package-release.ps1 @@ -51,6 +51,7 @@ $temporaryZip = Join-Path $outputRoot ".$PackageBrand-v$Version-windows-x64-$PID $manifest = [ordered]@{ 'pebrel.exe' = Join-Path $targetRoot 'pebrel.exe' 'README.md' = Join-Path $repo 'README.md' + 'README.zh-CN.md' = Join-Path $repo 'README.zh-CN.md' 'runtime/pebrel-hook.exe' = Join-Path $targetRoot 'pebrel-hook.exe' 'runtime/conpty.dll' = Join-Path $targetRoot 'conpty.dll' 'runtime/OpenConsole.exe' = Join-Path $targetRoot 'OpenConsole.exe' diff --git a/scripts/platform_cfg_budget.txt b/scripts/platform_cfg_budget.txt index 9bae829..84fa17b 100644 --- a/scripts/platform_cfg_budget.txt +++ b/scripts/platform_cfg_budget.txt @@ -1 +1 @@ -494 +491 diff --git a/scripts/prepare-windows-runtime.ps1 b/scripts/prepare-windows-runtime.ps1 new file mode 100644 index 0000000..67b0d63 --- /dev/null +++ b/scripts/prepare-windows-runtime.ps1 @@ -0,0 +1,61 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string] $Destination, + [string] $ArchivePath +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) + +# Reuse the Microsoft 1.22 runtime pair already shipped with Pebrel's predecessor. +# Only these two redistributables are extracted, never the older application. +$archiveHash = '9B2413144C0434E29749CCBD5C2B0F93E930DAFD22634DD70D34B763037E0DA4' +$sourceUrl = 'https://github.com/Kuddev/pebrel/releases/download/v1.5.0/NebulaTerminal-v1.5.0-windows-x64.zip' +$expected = [ordered]@{ + 'conpty.dll' = '375BFB0479B6C53836AB307E3F9FD17BEDBD733F2E9690943D0F12E72FB80777' + 'OpenConsole.exe' = '55B18996761C88C351820E82508E05AB0EC2194AEEAD20724FDFAEEDEC076EF4' +} + +if ([string]::IsNullOrWhiteSpace($ArchivePath)) { + $ArchivePath = Join-Path ([System.IO.Path]::GetTempPath()) 'pebrel-conpty-source-v1.5.0.zip' + if (-not (Test-Path -LiteralPath $ArchivePath -PathType Leaf)) { + Invoke-WebRequest -Uri $sourceUrl -OutFile $ArchivePath -UseBasicParsing -TimeoutSec 120 + } +} +if ((Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash -ne $archiveHash) { + throw 'The pinned runtime source archive failed SHA256 verification.' +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem +$archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path $ArchivePath).Path) +try { + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + foreach ($name in $expected.Keys) { + $target = Join-Path $Destination $name + if ((Test-Path -LiteralPath $target -PathType Leaf) -and + (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash -eq $expected[$name]) { + continue + } + $entry = $archive.GetEntry("runtime\$name") + if ($null -eq $entry) { throw "Runtime archive is missing runtime/$name" } + $temporary = "$target.$([guid]::NewGuid().ToString('N')).tmp" + try { + $inputStream = $entry.Open() + try { + $outputStream = [System.IO.File]::Create($temporary) + try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() } + } finally { $inputStream.Dispose() } + if ((Get-FileHash -LiteralPath $temporary -Algorithm SHA256).Hash -ne $expected[$name]) { + throw "The pinned $name failed SHA256 verification." + } + Move-Item -LiteralPath $temporary -Destination $target -Force + } finally { + if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary } + } + } +} finally { $archive.Dispose() } + +foreach ($name in $expected.Keys) { + Write-Output "$name SHA256 $($expected[$name])" +} diff --git a/scripts/preview_release.py b/scripts/preview_release.py index e7a9704..ac8f070 100644 --- a/scripts/preview_release.py +++ b/scripts/preview_release.py @@ -76,7 +76,8 @@ def validate_evidence(directory: Path, commit: str) -> None: if (report.get("status") != "passed" or report.get("commit") != commit or report.get("architecture") != architecture or report.get("launch_method") != "launchservices" - or report.get("utf8_locale") is not True or report.get("home_cwd") is not True): + or report.get("utf8_locale") is not True or report.get("home_cwd") is not True + or report.get("screenshot") != "captured" or report.get("rendered_text") is not True): raise ManifestError(f"installed macOS application did not pass LaunchServices smoke test: {name}") diff --git a/scripts/stable_release.py b/scripts/stable_release.py new file mode 100644 index 0000000..c5350f9 --- /dev/null +++ b/scripts/stable_release.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python3 +"""Validate the complete stable Pebrel asset set before publishing a Release.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import re +import sys + +if __package__: + from scripts.preview_release import MIN_ASSET_SIZE, VERSION_PATTERN, sha256, write_atomic +else: + # The release workflow invokes this file directly. In that mode Python puts + # `scripts/` on sys.path rather than the repository root. + from preview_release import MIN_ASSET_SIZE, VERSION_PATTERN, sha256, write_atomic + + +STABLE_SHA256_PLACEHOLDER = "" +PENDING_SHA256 = "PENDING FINAL BUILD" +STABLE_VERSION_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") + + +class StableReleaseError(ValueError): + """The stable asset set or release notes are incomplete or unsafe.""" + + +def validate_version(version: str) -> None: + if not VERSION_PATTERN.fullmatch(version) or not STABLE_VERSION_PATTERN.fullmatch(version): + raise StableReleaseError(f"invalid stable version: {version!r}") + + +def expected_asset_names(version: str) -> tuple[str, ...]: + validate_version(version) + return ( + f"Pebrel-v{version}-linux-x64-preview.AppImage", + f"Pebrel-v{version}-linux-x64-preview.deb", + f"Pebrel-v{version}-linux-x64-preview.tar.gz", + f"Pebrel-v{version}-macos-arm64-preview.dmg", + f"Pebrel-v{version}-macos-x64-preview.dmg", + f"Pebrel-v{version}-windows-x64.zip", + f"Pebrel-v{version}-windows-x64-setup.exe", + f"NebulaTerminal-{version}-windows-x64-setup.exe", + ) + + +def mark_platform_previews(directory: Path, version: str) -> None: + for name in expected_asset_names(version): + if "-preview." not in name: + continue + original = directory / name.replace("-preview.", ".", 1) + target = directory / name + if original.exists(): + if target.exists() or original.is_symlink() or not original.is_file(): + raise StableReleaseError(f"cannot mark platform preview without replacing a file: {name}") + original.rename(target) + + +def _check_magic(path: Path) -> None: + with path.open("rb") as stream: + header = stream.read(8) + trailer = b"" + if path.suffix == ".dmg": + if path.stat().st_size < 512: + raise StableReleaseError(f"DMG is too small to contain a UDIF trailer: {path.name}") + stream.seek(-512, os.SEEK_END) + trailer = stream.read(4) + + if path.suffix == ".exe" and not header.startswith(b"MZ"): + raise StableReleaseError(f"Windows installer has no PE header: {path.name}") + if path.suffix == ".zip" and not header.startswith((b"PK\x03\x04", b"PK\x05\x06")): + raise StableReleaseError(f"Windows ZIP has an invalid ZIP header: {path.name}") + if path.name.endswith(".AppImage") and not header.startswith(b"\x7fELF"): + raise StableReleaseError(f"AppImage is not an ELF executable: {path.name}") + if path.suffix == ".deb" and header != b"!\n": + raise StableReleaseError(f"Debian package has an invalid ar header: {path.name}") + if path.name.endswith(".tar.gz") and not header.startswith(b"\x1f\x8b"): + raise StableReleaseError(f"tar.gz has an invalid gzip header: {path.name}") + if path.suffix == ".dmg" and trailer != b"koly": + raise StableReleaseError(f"DMG has no UDIF trailer: {path.name}") + + +def validate_assets(directory: Path, version: str) -> list[Path]: + expected = set(expected_asset_names(version)) + generated = {"SHA256SUMS", "RELEASE_NOTES.md"} + if not directory.is_dir(): + raise StableReleaseError(f"asset directory does not exist: {directory}") + + actual = {path.name for path in directory.iterdir() if path.name not in generated} + missing = sorted(expected - actual) + unexpected = sorted(actual - expected) + if missing or unexpected: + details: list[str] = [] + if missing: + details.append(f"missing: {', '.join(missing)}") + if unexpected: + details.append(f"unexpected: {', '.join(unexpected)}") + raise StableReleaseError("stable asset manifest differs: " + "; ".join(details)) + + assets = [directory / name for name in sorted(expected)] + for path in assets: + if not path.is_file() or path.is_symlink(): + raise StableReleaseError(f"asset is not a regular file: {path.name}") + if path.stat().st_size < MIN_ASSET_SIZE: + raise StableReleaseError(f"asset is unexpectedly small: {path.name}") + _check_magic(path) + + installer = directory / f"Pebrel-v{version}-windows-x64-setup.exe" + legacy = directory / f"NebulaTerminal-{version}-windows-x64-setup.exe" + if sha256(installer) != sha256(legacy): + raise StableReleaseError("legacy Windows installer alias is not byte-identical to Pebrel installer") + return assets + + +def validate_notes(source: Path, version: str) -> str: + notes = source.read_text(encoding="utf-8").strip() + if not notes.startswith(f"# Pebrel {version}\n"): + raise StableReleaseError("stable release notes have the wrong title") + for heading in ("## English", "## 中文", "## Contributors"): + if notes.splitlines().count(heading) != 1: + raise StableReleaseError(f"stable release notes require one {heading} section") + checksum_headings = ("\n## SHA256\n", "\n**SHA256**\n") + present_checksums = [heading for heading in checksum_headings if notes.count(heading) == 1] + if len(present_checksums) != 1: + raise StableReleaseError("stable release notes require one SHA256 section") + english_start = notes.index("\n## English\n") + chinese_start = notes.index("\n## 中文\n") + contributors_start = notes.index("\n## Contributors\n") + checksum_start = notes.index(present_checksums[0]) + if not english_start < chinese_start < contributors_start < checksum_start: + raise StableReleaseError("stable release notes sections are out of order") + pairs = (("Added", "新增"), ("Fixed", "修复"), ("Improved", "改进")) + english = notes[english_start:chinese_start] + chinese = notes[chinese_start:contributors_start] + if not any( + f"### {en}" in english and f"### {zh}" in chinese for en, zh in pairs + ): + raise StableReleaseError("stable release notes need matching bilingual change categories") + contributors = notes[contributors_start:checksum_start] + if not re.search(r"github\.com/[^)]+", contributors, flags=re.IGNORECASE): + raise StableReleaseError("stable release notes must identify contributors with GitHub links") + checksum_body = notes[checksum_start:].strip() + if STABLE_SHA256_PLACEHOLDER not in checksum_body: + listed_names = set(re.findall(r"`([^`]+)`:\s*`(?:[0-9a-fA-F]{64}|PENDING FINAL BUILD)`", checksum_body)) + listed_names.update( + match.group(1) + for match in re.finditer(r"(?m)^\s*[0-9a-fA-F]{64}\s{2}(\S+)\s*$", checksum_body) + ) + expected_names = set(expected_asset_names(version)) + if listed_names != expected_names: + missing = sorted(expected_names - listed_names) + unexpected = sorted(listed_names - expected_names) + details = [] + if missing: + details.append("missing: " + ", ".join(missing)) + if unexpected: + details.append("unexpected: " + ", ".join(unexpected)) + raise StableReleaseError("stable release notes SHA256 asset names differ: " + "; ".join(details)) + if ( + STABLE_SHA256_PLACEHOLDER not in checksum_body + and PENDING_SHA256 not in checksum_body + and not re.search(r"[0-9a-fA-F]{64}", checksum_body) + ): + raise StableReleaseError("stable release notes need a SHA256 placeholder or checksums") + return notes + "\n" + + +def checksum_text(assets: list[Path]) -> str: + return "".join(f"{sha256(path)} {path.name}\n" for path in assets) + + +def validate_changelog(source: Path, version: str) -> str: + changelog = source.read_text(encoding="utf-8") + match = re.search(rf"(?m)^## {re.escape(version)}(?: - [^\n]*)?\n", changelog) + if match is None: + raise StableReleaseError(f"CHANGELOG.md is missing the {version} entry") + entry = re.split(r"(?m)^## ", changelog[match.end() :], maxsplit=1)[0] + if not entry.strip(): + raise StableReleaseError(f"CHANGELOG.md has an empty {version} entry") + missing = [name for name in expected_asset_names(version) if name not in entry] + if missing: + raise StableReleaseError("CHANGELOG.md is missing stable asset names: " + ", ".join(missing)) + return entry + + +def verify_release(metadata: dict, directory: Path, version: str, commit: str, tag_commit: str) -> None: + expected_names = set(expected_asset_names(version)) | {"SHA256SUMS"} + if ( + metadata.get("name") != f"Pebrel {version}" + or metadata.get("tag_name") != f"v{version}" + or metadata.get("draft") is not False + or metadata.get("prerelease") is not False + or tag_commit != commit + ): + raise StableReleaseError("remote stable title, tag, commit, or publication state differs") + notes = (directory / "RELEASE_NOTES.md").read_text(encoding="utf-8") + remote_notes = (metadata.get("body") or "").replace("\r\n", "\n").replace("\r", "\n") + if remote_notes.strip() != notes.strip(): + raise StableReleaseError("remote stable Release notes differ from the verified local notes") + assets = metadata.get("assets") or [] + if len(assets) != len(expected_names) or {asset.get("name") for asset in assets} != expected_names: + raise StableReleaseError("remote stable asset names differ") + for asset in assets: + path = directory / asset["name"] + if ( + asset.get("label") not in (None, "") + or asset.get("state") != "uploaded" + or asset.get("size") != path.stat().st_size + or asset.get("digest") != f"sha256:{sha256(path)}" + ): + raise StableReleaseError(f"remote asset label, state, size, or SHA256 differs: {path.name}") + + +def render_notes(notes: str, checksums: str) -> str: + if STABLE_SHA256_PLACEHOLDER in notes: + return notes.replace(STABLE_SHA256_PLACEHOLDER, f"```text\n{checksums.rstrip()}\n```") + if PENDING_SHA256 in notes: + rendered = notes + for line in checksums.splitlines(): + digest, name = line.split(" ", 1) + rendered = re.sub( + rf"(`{re.escape(name)}`:\s*`){re.escape(PENDING_SHA256)}(`)", + rf"\g<1>{digest}\g<2>", + rendered, + ) + if PENDING_SHA256 in rendered: + raise StableReleaseError("stable release notes contain an unknown pending checksum entry") + return rendered + else: + missing = [line for line in checksums.splitlines() if line not in notes] + if missing: + raise StableReleaseError( + "stable release notes have stale or incomplete SHA256 entries: " + + ", ".join(line.rsplit(" ", 1)[-1] for line in missing) + ) + return notes + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory", type=Path) + parser.add_argument("--version", required=True) + parser.add_argument("--mark-platform-previews", action="store_true") + parser.add_argument("--notes", type=Path) + parser.add_argument("--changelog", type=Path) + parser.add_argument("--write-checksums", type=Path) + parser.add_argument("--write-notes", type=Path) + parser.add_argument("--verify-release", type=Path) + parser.add_argument("--commit") + parser.add_argument("--tag-commit") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + if args.mark_platform_previews: + mark_platform_previews(args.directory.resolve(), args.version) + assets = validate_assets(args.directory.resolve(), args.version) + notes = None + if args.notes: + notes = validate_notes(args.notes.resolve(), args.version) + if args.changelog: + validate_changelog(args.changelog.resolve(), args.version) + checksums = checksum_text(assets) + if args.write_checksums: + write_atomic(args.write_checksums.resolve(), checksums) + if args.write_notes: + if notes is None: + raise StableReleaseError("--write-notes requires --notes") + write_atomic(args.write_notes.resolve(), render_notes(notes, checksums)) + if args.verify_release: + if not args.commit or not args.tag_commit: + raise StableReleaseError("--verify-release requires --commit and --tag-commit") + verify_release( + json.loads(args.verify_release.read_text(encoding="utf-8")), + args.directory.resolve(), + args.version, + args.commit, + args.tag_commit, + ) + for path in assets: + print(f"{sha256(path)} {path.name}") + except (StableReleaseError, OSError, json.JSONDecodeError) as error: + print(f"stable release error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/tests/installer.tests.ps1 b/scripts/tests/installer.tests.ps1 index 3ca18a9..5acaf8e 100644 --- a/scripts/tests/installer.tests.ps1 +++ b/scripts/tests/installer.tests.ps1 @@ -34,7 +34,7 @@ $requiredPatterns = [ordered]@{ 'Pebrel display name' = 'AppName=Pebrel' 'compatible installer identity' = 'AppId=\{\{61022144-7D0A-4E54-94F2-C329A8F58656\}' 'Pebrel default asset name' = '#define PackageBrand "Pebrel"' - 'explicit package brand' = 'OutputBaseFilename=\{#PackageBrand\}-\{#AppVersion\}-windows-x64-setup' + 'explicit package brand' = 'OutputBaseFilename=\{#PackageBrand\}-v\{#AppVersion\}-windows-x64-setup' 'localized Chinese context menu label' = 'chinesesimplified\.OpenInPebrel=\S.+' 'directory background context menu' = 'Software\\Classes\\Directory\\Background\\shell\\Pebrel' 'selected directory context menu' = 'Software\\Classes\\Directory\\shell\\Pebrel' diff --git a/scripts/tests/package-release.tests.ps1 b/scripts/tests/package-release.tests.ps1 index 2f063bf..6226ccb 100644 --- a/scripts/tests/package-release.tests.ps1 +++ b/scripts/tests/package-release.tests.ps1 @@ -71,6 +71,7 @@ try { $expected = @( 'README.md' + 'README.zh-CN.md' 'docs/CHANGELOG.md' 'docs/INSTALL.md' 'docs/lua-configuration.md' @@ -94,8 +95,8 @@ try { } $rootFiles = @($actual | Where-Object { -not $_.Contains('/') }) - if (@(Compare-Object -ReferenceObject @('README.md', 'pebrel.exe') -DifferenceObject $rootFiles).Count -ne 0) { - throw "ZIP root must contain only README.md and pebrel.exe" + if (@(Compare-Object -ReferenceObject @('README.md', 'README.zh-CN.md', 'pebrel.exe') -DifferenceObject $rootFiles).Count -ne 0) { + throw "ZIP root must contain the English and Chinese READMEs and pebrel.exe" } & $packageScript -Version 'unreleased' -PackageBrand NebulaTerminal -SkipBuild -AllowStale -OutputDirectory $resolvedOutput -TargetDirectory $TargetDirectory diff --git a/scripts/tests/test_ai_session_probe.py b/scripts/tests/test_ai_session_probe.py new file mode 100644 index 0000000..7b7ae7b --- /dev/null +++ b/scripts/tests/test_ai_session_probe.py @@ -0,0 +1,61 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +CHILD = r''' +import ctypes, json, sys +ctypes.CDLL(None).prctl(15, b"codex", 0, 0, 0) +handles = [open(path, "rb") for path in json.loads(sys.argv[1])] +print("ready", flush=True) +sys.stdin.read() +''' + + +@unittest.skipUnless(sys.platform == "linux", "requires Linux procfs (also runs inside WSL)") +class ActiveRolloutProbeTests(unittest.TestCase): + def test_probe_binds_custom_home_to_exact_instance_and_pane(self): + source = (ROOT / "nebula_app/src/platform/ai_session_identity.rs").read_text() + script = source.split('const PROBE_SCRIPT: &str = r###"', 1)[1].split('"###;', 1)[0] + with tempfile.TemporaryDirectory(prefix="pebrel-identity-") as temporary: + codex_directory = Path(temporary) / "custom codex" + sessions = codex_directory / "sessions/2026/09/07" + sessions.mkdir(parents=True) + main_id = "01a079fa-4a9b-7d93-8a4a-4a7a9edaf247" + guardian_id = "01a079f7-7e36-7232-882b-f06d3bde9df8" + paths = [] + for identity, origin in ((main_id, "cli"), (guardian_id, {"subagent": "guardian"})): + path = sessions / f"rollout-x-{identity}.jsonl" + path.write_text(json.dumps({"type": "session_meta", "payload": { + "id": identity, "source": origin, + }}) + "\n", encoding="utf-8") + paths.append(str(path)) + instance = f"probe-test-{os.getpid()}" + environment = dict(os.environ, PEBREL_PROCESS_ID=instance, + PEBREL_PANE_ID="713", CODEX_HOME=str(codex_directory)) + child = subprocess.Popen([sys.executable, "-c", CHILD, json.dumps(paths)], + env=environment, stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + self.assertEqual(child.stdout.readline().strip(), "ready") + result = subprocess.run(["sh", "-c", script, "probe", "713", instance], + capture_output=True, text=True, check=True, timeout=5) + records = [json.loads(line.split("\t", 3)[3]) + for line in result.stdout.splitlines()] + self.assertEqual({item["payload"]["id"] for item in records}, + {main_id, guardian_id}) + for pane, owner in (("714", instance), ("713", instance + "-other")): + mismatch = subprocess.run(["sh", "-c", script, "probe", pane, owner], + capture_output=True, text=True, check=True, timeout=5) + self.assertEqual(mismatch.stdout, "") + finally: + child.communicate(timeout=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_macos_dmg.py b/scripts/tests/test_macos_dmg.py new file mode 100644 index 0000000..e70da36 --- /dev/null +++ b/scripts/tests/test_macos_dmg.py @@ -0,0 +1,82 @@ +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from scripts.macos_dmg import MIB, image_size_mib + + +class DmgSizingTests(unittest.TestCase): + def test_sparse_binary_uses_logical_size(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + with (root / "pebrel").open("wb") as stream: + stream.truncate(512 * MIB) + self.assertGreater(image_size_mib(root), 640) + + @unittest.skipIf(os.name == "nt", "requires unprivileged symlinks") + def test_application_link_does_not_follow_external_files(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + stage = root / "stage" + stage.mkdir() + external = root / "Applications" + external.mkdir() + with (external / "large").open("wb") as stream: + stream.truncate(1024 * MIB) + (stage / "Applications").symlink_to(external, target_is_directory=True) + self.assertEqual(image_size_mib(stage), 64) + + def test_small_files_include_allocation_overhead(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for index in range(100): + (root / str(index)).write_bytes(b"x") + self.assertGreaterEqual(image_size_mib(root), 64) + + def test_missing_source_is_rejected(self): + with tempfile.TemporaryDirectory() as temporary: + with self.assertRaises(ValueError): + image_size_mib(Path(temporary) / "missing") + + @unittest.skipUnless(sys.platform == "darwin", "requires native hdiutil") + def test_native_image_contains_complete_sparse_binary(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + stage = root / "stage" + executable = stage / "Pebrel Preview.app/Contents/MacOS/pebrel" + executable.parent.mkdir(parents=True) + with executable.open("wb") as stream: + stream.write(b"pebrel-start") + stream.seek(64 * MIB) + stream.write(b"pebrel-end") + (stage / "Applications").symlink_to("/Applications") + image = root / "test.dmg" + subprocess.run([ + "hdiutil", "create", "-volname", "Pebrel Preview", "-fs", "HFS+", + "-size", f"{image_size_mib(stage)}m", "-srcfolder", str(stage), + "-format", "UDZO", str(image), + ], check=True, capture_output=True) + mount = root / "mount" + mount.mkdir() + subprocess.run([ + "hdiutil", "attach", str(image), "-nobrowse", "-readonly", + "-mountpoint", str(mount), + ], check=True, capture_output=True) + try: + copied = mount / executable.relative_to(stage) + self.assertEqual(copied.stat().st_size, executable.stat().st_size) + with copied.open("rb") as stream: + self.assertEqual(stream.read(12), b"pebrel-start") + stream.seek(64 * MIB) + self.assertEqual(stream.read(), b"pebrel-end") + self.assertEqual(os.readlink(mount / "Applications"), "/Applications") + finally: + subprocess.run(["hdiutil", "detach", str(mount)], check=True, + capture_output=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_pebrel_branding.py b/scripts/tests/test_pebrel_branding.py index 0783705..ce3ff45 100644 --- a/scripts/tests/test_pebrel_branding.py +++ b/scripts/tests/test_pebrel_branding.py @@ -74,7 +74,7 @@ class PebrelBrandingTests(unittest.TestCase): self.assertIn("Stale binary:", source) installer = self.source("scripts/installer.iss") self.assertIn('#define PackageBrand "Pebrel"', installer) - self.assertIn("OutputBaseFilename={#PackageBrand}-{#AppVersion}-windows-x64-setup", installer) + self.assertIn("OutputBaseFilename={#PackageBrand}-v{#AppVersion}-windows-x64-setup", installer) builder = self.source("scripts/build-installer.ps1") self.assertIn('"/DPackageBrand=$PackageBrand"', builder) @@ -82,12 +82,13 @@ class PebrelBrandingTests(unittest.TestCase): check = self.source("nebula_app/src/update_check.rs") download = self.source("nebula_app/src/update_download.rs") self.assertIn('format!("Pebrel-{version}-windows-x64-setup.exe")', check) + self.assertIn('format!("Pebrel-v{version}-windows-x64-setup.exe")', check) self.assertIn('format!("NebulaTerminal-{version}-windows-x64-setup.exe")', check) self.assertIn("windows_x64_installer_names(version)", check) self.assertIn("windows_x64_installer_names(&asset.version).contains(&asset.name)", download) def test_notification_identity_uses_pebrel(self): - source = self.source("nebula_app/src/notify.rs") + source = self.source("nebula_app/src/platform/notifications.rs") self.assertIn('AUMID: &str = "com.pebrel.terminal";', source) self.assertIn('set_reg_sz(&subkey, "DisplayName", crate::brand::NAME)', source) diff --git a/scripts/tests/test_preview_release.py b/scripts/tests/test_preview_release.py index dfa0dd0..7416c8a 100644 --- a/scripts/tests/test_preview_release.py +++ b/scripts/tests/test_preview_release.py @@ -199,10 +199,18 @@ class PreviewReleaseTests(unittest.TestCase): (root / f"macos-{architecture}-launch.json").write_text(json.dumps({ "status": "passed", "commit": "a" * 40, "architecture": architecture, "launch_method": "launchservices", "utf8_locale": True, "home_cwd": True, + "screenshot": "captured", "rendered_text": True, }), encoding="utf-8") validate_evidence(root, "a" * 40) with self.assertRaisesRegex(ManifestError, "source commit"): validate_evidence(root, "b" * 40) + launch_path = root / "macos-aarch64-launch.json" + launch = json.loads(launch_path.read_text(encoding="utf-8")) + for field, value in (("rendered_text", False), ("screenshot", "unavailable")): + with self.subTest(field=field): + launch_path.write_text(json.dumps({**launch, field: value}), encoding="utf-8") + with self.assertRaisesRegex(ManifestError, "LaunchServices"): + validate_evidence(root, "a" * 40) (root / "macos-aarch64-launch.json").write_text('{}', encoding="utf-8") with self.assertRaisesRegex(ManifestError, "LaunchServices"): validate_evidence(root, "a" * 40) diff --git a/scripts/tests/test_stable_release.py b/scripts/tests/test_stable_release.py new file mode 100644 index 0000000..b8a8cf4 --- /dev/null +++ b/scripts/tests/test_stable_release.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest + +from scripts.stable_release import ( + STABLE_SHA256_PLACEHOLDER, + StableReleaseError, + expected_asset_names, + render_notes, + validate_assets, + validate_changelog, + validate_notes, + verify_release, +) +from scripts.preview_release import sha256 +from scripts.preview_release import MIN_ASSET_SIZE + + +VERSION = "1.6.0" + + +def write_fake_asset(path: Path) -> None: + if path.suffix == ".exe": + header = b"MZ" + b"\0" * 6 + elif path.suffix == ".zip": + header = b"PK\x03\x04" + b"\0" * 4 + elif path.name.endswith(".AppImage"): + header = b"\x7fELF\x02\x01\x01\0" + elif path.suffix == ".deb": + header = b"!\n" + elif path.name.endswith(".tar.gz"): + header = b"\x1f\x8b\x08\0\0\0\0\0" + elif path.suffix == ".dmg": + header = b"\0" * 8 + else: + raise AssertionError(path) + path.write_bytes(header + b"\0" * (MIN_ASSET_SIZE - len(header))) + if path.suffix == ".dmg": + with path.open("r+b") as stream: + stream.seek(-512, 2) + stream.write(b"koly") + + +def notes(checksum_placeholder: bool = True) -> str: + checksum = STABLE_SHA256_PLACEHOLDER if checksum_placeholder else "0" * 64 + return f"""# Pebrel {VERSION} + +## English + +### Added +- A stable package set. + +## 中文 + +### 新增 +- 稳定版安装包集合。 + +## Contributors + +\"@Kuddev + +## SHA256 + +{checksum} +""" + + +class StableReleaseTests(unittest.TestCase): + def test_stable_workflow_runs_gpui_interaction_tests_on_every_platform(self) -> None: + root = Path(__file__).resolve().parents[2] + workflow = (root / ".github/workflows/release.yml").read_text(encoding="utf-8") + command = "cargo test --locked -p nebula --bin pebrel --features gpui-test-support gpui_shell::" + self.assertEqual(workflow.count(command), 3) + windows_step = workflow.split(" - name: Test workspace and native harness\n", 1)[1] + windows_step = windows_step.split(" - name:", 1)[0] + self.assertIn("shell: pwsh", windows_step) + lines = [line.strip() for line in windows_step.splitlines()] + commands = 0 + for index, line in enumerate(lines): + if line.startswith(("cargo test ", "python -m unittest ")): + commands += 1 + self.assertEqual(lines[index + 1], "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }") + self.assertEqual(commands, 5) + + def test_native_packagers_expose_stable_channel_without_preview_id(self) -> None: + root = Path(__file__).resolve().parents[2] + linux = (root / "scripts/package-linux.sh").read_text(encoding="utf-8") + macos = (root / "scripts/package-macos.sh").read_text(encoding="utf-8") + for source in (linux, macos): + self.assertIn('--channel', source) + self.assertIn('channel" == "stable"', source) + self.assertIn('stable packages must not include --preview-id', source) + self.assertIn('asset_architecture="x64"', linux) + self.assertIn('asset_architecture="x64"', macos) + self.assertIn('asset_architecture="arm64"', macos) + + def test_expected_assets_use_stable_public_names(self) -> None: + names = expected_asset_names(VERSION) + self.assertIn("Pebrel-v1.6.0-linux-x64-preview.AppImage", names) + self.assertIn("Pebrel-v1.6.0-macos-arm64-preview.dmg", names) + self.assertIn("Pebrel-v1.6.0-macos-x64-preview.dmg", names) + self.assertIn("Pebrel-v1.6.0-windows-x64-setup.exe", names) + self.assertIn("NebulaTerminal-1.6.0-windows-x64-setup.exe", names) + self.assertEqual(len(names), 8) + + def test_stable_manifest_rejects_prerelease_versions(self) -> None: + with self.assertRaisesRegex(StableReleaseError, "invalid stable version"): + expected_asset_names("1.6.0-rc.1") + + def test_complete_assets_require_identical_legacy_installer(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in expected_asset_names(VERSION): + write_fake_asset(root / name) + self.assertEqual(len(validate_assets(root, VERSION)), 8) + legacy = root / "NebulaTerminal-1.6.0-windows-x64-setup.exe" + legacy.write_bytes(legacy.read_bytes()[:-1] + b"x") + with self.assertRaisesRegex(StableReleaseError, "byte-identical"): + validate_assets(root, VERSION) + + def test_asset_magic_is_checked(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in expected_asset_names(VERSION): + write_fake_asset(root / name) + (root / "Pebrel-v1.6.0-linux-x64-preview.AppImage").write_bytes(b"bad" + b"\0" * MIN_ASSET_SIZE) + with self.assertRaisesRegex(StableReleaseError, "ELF"): + validate_assets(root, VERSION) + + def test_notes_require_bilingual_categories_and_contributor_link(self) -> None: + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "v1.6.0.md" + source.write_text(notes(), encoding="utf-8") + self.assertEqual(validate_notes(source, VERSION), notes()) + source.write_text(notes().replace("https://github.com/Kuddev", "https://example.invalid"), encoding="utf-8") + with self.assertRaisesRegex(StableReleaseError, "GitHub links"): + validate_notes(source, VERSION) + + def test_changelog_must_list_the_same_stable_asset_names(self) -> None: + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "CHANGELOG.md" + source.write_text( + "## 1.6.0 - 2026-09-07\n\n" + + "\n".join(f"- `{name}`: `PENDING FINAL BUILD`" for name in expected_asset_names(VERSION)) + + "\n", + encoding="utf-8", + ) + self.assertTrue(validate_changelog(source, VERSION)) + source.write_text(source.read_text(encoding="utf-8").replace("Pebrel-v1.6.0", "Pebrel-1.6.0"), encoding="utf-8") + with self.assertRaisesRegex(StableReleaseError, "missing stable asset"): + validate_changelog(source, VERSION) + + def test_render_notes_replaces_placeholder_with_all_hashes(self) -> None: + rendered = render_notes(notes(), "a" * 64 + " Pebrel-v1.6.0-windows-x64.zip\n") + self.assertNotIn(STABLE_SHA256_PLACEHOLDER, rendered) + self.assertIn("a" * 64 + " Pebrel-v1.6.0-windows-x64.zip", rendered) + + def test_render_notes_replaces_pending_named_entries(self) -> None: + source = notes().replace(STABLE_SHA256_PLACEHOLDER, "- `Pebrel-v1.6.0-windows-x64.zip`: `PENDING FINAL BUILD`") + rendered = render_notes(source, "a" * 64 + " Pebrel-v1.6.0-windows-x64.zip\n") + self.assertIn("`Pebrel-v1.6.0-windows-x64.zip`: `" + "a" * 64, rendered) + + def test_render_notes_rejects_stale_explicit_hashes(self) -> None: + with self.assertRaisesRegex(StableReleaseError, "stale or incomplete"): + render_notes(notes(False), "a" * 64 + " Pebrel-v1.6.0-windows-x64.zip\n") + + def test_remote_release_verification_checks_all_assets_and_notes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name in expected_asset_names(VERSION): + write_fake_asset(root / name) + (root / "SHA256SUMS").write_text("checksums\n", encoding="utf-8") + release_notes = notes().replace(STABLE_SHA256_PLACEHOLDER, "rendered checksums") + (root / "RELEASE_NOTES.md").write_text(release_notes, encoding="utf-8") + names = [*expected_asset_names(VERSION), "SHA256SUMS"] + metadata = { + "name": "Pebrel 1.6.0", + "tag_name": "v1.6.0", + "draft": False, + "prerelease": False, + "body": release_notes, + "assets": [ + { + "name": name, + "label": "", + "state": "uploaded", + "size": (root / name).stat().st_size, + "digest": f"sha256:{sha256(root / name)}", + } + for name in names + ], + } + verify_release(metadata, root, VERSION, "a" * 40, "a" * 40) + metadata["body"] = release_notes.replace("\n", "\r\n") + verify_release(metadata, root, VERSION, "a" * 40, "a" * 40) + metadata["assets"][0]["label"] = "wrong" + with self.assertRaisesRegex(StableReleaseError, "label"): + verify_release(metadata, root, VERSION, "a" * 40, "a" * 40) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_xplat.py b/scripts/tests/test_xplat.py new file mode 100644 index 0000000..55720a1 --- /dev/null +++ b/scripts/tests/test_xplat.py @@ -0,0 +1,176 @@ +"""Exercise metadata-check isolation; the Cargo recorder performs no compilation.""" + +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +MAC_TARGET = "aarch64-apple-darwin" + + +@unittest.skipUnless(os.name == "posix" and shutil.which("bash"), "requires a POSIX Bash host") +class XplatIsolationTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="nebula-xplat-test-") + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.repo = self.root / "repo with spaces" + self.scripts = self.repo / "scripts/xplat" + shutil.copytree(ROOT / "scripts/xplat", self.scripts) + self.output = self.repo / ".target-xplat/metadata-only-v1" + self.native = self.root / "native target" + self.native.mkdir() + self.native_sentinel = self.native / "keep.txt" + self.native_sentinel.write_text("native cache", encoding="utf-8") + self.calls = self.root / "cargo-calls.jsonl" + self.bin = self.root / "bin" + self.bin.mkdir() + rustc = self.bin / "rustc" + rustc.write_text( + '#!/usr/bin/env bash\nprintf "rustc test\\nhost: %s\\n" "$XPLAT_TEST_HOST"\n', + encoding="utf-8", + ) + cargo = self.bin / "cargo" + cargo.write_text( + "#!/usr/bin/env python3\n" + "import json, os, sys\n" + "from pathlib import Path\n" + "record = {'args': sys.argv[1:], 'target': os.environ['CARGO_TARGET_DIR'], " + "'build_dir': os.environ['CARGO_BUILD_BUILD_DIR'], " + "'cc': os.environ.get('CC_aarch64_apple_darwin')}\n" + "with Path(os.environ['XPLAT_TEST_LOG']).open('a', encoding='utf-8') as log:\n" + " log.write(json.dumps(record) + '\\n')\n" + "raise SystemExit(int(os.environ.get('XPLAT_TEST_EXIT', '0')))\n", + encoding="utf-8", + ) + rustc.chmod(0o755) + cargo.chmod(0o755) + + def run_check(self, *args, host="x86_64-pc-windows-msvc", exit_code=0): + env = { + **os.environ, + "PATH": str(self.bin) + os.pathsep + os.environ["PATH"], + "RUSTC": str(self.bin / "rustc"), + "CARGO_TARGET_DIR": str(self.native), + "CARGO_BUILD_BUILD_DIR": str(self.native / "intermediate"), + "XPLAT_TEST_HOST": host, + "XPLAT_TEST_LOG": str(self.calls), + "XPLAT_TEST_EXIT": str(exit_code), + } + return subprocess.run( + [str(self.scripts / "check.sh"), *(args or ("macos",))], + cwd=self.root, env=env, text=True, capture_output=True, timeout=10, + ) + + def recordings(self): + if not self.calls.exists(): + return [] + return [json.loads(line) for line in self.calls.read_text(encoding="utf-8").splitlines()] + + def build_out(self, base, package): + result = base / MAC_TARGET / "debug/build" / package / "out" + result.mkdir(parents=True) + return result + + def test_inherited_native_output_is_preserved_and_cargo_is_locked(self): + native_out = self.build_out(self.native, "media-native") + result = self.run_check("macos", "--features", "gpui-shell", "-p", "nebula") + self.assertEqual(result.returncode, 0, result.stderr) + call, = self.recordings() + self.assertEqual(call["target"], str(self.output)) + self.assertEqual(call["build_dir"], str(self.output)) + args = call["args"] + self.assertIn("--locked", args) + self.assertEqual(args[args.index("--target-dir") + 1], str(self.output)) + self.assertEqual(args[-4:], ["--features", "gpui-shell", "-p", "nebula"]) + self.assertEqual(list(native_out.iterdir()), []) + self.assertEqual(self.native_sentinel.read_text(encoding="utf-8"), "native cache") + self.assertIn("not a native build/test", result.stdout) + + def test_native_target_is_rejected_before_any_output_or_cargo_call(self): + for selector, host in [("macos", MAC_TARGET), ("linux", "x86_64-unknown-linux-gnu"), ("all", MAC_TARGET)]: + with self.subTest(selector=selector): + result = self.run_check(selector, host=host) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("refusing native target", result.stderr) + self.assertEqual(self.recordings(), []) + self.assertFalse(self.output.exists()) + + def test_cargo_target_and_configuration_overrides_are_rejected(self): + for options in [ + ["--target-dir", str(self.native)], [f"--target-dir={self.native}"], + ["--target-d", str(self.native)], ["--config", "build.target-dir='target'"], + ["--config=build.target-dir='target'"], ["--target", "x86_64-unknown-linux-gnu"], + ["--manifest-path", "elsewhere/Cargo.toml"], ["--release"], + ]: + with self.subTest(options=options): + result = self.run_check("macos", *options) + self.assertEqual(result.returncode, 2, result.stderr) + self.assertEqual(self.recordings(), []) + self.assertFalse(self.output.exists()) + + def test_unmarked_existing_metadata_directory_is_not_repurposed(self): + self.output.mkdir(parents=True) + sentinel = self.output / "user-data" + sentinel.write_text("keep", encoding="utf-8") + result = self.run_check() + self.assertEqual(result.returncode, 2, result.stderr) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "keep") + self.assertEqual(self.recordings(), []) + + def test_stubs_are_seeded_only_in_owned_metadata_output(self): + self.assertEqual(self.run_check().returncode, 0) + media = self.build_out(self.output, "media-check") + shaders = self.build_out(self.output, "gpui_macos-check") + native_media = self.build_out(self.native, "media-native") + native_shaders = self.build_out(self.native, "gpui_macos-native") + result = self.run_check() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((media / "bindings.rs").read_bytes(), (self.scripts / "media-bindings-stub.rs").read_bytes()) + self.assertEqual((shaders / "shaders.metallib").read_bytes(), b"") + self.assertEqual(list(native_media.iterdir()), []) + self.assertEqual(list(native_shaders.iterdir()), []) + + def test_symlinked_output_root_is_rejected(self): + (self.repo / ".target-xplat").symlink_to(self.native, target_is_directory=True) + result = self.run_check() + self.assertEqual(result.returncode, 2, result.stderr) + self.assertEqual(self.recordings(), []) + self.assertEqual([path.name for path in self.native.iterdir()], ["keep.txt"]) + + def test_symlinked_stub_directory_cannot_escape_owned_output(self): + self.assertEqual(self.run_check().returncode, 0) + package = self.output / MAC_TARGET / "debug/build/media-escape" + package.mkdir(parents=True) + (package / "out").symlink_to(self.native, target_is_directory=True) + result = self.run_check() + self.assertEqual(result.returncode, 2, result.stderr) + self.assertIn("escapes metadata directory", result.stderr) + self.assertEqual(len(self.recordings()), 1) + self.assertFalse((self.native / "bindings.rs").exists()) + + def test_symlinked_stub_file_cannot_overwrite_native_file(self): + self.assertEqual(self.run_check().returncode, 0) + media = self.build_out(self.output, "media-escape") + (media / "bindings.rs").symlink_to(self.native_sentinel) + result = self.run_check() + self.assertEqual(result.returncode, 2, result.stderr) + self.assertEqual(self.native_sentinel.read_text(encoding="utf-8"), "native cache") + + def test_cargo_failure_is_reported_unchanged(self): + result = self.run_check(exit_code=17) + self.assertEqual(result.returncode, 17) + self.assertEqual(len(self.recordings()), 1) + + def test_posix_compiler_can_be_invoked_directly(self): + result = subprocess.run( + [str(self.scripts / "fake-cc.sh"), "--version"], + text=True, capture_output=True, timeout=10, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("cross-check stub", result.stdout) diff --git a/scripts/tests/windows-runtime.tests.ps1 b/scripts/tests/windows-runtime.tests.ps1 new file mode 100644 index 0000000..f684460 --- /dev/null +++ b/scripts/tests/windows-runtime.tests.ps1 @@ -0,0 +1,28 @@ +[CmdletBinding()] +param([Parameter(Mandatory = $true)][string] $ArchivePath) + +$ErrorActionPreference = 'Stop' +$prepare = Join-Path $PSScriptRoot '../prepare-windows-runtime.ps1' +$root = Join-Path ([IO.Path]::GetTempPath()) "pebrel-runtime-test-$([guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Path $root | Out-Null +try { + $destination = Join-Path $root 'runtime' + & $prepare -Destination $destination -ArchivePath $ArchivePath + $before = @(Get-ChildItem $destination | Sort-Object Name | ForEach-Object { $_.LastWriteTimeUtc.Ticks }) + & $prepare -Destination $destination -ArchivePath $ArchivePath + $after = @(Get-ChildItem $destination | Sort-Object Name | ForEach-Object { $_.LastWriteTimeUtc.Ticks }) + if (@(Compare-Object $before $after).Count -ne 0) { throw 'Verified runtime files were unnecessarily replaced.' } + if (@(Get-ChildItem $destination).Count -ne 2) { throw 'Unexpected files were extracted.' } + + $invalid = Join-Path $root 'different.zip' + [IO.File]::WriteAllBytes($invalid, [byte[]]@(80, 75, 0, 0)) + $rejected = $false + try { & $prepare -Destination (Join-Path $root 'rejected') -ArchivePath $invalid } + catch { + if ($_.Exception.Message -notlike '*SHA256 verification*') { throw } + $rejected = $true + } + if (-not $rejected) { throw 'A different archive was accepted.' } + if (Test-Path (Join-Path $root 'rejected')) { throw 'Files were created before archive verification.' } + Write-Output 'windows-runtime.tests.ps1: PASS (pinned files, incremental reuse, hash mismatch)' +} finally { Remove-Item -LiteralPath $root -Recurse -Force } diff --git a/scripts/xplat/check.sh b/scripts/xplat/check.sh old mode 100644 new mode 100755 index f662d8b..4d88346 --- a/scripts/xplat/check.sh +++ b/scripts/xplat/check.sh @@ -1,14 +1,16 @@ #!/usr/bin/env bash # 在 Windows 本机对 macOS / Linux 目标做 `cargo check`,不需要对应的 SDK。 # -# 用法:scripts/xplat/check.sh [macos|linux|all] +# 用法:scripts/xplat/check.sh [macos|linux|all] [cargo check options] # # 这是跨目标元数据检查,可发现部分 cfg 与依赖声明不一致的问题 # (比如 Cargo.toml 里按平台排除了某个 crate 而代码无条件使用)。 # 使用占位 C 编译器与绑定,不能替代原生 runner 的真实 build/test。 -set -eu +# 输出固定在 .target-xplat/metadata-only-v1,绝不复用 CARGO_TARGET_DIR。 +set -euo pipefail here="$(cd "$(dirname "$0")" && pwd)" -repo="$(cd "$here/../.." && pwd)" +repo="$(cd "$here/../.." && pwd -P)" +fail() { echo "xplat: $*" >&2; exit 2; } targets=() case "${1:-all}" in macos) targets=(aarch64-apple-darwin) ;; @@ -16,29 +18,93 @@ case "${1:-all}" in all) targets=(aarch64-apple-darwin x86_64-unknown-linux-gnu) ;; *) echo "usage: $0 [macos|linux|all]" >&2; exit 2 ;; esac -export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$repo/.target-xplat}" -case "$(uname -s)" in MINGW*|MSYS*|CYGWIN*) fake="$(cygpath -w "$here/fake-cc.cmd")" ;; *) fake="$here/fake-cc.sh" ;; esac +if [ "$#" -gt 0 ]; then shift; fi +# Keep feature/package selection and diagnostics, but reject Cargo configuration +# and output overrides (including abbreviated flags) before creating any files. +check_args=() +while [ "$#" -gt 0 ]; do + case "$1" in + --all-targets|--all-features|--no-default-features|--lib|--bins|--examples|--tests|--benches|--keep-going|-v|-vv|--verbose|-q|--quiet) + check_args+=("$1"); shift ;; + -p|--package|-F|--features|-j|--jobs|--bin|--example|--test|--bench|--exclude|--message-format|--color) + [ "$#" -gt 1 ] && [[ "$2" != -* ]] || fail "missing value for $1" + check_args+=("$1" "$2"); shift 2 ;; + --package=*|--features=*|--jobs=*|--bin=*|--example=*|--test=*|--bench=*|--exclude=*|--message-format=*|--color=*) + check_args+=("$1"); shift ;; + *) fail "unsupported check argument: $1 (target, output, profile and config overrides are disabled)" ;; + esac +done +cd "$repo" +host_info="$("${RUSTC:-rustc}" -vV)" +host="" +while IFS= read -r line; do + line="${line%$'\r'}" + case "$line" in 'host: '*) host="${line#host: }" ;; esac +done <<< "$host_info" +[ -n "$host" ] || fail "rustc did not report its host target" +for target in "${targets[@]}"; do + [ "$target" != "$host" ] || fail "refusing native target $target; use real cargo build/test" +done +target_dir="$repo/.target-xplat/metadata-only-v1" +marker="$target_dir/.nebula-metadata-only" +for dir in "$repo/.target-xplat" "$target_dir" "$marker"; do + [ ! -L "$dir" ] || fail "refusing symlinked metadata output: $dir" +done +if [ -e "$target_dir" ]; then + [ -d "$target_dir" ] && [ -f "$marker" ] && + [ "$(cat "$marker")" = 'nebula cross-target metadata only v1' ] || + fail "refusing existing output without its metadata-only marker: $target_dir" +else + mkdir -p "$target_dir" + printf '%s\n' 'nebula cross-target metadata only v1' > "$marker" +fi +cargo_target_dir="$target_dir" +case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) + fake="$(cygpath -w "$here/fake-cc.cmd")" + cargo_target_dir="$(cygpath -w "$target_dir")" ;; + *) fake="$here/fake-cc.sh" ;; +esac +check_owned_directory() { + local resolved + resolved="$(cd "$1" && pwd -P)" + case "$resolved/" in "$target_dir/"*) ;; *) fail "stub output escapes metadata directory: $1" ;; esac +} status=0 seed_media_stub() { # zed `media` 的 build.rs 按宿主 cfg 决定是否生成 bindings.rs,Windows 宿主 # 交叉到 macOS 时什么都不生成。把桩放进它的 OUT_DIR(首轮 check 后才存在)。 - local root="$CARGO_TARGET_DIR/$1/debug/build" dir + local root="$target_dir/$1/debug/build" dir + [ -d "$root" ] || return 0 + check_owned_directory "$root" for dir in "$root"/media-*/out; do - [ -d "$dir" ] && [ ! -f "$dir/bindings.rs" ] && cp "$here/media-bindings-stub.rs" "$dir/bindings.rs" + [ -d "$dir" ] || continue + check_owned_directory "$dir" + [ ! -L "$dir/bindings.rs" ] || fail "symlinked bindings output: $dir" + if [ ! -e "$dir/bindings.rs" ]; then + cp "$here/media-bindings-stub.rs" "$dir/bindings.rs" + fi done # gpui_macos 用 `include_bytes!` 嵌 Metal 着色器库,同样只有 Mac 宿主能编; # check 不执行它,空文件即可。 for dir in "$root"/gpui_macos-*/out; do - [ -d "$dir" ] && [ ! -f "$dir/shaders.metallib" ] && : > "$dir/shaders.metallib" + [ -d "$dir" ] || continue + check_owned_directory "$dir" + [ ! -L "$dir/shaders.metallib" ] || fail "symlinked shader output: $dir" + if [ ! -e "$dir/shaders.metallib" ]; then + : > "$dir/shaders.metallib" + fi done return 0 } for target in "${targets[@]}"; do - echo "== cargo check --target $target" + echo "== metadata-only cargo check --target $target (not a native build/test)" env_target="$(echo "$target" | tr '-' '_')" case "$target" in *apple-darwin) seed_media_stub "$target" ;; esac - env "CC_${env_target}=$fake" "CXX_${env_target}=$fake" "AR_${env_target}=$fake" \ + env "CARGO_TARGET_DIR=$cargo_target_dir" "CARGO_BUILD_BUILD_DIR=$cargo_target_dir" \ + "CC_${env_target}=$fake" "CXX_${env_target}=$fake" "AR_${env_target}=$fake" \ "PKG_CONFIG_ALLOW_CROSS=1" "RUST_FONTCONFIG_DLOPEN=1" \ - cargo check --offline --workspace --target "$target" "${@:2}" || status=$? + cargo check --locked --offline --workspace --target "$target" \ + --target-dir "$cargo_target_dir" ${check_args[@]+"${check_args[@]}"} || status=$? done exit $status diff --git a/scripts/xplat/fake-cc.sh b/scripts/xplat/fake-cc.sh old mode 100644 new mode 100755