From 0e6acf89e3d9d6773fac96033fff585ab47e9029 Mon Sep 17 00:00:00 2001 From: kuddev Date: Sat, 5 Sep 2026 14:29:01 +0800 Subject: [PATCH] wip: prepare cross-platform previews and workspace improvements Capture cross-platform startup, SSH credentials, preview packaging gates, and current SSH, answer-reader, math, and icon work. Remove unrelated product comparisons from public source comments. Preview helper and shell tests pass locally. Native macOS/Linux build, test, and package validation remain pending in CI. Local Windows-only hooks cannot execute under WSL; content and workflow checks were run separately. --- .github/workflows/preview-packages.yml | 548 +++++++++++ .gitignore | 1 + Cargo.lock | 3 + INSTALL.md | 59 ++ docs/preview-release-checklist.md | 116 +++ extra/logo/README.md | 79 +- extra/logo/nebula-dark.png | Bin 0 -> 32443 bytes extra/logo/nebula-dark.svg | 14 + extra/logo/nebula-light.png | Bin 0 -> 33124 bytes extra/logo/nebula-light.svg | 14 + extra/logo/nebula-titanium.png | Bin 0 -> 33010 bytes extra/logo/nebula-titanium.svg | 14 + extra/logo/nebula.png | Bin 138961 -> 33124 bytes nebula_app/Cargo.toml | 17 +- nebula_app/build.rs | 7 +- nebula_app/res/shell/bashrc | 55 ++ nebula_app/res/shell/zprofile | 13 + nebula_app/res/shell/zshenv | 13 + nebula_app/res/shell/zshrc | 42 + nebula_app/src/ai_hook.rs | 32 +- nebula_app/src/app_icon.rs | 290 ++++++ nebula_app/src/assistant_answer.rs | 291 ++++++ nebula_app/src/assistant_answer/document.rs | 421 +++++++++ nebula_app/src/cli.rs | 3 +- nebula_app/src/daemon.rs | 40 + nebula_app/src/display/ssh_editor_input.rs | 3 + nebula_app/src/display/terminal_math.rs | 440 ++++++++- nebula_app/src/event.rs | 4 +- nebula_app/src/font_install.rs | 10 +- nebula_app/src/gpui_shell/config.rs | 18 +- nebula_app/src/gpui_shell/math_view.rs | 101 ++- nebula_app/src/gpui_shell/mod.rs | 73 +- nebula_app/src/gpui_shell/settings_pane.rs | 706 +++++++++----- .../src/gpui_shell/settings_pane/app_icon.rs | 77 ++ .../gpui_shell/settings_pane/font_picker.rs | 10 +- .../src/gpui_shell/settings_pane/keymap.rs | 3 + nebula_app/src/gpui_shell/ssh_settings.rs | 858 +++++------------- .../src/gpui_shell/ssh_settings/advanced.rs | 529 +++++++++++ .../src/gpui_shell/ssh_settings/editor.rs | 549 +++++++++++ .../src/gpui_shell/terminal/answer_reader.rs | 523 +++++++++++ nebula_app/src/gpui_shell/terminal/element.rs | 58 +- .../src/gpui_shell/terminal/inline_image.rs | 312 +++++++ nebula_app/src/gpui_shell/terminal/mod.rs | 2 + .../src/gpui_shell/terminal/osc_links.rs | 4 +- nebula_app/src/gpui_shell/terminal/session.rs | 13 +- nebula_app/src/gpui_shell/terminal/view.rs | 122 ++- .../src/gpui_shell/terminal/view/runtime.rs | 9 + nebula_app/src/gpui_shell/theme.rs | 73 +- nebula_app/src/gpui_shell/workspace.rs | 234 ++++- .../gpui_shell/workspace/quick_terminal.rs | 6 +- .../src/gpui_shell/workspace/remote_files.rs | 42 +- .../src/gpui_shell/workspace/residency.rs | 7 +- .../src/gpui_shell/workspace/sidebar.rs | 2 +- .../src/gpui_shell/workspace/ssh_dialog.rs | 164 ++++ nebula_app/src/gpui_shell/workspace/tests.rs | 6 + .../src/gpui_shell/workspace/top_tabs.rs | 2 +- .../src/gpui_shell/workspace/update_dialog.rs | 8 + .../src/gpui_shell/workspace/windowing.rs | 12 + nebula_app/src/main.rs | 25 +- nebula_app/src/math/compile.rs | 67 +- nebula_app/src/math/mod.rs | 2 +- nebula_app/src/math/parser.rs | 148 ++- nebula_app/src/notify.rs | 53 +- nebula_app/src/platform/capabilities.rs | 102 +++ nebula_app/src/platform/credentials.rs | 175 ++++ nebula_app/src/platform/dirs.rs | 40 +- nebula_app/src/platform/fonts.rs | 74 ++ nebula_app/src/platform/mod.rs | 10 + nebula_app/src/platform/notifications.rs | 33 + nebula_app/src/platform/shell.rs | 98 ++ nebula_app/src/platform/shell_integration.rs | 90 ++ nebula_app/src/platform/startup.rs | 15 + nebula_app/src/process_tree.rs | 118 ++- nebula_app/src/runtime_api/command.rs | 6 +- nebula_app/src/shell_detect.rs | 93 +- nebula_app/src/ssh.rs | 2 +- nebula_app/src/ssh_credentials.rs | 74 +- nebula_app/src/ssh_profiles.rs | 66 +- nebula_app/src/ssh_profiles/connection.rs | 189 ++++ .../src/ssh_profiles/connection/tests.rs | 209 +++++ nebula_app/src/ssh_prompt.rs | 190 ++++ nebula_app/src/ssh_proxy.rs | 40 +- nebula_app/src/ssh_session.rs | 532 +++++++---- nebula_app/src/ssh_session/route.rs | 215 +++++ nebula_app/src/ssh_session/route/tests.rs | 253 ++++++ .../src/ssh_session/route/tests/loopback.rs | 488 ++++++++++ nebula_app/src/ssh_sftp.rs | 52 ++ nebula_app/src/sync.rs | 1 + nebula_app/src/tray.rs | 40 +- nebula_app/src/update_check.rs | 4 + nebula_app/windows/nebula-dark.ico | Bin 0 -> 20266 bytes nebula_app/windows/nebula-light.ico | Bin 0 -> 20567 bytes nebula_app/windows/nebula-titanium.ico | Bin 0 -> 20545 bytes nebula_app/windows/nebula.ico | Bin 80168 -> 20567 bytes nebula_app/windows/nebula.rc | 2 + nebula_hook/src/main.rs | 39 +- nebula_settings/src/lib.rs | 119 ++- nebula_terminal/src/event.rs | 8 +- nebula_terminal/src/event_loop.rs | 4 +- nebula_terminal/src/osc_cwd.rs | 146 ++- nebula_terminal/src/tty/unix.rs | 9 + .../io.github.kuddev.nebula.preview.desktop | 12 + ....github.kuddev.nebula.preview.metainfo.xml | 28 + packaging/macos/Info.plist | 36 + scripts/check_platform_cfg.py | 73 ++ scripts/conformance/README.md | 94 ++ scripts/conformance/__init__.py | 1 + scripts/conformance/cases/__init__.py | 1 + scripts/conformance/cases/boot.py | 58 ++ scripts/conformance/cases/cjk_roundtrip.py | 22 + scripts/conformance/cases/close.py | 9 + scripts/conformance/cases/echo.py | 12 + scripts/conformance/cases/paste.py | 37 + scripts/conformance/cases/resize.py | 50 + scripts/conformance/cases/scrollback.py | 26 + scripts/conformance/cases/session.py | 90 ++ scripts/conformance/cases/split.py | 64 ++ scripts/conformance/cases/ssh_loop.py | 10 + scripts/conformance/golden/common.json | 41 + scripts/conformance/golden/whitelist.json | 21 + scripts/conformance/golden/windows.json | 45 + scripts/conformance/harness.py | 729 +++++++++++++++ scripts/conformance/macos_launch.py | 133 +++ scripts/conformance/run.py | 309 +++++++ scripts/conformance/tests/__init__.py | 1 + scripts/conformance/tests/test_harness.py | 164 ++++ scripts/package-linux.sh | 333 +++++++ scripts/package-macos.sh | 276 ++++++ scripts/platform_cfg_budget.txt | 1 + scripts/preview_release.py | 422 +++++++++ scripts/render-app-icons.mjs | 86 ++ scripts/tests/test_app_icons.py | 148 +++ scripts/tests/test_platform_cfg.py | 26 + scripts/tests/test_preview_release.py | 232 +++++ scripts/tests/test_shell_integration.py | 157 ++++ 135 files changed, 13133 insertions(+), 1497 deletions(-) create mode 100644 .github/workflows/preview-packages.yml create mode 100644 docs/preview-release-checklist.md create mode 100644 extra/logo/nebula-dark.png create mode 100644 extra/logo/nebula-dark.svg create mode 100644 extra/logo/nebula-light.png create mode 100644 extra/logo/nebula-light.svg create mode 100644 extra/logo/nebula-titanium.png create mode 100644 extra/logo/nebula-titanium.svg create mode 100644 nebula_app/res/shell/bashrc create mode 100644 nebula_app/res/shell/zprofile create mode 100644 nebula_app/res/shell/zshenv create mode 100644 nebula_app/res/shell/zshrc create mode 100644 nebula_app/src/app_icon.rs create mode 100644 nebula_app/src/assistant_answer.rs create mode 100644 nebula_app/src/assistant_answer/document.rs create mode 100644 nebula_app/src/gpui_shell/settings_pane/app_icon.rs create mode 100644 nebula_app/src/gpui_shell/ssh_settings/advanced.rs create mode 100644 nebula_app/src/gpui_shell/ssh_settings/editor.rs create mode 100644 nebula_app/src/gpui_shell/terminal/answer_reader.rs create mode 100644 nebula_app/src/gpui_shell/terminal/inline_image.rs create mode 100644 nebula_app/src/gpui_shell/workspace/ssh_dialog.rs create mode 100644 nebula_app/src/platform/capabilities.rs create mode 100644 nebula_app/src/platform/credentials.rs create mode 100644 nebula_app/src/platform/fonts.rs create mode 100644 nebula_app/src/platform/notifications.rs create mode 100644 nebula_app/src/platform/shell.rs create mode 100644 nebula_app/src/platform/shell_integration.rs create mode 100644 nebula_app/src/platform/startup.rs create mode 100644 nebula_app/src/ssh_profiles/connection.rs create mode 100644 nebula_app/src/ssh_profiles/connection/tests.rs create mode 100644 nebula_app/src/ssh_prompt.rs create mode 100644 nebula_app/src/ssh_session/route.rs create mode 100644 nebula_app/src/ssh_session/route/tests.rs create mode 100644 nebula_app/src/ssh_session/route/tests/loopback.rs create mode 100644 nebula_app/windows/nebula-dark.ico create mode 100644 nebula_app/windows/nebula-light.ico create mode 100644 nebula_app/windows/nebula-titanium.ico create mode 100644 packaging/linux/io.github.kuddev.nebula.preview.desktop create mode 100644 packaging/linux/io.github.kuddev.nebula.preview.metainfo.xml create mode 100644 packaging/macos/Info.plist create mode 100644 scripts/check_platform_cfg.py create mode 100644 scripts/conformance/README.md create mode 100644 scripts/conformance/__init__.py create mode 100644 scripts/conformance/cases/__init__.py create mode 100644 scripts/conformance/cases/boot.py create mode 100644 scripts/conformance/cases/cjk_roundtrip.py create mode 100644 scripts/conformance/cases/close.py create mode 100644 scripts/conformance/cases/echo.py create mode 100644 scripts/conformance/cases/paste.py create mode 100644 scripts/conformance/cases/resize.py create mode 100644 scripts/conformance/cases/scrollback.py create mode 100644 scripts/conformance/cases/session.py create mode 100644 scripts/conformance/cases/split.py create mode 100644 scripts/conformance/cases/ssh_loop.py create mode 100644 scripts/conformance/golden/common.json create mode 100644 scripts/conformance/golden/whitelist.json create mode 100644 scripts/conformance/golden/windows.json create mode 100644 scripts/conformance/harness.py create mode 100644 scripts/conformance/macos_launch.py create mode 100644 scripts/conformance/run.py create mode 100644 scripts/conformance/tests/__init__.py create mode 100644 scripts/conformance/tests/test_harness.py create mode 100644 scripts/package-linux.sh create mode 100644 scripts/package-macos.sh create mode 100644 scripts/platform_cfg_budget.txt create mode 100644 scripts/preview_release.py create mode 100644 scripts/render-app-icons.mjs create mode 100644 scripts/tests/test_app_icons.py create mode 100644 scripts/tests/test_platform_cfg.py create mode 100644 scripts/tests/test_preview_release.py create mode 100644 scripts/tests/test_shell_integration.py diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml new file mode 100644 index 0000000..0868d47 --- /dev/null +++ b/.github/workflows/preview-packages.yml @@ -0,0 +1,548 @@ +name: Cross-platform Preview packages + +run-name: >- + Cross-platform Preview (${{ inputs.preview_id || format('run-{0}', github.run_number) }}) + +on: + pull_request: + paths: ["**/*.rs", "**/Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "nebula_app/res/**", "assets/**", "packaging/**", "scripts/**", ".github/workflows/preview-packages.yml"] + push: + branches: [main] + paths: ["**/*.rs", "**/Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "nebula_app/res/**", "assets/**", "packaging/**", "scripts/**", ".github/workflows/preview-packages.yml"] + workflow_dispatch: + inputs: + preview_id: + description: Optional short Preview id; defaults to the Actions run and attempt numbers + required: false + type: string + publish: + description: Create a public GitHub prerelease after every package and conformance job passes + required: true + default: false + type: boolean + macos_signing: + description: Developer ID mode requires the Apple signing and notarization secrets + type: choice + options: [adhoc, developer-id] + default: adhoc + +concurrency: + group: preview-${{ github.ref }}-${{ inputs.publish || false }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: "1" + RUST_TOOLCHAIN: "1.97.1" + MACOS_SIGNING: ${{ inputs.macos_signing || 'adhoc' }} + PYTHONUTF8: "1" + +jobs: + prepare: + name: Validate Preview inputs + runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.metadata.outputs.version }} + preview_id: ${{ steps.metadata.outputs.preview_id }} + asset_version: ${{ steps.metadata.outputs.asset_version }} + steps: + - uses: actions/checkout@v4 + + - name: Resolve package metadata + id: metadata + env: + REQUESTED_PREVIEW_ID: ${{ inputs.preview_id }} + PUBLIC_PREVIEW: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }} + run: | + version="$(python3 scripts/preview_release.py \ + --print-package-version nebula_app/Cargo.toml)" + + preview_id="$REQUESTED_PREVIEW_ID" + if [[ -z "$preview_id" ]]; then + if [[ "$PUBLIC_PREVIEW" == true ]]; then + echo "public Preview requires an explicit preview_id and committed release notes" >&2 + exit 1 + fi + preview_id="${GITHUB_RUN_NUMBER}.${GITHUB_RUN_ATTEMPT}" + fi + VERSION="$version" PREVIEW_ID="$preview_id" python3 - <<'PY' + import os + from scripts.preview_release import validate_labels + + validate_labels(os.environ["VERSION"], os.environ["PREVIEW_ID"]) + PY + + { + echo "version=$version" + echo "preview_id=$preview_id" + echo "asset_version=$version-preview.$preview_id" + } >>"$GITHUB_OUTPUT" + + - name: Require synchronized release notes before a public Preview build + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }} + env: + PACKAGE_VERSION: ${{ steps.metadata.outputs.version }} + PREVIEW_ID: ${{ steps.metadata.outputs.preview_id }} + run: >- + python3 scripts/preview_release.py --check-reviewed-notes + --version "$PACKAGE_VERSION" --preview-id "$PREVIEW_ID" + --macos-signing "$MACOS_SIGNING" + + - name: Check platform cfg budget + run: python3 scripts/check_platform_cfg.py + + - name: Test packaging and conformance harnesses + run: >- + python3 -m unittest + scripts.tests.test_preview_release + scripts.tests.test_platform_cfg + scripts.conformance.tests.test_harness + + - name: Compile Python helpers + run: python3 -m compileall -q scripts/preview_release.py scripts/conformance + + - name: Parse packaging scripts + run: bash -n scripts/package-linux.sh scripts/package-macos.sh + + linux: + name: Linux x86_64 Preview + needs: prepare + runs-on: ubuntu-22.04 + timeout-minutes: 120 + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + PREVIEW_ID: ${{ needs.prepare.outputs.preview_id }} + ASSET_VERSION: ${{ needs.prepare.outputs.asset_version }} + LINUXDEPLOY_VERSION: 1-alpha-20251107-1 + LINUXDEPLOY_SHA256: c20cd71e3a4e3b80c3483cef793cda3f4e990aca14014d23c544ca3ce1270b4d + steps: + - uses: actions/checkout@v4 + + - 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 + + - name: Test workspace natively + run: cargo test --locked --workspace + + - name: Test real Bash and Zsh startup contracts + run: python3 -m unittest scripts.tests.test_shell_integration -v + + - name: Test SSH confirmation and password dialogs + run: cargo test --locked -p nebula --features gpui-test-support ssh_dialog + + - name: Build GPUI release binary + run: cargo build --locked --release -p nebula --bin nebula --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 AppImage, tar.gz, and Debian Preview + run: | + SOURCE_DATE_EPOCH="$(git show -s --format=%ct "$GITHUB_SHA")" + export SOURCE_DATE_EPOCH + bash scripts/package-linux.sh \ + --binary target/release/nebula \ + --version "$PACKAGE_VERSION" \ + --preview-id "$PREVIEW_ID" \ + --output-directory dist \ + --linuxdeploy "$LINUXDEPLOY" \ + --force + + - name: Verify portable archive layout + run: | + archive="dist/NebulaTerminal-v$ASSET_VERSION-linux-x86_64.tar.gz" + root="NebulaTerminal-v$ASSET_VERSION-linux-x86_64" + tar -tzf "$archive" "$root/AppRun" + tar -tzf "$archive" "$root/usr/share/applications/io.github.kuddev.nebula.preview.desktop" + + - name: Run AppImage conformance under X11 + run: | + xvfb-run -a -s "-screen 0 1600x1000x24" \ + python3 scripts/conformance/run.py \ + --app "dist/NebulaTerminal-v$ASSET_VERSION-linux-x86_64.AppImage" \ + --platform linux-x86_64-appimage \ + --output dist/conformance/linux-appimage-report.json + + - name: Run AppImage conformance under Wayland + run: | + runtime_directory="$RUNNER_TEMP/nebula-wayland-runtime" + mkdir -p "$runtime_directory" dist/conformance + chmod 0700 "$runtime_directory" + XDG_RUNTIME_DIR="$runtime_directory" \ + weston \ + --backend=headless-backend.so \ + --socket=wayland-nebula \ + --width=1600 \ + --height=1000 \ + --idle-time=0 \ + --log=dist/conformance/weston.log & + weston_pid=$! + cleanup_weston() { + kill "$weston_pid" >/dev/null 2>&1 || true + wait "$weston_pid" >/dev/null 2>&1 || true + } + trap cleanup_weston EXIT + + for _ in $(seq 1 100); do + if [[ -S "$runtime_directory/wayland-nebula" ]]; then + break + fi + if ! kill -0 "$weston_pid" >/dev/null 2>&1; then + echo "Weston exited before creating its Wayland socket" >&2 + exit 1 + fi + sleep 0.1 + done + test -S "$runtime_directory/wayland-nebula" + + DISPLAY='' \ + XDG_RUNTIME_DIR="$runtime_directory" \ + WAYLAND_DISPLAY=wayland-nebula \ + python3 scripts/conformance/run.py \ + --app "dist/NebulaTerminal-v$ASSET_VERSION-linux-x86_64.AppImage" \ + --platform linux-x86_64-appimage-wayland \ + --output dist/conformance/linux-appimage-wayland-report.json + + - name: Install and run Debian package conformance under X11 + run: | + sudo apt-get install -y \ + "./dist/NebulaTerminal-v$ASSET_VERSION-linux-x86_64.deb" + test "$(dpkg-query -W -f='${Status}' nebula-terminal-preview)" = \ + "install ok installed" + xvfb-run -a -s "-screen 0 1600x1000x24" \ + python3 scripts/conformance/run.py \ + --app /usr/bin/nebula \ + --platform linux-x86_64-deb \ + --output dist/conformance/linux-deb-report.json + + - name: Upload Linux Preview packages + if: always() + uses: actions/upload-artifact@v4 + with: + name: preview-package-linux-x86_64 + 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: preview-evidence-linux-x86_64 + path: dist/conformance + if-no-files-found: warn + retention-days: 14 + + macos: + name: macOS ${{ matrix.architecture }} Preview + needs: prepare + timeout-minutes: 120 + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15 + architecture: aarch64 + native_architecture: arm64 + - runner: macos-15-intel + architecture: x86_64 + native_architecture: x86_64 + runs-on: ${{ matrix.runner }} + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + PREVIEW_ID: ${{ needs.prepare.outputs.preview_id }} + ASSET_VERSION: ${{ needs.prepare.outputs.asset_version }} + MACOSX_DEPLOYMENT_TARGET: "14.0" + steps: + - uses: actions/checkout@v4 + + - name: Verify native runner architecture + run: test "$(uname -m)" = "${{ matrix.native_architecture }}" + + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - uses: Swatinem/rust-cache@v2 + + - 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 SSH confirmation and password dialogs + run: cargo test --locked -p nebula --features gpui-test-support ssh_dialog + + - name: Build GPUI release binary + run: cargo build --locked --release -p nebula --bin nebula --features gpui-shell + + - name: Import signing credentials into a temporary keychain + if: ${{ github.event_name == 'workflow_dispatch' && inputs.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/nebula-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"] + for name in required: + if not os.environ.get(name): + raise SystemExit(f"missing required signing secret: {name}") + 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/preview.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 nebula-preview --keychain "$keychain" \ + --key "$SIGNING_DIRECTORY/AuthKey.p8" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" + echo "NEBULA_SIGNING_KEYCHAIN=$keychain" >>"$GITHUB_ENV" + + - name: Package Preview DMG with the requested signing mode + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + signing_args=() + if [[ "$MACOS_SIGNING" == developer-id ]]; then + test -n "$NEBULA_SIGNING_KEYCHAIN" + signing_args=(--sign-identity "$APPLE_SIGNING_IDENTITY" --notary-profile nebula-preview \ + --signing-keychain "$NEBULA_SIGNING_KEYCHAIN") + fi + bash scripts/package-macos.sh \ + --binary target/release/nebula \ + --version "$PACKAGE_VERSION" \ + --preview-id "$PREVIEW_ID" \ + --architecture "${{ matrix.architecture }}" \ + --build-number "$GITHUB_RUN_NUMBER" \ + --output-directory dist \ + --force "${signing_args[@]}" + + - name: Mount final DMG and run conformance + run: | + dmg="dist/NebulaTerminal-v$ASSET_VERSION-macos-${{ matrix.architecture }}.dmg" + mount_point="$RUNNER_TEMP/nebula-preview-${{ matrix.architecture }}" + mkdir -p "$mount_point" + hdiutil attach "$dmg" -nobrowse -readonly -mountpoint "$mount_point" + cleanup_mount() { + hdiutil detach "$mount_point" || hdiutil detach "$mount_point" -force + } + trap cleanup_mount EXIT + + app="$mount_point/Nebula Terminal Preview.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.nebula.preview" + test "$(lipo -archs "$app/Contents/MacOS/nebula")" = \ + "${{ 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() && github.event_name == 'workflow_dispatch' && inputs.macos_signing == 'developer-id' }} + run: | + directory="$RUNNER_TEMP/nebula-signing" + if [[ -f "$directory/preview.keychain-db" ]]; then + security delete-keychain "$directory/preview.keychain-db" + fi + if [[ -d "$directory" ]]; then + rm -rf -- "$directory" + fi + + - name: Upload macOS Preview package + if: always() + uses: actions/upload-artifact@v4 + with: + name: preview-package-macos-${{ matrix.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: preview-evidence-macos-${{ matrix.architecture }} + path: dist/conformance + if-no-files-found: warn + retention-days: 14 + + windows: + name: Windows same-commit conformance baseline + needs: prepare + runs-on: windows-2022 + timeout-minutes: 120 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - uses: Swatinem/rust-cache@v2 + - name: Test workspace natively + run: cargo test --locked --workspace + - name: Test SSH confirmation and password dialogs + run: cargo test --locked -p nebula --features gpui-test-support ssh_dialog + - name: Build GPUI comparison binary + run: cargo build --locked --release -p nebula --bin nebula --features gpui-shell + - name: Run Windows conformance + run: >- + python scripts/conformance/run.py --app target/release/nebula.exe + --platform windows-x86_64 --output dist/conformance/windows-report.json + - name: Upload Windows conformance evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: preview-evidence-windows-x86_64 + path: dist/conformance + if-no-files-found: warn + retention-days: 14 + + aggregate: + name: Compare native evidence and verify Preview packages + needs: [prepare, linux, macos, windows] + runs-on: ubuntu-22.04 + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + PREVIEW_ID: ${{ needs.prepare.outputs.preview_id }} + ASSET_VERSION: ${{ needs.prepare.outputs.asset_version }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + pattern: preview-package-* + merge-multiple: true + path: release-dist + + - uses: actions/download-artifact@v4 + with: + pattern: preview-evidence-* + merge-multiple: true + path: preview-evidence + + - name: Validate exact package manifest and generate checksums + env: + PUBLIC_PREVIEW: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }} + run: | + notes_args=() + if [[ "$PUBLIC_PREVIEW" == true ]]; then + notes_args=(--use-reviewed-notes) + fi + python3 scripts/preview_release.py release-dist \ + --version "$PACKAGE_VERSION" \ + --preview-id "$PREVIEW_ID" \ + --write-checksums release-dist/SHA256SUMS \ + --write-notes release-dist/PREVIEW_NOTES.md \ + --repository "$GITHUB_REPOSITORY" \ + --commit "$GITHUB_SHA" \ + --evidence-dir preview-evidence \ + --macos-signing "$MACOS_SIGNING" "${notes_args[@]}" + + - name: Upload complete Preview set + uses: actions/upload-artifact@v4 + with: + name: NebulaTerminal-v${{ needs.prepare.outputs.asset_version }} + path: release-dist + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish verified Preview only on explicit request + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }} + needs: [prepare, aggregate] + runs-on: ubuntu-22.04 + permissions: + contents: write + env: + PACKAGE_VERSION: ${{ needs.prepare.outputs.version }} + PREVIEW_ID: ${{ needs.prepare.outputs.preview_id }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: NebulaTerminal-v${{ needs.prepare.outputs.asset_version }} + path: release-dist + - name: Create public GitHub prerelease + run: | + tag="preview-v$PACKAGE_VERSION-$PREVIEW_ID" + title="Nebula Terminal $PACKAGE_VERSION Preview $PREVIEW_ID" + if gh release view "$tag" >/dev/null 2>&1; then + echo "refusing to replace existing Preview release: $tag" >&2 + exit 1 + fi + if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" >/dev/null 2>&1; then + echo "refusing to reuse existing Preview tag: $tag" >&2 + exit 1 + fi + gh release create "$tag" \ + release-dist/*.AppImage \ + release-dist/*.deb \ + release-dist/*.dmg \ + release-dist/*.tar.gz \ + release-dist/SHA256SUMS \ + --target "$GITHUB_SHA" \ + --title "$title" \ + --notes-file release-dist/PREVIEW_NOTES.md \ + --prerelease --latest=false + + gh api "repos/$GITHUB_REPOSITORY/releases/tags/$tag" >"$RUNNER_TEMP/preview-release.json" + tag_commit="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$tag" --jq .object.sha)" + python3 scripts/preview_release.py release-dist \ + --version "$PACKAGE_VERSION" --preview-id "$PREVIEW_ID" --commit "$GITHUB_SHA" \ + --verify-release "$RUNNER_TEMP/preview-release.json" --tag-commit "$tag_commit" + gh release view "$tag" --json url --jq .url diff --git a/.gitignore b/.gitignore index 5cd5549..16e73df 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ tocodex-docs/ !/docs/math-rendering-test.md !/docs/runtime-control-api.md !/docs/runtime-api-v1.schema.json +!/docs/preview-release-checklist.md !/docs/skills/ !/docs/skills/nebula-runtime/ !/docs/skills/nebula-runtime/SKILL.md diff --git a/Cargo.lock b/Cargo.lock index 4554d46..8a659ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5052,6 +5052,7 @@ dependencies = [ "image", "libc", "log", + "markdown", "mlua", "nebula-completions", "nebula-settings", @@ -5060,6 +5061,7 @@ dependencies = [ "nebula_config_derive", "nebula_terminal", "notify 8.2.0", + "notify-rust", "objc2 0.6.3", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", @@ -5076,6 +5078,7 @@ dependencies = [ "rusqlite", "russh", "russh-sftp", + "security-framework", "serde", "serde_json", "serde_path_to_error", diff --git a/INSTALL.md b/INSTALL.md index dcf1121..0fbdc18 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,5 +1,64 @@ # Installing Nebula +## Linux Preview + +Cross-platform Preview runs provide three Linux x86_64 assets. They are test +builds rather than stable releases. + +- Debian/Ubuntu: install + `NebulaTerminal-v-preview.-linux-x86_64.deb` with + `sudo apt install ./NebulaTerminal-v-preview.-linux-x86_64.deb`. + Remove it with `sudo apt remove nebula-terminal-preview`. +- AppImage: make + `NebulaTerminal-v-preview.-linux-x86_64.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 +Runtime API conformance suite against the final AppImage under X11 and +Wayland, and against the installed Debian package under X11. + +SSH password login works without a keyring. Saving SSH passwords or encrypted-key +passphrases requires `libsecret-tools` and an unlocked Secret Service keyring +(for example GNOME Keyring or a compatible KWallet setup). The Debian package +recommends these dependencies. If storage is unavailable, enter the secret for +the current connection instead; Nebula does not silently claim to save it. + +## macOS Preview + +Download the DMG matching the Mac architecture: + +- `macos-aarch64.dmg` for Apple Silicon Macs. +- `macos-x86_64.dmg` for Intel Macs. + +Open the DMG and drag **Nebula Terminal Preview** into Applications. These +Preview builds default to 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 +credentials and fails rather than falling back to ad-hoc signing. + +The workflow builds both architectures on native macOS 15 runners. It checks +the final mounted DMG and launches a private installed copy through +LaunchServices with a minimal PATH and unset locale. The package deployment +target is macOS 14, but macOS 14 itself still needs separate runtime validation; +a deployment target is not a tested-OS guarantee. + +SSH secrets are stored in macOS Keychain only when requested. A GUI launch uses +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, +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). + ## Windows installer (recommended) 1. Download `NebulaTerminal--windows-x64-setup.exe` from the diff --git a/docs/preview-release-checklist.md b/docs/preview-release-checklist.md new file mode 100644 index 0000000..8a72b38 --- /dev/null +++ b/docs/preview-release-checklist.md @@ -0,0 +1,116 @@ +# Linux / macOS Preview 发布检查表 + +更新:2026-09-05。本文描述已经接入的代码和门禁,不代表已完成原生发布。 + +## 1. 不需要本地 Mac 的发布入口 + +1. 将本次要发布的代码、资源、测试、`packaging/` 和 workflow **按具体路径**提交并推送。 + 当前工作区存在其他修改和未跟踪文件,不要使用全量暂存、清理或重置。 + `workflow_dispatch` 工作流需要先存在于默认分支,之后才能在 Actions 中选择待发布分支。 +2. GitHub Actions → **Cross-platform Preview packages** → **Run workflow**。 + 首轮保留 `publish=false`、`macos_signing=adhoc`。`preview_id` 可以留空(run/attempt 编号), + 或填 `20260905.1` 一类不超过 32 字符的唯一标识。 +3. 等待 Linux、Mac Apple Silicon、Mac Intel、Windows 对照和聚合全部成功。 + 下载 `NebulaTerminal-v-preview.` artifact;分别查看各平台的 + `preview-evidence-*`,包括 runtime 报告和 Mac 安装启动日志/截图。 +4. 先分发 artifact 给预览测试者,完成第 4 节的人工检查。公开发布前,按第 1.1 节确定新的唯一 id, + 提交同步的 Changelog 和 Release Note;只写已有代码、构建或运行证据的变化。 + 然后选择这份已审阅源码提交,显式填写相同 `preview_id`,发起 `publish=true`。 + 工作流会先校验说明,再重新构建并重新验收,不能跳过构建或复用旧包。 +5. 成功后生成 `preview-v-` prerelease,不设置为 latest,不覆盖已有标签或 Release。 + 工作流重新读取远端标题、正文、标签指向、文件名、空 label、状态、大小与 SHA256。 + 任一步失败都要调查日志,不能通过重用旧包或伪造报告绕过。 +6. 从最终 artifact 取 `PREVIEW_NOTES.md`,原样同步到该 Preview 的独立 Release Note,补上真实 SHA256, + 再提交这份纯文档修订;正文必须与 GitHub Release 一致。不要移动对应二进制的 Preview 标签。 + +本次改动没有执行提交、推送、创建标签或发布;默认行为只是构建和上传 Actions artifact。 + +### 1.1 公开发布说明的预构建校验 + +`publish=false` 可以使用自动编号和生成的验收摘要;它不等于正式发布说明。 +`publish=true` 必须显式选择 id,并在构建提交中准备 +`docs/release-notes/v-preview..md` 及 `CHANGELOG.md` 同版本条目。 + +- 独立说明标题为 `# Nebula Terminal Preview `,依次包含 `## English`、`## 中文`、 + `## Contributors`、`## SHA256`。沿用 `Added / Fixed / Improved` 与 `新增 / 修复 / 改进` 对称分类。 +- Changelog 标题为 `## -preview. - YYYY-MM-DD`;完整复制独立说明中 English 到 + 中文末尾的内容,只将这些内容的标题加深一级。校验忽略这一层标题差异,正文必须一致。 +- 在 Contributors 区域写入 `macOS signing / macOS 签名: \`adhoc\`` 或 + `macOS signing / macOS 签名: \`developer-id\``,与工作流选择一致;中英文均准确披露签名限制。 +- SHA256 区域构建前只写 ``;聚合在原生证据通过后,用最终资产校验和 + 替换该占位符,绝不接受旧 checksum。公开发布使用审阅后的正文,不用自动摘要覆盖用户说明。 +- 缺失说明、Changelog 不一致、双语分类不对称或签名模式不符,都会在正式构建前失败。 + +## 2. 产物与自动验收范围 + +| 平台 | 产物 | 原生自动验收 | +| --- | --- | --- | +| Linux x86_64 / Ubuntu 22.04 | AppImage、tar.gz、deb | workspace 单测、SSH 弹窗测试、Bash/Zsh PTY 测试;最终 AppImage 的 X11/Wayland conformance;安装 deb 后的 X11 conformance | +| macOS Apple Silicon | aarch64 DMG | macOS 15 原生构建/单测、SSH 弹窗测试、Zsh PTY 测试、签名/架构检查、DMG 内应用 conformance、安装副本的 LaunchServices 启动 | +| macOS Intel | x86_64 DMG | 同上,在独立 Intel runner 执行 | +| Windows x86_64 | 只作对照,不发布 Windows 包 | 同一提交的 workspace/SSH 弹窗测试与 Runtime API conformance | + +聚合必须拿到 **6 份 runtime 报告 + 2 份 Mac 安装启动报告**,核对来源提交和平台, +验证公共不变量,并在白名单外逐字段比较。缺少报告、失败或 Mac GUI 启动没有 UTF-8 locale / +home cwd 都会阻止聚合。PR 和 main push 也执行门禁,但只有显式 dispatch 可以取得发布权限。 + +Mac 安装启动用 `ditto` 复制最终 DMG 中的 `.app`,通过 `open -n -W -a` 启动, +不是只直接执行 `Contents/MacOS/nebula`。测试配置独立,清理只针对这份唯一临时安装路径的进程; +不会按进程名结束用户的其他 Nebula。截图作为人工审核材料,不冒充像素一致性测试。 + +Linux GLIBC 依赖上限为 2.35;macOS 编译 deployment target 为 14.0,但原生 runner 是 15。 +**不能把 macOS 14 deployment target 写成 macOS 14 已实测。** + +打包脚本检查二进制新鲜度、GPUI 标识和版本。Mac 拒绝未打包的非系统动态库;Linux 检查真实 +动态库依赖并由 `dpkg-shlibdeps` 生成 Debian 依赖。归档与安装包必须来自同一提交的全新构建。 + +## 3. 两种 Mac 签名模式 + +- `adhoc`:无需 Apple 账号即可生成预览 DMG,但不是 Developer ID 签名,也没有 Apple 公证。 + 下载后的 Gatekeeper 体验不同于正式签名应用,必须在说明中披露;不提供关闭全局安全机制的指令。 +- `developer-id`:准备以下 Actions secrets;工作流只在显式选择此模式时导入临时 keychain。 + 包装使用 hardened runtime 与 timestamp,提交最终 DMG 给 `notarytool`,只有 `Accepted` + 才附加和验证票据并做 `spctl` 检查。缺少凭据、签名或公证失败都终止,绝不降级。 + +| Secret | 内容 | +| --- | --- | +| `APPLE_CERTIFICATE_BASE64` | Developer ID Application 证书及私钥的 `.p12`,base64 编码 | +| `APPLE_CERTIFICATE_PASSWORD` | `.p12` 密码 | +| `APPLE_SIGNING_IDENTITY` | 完整 `Developer ID Application: ...` 身份名称 | +| `APPLE_API_KEY_BASE64` | Apple 公证 API `.p8` 密钥,base64 编码 | +| `APPLE_API_KEY_ID` | API key id | +| `APPLE_API_ISSUER` | API issuer id | + +不要把这些内容写进仓库、报告或日志。两个架构 runner 分别完成签名/公证;临时凭据在 always +清理步骤删除。尚未提供上述凭据时,只能验证 ad-hoc 路线,不能宣称签名发布已完成。 + +## 4. 仍需原生人工检查 + +这些检查适合让预览测试者完成,并记录**版本、提交、系统版本、架构和截图/日志**: + +- 从浏览器下载 DMG,保留下载隔离属性,拖到 Applications 后首次打开。CI 的本地产物安装 + 不等于真实下载的 Gatekeeper 测试;分别核对已公证和 ad-hoc 包的说明。 +- 中文输入法的组合态、候选框、回车确认;Option/Alt、Cmd+C/V、Ctrl+C、选区和多行粘贴。 + `cjk_roundtrip` 只是 UTF-8 Runtime API 输入,不是输入法测试。 +- Retina/外接屏、系统缩放、深浅色、字体/Nerd Font 图标、通知授权与后台通知。 +- SSH:陌生主机取消不写 known_hosts;明确确认后连接;已保存主机指纹改变时必须拒绝; + 密码、MFA、加密私钥、重连,以及 SFTP 列目录/上传/下载。不要为了自动化新增信任绕过接口。 +- 凭据:Mac Keychain 允许/拒绝访问;Linux 有/无 `libsecret-tools`、keyring 锁定/未启动。 + 无可用存储时仍可手动输入本次连接所需秘密;保存失败必须可见,不得提示已经保存。 +- Mac 默认 Zsh 和显式选 Zsh 都加载用户登录配置;测试自定义 ZDOTDIR。Linux Bash 保留 + `.bashrc`、PROMPT_COMMAND 和已有 DEBUG trap。自定义 `-c` / rcfile 不注入。 + +`ssh_loop` 当前仍明确 skipped,因为 Runtime API 没有 `ssh.open`。独立 SSH 确认通道单测 +及 GPUI 弹窗测试不能代替完整 SSH/SFTP 联调;未来 API 增加 `ssh.open` 后,未实现的 case +将直接失败,而不继续静默 skip。 + +## 5. Preview 的明确边界 + +- Linux/macOS 暂不启用系统托盘、关窗隐藏驻留、全局快速终端热键、系统提示音、自动安装更新、 + 自动本地 AI hook 配置。最后窗口关闭就退出;没有假装可用的空实现开关。 +- 系统通知后端已经接入,但投递依赖桌面服务、应用身份和系统权限,不保证每次都出现横幅。 +- OSC 7 / 133 注入覆盖 Zsh 与默认 Linux Bash。Mac Bash 保留原生登录启动,不强改其配置; + Fish/Nushell/自定义 shell 不在此注入范围。旧 Bash 已有 DEBUG trap 时不覆盖它,命令边界 + 能力可能降低;不以破坏用户 hook 换取表面一致。 +- 五件套和 SHA256 完整只代表产物结构通过。真正原生 Actions 成功之前,不称为“Mac/Linux 已验证可用”, + 更不能升级为与 Windows 完全等价的 Stable 承诺。 diff --git a/extra/logo/README.md b/extra/logo/README.md index 23b1bdb..8079f5a 100644 --- a/extra/logo/README.md +++ b/extra/logo/README.md @@ -1,6 +1,81 @@ -# Third-Party Logo Assets +# Logo Assets -## Grok +## Nebula App Icon + +The rounded, asymmetric symbol and cutout prompt are shared by three curated +colorways. The product name remains Nebula. + +| Setting | Source | Role | +| --- | --- | --- | +| `silver-violet` | `nebula-light.svg` | Default identity; palette 12 | +| `graphite-violet` | `nebula-dark.svg` | Graphite tile with restrained gray-violet mark | +| `titanium` | `nebula-titanium.svg` | Neutral, high-contrast alternative; palette 21 | + +The default is silver violet: it retains the soft character without a bright +candy color or a generic black-and-white tool identity. The dark alternative +uses graphite rather than saturated purple. These are design judgments, not +claims of exclusive colors or completed trademark clearance. + +### Switching + +GPUI Settings → Appearance → App icon writes `app_icon=` to +`nebula_settings.txt`. Missing, invalid and retired values fall back to silver +violet. Appearance reset restores the default; appearance backup includes the +selection. Icon selection is independent of terminal/system theme selection. + +On Windows, selection updates open window icons (including Alt+Tab), the +tray, and in-app previews. New windows and subsequent notifications use the +selection too. Window icons use per-window DPI and are reapplied when the +window's scale changes. Existing pinned shortcuts, Explorer's EXE icon and +the installer keep the default brand icon; no shortcut, executable or system +icon cache is rewritten. Linux/macOS currently change only the in-app preview; +their Dock/launcher icon remains the packaged default. + +### Clarity and resource budget + +Each ICO contains directly rendered 32-bit RGBA PNG frames at 16, 20, 24, 32, +40, 48, 64, 96, 128 and 256 physical pixels. The 16/20/24 frames use a slightly +larger mark (100 rather than 90 SVG units) and an 11-unit rather than 10-unit +cutout stroke. The tile bounds and symbol paths are unchanged. This is optical +sizing, not upscaling a small bitmap. Higher sizes preserve the master geometry. + +PNG IDAT data is recompressed losslessly; no palette quantization or alpha +reduction is used. All three ICOs together must remain below 64 KiB, enforced +by the generator and tests. Native icon handles are reused in a bounded cache +and owned tray handles are destroyed when replaced. + +Windows embeds exactly three icon groups. In-app, tray and notification PNG +bytes are read from those same native resources, not a second embedded PNG +collection. Non-Windows builds embed the same three ICOs. `nebula.png` and +the three 1024px PNG exports are packaging/design assets, not extra GPUI +runtime embeds. `nebula.ico` is a byte-identical alias of `nebula-light.ico`; +only the former is linked as the default icon group. + +Regenerate using Node.js 24+ with `@resvg/resvg-js` (verified with 2.6.2): + +```sh +node scripts/render-app-icons.mjs /path/to/renderer/package.json +python3 -m unittest scripts.tests.test_app_icons -v +cargo test -p nebula-settings --offline --locked +cargo test -p nebula --bin nebula --features gpui-shell app_icon::tests +``` + +The renderer is development-only; it adds no application dependency. The +optional argument locates a separate renderer installation. The default +`nebula.ico` and `nebula.png` exports always remain silver violet. + +### Design archive + +The 24-color exploration remains in +`docs/design/icon-color-lab-2026-09-05/index.html`. Original sage SVG masters +remain in `docs/design/icon-final-2026-09-05/archive/`. Neither archive is +embedded or copied by the Windows release asset manifest. Strong-green, +saturated confectionery-purple and other low-value recolors are deliberately +absent from the application picker, not deleted from the design history. + +## Third-Party Logo Assets + +### Grok `ai_grok_dark.png` and `ai_grok_light.png` are unmodified copies of `Grok_Logomark_Dark.png` and `Grok_Logomark_Light.png` from the official xAI diff --git a/extra/logo/nebula-dark.png b/extra/logo/nebula-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..5c31e0e4c5200da2d04d296ce9c170bcd5c26d51 GIT binary patch literal 32443 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 Y{=}ho@{io-u%UF1o;s3o_`)Cm2M}A2KmY&$ literal 0 HcmV?d00001 diff --git a/extra/logo/nebula-dark.svg b/extra/logo/nebula-dark.svg new file mode 100644 index 0000000..94208af --- /dev/null +++ b/extra/logo/nebula-dark.svg @@ -0,0 +1,14 @@ + + Nebula graphite violet app icon + + + + + + + + + + + + diff --git a/extra/logo/nebula-light.png b/extra/logo/nebula-light.png new file mode 100644 index 0000000000000000000000000000000000000000..1c8b966625036ce5167ec4df98e699476ae7724b GIT binary patch literal 33124 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

EgR;q$NO7r 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 upR$(tzs&nTFY*7HM_{e_|C=Y?$K( + Nebula silver violet app icon + + + + + + + + + + + + diff --git a/extra/logo/nebula-titanium.png b/extra/logo/nebula-titanium.png new file mode 100644 index 0000000000000000000000000000000000000000..83df539552f4108754545299d58448c171b7c9c6 GIT binary patch literal 33010 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 + Nebula titanium app icon + + + + + + + + + + + + diff --git a/extra/logo/nebula.png b/extra/logo/nebula.png index 615424305fc921e2e4a6f5c9cc5fece596dd77b7..1c8b966625036ce5167ec4df98e699476ae7724b 100644 GIT binary patch literal 33124 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

EgR;q$NO7r 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
NFAwSk%EIOsKy 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 diff --git a/nebula_app/Cargo.toml b/nebula_app/Cargo.toml index e4d9ad6..fa394e2 100644 --- a/nebula_app/Cargo.toml +++ b/nebula_app/Cargo.toml @@ -100,6 +100,7 @@ polling = "3.11.0" # Markdown viewer: CommonMark + GFM (tables / task lists / strikethrough) # event parser; `display::markdown_view` renders the folded block AST. pulldown-cmark = { version = "0.13", default-features = false } +markdown = "=1.0.0" pulldown-latex = "=0.7.1" # rsa 不能省:云厂商控制台下载的 .pem(PKCS#1)与 OpenSSH 容器里的 RSA # 私钥都靠它解析;缺失时 load_secret_key 直接 UnsupportedKeyType。 @@ -129,6 +130,10 @@ getrandom = "0.3.3" pollster = { version = "0.4", optional = true } wgpu = { version = "25.0.2", optional = true } regex = "1.13.1" +# `image` 被 GPUI 壳的壁纸、文档 tab、行内图片、数学视图无条件使用,必须是 +# 无条件依赖;此前按 `cfg(not(target_os = "macos"))` 排除会让 macOS 目标在 +# 编译期找不到 crate(2026-09-02 交叉 check 首个暴露的问题)。 +image = { version = "0.25.8", default-features = false, features = ["bmp", "gif", "jpeg", "png", "webp"] } rusqlite = { version = "0.40.2", features = ["bundled"] } sha1 = "0.11.0" @@ -146,17 +151,26 @@ xdg = "3.0.0" [target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies] rfd = "0.17.2" +notify-rust = "=4.18.0" + +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +# GPUI 壳只把 winit 当类型库(EventLoopProxy / WindowId / ModifiersState), +# 不启动它的事件循环;但 winit 在 Linux 上没有任何后端 feature 就拒绝编译 +# (platform_impl/mod.rs "platform not supported")。dlopen 变体不引入链接期 +# 依赖,运行时缺库也只是旧壳不可用。 +winit = { version = "0.30.9", default-features = false, features = ["x11", "wayland", "wayland-dlopen"] } [target.'cfg(not(target_os = "macos"))'.dependencies] -image = { version = "0.25.8", default-features = false, features = ["bmp", "gif", "jpeg", "png", "webp"] } png = { version = "0.17.5", default-features = false, optional = true } [target.'cfg(target_os = "macos")'.dependencies] +security-framework = "=3.7.0" objc2 = "0.6.1" objc2-foundation = { version = "0.3.1", default-features = false, features = [ "std", "NSString", "NSLocale", + "NSBundle", "NSUserDefaults", ] } objc2-app-kit = { version = "0.3.1", default-features = false, features = [ @@ -187,6 +201,7 @@ windows-sys = { version = "0.59", features = [ # WinEvent hook (SetWinEventHook) for native move/size stage tracking: # WM_ENTER/EXITSIZEMOVE are sent messages that never reach a msg hook. "Win32_UI_Accessibility", + "Win32_UI_HiDpi", # COM + Shell: build the Start-menu shortcut that carries our AUMID so the # toast is attributed to Nebula (IShellLink + IPersistFile + IPropertyStore). "Win32_System_Com", diff --git a/nebula_app/build.rs b/nebula_app/build.rs index e19c5fc..c5d5a2b 100644 --- a/nebula_app/build.rs +++ b/nebula_app/build.rs @@ -20,13 +20,18 @@ fn main() { #[cfg(feature = "legacy-shell")] generate_legacy_gl_bindings(); + // build script 里的 `cfg(windows)` 是**宿主**平台;交叉到 macOS/Linux 时 + // 宿主仍是 Windows,必须再看目标(`CARGO_CFG_WINDOWS` 只在目标是 Windows + // 时设置),否则会往 Mach-O/ELF 里嵌 .rc 资源、给 Unix 包部署 ConPTY。 #[cfg(windows)] - { + if env::var_os("CARGO_CFG_WINDOWS").is_some() { // Re-embed the icon whenever the .ico OR .rc changes. `embed_resource` // emits a rerun-if-changed for the .rc only, which pins Cargo to that // file and makes it silently skip .ico-only updates — leaving the stale // Nebula icon embedded in the exe. Declaring the .ico here fixes that. println!("cargo:rerun-if-changed=windows/nebula.ico"); + println!("cargo:rerun-if-changed=windows/nebula-dark.ico"); + println!("cargo:rerun-if-changed=windows/nebula-titanium.ico"); println!("cargo:rerun-if-changed=windows/nebula.rc"); embed_resource::compile("./windows/nebula.rc", embed_resource::NONE) .manifest_required() diff --git a/nebula_app/res/shell/bashrc b/nebula_app/res/shell/bashrc new file mode 100644 index 0000000..ec26ac0 --- /dev/null +++ b/nebula_app/res/shell/bashrc @@ -0,0 +1,55 @@ +if [[ -r $HOME/.bashrc ]]; then + source "$HOME/.bashrc" +fi + +_nebula_cwd() { + local LC_ALL=C cwd_value=$PWD encoded='' character ordinal index + for ((index=0; index<${#cwd_value}; index++)); do + character=${cwd_value:index:1} + case $character in + [a-zA-Z0-9/._~-]) encoded+=$character ;; + *) printf -v ordinal '%02X' "'$character"; encoded+="%$ordinal" ;; + esac + done + printf '\e]7;file://localhost%s\a' "$encoded" +} + +_nebula_preexec() { + if [[ ${_nebula_at_prompt:-0} == 1 && $BASH_COMMAND != _nebula_capture_status* ]]; then + _nebula_at_prompt=0 + _nebula_command_running=1 + printf '\e]133;C\a' + fi +} + +_nebula_capture_status() { + _nebula_last_status=$? + if [[ ${_nebula_native_preexec:-0} == 1 ]]; then + _nebula_command_running=${_nebula_at_prompt:-0} + fi + _nebula_at_prompt=0 + return "$_nebula_last_status" +} + +_nebula_precmd() { + if [[ ${_nebula_command_running:-0} == 1 ]]; then + printf '\e]133;D;%s\a' "$_nebula_last_status" + fi + _nebula_command_running=0 + _nebula_cwd + printf '\e]133;A\a' + _nebula_at_prompt=1 + return "$_nebula_last_status" +} + +if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 4))); then + _nebula_native_preexec=1 + PS0=$'\e]133;C\a'${PS0-} +elif [[ -z $(trap -p DEBUG) ]]; then + trap '_nebula_preexec' DEBUG +fi +if [[ $(declare -p PROMPT_COMMAND 2>/dev/null) == 'declare -a '* ]]; then + PROMPT_COMMAND=(_nebula_capture_status "${PROMPT_COMMAND[@]}" _nebula_precmd) +else + PROMPT_COMMAND="_nebula_capture_status;${PROMPT_COMMAND:+$PROMPT_COMMAND;}_nebula_precmd" +fi diff --git a/nebula_app/res/shell/zprofile b/nebula_app/res/shell/zprofile new file mode 100644 index 0000000..0521afe --- /dev/null +++ b/nebula_app/res/shell/zprofile @@ -0,0 +1,13 @@ +if [[ $NEBULA_ZDOTDIR_WAS_SET == 1 ]]; then + ZDOTDIR=$NEBULA_ORIGINAL_ZDOTDIR +else + unset ZDOTDIR +fi +if [[ -r ${ZDOTDIR-$HOME}/.zprofile ]]; then + source "${ZDOTDIR-$HOME}/.zprofile" +fi +NEBULA_ZDOTDIR_WAS_SET=${+ZDOTDIR} +NEBULA_ORIGINAL_ZDOTDIR=${ZDOTDIR-} +if [[ -o rcs ]]; then + export ZDOTDIR=$NEBULA_ZSH_INTEGRATION +fi diff --git a/nebula_app/res/shell/zshenv b/nebula_app/res/shell/zshenv new file mode 100644 index 0000000..85c007d --- /dev/null +++ b/nebula_app/res/shell/zshenv @@ -0,0 +1,13 @@ +if [[ ${NEBULA_ZDOTDIR_WAS_SET:-0} == 1 ]]; then + ZDOTDIR=$NEBULA_ORIGINAL_ZDOTDIR +else + unset ZDOTDIR +fi +if [[ -r ${ZDOTDIR-$HOME}/.zshenv ]]; then + source "${ZDOTDIR-$HOME}/.zshenv" +fi +NEBULA_ZDOTDIR_WAS_SET=${+ZDOTDIR} +NEBULA_ORIGINAL_ZDOTDIR=${ZDOTDIR-} +if [[ -o rcs ]]; then + export ZDOTDIR=$NEBULA_ZSH_INTEGRATION +fi diff --git a/nebula_app/res/shell/zshrc b/nebula_app/res/shell/zshrc new file mode 100644 index 0000000..48a08fd --- /dev/null +++ b/nebula_app/res/shell/zshrc @@ -0,0 +1,42 @@ +if [[ $NEBULA_ZDOTDIR_WAS_SET == 1 ]]; then + ZDOTDIR=$NEBULA_ORIGINAL_ZDOTDIR +else + unset ZDOTDIR +fi +if [[ -r ${ZDOTDIR-$HOME}/.zshrc ]]; then + source "${ZDOTDIR-$HOME}/.zshrc" +fi +unset NEBULA_ZDOTDIR_WAS_SET NEBULA_ORIGINAL_ZDOTDIR NEBULA_ZSH_INTEGRATION + +_nebula_cwd() { + emulate -L zsh + local LC_ALL=C cwd_value=$PWD encoded='' character ordinal index + for ((index=1; index<=${#cwd_value}; index++)); do + character=${cwd_value[index]} + case $character in + [a-zA-Z0-9/._~-]) encoded+=$character ;; + *) printf -v ordinal '%02X' "'$character"; encoded+="%$ordinal" ;; + esac + done + printf '\e]7;file://localhost%s\a' "$encoded" +} + +_nebula_preexec() { + _nebula_command_running=1 + printf '\e]133;C\a' +} + +_nebula_precmd() { + local status_code=$? + if [[ ${_nebula_command_running:-0} == 1 ]]; then + printf '\e]133;D;%s\a' "$status_code" + fi + _nebula_command_running=0 + _nebula_cwd + printf '\e]133;A\a' + return "$status_code" +} + +autoload -Uz add-zsh-hook +add-zsh-hook preexec _nebula_preexec +precmd_functions=(_nebula_precmd ${precmd_functions:#_nebula_precmd}) diff --git a/nebula_app/src/ai_hook.rs b/nebula_app/src/ai_hook.rs index 452da4b..5647919 100644 --- a/nebula_app/src/ai_hook.rs +++ b/nebula_app/src/ai_hook.rs @@ -319,6 +319,8 @@ impl AttentionContext { /// A typed AI-CLI lifecycle event, parsed from one pipe connection. #[derive(Debug, Clone)] pub struct AiHookEvent { + pub answer: Option, + pub answer_cwd: Option, /// Pane hosting the CLI (from `NEBULA_PANE_ID`); `None` falls back to the /// focused pane (only happens when the env was stripped along the way). pub pane: Option, @@ -560,6 +562,7 @@ fn event_fingerprint(event: &AiHookEvent) -> u64 { let mut hasher = std::collections::hash_map::DefaultHasher::new(); event.kind.hash(&mut hasher); event.message.hash(&mut hasher); + event.answer.hash(&mut hasher); event.background_tasks.hash(&mut hasher); if let Some(attention) = event.attention.as_ref() { attention.cwd.hash(&mut hasher); @@ -738,7 +741,14 @@ fn parse_envelope(bytes: &[u8]) -> Option { selection: context_string(&payload, &["selection", "selected_text", "selectedText"]), raw_context: sanitized_raw_context(&payload), }); + let answer = crate::assistant_answer::AssistantAnswer::from_hook(&source, &payload); + let answer_cwd = payload.get("cwd").and_then(Value::as_str) + .filter(|path| path.len() <= 4096 && !path.chars().any(char::is_control)) + .map(std::path::PathBuf::from) + .filter(|path| path.is_absolute()); Some(AiHookEvent { + answer, + answer_cwd, pane, source, kind, @@ -1172,6 +1182,24 @@ mod remote_tests { assert!(!capabilities_for("pi").attention_context); } + #[test] + fn distinct_original_answers_are_not_swallowed_by_preview_deduplication() { + for provider in ["claude", "codex"] { + let mut gate = AiHookEventGate::default(); + for ending in ["first", "second"] { + let answer = format!("{}{ending}", "shared preview ".repeat(500)); + let payload = if provider == "claude" { + serde_json::json!({"hook_event_name": "Stop", "session_id": "session", "last_assistant_message": answer}) + } else { + serde_json::json!({"type": "agent-turn-complete", "thread-id": "session", "last-assistant-message": answer}) + }; + let raw = format!("nebula-hook/1 source={provider} pane=3\n{payload}"); + let event = parse_remote_envelope(raw.as_bytes(), Some(3)).unwrap(); + assert!(gate.accept(&event, 3)); + } + } + } + #[test] fn event_gate_rejects_duplicates_and_out_of_order_provider_events() { let mut gate = AiHookEventGate::default(); @@ -1465,10 +1493,10 @@ mod win { } buf.extend_from_slice(&chunk[..read as usize]); if buf.len() > (1 << 20) { - break; // hard cap: nothing legitimate is this big + break; } } - if let Some(mut event) = parse_envelope(&buf) { + if buf.len() <= (1 << 20) && let Some(mut event) = parse_envelope(&buf) { event.client_pid = client_pid; // agent 的进程身份:helper 的父进程往往是执行 hook 命令的 // shell,agent 在更上一层,所以要沿祖先链找。用它区分嵌套 diff --git a/nebula_app/src/app_icon.rs b/nebula_app/src/app_icon.rs new file mode 100644 index 0000000..58c8015 --- /dev/null +++ b/nebula_app/src/app_icon.rs @@ -0,0 +1,290 @@ +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU8, Ordering}; + +use nebula_settings::AppIconName; + +pub const FRAME_SIZES: [u32; 10] = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256]; + +fn selection() -> &'static AtomicU8 { + static SELECTION: OnceLock = OnceLock::new(); + SELECTION.get_or_init(|| AtomicU8::new(nebula_settings::RuntimeSettings::load().app_icon as u8)) +} + +pub fn selected() -> AppIconName { + AppIconName::ALL[selection().load(Ordering::Relaxed) as usize] +} + +pub fn set_selected(variant: AppIconName) -> bool { + selection().swap(variant as u8, Ordering::Relaxed) != variant as u8 +} + +pub fn frame_size(requested: u32) -> u32 { + FRAME_SIZES.into_iter().find(|size| *size >= requested).unwrap_or(256) +} + +#[cfg(windows)] +pub fn png(variant: AppIconName, requested: u32) -> Option<&'static [u8]> { + windows::png(variant, frame_size(requested)) +} + +#[cfg(not(windows))] +pub fn png(variant: AppIconName, requested: u32) -> Option<&'static [u8]> { + let bytes: &[u8] = match variant { + AppIconName::SilverViolet => include_bytes!("../windows/nebula.ico"), + AppIconName::GraphiteViolet => include_bytes!("../windows/nebula-dark.ico"), + AppIconName::Titanium => include_bytes!("../windows/nebula-titanium.ico"), + }; + ico_png(bytes, requested) +} + +#[cfg(any(not(windows), test))] +fn ico_png(bytes: &[u8], requested: u32) -> Option<&[u8]> { + if bytes.get(..4)? != [0, 0, 1, 0] { + return None; + } + let count = u16::from_le_bytes(bytes.get(4..6)?.try_into().ok()?) as usize; + let size = frame_size(requested); + for index in 0..count { + let entry = bytes.get(6 + index * 16..22 + index * 16)?; + let width = if entry[0] == 0 { 256 } else { u32::from(entry[0]) }; + if width != size || entry[0] != entry[1] { + continue; + } + let length = u32::from_le_bytes(entry[8..12].try_into().ok()?) as usize; + let offset = u32::from_le_bytes(entry[12..16].try_into().ok()?) as usize; + let frame = bytes.get(offset..offset.checked_add(length)?)?; + return frame.starts_with(b"\x89PNG\r\n\x1a\n").then_some(frame); + } + None +} + +#[cfg(feature = "gpui-shell")] +pub fn preview(variant: AppIconName) -> Option> { + static IMAGES: OnceLock<[Option>; 3]> = OnceLock::new(); + IMAGES.get_or_init(|| { + AppIconName::ALL.map(|variant| { + png(variant, 256).map(|bytes| { + std::sync::Arc::new(gpui::Image::from_bytes(gpui::ImageFormat::Png, bytes.to_vec())) + }) + }) + })[variant as usize] + .clone() +} + +#[cfg(windows)] +pub mod windows { + use std::cell::RefCell; + use std::collections::HashMap; + + use nebula_settings::AppIconName; + use windows_sys::Win32::Foundation::HWND; + use windows_sys::Win32::System::LibraryLoader::{ + FindResourceW, GetModuleHandleW, LoadResource, LockResource, SizeofResource, + }; + use windows_sys::Win32::UI::HiDpi::{GetDpiForWindow, GetSystemMetricsForDpi}; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + DestroyIcon, HICON, ICON_BIG, ICON_SMALL, IMAGE_ICON, LoadImageW, + LookupIconIdFromDirectoryEx, RT_GROUP_ICON, RT_ICON, SM_CXICON, SM_CXSMICON, SendMessageW, + WM_SETICON, + }; + + struct NativeIcon(HICON); + + impl Drop for NativeIcon { + fn drop(&mut self) { + unsafe { DestroyIcon(self.0) }; + } + } + + thread_local! { + static ICONS: RefCell> = RefCell::new(HashMap::new()); + } + + fn resource_id(variant: AppIconName) -> u16 { + 0x101 + variant as u16 + } + + fn resource_bytes(identifier: u16, kind: *const u16) -> Option<&'static [u8]> { + unsafe { + let module = GetModuleHandleW(std::ptr::null()); + if module.is_null() { + return None; + } + let resource = FindResourceW(module, identifier as usize as *const u16, kind); + if resource.is_null() { + return None; + } + let length = SizeofResource(module, resource) as usize; + let loaded = LoadResource(module, resource); + if loaded.is_null() || length == 0 { + return None; + } + let pointer = LockResource(loaded) as *const u8; + if pointer.is_null() { + return None; + } + Some(std::slice::from_raw_parts(pointer, length)) + } + } + + pub(super) fn png(variant: AppIconName, size: u32) -> Option<&'static [u8]> { + let group = resource_bytes(resource_id(variant), RT_GROUP_ICON)?; + let identifier = + unsafe { LookupIconIdFromDirectoryEx(group.as_ptr(), 1, size as i32, size as i32, 0) }; + if identifier <= 0 { + return None; + } + let bytes = resource_bytes(identifier as u16, RT_ICON)?; + bytes.starts_with(b"\x89PNG\r\n\x1a\n").then_some(bytes) + } + + pub fn set_window(hwnd: HWND, variant: AppIconName) { + let dpi = unsafe { GetDpiForWindow(hwnd) }.max(96); + ICONS.with(|cache| { + let mut cache = cache.borrow_mut(); + for (slot, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] { + let requested = unsafe { GetSystemMetricsForDpi(metric, dpi) }.max(16) as u32; + let size = super::frame_size(requested); + let icon = cache.entry((variant, size)).or_insert_with(|| { + NativeIcon(unsafe { + LoadImageW( + GetModuleHandleW(std::ptr::null()), + resource_id(variant) as usize as *const u16, + IMAGE_ICON, + size as i32, + size as i32, + 0, + ) + }) + }); + if !icon.0.is_null() { + unsafe { SendMessageW(hwnd, WM_SETICON, slot as usize, icon.0 as isize) }; + } + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_selection_never_undersamples_within_budget() { + for size in FRAME_SIZES { + assert_eq!(frame_size(size), size); + } + assert_eq!(frame_size(17), 20); + assert_eq!(frame_size(25), 32); + assert_eq!(frame_size(144), 256); + assert_eq!(frame_size(512), 256); + } + + #[test] + fn all_presets_have_exact_png_frames_and_fit_budget() { + let assets: [&[u8]; 3] = [ + include_bytes!("../windows/nebula.ico"), + include_bytes!("../windows/nebula-dark.ico"), + include_bytes!("../windows/nebula-titanium.ico"), + ]; + assert!(assets.iter().map(|bytes| bytes.len()).sum::() <= 64 * 1024); + for bytes in assets { + for size in FRAME_SIZES { + let frame = ico_png(bytes, size).expect("exact PNG frame"); + let decoded = image::load_from_memory(frame).expect("valid PNG"); + assert_eq!((decoded.width(), decoded.height()), (size, size)); + assert_eq!(decoded.to_rgba8().get_pixel(0, 0)[3], 0); + } + } + } + + #[test] + fn corrupt_ico_is_rejected() { + for length in 0..22 { + assert!(ico_png(&vec![0; length], 16).is_none()); + } + let mut bytes = include_bytes!("../windows/nebula.ico").to_vec(); + bytes[18..22].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(ico_png(&bytes, 16).is_none()); + } + + #[cfg(windows)] + #[test] + fn executable_resources_match_presets_without_duplicate_pngs() { + let assets: [&[u8]; 3] = [ + include_bytes!("../windows/nebula.ico"), + include_bytes!("../windows/nebula-dark.ico"), + include_bytes!("../windows/nebula-titanium.ico"), + ]; + for (variant, bytes) in AppIconName::ALL.into_iter().zip(assets) { + for size in FRAME_SIZES { + assert_eq!(png(variant, size), ico_png(bytes, size)); + assert!(png(variant, size).is_some()); + } + } + } + + #[cfg(windows)] + #[test] + fn native_switch_reuses_handles_and_preserves_two_icon_sizes() { + use windows_sys::Win32::Graphics::Gdi::{BITMAP, DeleteObject, GetObjectW}; + use windows_sys::Win32::UI::WindowsAndMessaging::{ + CreateWindowExW, DestroyWindow, GetIconInfo, ICON_BIG, ICON_SMALL, ICONINFO, + SendMessageW, WM_GETICON, + }; + let class: Vec = "STATIC\0".encode_utf16().collect(); + let hwnd = unsafe { + CreateWindowExW( + 0, + class.as_ptr(), + class.as_ptr(), + 0, + 0, + 0, + 100, + 100, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null(), + ) + }; + assert!(!hwnd.is_null()); + let mut cached = Vec::new(); + for iteration in 0..10 { + for (variant_index, variant) in AppIconName::ALL.into_iter().enumerate() { + windows::set_window(hwnd, variant); + let handles = [ICON_SMALL, ICON_BIG] + .map(|slot| unsafe { SendMessageW(hwnd, WM_GETICON, slot as usize, 0) }); + assert!(handles.iter().all(|handle| *handle != 0)); + assert_ne!(handles[0], handles[1]); + if iteration == 0 { + cached.push(handles); + let sizes = handles.map(|handle| unsafe { + let mut info: ICONINFO = std::mem::zeroed(); + assert_ne!(GetIconInfo(handle as _, &mut info), 0); + let mut bitmap: BITMAP = std::mem::zeroed(); + assert_ne!( + GetObjectW( + info.hbmColor, + std::mem::size_of::() as i32, + &mut bitmap as *mut BITMAP as _ + ), + 0 + ); + DeleteObject(info.hbmColor); + DeleteObject(info.hbmMask); + assert_eq!(bitmap.bmWidth, bitmap.bmHeight); + bitmap.bmWidth + }); + assert!(sizes[0] < sizes[1]); + } else { + assert_eq!(handles, cached[variant_index]); + } + } + } + assert_ne!(cached[0], cached[1]); + assert_ne!(cached[1], cached[2]); + unsafe { DestroyWindow(hwnd) }; + } +} diff --git a/nebula_app/src/assistant_answer.rs b/nebula_app/src/assistant_answer.rs new file mode 100644 index 0000000..48e0022 --- /dev/null +++ b/nebula_app/src/assistant_answer.rs @@ -0,0 +1,291 @@ +use std::fmt; +use std::path::PathBuf; +use std::sync::Arc; + +use serde_json::Value; + +pub mod document; + +pub const MAX_ANSWER_BYTES: usize = 128 * 1024; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub enum AssistantAnswer { + Complete(Arc), + Missing, + TooLarge { bytes: usize }, +} + +impl AssistantAnswer { + pub fn from_hook(source: &str, payload: &Value) -> Option { + let field = match source { + "claude" if payload.get("hook_event_name")?.as_str()? == "Stop" => { + "last_assistant_message" + }, + "codex" if payload.get("type")?.as_str()? == "agent-turn-complete" => { + "last-assistant-message" + }, + _ => return None, + }; + Some(match payload.get(field).and_then(Value::as_str) { + Some(text) if text.len() > MAX_ANSWER_BYTES => Self::TooLarge { bytes: text.len() }, + Some(text) if !text.trim().is_empty() => Self::Complete(Arc::from(text)), + _ => Self::Missing, + }) + } + + pub fn source(&self) -> Option<&Arc> { + match self { + Self::Complete(source) => Some(source), + _ => None, + } + } + + pub fn notice(&self) -> Option { + match self { + Self::Complete(_) => None, + Self::Missing => Some("未收到完整回答原文;保留终端内容,不从屏幕猜测。".into()), + Self::TooLarge { bytes } => Some(format!( + "回答原文共 {bytes} 字节,超过 {} KiB 阅读上限;未截断渲染,请在终端查看。", + MAX_ANSWER_BYTES / 1024 + )), + } + } +} + +impl fmt::Debug for AssistantAnswer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Complete(source) => formatter + .debug_struct("Complete") + .field("bytes", &source.len()) + .finish_non_exhaustive(), + Self::Missing => formatter.write_str("Missing"), + Self::TooLarge { bytes } => { + formatter.debug_struct("TooLarge").field("bytes", bytes).finish() + }, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AnswerSnapshot { + pub provider: String, + pub session_id: String, + pub received_sequence: u64, + pub content: AssistantAnswer, + pub cwd: Option, +} + +#[derive(Default)] +pub struct AnswerInbox { + owner: Option<(String, String)>, + closed: bool, + last_sequence: u64, + pub latest: Option, +} + +impl AnswerInbox { + pub fn observe(&mut self, event: &crate::ai_hook::AiHookEvent, pane_id: u64) -> bool { + use crate::ai_hook::AiHookKind; + + if event.pane != Some(pane_id) + || !matches!(event.source.as_str(), "claude" | "codex") + || event.received_sequence <= self.last_sequence + { + return false; + } + let Some(session_id) = event.session_id.as_deref().filter(|id| !id.is_empty()) else { + return false; + }; + let identity = (event.source.clone(), session_id.to_owned()); + let starts_session = + matches!(event.kind, AiHookKind::SessionStart | AiHookKind::PromptSubmit); + if self.owner.as_ref().is_some_and(|owner| owner != &identity) && !starts_session { + return false; + } + if self.closed && !starts_session { + return false; + } + if self.owner.as_ref() != Some(&identity) || starts_session && self.closed { + self.latest = None; + } + self.owner = Some(identity); + self.last_sequence = event.received_sequence; + if starts_session { + self.closed = false; + } + if event.kind == AiHookKind::SessionEnd { + self.closed = true; + } + if let Some(content) = event.answer.clone() { + self.latest = Some(AnswerSnapshot { + provider: event.source.clone(), + session_id: session_id.to_owned(), + received_sequence: event.received_sequence, + content, + cwd: event.answer_cwd.clone(), + }); + return true; + } + false + } + + pub fn close(&mut self) { + self.closed = self.owner.is_some(); + } + + pub fn begin_command(&mut self) { + self.owner = None; + self.closed = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn payload(provider: &str, text: &str) -> Value { + match provider { + "claude" => json!({"hook_event_name": "Stop", "last_assistant_message": text}), + "codex" => json!({"type": "agent-turn-complete", "last-assistant-message": text}), + _ => unreachable!(), + } + } + + #[test] + fn both_providers_preserve_complete_utf8_and_math_verbatim() { + let source = format!( + "{}\n$$\n\\widehat{{f}}(\\xi)\n=\n+ \\int_0^1 x\\,dx\n\\begin{{cases}}a&b\\\\\nc&d\\end{{cases}}\n$$\n", + "中文与 $HOME 和代码 `$$`。".repeat(500) + ); + for provider in ["claude", "codex"] { + let answer = AssistantAnswer::from_hook(provider, &payload(provider, &source)).unwrap(); + assert_eq!(answer.source().unwrap().as_ref(), source); + } + } + + #[test] + fn oversized_answers_are_reported_not_cut_into_partial_formulas() { + for provider in ["claude", "codex"] { + let source = "a".repeat(MAX_ANSWER_BYTES + 1); + let answer = AssistantAnswer::from_hook(provider, &payload(provider, &source)).unwrap(); + assert_eq!(answer, AssistantAnswer::TooLarge { bytes: source.len() }); + assert!(answer.source().is_none()); + assert!(answer.notice().unwrap().contains("未截断")); + } + } + + #[test] + fn absent_wrongly_named_or_empty_fields_never_become_screen_guesses() { + for provider in ["claude", "codex"] { + assert_eq!( + AssistantAnswer::from_hook(provider, &payload(provider, " \n")), + Some(AssistantAnswer::Missing) + ); + } + assert_eq!( + AssistantAnswer::from_hook( + "claude", + &json!({"hook_event_name": "Stop", "last-assistant-message": "wrong"}) + ), + Some(AssistantAnswer::Missing) + ); + assert_eq!( + AssistantAnswer::from_hook( + "codex", + &json!({"type": "agent-turn-complete", "last_assistant_message": "wrong"}) + ), + Some(AssistantAnswer::Missing) + ); + assert!( + AssistantAnswer::from_hook( + "claude", + &json!({"hook_event_name": "Notification", "last_assistant_message": "wrong"}) + ) + .is_none() + ); + } + + #[test] + fn debug_output_never_contains_answer_text() { + let answer = AssistantAnswer::Complete(Arc::from("private-source-secret")); + assert!(!format!("{answer:?}").contains("private-source-secret")); + } + + fn event( + provider: &str, + session: &str, + pane: u64, + sequence: u64, + kind: crate::ai_hook::AiHookKind, + ) -> crate::ai_hook::AiHookEvent { + let mut body = payload(provider, "answer"); + body[if provider == "claude" { "session_id" } else { "thread-id" }] = json!(session); + let wire = format!("nebula-hook/1 source={provider} pane={pane}\n{body}"); + let mut event = crate::ai_hook::parse_remote_envelope(wire.as_bytes(), Some(pane)).unwrap(); + event.received_sequence = sequence; + event.kind = kind; + if kind != crate::ai_hook::AiHookKind::TurnDone { + event.answer = None; + } + event + } + + #[test] + fn panes_sessions_and_closed_sessions_cannot_cross_bind_answers() { + use crate::ai_hook::AiHookKind::*; + for provider in ["claude", "codex"] { + let mut inbox = AnswerInbox::default(); + assert!(!inbox.observe(&event(provider, "old", 2, 1, TurnDone), 1)); + assert!(inbox.observe(&event(provider, "old", 1, 2, TurnDone), 1)); + inbox.observe(&event(provider, "new", 1, 3, SessionStart), 1); + assert!(inbox.latest.is_none()); + assert!(!inbox.observe(&event(provider, "old", 1, 4, TurnDone), 1)); + assert!(inbox.observe(&event(provider, "new", 1, 5, TurnDone), 1)); + inbox.observe(&event(provider, "new", 1, 6, SessionEnd), 1); + assert!(!inbox.observe(&event(provider, "new", 1, 7, TurnDone), 1)); + assert_eq!(inbox.latest.as_ref().unwrap().received_sequence, 5); + } + } + + #[test] + fn fallback_focus_and_out_of_order_events_do_not_supply_reader_content() { + use crate::ai_hook::AiHookKind::TurnDone; + let mut inbox = AnswerInbox::default(); + let mut missing_pane = event("codex", "thread", 1, 1, TurnDone); + missing_pane.pane = None; + assert!(!inbox.observe(&missing_pane, 1)); + assert!(inbox.observe(&event("codex", "thread", 1, 3, TurnDone), 1)); + assert!(!inbox.observe(&event("codex", "thread", 1, 2, TurnDone), 1)); + inbox.close(); + assert!(!inbox.observe(&event("codex", "thread", 1, 4, TurnDone), 1)); + } + + #[test] + fn new_cli_command_accepts_another_codex_session_without_erasing_last_answer_early() { + use crate::ai_hook::AiHookKind::TurnDone; + let mut inbox = AnswerInbox::default(); + assert!(inbox.observe(&event("codex", "old", 1, 1, TurnDone), 1)); + inbox.close(); + inbox.begin_command(); + assert_eq!(inbox.latest.as_ref().unwrap().session_id, "old"); + assert!(inbox.observe(&event("codex", "new", 1, 2, TurnDone), 1)); + assert_eq!(inbox.latest.as_ref().unwrap().session_id, "new"); + assert!(!inbox.observe(&event("codex", "old", 1, 3, TurnDone), 1)); + } + + #[test] + fn image_directory_is_captured_with_the_completed_answer() { + use crate::ai_hook::AiHookKind::TurnDone; + let directory = tempfile::tempdir().unwrap(); + for provider in ["claude", "codex"] { + let mut completion = event(provider, "session", 1, 1, TurnDone); + completion.answer_cwd = Some(directory.path().to_path_buf()); + let mut inbox = AnswerInbox::default(); + assert!(inbox.observe(&completion, 1)); + completion.answer_cwd = None; + assert_eq!(inbox.latest.as_ref().unwrap().cwd.as_deref(), Some(directory.path())); + } + } +} diff --git a/nebula_app/src/assistant_answer/document.rs b/nebula_app/src/assistant_answer/document.rs new file mode 100644 index 0000000..37c275f --- /dev/null +++ b/nebula_app/src/assistant_answer/document.rs @@ -0,0 +1,421 @@ +use std::collections::HashMap; +use std::io::Read; +use std::ops::Range; +use std::path::{Path, PathBuf}; + +use markdown::{ParseOptions, mdast::Node, to_mdast}; + +pub const MAX_IMAGES: usize = 8; +pub const MAX_IMAGE_BYTES: u64 = 12 * 1024 * 1024; +pub const IMAGE_LANGUAGE: &str = "nebula-answer-image"; + +#[derive(Clone)] +pub struct AnswerImage { + pub target: String, + pub alt: String, + pub placeholder_start: usize, +} + +pub struct ReaderDocument { + pub markdown: String, + pub images: Vec, + pub images_omitted: usize, +} + +struct NormalizedSource { + text: String, + inline_math: Vec>, +} + +pub fn prepare(source: &str) -> ReaderDocument { + let normalized = normalize_math_delimiters(source); + let source = normalized.text.as_str(); + let Ok(root) = to_mdast(source, &parse_options()) else { + return ReaderDocument { + markdown: literal_markdown(source), + images: Vec::new(), + images_omitted: 0, + }; + }; + let mut definitions = HashMap::new(); + let mut nodes = vec![&root]; + while let Some(node) = nodes.pop() { + if let Node::Definition(definition) = node { + definitions.entry(definition.identifier.as_str()).or_insert(definition.url.as_str()); + } + if let Some(children) = node.children() { + nodes.extend(children.iter().rev()); + } + } + let mut replacements: Vec<(Range, String, Option)> = Vec::new(); + let mut images_omitted = 0; + let mut image_count = 0; + nodes.push(&root); + while let Some(node) = nodes.pop() { + let image = match node { + Node::Image(image) => Some((image.url.as_str(), image.alt.as_str())), + Node::ImageReference(image) => definitions + .get(image.identifier.as_str()) + .map(|target| (*target, image.alt.as_str())), + _ => None, + }; + let range = node.position().map(|position| position.start.offset..position.end.offset); + match (node, range) { + (_, Some(range)) if image.is_some() => { + let (target, alt) = image.unwrap(); + if image_count >= MAX_IMAGES { + images_omitted += 1; + replacements.push(( + range, + "\n\n图片数量超过本次阅读上限;原文仍可复制。\n\n".into(), + None, + )); + continue; + } + let alt = if alt.is_empty() { + format!("图片 {}", image_count + 1) + } else { + alt.to_owned() + }; + let image = AnswerImage { target: target.to_owned(), alt, placeholder_start: 0 }; + let replacement = format!("\n\n```{IMAGE_LANGUAGE}\n{image_count}\n```\n\n"); + replacements.push((range, replacement, Some(image))); + image_count += 1; + }, + (Node::Html(_), Some(range)) => { + let escaped = source[range.clone()] + .replace('&', "&") + .replace('<', "<") + .replace('>', ">"); + replacements.push((range, escaped, None)); + }, + (Node::InlineMath(math), Some(range)) + if !explicit_inline_math(&math.value) + && !normalized.inline_math.contains(&range) => + { + let literal = source[range.clone()].replace('$', "\\$"); + replacements.push((range, literal, None)); + }, + _ => { + if let Some(children) = node.children() { + nodes.extend(children.iter().rev()); + } + }, + } + } + replacements.sort_by_key(|(range, _, _)| range.start); + let mut markdown = String::with_capacity(source.len()); + let mut images = Vec::new(); + let mut offset = 0; + for (range, replacement, image) in replacements { + if range.start < offset { + continue; + } + markdown.push_str(&source[offset..range.start]); + if let Some(mut image) = image { + image.placeholder_start = markdown.len() + 2; + images.push(image); + } + markdown.push_str(&replacement); + offset = range.end; + } + markdown.push_str(&source[offset..]); + ReaderDocument { markdown, images, images_omitted } +} + +fn parse_options() -> ParseOptions { + let mut options = ParseOptions::gfm(); + options.constructs.math_flow = true; + options.constructs.math_text = true; + options +} + +fn explicit_inline_math(source: &str) -> bool { + let trimmed = source.trim(); + source == trimmed + && (trimmed.chars().count() == 1 && trimmed.chars().all(char::is_alphabetic) + || trimmed.contains(['\\', '^', '_', '=', '+', '<', '>'])) +} + +fn normalize_math_delimiters(source: &str) -> NormalizedSource { + let mut protected = Vec::new(); + let Ok(root) = to_mdast(source, &parse_options()) else { + return NormalizedSource { text: source.to_owned(), inline_math: Vec::new() }; + }; + let mut nodes = vec![&root]; + while let Some(node) = nodes.pop() { + if matches!( + node, + Node::Code(_) + | Node::InlineCode(_) + | Node::Html(_) + | Node::Math(_) + | Node::InlineMath(_) + | Node::Image(_) + | Node::ImageReference(_) + | Node::Link(_) + | Node::LinkReference(_) + ) { + if let Some(position) = node.position() { + protected.push(position.start.offset..position.end.offset); + } + } else if let Some(children) = node.children() { + nodes.extend(children.iter().rev()); + } + } + protected.sort_by_key(|range| range.start); + let mut result = String::with_capacity(source.len()); + let mut inline_math = Vec::new(); + let mut offset = 0; + let mut protected_index = 0; + let mut no_closing_display = false; + let mut inline_search_after = 0; + while offset < source.len() { + while protected_index < protected.len() && protected[protected_index].end <= offset { + protected_index += 1; + } + if let Some(range) = protected.get(protected_index).filter(|range| range.contains(&offset)) + { + result.push_str(&source[offset..range.end]); + offset = range.end; + continue; + } + let remaining = &source[offset..]; + if remaining.starts_with(r"\\") { + result.push_str(r"\\"); + offset += 2; + continue; + } + if !no_closing_display + && remaining.starts_with(r"\[") + && (offset == 0 || source.as_bytes()[offset - 1] == b'\n') + { + let opening_end = remaining.find('\n').unwrap_or(remaining.len()); + if remaining[..opening_end].trim() == r"\[" { + let body_start = offset + opening_end; + let mut closing_start = body_start; + for line in source[body_start..].split_inclusive('\n') { + if line.trim() == r"\]" { + result.push_str("$$"); + result.push_str(&source[body_start..closing_start]); + result.push_str("$$"); + if line.ends_with('\n') { + result.push('\n'); + } + offset = closing_start + line.len(); + break; + } + closing_start += line.len(); + } + if offset > body_start { + continue; + } + no_closing_display = true; + } + } else if offset >= inline_search_after && remaining.starts_with(r"\(") { + let line_end = remaining.find('\n').unwrap_or(remaining.len()); + if let Some(closing) = remaining[2..line_end].find(r"\)") { + let body = &remaining[2..closing + 2]; + if !body.contains(['$', '`']) { + let start = result.len(); + result.push('$'); + result.push_str(body); + result.push('$'); + inline_math.push(start..result.len()); + offset += closing + 4; + continue; + } + } else { + inline_search_after = offset + line_end; + } + } + let character = remaining.chars().next().unwrap(); + result.push(character); + offset += character.len_utf8(); + } + NormalizedSource { text: result, inline_math } +} + +pub fn literal_markdown(source: &str) -> String { + let fence = "`".repeat( + source.split(|character| character != '`').map(str::len).max().unwrap_or(0).max(2) + 1, + ); + format!("{fence}text\n{source}\n{fence}\n") +} + +pub fn local_image_path(target: &str, base: &Path) -> Result { + if target.len() > 4096 || target.chars().any(char::is_control) { + return Err("图片路径无效,未读取文件。".into()); + } + let lower = target.to_ascii_lowercase(); + if lower.contains("://") + || lower.starts_with("data:") + || lower.starts_with("file:") + || network_path(target) + || network_path(&base.to_string_lossy()) + { + return Err("网络图片不会自动下载;可选择本地图片查看。".into()); + } + let path = Path::new(target); + let path = if path.is_absolute() { path.to_path_buf() } else { base.join(path) }; + let root = base.canonicalize().map_err(|_| "无法确认当前本地目录,未读取图片。".to_owned())?; + let path = path.canonicalize().map_err(|_| "图片文件不存在或无法访问。".to_owned())?; + if !path.starts_with(root) { + return Err("图片不在当前目录内;请主动选择文件后查看。".into()); + } + Ok(path) +} + +fn network_path(path: &str) -> bool { + path.starts_with(['\\', '/']) + && path.as_bytes().get(1).is_some_and(|next| matches!(next, b'\\' | b'/')) +} + +pub fn read_image(path: &Path) -> Result, String> { + if !path + .metadata() + .is_ok_and(|metadata| metadata.is_file() && metadata.len() <= MAX_IMAGE_BYTES) + { + return Err("图片不是普通文件,或超过 12 MiB 上限。".into()); + } + let file = std::fs::File::open(path).map_err(|_| "无法打开图片文件。".to_owned())?; + let metadata = file.metadata().map_err(|_| "无法读取图片文件信息。".to_owned())?; + if !metadata.is_file() || metadata.len() > MAX_IMAGE_BYTES { + return Err("图片不是普通文件,或超过 12 MiB 上限。".into()); + } + let mut bytes = Vec::new(); + file.take(MAX_IMAGE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| "无法完整读取图片。".to_owned())?; + if bytes.len() as u64 > MAX_IMAGE_BYTES { + return Err("图片超过 12 MiB 上限,未解码。".into()); + } + if !matches!( + image::guess_format(&bytes), + Ok(image::ImageFormat::Png | image::ImageFormat::Jpeg) + ) { + return Err("本版阅读视图仅支持 PNG / JPEG 图片。".into()); + } + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn math_source_keeps_every_operator_and_matrix_row() { + let source = "# 回答\n\n$$\n\\widehat{f}(\\xi)\n=\n+ \\int_0^1 x\\,dx\n\\begin{pmatrix}a&b\\\\\nc&d\\end{pmatrix}\n$$\n"; + assert_eq!(prepare(source).markdown, source); + } + + #[test] + fn bracket_math_is_protected_before_markdown_headings_and_lists() { + let source = "前文 \\(x^2\\)\n\n\\[\n\\widehat{f}(\\xi)\n=\n+ \\int_0^1 x\\,dx\n\\begin{pmatrix}a&b\\\\\nc&d\\end{pmatrix}\n\\]\n"; + let document = prepare(source); + assert!(document.markdown.contains("前文 $x^2$")); + assert!(document.markdown.contains("$$\n\\widehat{f}")); + let root = to_mdast(&document.markdown, &parse_options()).unwrap(); + let formulas = root + .children() + .unwrap() + .iter() + .filter_map(|node| match node { + Node::Math(math) => Some(math.value.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(formulas.len(), 1); + assert!(formulas[0].contains("\n=\n+ \\int")); + assert!(formulas[0].contains("a&b\\\\\nc&d")); + } + + #[test] + fn math_delimiters_inside_code_and_escaped_backslashes_are_literal() { + let source = "`\\(x\\)`\n\n```tex\n\\[\nx=1\n\\]\n```\n\n\\\\(x\\\\)\n"; + assert_eq!(normalize_math_delimiters(source).text, source); + assert_eq!(normalize_math_delimiters("\\[\nx=1\n").text, "\\[\nx=1\n"); + } + + #[test] + fn explicit_inline_brackets_do_not_need_operator_guessing() { + assert_eq!( + prepare(r"函数 \(f(x)\),价格 $5 and $10").markdown, + r"函数 $f(x)$,价格 \$5 and \$10" + ); + } + + #[test] + fn reference_images_and_extra_images_remain_bounded() { + let source = format!("{}\n[plot]: plot.png\n", "![图][plot]\n\n".repeat(MAX_IMAGES + 2)); + let document = prepare(&source); + assert_eq!(document.images.len(), MAX_IMAGES); + assert_eq!(document.images_omitted, 2); + assert!( + document.images.iter().all(|image| image.target == "plot.png" && image.alt == "图") + ); + } + + #[test] + fn image_loader_rejects_wrong_formats_directories_and_oversized_files() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("not-an-image.png"); + std::fs::write(&path, b"not an image").unwrap(); + assert!(read_image(&path).is_err()); + assert!(read_image(directory.path()).is_err()); + std::fs::File::create(&path).unwrap().set_len(MAX_IMAGE_BYTES + 1).unwrap(); + assert!(read_image(&path).is_err()); + } + + #[test] + fn images_are_explicit_placeholders_not_automatic_network_requests() { + let document = prepare( + "文字\n\n![图](plot.png)\n\n下一段\n\n![远程](https://example.invalid/private.png)", + ); + assert_eq!(document.images.len(), 2); + assert!(!document.markdown.contains("https://")); + assert!(!document.markdown.contains("plot.png")); + for image in &document.images { + assert!( + document.markdown[image.placeholder_start..].starts_with("```nebula-answer-image") + ); + } + assert!(document.markdown.contains("下一段")); + } + + #[test] + fn code_and_money_stay_literal_and_html_cannot_fetch_images() { + let source = "价格 $5 and $10\n\n```md\n![code](secret.png)\n$$x=1$$\n```\n\n\n"; + let document = prepare(source); + assert!(document.images.is_empty()); + assert!(document.markdown.contains("价格 \\$5 and \\$10")); + assert!(document.markdown.contains("![code](secret.png)")); + assert!(!document.markdown.contains("(program: &str, args: I) -> io::Result<()> +where + I: IntoIterator + Copy, + S: AsRef, +{ + spawn_daemon(program, args) +} + +#[cfg(not(windows))] +pub fn spawn_detached(program: &str, args: I) -> io::Result<()> +where + I: IntoIterator + Copy, + S: AsRef, +{ + let mut command = Command::new(program); + command.args(args).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + unsafe { + command + .pre_exec(|| { + match libc::fork() { + -1 => return Err(io::Error::last_os_error()), + 0 => (), + _ => libc::_exit(0), + } + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }) + .spawn()? + .wait() + .map(|_| ()) + } +} + /// Get working directory of controlling process. #[cfg(not(any(windows, target_os = "openbsd")))] pub fn foreground_process_path( diff --git a/nebula_app/src/display/ssh_editor_input.rs b/nebula_app/src/display/ssh_editor_input.rs index f634b44..d9dc443 100644 --- a/nebula_app/src/display/ssh_editor_input.rs +++ b/nebula_app/src/display/ssh_editor_input.rs @@ -542,6 +542,8 @@ impl Display { auth: editor.auth, private_keys: editor.private_keys.clone(), password: (!editor.password.is_empty()).then(|| editor.password.clone()), + connection: Default::default(), + proxy_password: None, }); } @@ -790,6 +792,7 @@ impl Display { label: Some(label), // 空串 = 自动识别,不落盘;配置里只存用户明确选过的形状。 icon: (!editor.icon.is_empty()).then(|| editor.icon.clone()), + connection: profiles.for_destination(&destination).connection, }); if let Err(err) = profiles.save(&profile_path) { editor.error = Some(format!("保存 SSH Profile 失败: {err}")); diff --git a/nebula_app/src/display/terminal_math.rs b/nebula_app/src/display/terminal_math.rs index 44346c6..1990e21 100644 --- a/nebula_app/src/display/terminal_math.rs +++ b/nebula_app/src/display/terminal_math.rs @@ -40,6 +40,11 @@ const BARE_PAREN_SEARCH_ROWS: usize = 8; /// Same budget for the multi-row `[` block. It may legitimately hold an /// `aligned` environment, so it gets more room than an inline paren. const BARE_BRACKET_SEARCH_ROWS: usize = 24; +/// How far below its opener a standalone display block may still close once +/// the search has had to step over a TUI paragraph gap (see +/// [`TextGrid::find_closing`]). Bridging a gap is a guess, so unlike the plain +/// search it is not allowed to run to the bottom of the grid. +const DISPLAY_BLOCK_GAP_SEARCH_ROWS: usize = BARE_BRACKET_SEARCH_ROWS; /// Cells the whole frame may spend on closing searches for **bare** delimiters. /// /// The per-candidate row cap above is not enough on its own: one screen can hold @@ -911,22 +916,116 @@ impl TextGrid { /// math identifiers) and covers the whole region with one image. Stopping at /// the blank row leaves it unmatched instead, which is what hands it to the /// history reconstruction in [`TerminalMathState::scan_visible_grid`]. - fn find_closing(&self, mut position: GridPosition, delimiter: &[char]) -> Option { + /// + /// One blank row is **not** a paragraph break, though: the *TUI's* one. The + /// agent CLIs run the answer through a Markdown renderer that does not know + /// `$$`, and two of its rules split a display block with a blank row: + /// + /// * Claude Code (09-03 screenshot, see + /// `claude_code_display_blocks_from_screenshot_pair_and_compile`): a row + /// holding only `=` is a setext underline, so the row above becomes a + /// heading, the `=` disappears and a blank row is emitted in its place. + /// * Codex (`codex-rs/tui/src/markdown_render.rs`, verified in source, not + /// observed): a formula row starting with `- ` / `+ ` / `* ` interrupts + /// the paragraph as a list item (pulldown-cmark `firstpass.rs`, + /// `scan_paragraph_interrupt_no_table`), `start_list` pushes a blank + /// line in front of it and `start_item` renders the marker as `- ` + /// with the rest of the block, closer included, indented two cells. + /// + /// `open` is the opener's position; when it stands alone on its row the + /// search may step over such a gap, under four conditions that together + /// keep the orphan-closer case above on the pending path: + /// + /// 1. the block already has content above the gap — an orphan closer is + /// followed by its blank row immediately; + /// 2. the gap is a single row — two blank rows are a real section break; + /// 3. the row after the gap carries math evidence of its own — prose does + /// not, so the swallowed-paragraph shape stops right there; + /// 4. past a gap the closer must stand alone on its row, and lie within + /// [`DISPLAY_BLOCK_GAP_SEARCH_ROWS`] of the opener. + fn find_closing( + &self, + open: GridPosition, + mut position: GridPosition, + delimiter: &[char], + ) -> Option { + let standalone_opener = self.span_is_blank(open.row, position.column, self.columns) + && open.row == position.row; + let last_row = open.row.saturating_add(DISPLAY_BLOCK_GAP_SEARCH_ROWS); + let mut content_rows = 0usize; + let mut bridged = false; + // 上一行是刚跨过的空行:这一行要先自证是公式,搜索才继续。 + let mut gap_pending = false; loop { if position.row >= self.rows.len() { return None; } - if self.starts_with(position, delimiter) && !self.is_escaped(position) { + if self.starts_with(position, delimiter) + && !self.is_escaped(position) + && (!bridged || self.delimiter_owns_row(position, delimiter.len())) + { return Some(position); } let previous_row = position.row; position = self.next(position)?; - if position.row != previous_row && self.span_is_blank(position.row, 0, self.columns) { + if position.row == previous_row { + continue; + } + if self.span_is_blank(position.row, 0, self.columns) { + if !standalone_opener || content_rows == 0 || gap_pending { + return None; + } + gap_pending = true; + bridged = true; + continue; + } + content_rows += 1; + if bridged && position.row > last_row { return None; } + if gap_pending { + gap_pending = false; + if !self.row_is_only(position.row, delimiter) + && !self.row_has_math_evidence(position.row) + { + return None; + } + } } } + /// Whether the delimiter at `position` is the only ink on its row. + fn delimiter_owns_row(&self, position: GridPosition, length: usize) -> bool { + self.span_is_blank(position.row, 0, position.column) + && self.span_is_blank(position.row, position.column + length, self.columns) + } + + fn row_text(&self, row: usize) -> String { + self.rows.get(row).into_iter().flat_map(|cells| cells.iter().flatten()).collect() + } + + /// Whether `row` holds nothing but `delimiter`. + fn row_is_only(&self, row: usize, delimiter: &[char]) -> bool { + self.row_text(row).trim().chars().eq(delimiter.iter().copied()) + } + + /// Row-level version of the source test, for rows a bridged search has to + /// vouch for on their own. In the Codex shape the row after the gap is a + /// list item in the renderer's eyes, so its leading sign is also read as + /// the list marker it became: `- 2xy` has no evidence as a whole, but a + /// compact operand behind the marker is one, while `- 修复 SSH 重连` (a + /// real list item) is not. + fn row_has_math_evidence(&self, row: usize) -> bool { + let text = self.row_text(row); + let text = text.trim(); + if standard_formula_source(text, true) { + return true; + } + let body = text.trim_start_matches(['-', '+', '*']).trim_start(); + body.len() != text.len() + && (implicit_product_operand(body) || standard_formula_source(body, true)) + } + /// Closing search for **inline** delimiters. Inline TeX may cross a /// terminal's physical row only when that row was produced by a soft wrap: /// a real newline still ends the span, so two separate terminal lines can @@ -1362,7 +1461,7 @@ fn find_formula( // Inline delimiters follow the same rule for the same reason: crossing a // hard wrap merges two unrelated terminal lines into one formula. let close = if kind.is_display() && opens_a_block(grid, open) { - grid.find_closing(source_start, closing)? + grid.find_closing(open, source_start, closing)? } else { grid.find_closing_soft_wrap(source_start, closing)? }; @@ -1478,25 +1577,36 @@ fn find_bare_bracket_formula( // `[0, 1]` 区间的 `]` 不具备闭合资格,跳过继续找。搜索同样带行 // 预算:屏幕底部一个没闭合的 `[` 不该每帧扫到网格末尾。空行按 // 与 `find_closing` 同一条理由收尾(数学模式里没有分段),否则一个 - // 没闭合的 `[` 会把下面一整段正文连同它自己的公式吞成一条候选。 + // 没闭合的 `[` 会把下面一整段正文连同它自己的公式吞成一条候选—— + // 也按同一套条件放行 TUI 塞进来的那一行空行:吃掉 `\[` 的正是 + // 这个 Markdown 渲染器,它切开 `$$` 块的手法在这里一模一样。 + let mut content_rows = 0usize; + let mut gap_pending = false; grid.find_closing_bounded( source_start, BARE_BRACKET_SEARCH_ROWS, budget, |grid, position| { - if position.column == 0 - && position.row > open.row - && grid.span_is_blank(position.row, 0, grid.columns) - { - return None; + if position.column == 0 && position.row > open.row { + if grid.span_is_blank(position.row, 0, grid.columns) { + if content_rows == 0 || gap_pending { + return None; + } + gap_pending = true; + return Some(false); + } + content_rows += 1; + if std::mem::take(&mut gap_pending) + && !grid.row_is_only(position.row, &[']']) + && !grid.row_has_math_evidence(position.row) + { + return None; + } } if grid.character(position) != Some(']') || grid.is_escaped(position) { return Some(false); } - Some( - grid.span_is_blank(position.row, 0, position.column) - && grid.span_is_blank(position.row, position.column + 1, grid.columns), - ) + Some(grid.delimiter_owns_row(position, 1)) }, )? } else { @@ -3097,6 +3207,252 @@ mod tests { ); } + /// Codex 的形状,按源码推导而非截图:`codex-rs/tui/src/markdown_render.rs` + /// 只开 strikethrough/tables,不认识 `$$`;块里以 `- ` / `+ ` / `* ` 起行 + /// 的公式行被 pulldown-cmark 当成打断段落的列表项,`start_list` 在前面插 + /// 一行空行,`start_item` 把记号一律画成 `- `,后续行(含闭合 `$$`)作为 + /// 列表项续行缩进两格。「空行终止搜索」的规则会把这些块全判成孤立开头。 + /// (09-02 那张截图后来确认是 Claude Code,其形状见 + /// `claude_code_display_blocks_from_screenshot_pair_and_compile`。) + #[test] + fn tui_paragraph_gap_inside_a_standalone_display_block_is_bridged() { + let cases: [(&[&str], &str); 5] = [ + ( + &[ + "$$", + r"R_{\mu\nu} - \frac{1}{2} R g_{\mu\nu}", + "", + r"- \Lambda g_{\mu\nu} = \frac{8\pi G}{c^4} T_{\mu\nu}", + " $$", + ], + r"R_{\mu\nu} - \frac{1}{2} R g_{\mu\nu} - \Lambda g_{\mu\nu} = \frac{8\pi G}{c^4} T_{\mu\nu}", + ), + ( + &[ + " $$", + r" i\hbar \frac{\partial}{\partial t} \Psi", + "", + r" - \frac{\hbar^2}{2m} \nabla^2 \Psi + V \Psi = 0", + " $$", + ], + r"i\hbar \frac{\partial}{\partial t} \Psi - \frac{\hbar^2}{2m} \nabla^2 \Psi + V \Psi = 0", + ), + // 列表项之后还有普通续行:`\end{aligned}` 和闭合都缩进两格。 + ( + &[ + "$$", + r"\begin{aligned}", + r"F &= ma \\", + "", + r"- \nabla V &= m \ddot{x}", + r" \end{aligned}", + " $$", + ], + r"\begin{aligned} F &= ma \\ - \nabla V &= m \ddot{x} \end{aligned}", + ), + // 两次被切:块里有两行以减号开头。 + ( + &[ + "$$", + r"x^2", + "", + r"- 2xy", + "", + r"- y^2 = (x - y)^2 - 2y^2", + " $$", + ], + r"x^2 - 2xy - y^2 = (x - y)^2 - 2y^2", + ), + // 没有反斜杠命令也要认:`a^2` 的上标就是证据。 + ( + &["$$", r"a^2 + b^2", "", r"- c^2 = 0", " $$"], + r"a^2 + b^2 - c^2 = 0", + ), + ]; + + for (screen, expected) in cases { + let extracted = sources(screen); + assert_eq!(extracted.len(), 1, "{screen:?} must yield one display formula"); + let (source, display) = &extracted[0]; + assert!(display); + assert_eq!(source.split_whitespace().collect::>().join(" "), expected); + compile_formula(source, true, 18.0, 1.0, DEFAULT_LIMITS) + .unwrap_or_else(|error| panic!("{expected} must compile: {error:?}")); + let scan = scan_grid_result(&TextGrid::from_rows(screen)); + assert!(scan.unmatched_display.is_none(), "{screen:?} left a pending delimiter"); + } + } + + /// 桥接空行的四道闸,每道各一个反例。松开任何一道,`cat math.txt` + /// 那种孤立闭合就会重新把下面的正文吞进公式。 + #[test] + fn display_block_gap_bridging_stops_at_prose_double_gaps_and_inline_closers() { + // 空行后面是正文:没有数学证据,搜索到此为止。 + let prose: &[&str] = &["$$", r"x^2", "", "所以上式成立。", "$$"]; + // 连续两行空行是真正的分段,不是 TUI 塞的列表间距。 + let double_gap: &[&str] = &["$$", r"x^2", "", "", r"- y^2", " $$"]; + // 跨过空行之后闭合必须独占一行。 + let inline_closer: &[&str] = &["$$", r"x^2", "", r"- y^2 = z$$", "后文"]; + // 开头不独占一行(单行块被硬折行)不享受桥接。 + let inline_opener: &[&str] = &[r"$$x^2 +", "", r"- y^2", " $$"]; + // 开头后面紧跟空行:块里还没有内容,这是孤立闭合的形状。 + let empty_head: &[&str] = &["$$", "", r"- y^2", " $$"]; + for screen in [prose, double_gap, inline_closer, inline_opener, empty_head] { + let scan = scan_grid_result(&TextGrid::from_rows(screen)); + assert!( + scan.overlays.is_empty(), + "{screen:?} must not bridge the gap, got {:?}", + scan.overlays.iter().map(|overlay| overlay.source.as_ref()).collect::>() + ); + assert!(scan.unmatched_display.is_some(), "{screen:?} must stay pending"); + } + } + + /// 桥接有行预算:闭合离开头太远就当没有。预算和裸 `[` 块共用同一个数。 + #[test] + fn display_block_gap_bridging_has_a_row_budget() { + // 闭合落在第 DISPLAY_BLOCK_GAP_SEARCH_ROWS + 1 行:超出一行。 + let mut screen: Vec = vec!["$$".into(), r"x^2".into(), String::new()]; + screen.extend((0..DISPLAY_BLOCK_GAP_SEARCH_ROWS - 2).map(|index| format!("- y_{index}"))); + screen.push(" $$".into()); + let rows: Vec<&str> = screen.iter().map(String::as_str).collect(); + assert!(sources(&rows).is_empty(), "closer past the row budget must not pair"); + + // 少一行内容,闭合正好落在预算内。 + screen.truncate(screen.len() - 2); + screen.push(" $$".into()); + let rows: Vec<&str> = screen.iter().map(String::as_str).collect(); + assert_eq!(sources(&rows).len(), 1, "closer inside the row budget pairs"); + } + + /// 同一个渲染器把 `\[` 吃成 `[`,再用同一手法切块:裸方括号块走同样的桥。 + #[test] + fn tui_paragraph_gap_inside_a_bare_bracket_block_is_bridged() { + let screen = [ + "[", + r"\nabla \cdot \mathbf{E} = \frac{\rho}{\varepsilon_0}", + "", + r"- \nabla \times \mathbf{B} = 0", + " ]", + ]; + let extracted = sources(&screen); + assert_eq!(extracted.len(), 1); + assert!(extracted[0].1); + assert_eq!( + extracted[0].0.split_whitespace().collect::>().join(" "), + r"\nabla \cdot \mathbf{E} = \frac{\rho}{\varepsilon_0} - \nabla \times \mathbf{B} = 0", + ); + + // 反例同 `$$`:空行后是正文就停。 + let prose = ["[", r"\nabla \cdot \mathbf{E} = 0", "", "其中 E 是电场。", " ]"]; + assert!(sources(&prose).is_empty()); + } + + /// 用户 09-03 截图(图 #10,Claude Code 输出)里七个 `$$` 块在终端网格上的 + /// 原样。Claude Code 的 Markdown 渲染器留下两种形状: + /// * 独占一行的 `=` 是 setext 标题下划线,被吃掉后只剩一行空行(傅里叶、 + /// Attention); + /// * 反斜杠转义被消费:`\\` 变成 `\`(cases / pmatrix / aligned 的行尾), + /// `\,` 变成 `,`(高斯、傅里叶)。 + /// 前者的 `=` 在网格里已不存在,这里只要求块被完整配对并能编译;后者的 + /// 行尾单 `\` 必须仍能编译成换行。 + #[test] + fn claude_code_display_blocks_from_screenshot_pair_and_compile() { + let cases: [(&[&str], &str); 7] = [ + (&["$$", r"e^{i\pi}+1=0", "$$"], r"e^{i\pi}+1=0"), + ( + &["$$", r"\int_{-\infty}^{+\infty} e^{-x^2},dx=\sqrt{\pi}", "$$"], + r"\int_{-\infty}^{+\infty} e^{-x^2},dx=\sqrt{\pi}", + ), + ( + &[ + "$$", + r"\widehat{f}(\xi)", + "", + r"\int_{-\infty}^{+\infty}", + r"f(x)e^{-2\pi i x\xi},dx", + "$$", + ], + r"\widehat{f}(\xi) \int_{-\infty}^{+\infty} f(x)e^{-2\pi i x\xi},dx", + ), + ( + &[ + "$$", + r"\operatorname{Attention}(Q,K,V)", + "", + r"\operatorname{softmax}\left(", + r"\frac{QK^{\mathsf T}}{\sqrt{d_k}}", + r"\right)V", + "$$", + ], + r"\operatorname{Attention}(Q,K,V) \operatorname{softmax}\left( \frac{QK^{\mathsf T}}{\sqrt{d_k}} \right)V", + ), + ( + &[ + "$$", + r"\phi(x)=", + r"\begin{cases}", + r"x, & x\ge 0,\", + r"\alpha(e^x-1), & x<0.", + r"\end{cases}", + "$$", + ], + r"\phi(x)= \begin{cases} x, & x\ge 0,\ \alpha(e^x-1), & x<0. \end{cases}", + ), + ( + &[ + "$$", + "A=", + r"\begin{pmatrix}", + r"a_{11} & a_{12} & \cdots & a_{1n}\", + r"a_{21} & a_{22} & \cdots & a_{2n}\", + r"\vdots & \vdots & \ddots & \vdots\", + r"a_{m1} & a_{m2} & \cdots & a_{mn}", + r"\end{pmatrix}", + "$$", + ], + concat!( + r"A= \begin{pmatrix} a_{11} & a_{12} & \cdots & a_{1n}\ ", + r"a_{21} & a_{22} & \cdots & a_{2n}\ \vdots & \vdots & \ddots & \vdots\ ", + r"a_{m1} & a_{m2} & \cdots & a_{mn} \end{pmatrix}", + ), + ), + ( + &[ + "$$", + r"\begin{aligned}", + r"(a+b)^3", + r"&=(a+b)(a+b)^2\", + r"&=(a+b)(a^2+2ab+b^2)\", + r"&=a^3+3a^2b+3ab^2+b^3", + r"\end{aligned}", + "$$", + ], + concat!( + r"\begin{aligned} (a+b)^3 &=(a+b)(a+b)^2\ &=(a+b)(a^2+2ab+b^2)\ ", + r"&=a^3+3a^2b+3ab^2+b^3 \end{aligned}", + ), + ), + ]; + + for (screen, expected) in cases { + // Claude Code 把回答整体缩进两格;缩进与否都必须配对。 + let indented: Vec = screen.iter().map(|row| format!(" {row}")).collect(); + let indented: Vec<&str> = indented.iter().map(String::as_str).collect(); + for rows in [screen, indented.as_slice()] { + let extracted = sources(rows); + assert_eq!(extracted.len(), 1, "{rows:?} must yield one display formula"); + let (source, display) = &extracted[0]; + assert!(display); + assert_eq!(source.split_whitespace().collect::>().join(" "), expected); + compile_formula(source, true, 18.0, 1.0, DEFAULT_LIMITS) + .unwrap_or_else(|error| panic!("{expected} must compile: {error:?}")); + let scan = scan_grid_result(&TextGrid::from_rows(rows)); + assert!(scan.unmatched_display.is_none(), "{rows:?} left a pending delimiter"); + } + } + } + #[test] fn long_display_formula_with_trailing_literal_dollar_remains_renderable() { let source = concat!( @@ -4066,4 +4422,60 @@ d & -b \\ ); } } + + /// 每帧扫描与冷编译的量尺,不是断言。跑法: + /// `cargo test -p nebula --bin nebula terminal_math::tests::measure_scan -- --ignored --nocapture` + #[test] + #[ignore = "timing probe, run by hand"] + fn measure_scan_and_compile_cost() { + use std::time::Instant; + + // 50×200 的视口,塞满带 `$`、`[`、`(` 的正文:每个候选都触发一次搜索 + // 却没有一个能成公式,是扫描器最贵的形状。 + let prose = "price is $5 and $HOME/bin (see [1]) costs 3 (x) dollars $ USD [a, b] "; + let row: String = prose.repeat(200 / prose.chars().count() + 1).chars().take(200).collect(); + let rows: Vec<&str> = (0..50).map(|_| row.as_str()).collect(); + let grid = TextGrid::from_rows(&rows); + let iterations = 200; + let start = Instant::now(); + for _ in 0..iterations { + let scan = scan_grid_result(&grid); + assert!(scan.overlays.is_empty()); + } + let per_scan = start.elapsed() / iterations; + println!("scan 50x200 prose (worst case, 0 formulas): {per_scan:?} per frame"); + + // 同样尺寸,塞满能命中的行内公式:每帧的公式上限是 MAX_VISIBLE_FORMULAS。 + let formula_row = "the energy $E = mc^2$ and $\\int_0^1 x\\,dx = \\frac{1}{2}$ hold; "; + let row: String = + formula_row.repeat(200 / formula_row.chars().count() + 1).chars().take(200).collect(); + let rows: Vec<&str> = (0..50).map(|_| row.as_str()).collect(); + let grid = TextGrid::from_rows(&rows); + let start = Instant::now(); + for _ in 0..iterations { + let scan = scan_grid_result(&grid); + assert_eq!(scan.overlays.len(), MAX_VISIBLE_FORMULAS); + } + let per_scan = start.elapsed() / iterations; + println!("scan 50x200 inline formulas (capped at {MAX_VISIBLE_FORMULAS}): {per_scan:?} per frame"); + + let screenshot: [&str; 7] = [ + r"e^{i\pi}+1=0", + r"\int_{-\infty}^{+\infty} e^{-x^2},dx=\sqrt{\pi}", + "\\widehat{f}(\\xi)\n\n\\int_{-\\infty}^{+\\infty}\nf(x)e^{-2\\pi i x\\xi},dx", + "\\operatorname{Attention}(Q,K,V)\n\n\\operatorname{softmax}\\left(\n\\frac{QK^{\\mathsf T}}{\\sqrt{d_k}}\n\\right)V", + "\\phi(x)=\n\\begin{cases}\nx, & x\\ge 0,\\\n\\alpha(e^x-1), & x<0.\n\\end{cases}", + "A=\n\\begin{pmatrix}\na_{11} & a_{12} & \\cdots & a_{1n}\\\na_{21} & a_{22} & \\cdots & a_{2n}\\\n\\vdots & \\vdots & \\ddots & \\vdots\\\na_{m1} & a_{m2} & \\cdots & a_{mn}\n\\end{pmatrix}", + "\\begin{aligned}\n(a+b)^3\n&=(a+b)(a+b)^2\\\n&=(a+b)(a^2+2ab+b^2)\\\n&=a^3+3a^2b+3ab^2+b^3\n\\end{aligned}", + ]; + for source in screenshot { + let start = Instant::now(); + for _ in 0..iterations { + compile_formula(source, true, 18.0, 1.0, DEFAULT_LIMITS).expect("compiles"); + } + let per_compile = start.elapsed() / iterations; + let head: String = source.chars().take(28).map(|c| if c == '\n' { ' ' } else { c }).collect(); + println!("compile {head:<30} {per_compile:?} cold (cache hit is a map lookup)"); + } + } } diff --git a/nebula_app/src/event.rs b/nebula_app/src/event.rs index f4102a0..bdce4c3 100644 --- a/nebula_app/src/event.rs +++ b/nebula_app/src/event.rs @@ -3413,11 +3413,11 @@ impl input::Processor> { *self.ctx.dirty = true; } }, - TerminalEvent::InlineImage { png, abs_line, width, height } => { + TerminalEvent::InlineImage { data, abs_line, width, height } => { // Decode off the PTY thread (here, on the UI loop) and // anchor the pixels to the pane. Textures upload lazily // on first draw. - match crate::renderer::image::decode_png_bytes(&png) { + match crate::renderer::image::decode_png_bytes(&data) { Ok((px_w, px_h, rgba)) => { use std::sync::atomic::{AtomicU64, Ordering}; static NEXT_INLINE_IMAGE_ID: AtomicU64 = AtomicU64::new(1); diff --git a/nebula_app/src/font_install.rs b/nebula_app/src/font_install.rs index 0ee904d..809f33b 100644 --- a/nebula_app/src/font_install.rs +++ b/nebula_app/src/font_install.rs @@ -1,6 +1,5 @@ use std::path::{Path, PathBuf}; -#[cfg(windows)] use sha2::{Digest, Sha256}; pub const REQUIRED_FONT_FAMILY: &str = "Maple Mono Normal NF CN"; @@ -8,7 +7,6 @@ pub const REQUIRED_FONT_FAMILY: &str = "Maple Mono Normal NF CN"; /// The bundled face is shared by the legacy rasterizer and the GPUI text /// system. Keeping one static byte slice avoids letting the two shells drift /// to different font revisions. -#[cfg(windows)] pub static REQUIRED_FONT_BYTES: &[u8] = include_bytes!("../../assets/fonts/MapleMonoNormal-NF-CN-Regular.ttf"); @@ -262,10 +260,8 @@ pub fn probe_font_file_families(path: &Path) -> Result, String> { Ok(families) } -#[cfg(windows)] const MAX_IMPORTED_FONT_BYTES: usize = 64 * 1024 * 1024; -#[cfg(windows)] pub struct StoredFont { pub path: PathBuf, pub created: bool, @@ -275,16 +271,15 @@ pub struct StoredFont { /// `create_dir_all` 并把失败报给用户,读取侧(`imported_font_files`)容忍 /// 目录不存在。这里若顺手创建,既让读路径每次多一次无谓 IO,也会让写侧 /// 那条错误提示永不触发。 -#[cfg(windows)] pub fn imported_font_directory() -> PathBuf { crate::platform::dirs::data_dir().join("fonts") } -#[cfg(windows)] pub fn imported_font_files() -> Vec { let mut files = font_files_in(&imported_font_directory()); // 隔离启动把 `data_dir` 指到临时配置时,用户以前导入到 // `%APPDATA%\Nebula\fonts` 的字体会「消失」。探测实例仍应能看见它们。 + #[cfg(windows)] if std::env::var_os("NEBULA_CONFIG_DIR").is_some() { if let Some(appdata) = std::env::var_os("APPDATA") { let user_fonts = PathBuf::from(appdata).join("Nebula").join("fonts"); @@ -298,7 +293,6 @@ pub fn imported_font_files() -> Vec { files } -#[cfg(windows)] fn font_files_in(dir: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; entries @@ -308,7 +302,6 @@ fn font_files_in(dir: &Path) -> Vec { .collect() } -#[cfg(windows)] pub fn store_imported_font(source: &Path) -> Result { if !supported_font_extension(source) { return Err("只支持 .ttf、.otf、.ttc 和 .otc 字体文件".to_owned()); @@ -370,7 +363,6 @@ pub fn bundled_font_directory() -> PathBuf { /// 「安装字体」提示要打开的目录,保证里面真有 ttf 可装。1.1.0 起 zip 不再 /// 附带 20MB 字体副本(exe 内嵌同一字节),此时把内嵌字体落到数据目录, /// 与包内副本完全等价。落盘失败(只读盘等)退回原目录,提示仍可关闭。 -#[cfg(windows)] pub fn ensure_bundled_font_on_disk() -> PathBuf { let directory = bundled_font_directory(); if directory.join(REQUIRED_FONT_FILE).is_file() { diff --git a/nebula_app/src/gpui_shell/config.rs b/nebula_app/src/gpui_shell/config.rs index e84a237..05cc95f 100644 --- a/nebula_app/src/gpui_shell/config.rs +++ b/nebula_app/src/gpui_shell/config.rs @@ -289,18 +289,7 @@ fn rgba8(color: nebula_settings::Rgb8) -> gpui::Rgba { } fn default_font_family() -> &'static str { - #[cfg(windows)] - { - "Maple Mono Normal NF CN" - } - #[cfg(target_os = "macos")] - { - "Menlo" - } - #[cfg(not(any(windows, target_os = "macos")))] - { - "monospace" - } + crate::font_install::REQUIRED_FONT_FAMILY } /// 查找配置文件;顺序与 `nebula_app::config::installed_config` 一致。 @@ -638,6 +627,11 @@ mod tests { use crate::gpui_shell::terminal::colors::Palette; use nebula_settings::{LanguagePref, ThemeName}; + #[test] + fn default_terminal_font_is_bundled_on_every_platform() { + assert_eq!(super::default_font_family(), crate::font_install::REQUIRED_FONT_FAMILY); + } + #[test] fn explicit_runtime_languages_resolve_without_reading_system_locale() { assert_eq!(resolve_ui_language(LanguagePref::ZhCn), UiLanguage::ZhCn); diff --git a/nebula_app/src/gpui_shell/math_view.rs b/nebula_app/src/gpui_shell/math_view.rs index cca096e..9e3617a 100644 --- a/nebula_app/src/gpui_shell/math_view.rs +++ b/nebula_app/src/gpui_shell/math_view.rs @@ -27,7 +27,7 @@ use image::Frame; use crate::math::layout::MathLayout; use crate::math::rasterizer::MathGlyphRasterizer; -use crate::math::{DEFAULT_LIMITS, MIN_READABLE_MATH_PX, compile_formula}; +use crate::math::{DEFAULT_LIMITS, MIN_READABLE_MATH_PX, compile_formula, compile_formula_source}; /// 探针编译字号:注册的渲染闭包用它判定"这条公式能否编译",失败即让 /// 组件库走源码文本回退。解析/预算类失败与字号无关,任意正值等价。 @@ -57,7 +57,7 @@ const FIT_MARGIN: f32 = 0.98; pub fn register(cx: &mut App) { cx.set_global(MathAssets::new()); gpui_component::text::set_math_renderer(cx, |spec, _, cx| { - let assets = cx.global_mut::(); + let assets = source_assets(cx); if assets.rasterizer.is_none() { return None; } @@ -98,6 +98,7 @@ struct ImageGeometry { } pub(crate) struct MathAssets { + verbatim_source: bool, /// 字体解析失败时为 None:探针直接失败,所有公式回退源码文本。 rasterizer: Option, layouts: HashMap>>, @@ -107,9 +108,21 @@ pub(crate) struct MathAssets { impl gpui::Global for MathAssets {} +struct SourceMathAssets(MathAssets); + +impl gpui::Global for SourceMathAssets {} + +fn source_assets(cx: &mut App) -> &mut MathAssets { + if cx.try_global::().is_none() { + cx.set_global(SourceMathAssets(MathAssets { verbatim_source: true, ..MathAssets::new() })); + } + &mut cx.global_mut::().0 +} + impl MathAssets { fn new() -> Self { Self { + verbatim_source: false, rasterizer: MathGlyphRasterizer::new().ok(), layouts: HashMap::new(), images: HashMap::new(), @@ -155,15 +168,12 @@ impl MathAssets { if self.layouts.len() >= MAX_LAYOUTS { self.layouts.clear(); } - let compiled = compile_formula( - source.as_ref(), - display, - pixel_size, - pixels_per_point, - DEFAULT_LIMITS, - ) - .ok() - .map(Arc::new); + let compile = + if self.verbatim_source { compile_formula_source } else { compile_formula }; + let compiled = + compile(source.as_ref(), display, pixel_size, pixels_per_point, DEFAULT_LIMITS) + .ok() + .map(Arc::new); self.layouts.insert(key.clone(), compiled); } self.layouts.get(&key).and_then(Clone::clone) @@ -406,6 +416,8 @@ impl Element for MathView { let display = self.display; let nominal_px = f32::from(font_size); let pixels_per_point = crate::math::pixels_per_point(window.scale_factor()); + let raster_scale = window.scale_factor(); + let color = Rgba::from(text_style.color); let measure_slot = slot.clone(); let style = Style { flex_shrink: 0.0, ..Style::default() }; let layout_id = window.request_measured_layout(style, move |_, available, window, cx| { @@ -413,8 +425,20 @@ impl Element for MathView { AvailableSpace::Definite(width) => f32::from(width).max(8.0), AvailableSpace::MinContent | AvailableSpace::MaxContent => f32::INFINITY, }; - let assets = cx.global_mut::(); - match assets.fit(&source, display, nominal_px, pixels_per_point, max_width) { + let assets = source_assets(cx); + let fitted = assets + .fit(&source, display, nominal_px, pixels_per_point, max_width) + .filter(|(_, pixel_size)| { + assets.can_compose( + &source, + display, + *pixel_size, + pixels_per_point, + raster_scale, + color, + ) + }); + match fitted { Some((layout, pixel_size)) => { let bottom_pad = if display { 0.0 @@ -480,7 +504,7 @@ impl Element for MathView { if layout.metrics.width > bounds_width + 0.5 { let nominal = f32::from(text_style.font_size.to_pixels(window.rem_size())); let pixels_per_point = crate::math::pixels_per_point(window.scale_factor()); - let assets = cx.global_mut::(); + let assets = source_assets(cx); let Some((layout, pixel_size)) = assets.fit( &self.source, self.display, @@ -488,6 +512,7 @@ impl Element for MathView { pixels_per_point, bounds_width, ) else { + self.paint_source(bounds, window, cx); return; }; let baseline = bounds.bottom() - px(layout.metrics.depth); @@ -503,6 +528,24 @@ impl Element for MathView { } impl MathView { + fn paint_source(&self, bounds: Bounds, window: &mut Window, cx: &mut App) { + let style = window.text_style(); + let line = window.text_system().shape_line( + self.source.clone(), + style.font_size.to_pixels(window.rem_size()), + &[style.to_run(self.source.len())], + None, + ); + let _ = line.paint( + bounds.origin, + style.line_height_in_pixels(window.rem_size()), + gpui::TextAlign::Left, + None, + window, + cx, + ); + } + /// 位图贴到基线上(物理像素吸附),数学字体缺字的字符用 gpui 文本 /// 按各自字号补画在同一条基线上。 fn paint_math( @@ -516,17 +559,20 @@ impl MathView { ) { let text_style = window.text_style(); let color = Rgba::from(text_style.color); - paint_formula_image( + let raster_scale = window.scale_factor(); + let image = source_assets(cx).image( &self.source, self.display, pixel_size, - crate::math::pixels_per_point(window.scale_factor()), - bounds.left(), - baseline, + crate::math::pixels_per_point(raster_scale), + raster_scale, color, - window, - cx, ); + if image.is_none() { + self.paint_source(bounds, window, cx); + return; + } + paint_cached_image(image, bounds.left(), baseline, window); for op in &layout.text { let mut buffer = [0u8; 4]; @@ -569,11 +615,20 @@ pub(crate) fn paint_formula_image( ) { let raster_scale = window.scale_factor(); let assets = cx.global_mut::(); - let Some((render_image, geometry)) = - assets.image(source, display, pixel_size, pixels_per_point, raster_scale, color) - else { + let image = assets.image(source, display, pixel_size, pixels_per_point, raster_scale, color); + paint_cached_image(image, left, baseline, window); +} + +fn paint_cached_image( + image: Option<(Arc, ImageGeometry)>, + left: Pixels, + baseline: Pixels, + window: &mut Window, +) { + let Some((render_image, geometry)) = image else { return; }; + let raster_scale = window.scale_factor(); // 位图物理像素与屏幕 1:1:原点在物理网格上取整后再折回逻辑坐标。 let left_physical = (f32::from(left) * raster_scale).round(); let baseline_physical = (f32::from(baseline) * raster_scale).round(); diff --git a/nebula_app/src/gpui_shell/mod.rs b/nebula_app/src/gpui_shell/mod.rs index 5ac2777..71f3d5b 100644 --- a/nebula_app/src/gpui_shell/mod.rs +++ b/nebula_app/src/gpui_shell/mod.rs @@ -45,7 +45,6 @@ pub mod workspace; use assets::NebulaAssets; use gpui::{App, AppContext as _}; -#[cfg(windows)] use std::borrow::Cow; /// GPUI 主窗没有可靠的标准错误句柄;保留父进程重定向诊断,但写失败不能 @@ -63,6 +62,7 @@ pub(crate) enum GpuiShellEvent { MuxAttach, RuntimeControl(std::sync::Arc), UpdateAvailable(crate::update_check::UpdateCheckResult), + SshPrompt(std::sync::Arc), } /// 在当前线程启动 GPUI 运行时并打开主窗口,阻塞直至 UI 退出。 @@ -71,6 +71,10 @@ pub(crate) enum GpuiShellEvent { /// spike 形态从专用线程调用。一个进程内只允许调用一次。 pub fn run_shell(initial_cwd: Option) { let (shell_tx, shell_rx) = std::sync::mpsc::channel(); + crate::ssh_prompt::install({ + let sender = shell_tx.clone(); + move |request| sender.send(GpuiShellEvent::SshPrompt(request)).is_ok() + }); crate::update_check::spawn_gpui_once(shell_tx.clone()); let runtime_hub = crate::runtime_api::RuntimeHub::new(); crate::tray::init_gpui({ @@ -122,22 +126,29 @@ pub fn run_shell(initial_cwd: Option) { } } let _runtime_server = runtime_server; - gpui_platform::application().with_assets(NebulaAssets).run(move |cx| { - // GPUI is the only event loop in `--gpui` mode, so it owns the same - // per-process hook pipe before the first TerminalView spawns. - let ai_events = crate::ai_hook::spawn_gpui_server(); - #[cfg(windows)] - crate::ai_hook::spawn_config_guard(); - init(cx); - cx.activate(true); - open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd); - }); + // GPUI 默认只有 macOS 在最后一扇窗关掉后仍驻留(Dock 里留个没有窗口的 + // 进程)。Nebula 没有 Dock 重开入口,也没法在 Mac 上验证那个无窗状态, + // 首版三端统一:最后一扇窗关闭即退出(驻留另有 keep_session 能力位管)。 + gpui_platform::application() + .with_assets(NebulaAssets) + .with_quit_mode(gpui::QuitMode::LastWindowClosed) + .run(move |cx| { + // GPUI is the only event loop in `--gpui` mode, so it owns the same + // per-process hook pipe before the first TerminalView spawns. + let ai_events = crate::ai_hook::spawn_gpui_server(); + #[cfg(windows)] + crate::ai_hook::spawn_config_guard(); + init(cx); + cx.activate(true); + open_main_window(cx, ai_events, shell_rx, runtime_hub, initial_cwd); + }); crate::tray::shutdown(); } /// 组件库/主题/快捷键/用户配置的一次性初始化。 fn init(cx: &mut App) { - #[cfg(windows)] + // 三端都注册内嵌 Maple:Linux/macOS 的系统等宽字体没有 NF 图标码点, + // 侧栏与提示符会出方框;字形同源也是跨平台截图能互相比对的前提。 register_bundled_fonts(cx); // 组件库必须只初始化一次,否则全局 action、菜单和主题状态会重复注册。 @@ -159,7 +170,6 @@ fn init(cx: &mut App) { cx.set_global(settings); } -#[cfg(windows)] fn register_bundled_fonts(cx: &App) { // GPUI resolves a family through the system collection first and silently // falls back when it is absent. Add Maple before any component/window can @@ -278,20 +288,11 @@ pub(crate) fn reveal_all_windows(cx: &mut App) { /// exe 资源里有图标(`windows/nebula.rc` 的 `IDI_ICON`),任务栏按钮因此 /// 是对的;但 GPUI 注册窗口类时不带 `hIcon`,也从不发 `WM_SETICON`,于是 /// 悬停预览角标和 Alt+Tab 走系统默认白框占位图(用户 #51)。开窗后把资源 -/// 图标按系统尺寸各挂一份即可,`LR_SHARED` 句柄由系统缓存、无需释放。 +/// 图标按窗口 DPI 各挂一份,资源与句柄缓存由 `app_icon` 管理。 #[cfg(windows)] pub(crate) fn set_native_window_icon(window: &gpui::Window) { use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle}; - use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW; - use windows_sys::Win32::UI::WindowsAndMessaging::{ - GetSystemMetrics, ICON_BIG, ICON_SMALL, IMAGE_ICON, LR_SHARED, LoadImageW, SM_CXICON, - SM_CXSMICON, SendMessageW, WM_SETICON, - }; - - /// `windows/nebula.rc` 里的 `IDI_ICON`。 - const IDI_ICON: u16 = 0x101; - let Ok(handle) = HasWindowHandle::window_handle(window) else { return; }; @@ -300,19 +301,23 @@ pub(crate) fn set_native_window_icon(window: &gpui::Window) { }; let hwnd = handle.hwnd.get() as *mut core::ffi::c_void; - // SAFETY: 模块句柄取自身;LoadImageW 按 MAKEINTRESOURCE 约定接受资源序号, - // 失败返回 null;SendMessageW 对 null 图标只是清除该槽位,均安全失败。 - unsafe { - let module = GetModuleHandleW(std::ptr::null()); - let resource = IDI_ICON as usize as *const u16; - for (slot, metric) in [(ICON_SMALL, SM_CXSMICON), (ICON_BIG, SM_CXICON)] { - let size = GetSystemMetrics(metric); - let icon = LoadImageW(module, resource, IMAGE_ICON, size, size, LR_SHARED); - if !icon.is_null() { - SendMessageW(hwnd, WM_SETICON, slot as usize, icon as isize); + crate::app_icon::windows::set_window(hwnd, crate::app_icon::selected()); +} + +pub(crate) fn apply_app_icon(variant: nebula_settings::AppIconName, cx: &mut App) { + if !crate::app_icon::set_selected(variant) { + return; + } + crate::tray::refresh_app_icon(); + #[cfg(windows)] + cx.defer(|cx| { + for handle in cx.windows() { + if let Err(error) = handle.update(cx, |_, window, _| set_native_window_icon(window)) { + log::warn!("failed to update window icon: {error}"); } } - } + }); + cx.refresh_windows(); } #[cfg(windows)] diff --git a/nebula_app/src/gpui_shell/settings_pane.rs b/nebula_app/src/gpui_shell/settings_pane.rs index 0a566eb..88a7243 100644 --- a/nebula_app/src/gpui_shell/settings_pane.rs +++ b/nebula_app/src/gpui_shell/settings_pane.rs @@ -16,11 +16,11 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, Hsla, Image, - ImageFormat, InteractiveElement as _, IntoElement, KeyDownEvent, ModifiersChangedEvent, - MouseButton, MouseMoveEvent, ParentElement as _, Render, RenderImage, Rgba as GpuiRgba, - SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Task, Window, - anchored, deferred, div, img, px, + App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, Hsla, + InteractiveElement as _, IntoElement, KeyDownEvent, ModifiersChangedEvent, MouseButton, + MouseMoveEvent, ParentElement as _, Render, RenderImage, Rgba as GpuiRgba, SharedString, + StatefulInteractiveElement as _, Styled as _, Subscription, Task, Window, anchored, deferred, + div, img, px, }; use gpui_component::input::InputEvent; use gpui_component::select::{SelectEvent, SelectItem}; @@ -34,6 +34,7 @@ use crate::gpui_shell::config::{DEFAULT_CURSOR_BLINK, effective_cursor_blink}; use crate::gpui_shell::prelude::*; use crate::gpui_shell::widgets::NebulaButton; +mod app_icon; #[path = "background_color.rs"] mod background_color; mod design; @@ -73,6 +74,22 @@ const SECTION_IDS: [&str; 10] = [ "backup", ]; +/// Bilingual search aliases for the stable section routes. Search is a route +/// finder, so a query such as "font", "opacity", or "更新" lands on the +/// section that owns the control instead of merely filtering the current page. +const SECTION_SEARCH_TERMS: [&str; 10] = [ + "application app 应用 update 更新 version 版本 github support 支持", + "appearance 外观 theme 主题 font 字体 opacity 透明度 background 背景 cursor 光标 icon 图标", + "profiles 配置文件 shell terminal 终端 completion 补全 startup 启动", + "providers provider ai 供应商 模型 api", + "ssh host 主机 remote 远程 connection 连接", + "network 网络 proxy 代理 connectivity 连接", + "interaction 交互 copy 复制 paste 粘贴 tab 标签 panel 面板", + "keymap key binding shortcut 按键映射 快捷键", + "advanced 高级 session 会话 tray 托盘 restore 恢复", + "backup 备份 export 导出 restore 恢复", +]; + const HIDDEN_NAV_SECTIONS: &[usize] = &[3, 9]; /// 保留原来的分组展开顺序,组名不再渲染;数组里仍保存稳定的 [`SECTION_IDS`] @@ -109,8 +126,8 @@ fn visible_nav_sections() -> impl Iterator { // 这些几何值逐项来自旧壳 `display/settings.rs::settings_geometry`。GPUI // 设置页沿用同一节奏,避免组件默认间距把标题、分组和表单压成一条均匀列表。 -const SETTINGS_NAV_WIDTH: f32 = 196.0; -const SETTINGS_HEADER_HEIGHT: f32 = 48.0; +const SETTINGS_NAV_WIDTH: f32 = 232.0; +const SETTINGS_HEADER_HEIGHT: f32 = 52.0; const SETTINGS_GROUP_GAP: f32 = 32.0; const SETTINGS_GROUP_TITLE_HEIGHT: f32 = 26.0; @@ -238,6 +255,8 @@ fn issue_url() -> String { /// 宿主(workspace)监听:设置已写盘 / 终端目录已变 / 请求打开 SSH 会话。 pub enum SettingsPaneEvent { + /// Return to the workspace. Settings is a window-level page, not a tab. + Close, Changed, /// 导入 Profile 已落盘;Tab 的 Shell 面板若正打开,需要重建候选快照。 TerminalProfilesChanged, @@ -353,8 +372,11 @@ fn provider_input_placeholders(language: crate::display::UiLanguage) -> [&'stati fn localized_input_placeholder(key: &str, language: crate::display::UiLanguage) -> &'static str { match key { - "ssh_label" => language.pick("给这台机器起个名字", "Name this host"), + "ssh_label" => language.pick("例如:开发服务器", "e.g. Development server"), "ssh_password" => language.pick("留空则连接时询问", "Leave empty to ask when connecting"), + "ssh_proxy_username" => language.pick("可选", "Optional"), + "ssh_proxy_password" => language.pick("留空则保留已存密码", "Leave empty to keep the saved password"), + "ssh_jump_host" => language.pick("user@bastion:22 或 SSH config 别名", "user@bastion:22 or SSH config alias"), "ssh_icon_filter" => language.pick("搜索图标…", "Search icons..."), "font_family" => language.pick("输入字体名称", "Enter a font family"), "backup_password" => { @@ -882,9 +904,11 @@ pub struct SettingsPane { pub(super) runtime: RuntimeSettings, /// 当前分区(`SECTIONS` 下标);默认落在应用主页。 active_section: usize, - about_logo: Arc, about_update: AboutUpdateState, about_update_seq: u64, + about_last_checked: Option, + settings_search_input: Entity, + settings_search_trigger_bounds: Option>, /// 每项还带着自己的 `values` 表:`SelectState` 只认索引,而从代码侧 /// 改设置(还原默认值、命令面板切换)时手里只有配置文件记号,没有 /// 这张表就没法把闭框的选中项拉回去。 @@ -924,6 +948,11 @@ pub struct SettingsPane { pub(super) ssh_port_input: Entity, pub(super) ssh_label_input: Entity, pub(super) ssh_password_input: Entity, + pub(super) ssh_proxy_host_input: Entity, + pub(super) ssh_proxy_port_input: Entity, + pub(super) ssh_proxy_username_input: Entity, + pub(super) ssh_proxy_password_input: Entity, + pub(super) ssh_jump_host_input: Entity, /// 用户名输入框本身可自由编辑;候选层只提供最近使用值,不把输入约束成 /// 固定枚举。锚点跟随输入框,滚动/DPI 变化后仍贴在其下方。 pub(super) ssh_username_picker_open: bool, @@ -936,6 +965,7 @@ pub struct SettingsPane { /// 头像上一帧的窗口坐标,供弹层锚定(同字体目录的做法)。 pub(super) ssh_icon_trigger_bounds: Option>, pub(super) ssh_editor: Option, + pub(super) ssh_editor_focus_handle: FocusHandle, pub(super) ssh_editor_seq: u64, pub(super) ssh_test_seq: u64, pub(super) ssh_status: Option, @@ -1351,12 +1381,33 @@ impl SettingsPane { InputState::new(window, cx) .placeholder(localized_input_placeholder("ssh_icon_filter", language)) }); + let ssh_proxy_host_input = cx.new(|cx| InputState::new(window, cx).placeholder("127.0.0.1")); + let ssh_proxy_port_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("1080") + .pattern(regex::Regex::new(r"^\d{0,5}$").expect("static regex")) + }); + let ssh_proxy_username_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(localized_input_placeholder("ssh_proxy_username", language)) + }); + let ssh_proxy_password_input = cx.new(|cx| { + InputState::new(window, cx).masked(true) + .placeholder(localized_input_placeholder("ssh_proxy_password", language)) + }); + let ssh_jump_host_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(localized_input_placeholder("ssh_jump_host", language)) + }); for input in [ ssh_username_input.clone(), ssh_destination_input.clone(), ssh_port_input.clone(), ssh_label_input.clone(), ssh_password_input.clone(), + ssh_proxy_host_input.clone(), + ssh_proxy_port_input.clone(), + ssh_proxy_username_input.clone(), + ssh_proxy_password_input.clone(), + ssh_jump_host_input.clone(), ] { subscriptions.push(cx.subscribe_in( &input, @@ -1368,17 +1419,6 @@ impl SettingsPane { }, )); } - subscriptions.push(cx.subscribe_in( - &ssh_username_input, - window, - |this: &mut Self, _, event: &InputEvent, _, cx: &mut Context| { - if matches!(event, InputEvent::Focus) && this.ssh_editor.is_some() { - this.ssh_username_picker_open = true; - cx.notify(); - } - }, - )); - let font_family_input = Self::new_font_family_input(runtime.font_family.clone(), window, cx); subscriptions.push(cx.subscribe_in( @@ -1406,16 +1446,35 @@ impl SettingsPane { }); subscriptions.push(cx.intercept_keystrokes(keymap_interceptor)); + let settings_search_input = cx.new(|cx| { + InputState::new(window, cx).placeholder(language.pick( + "搜索全部设置,例如「字号」「透明度」「更新」", + "Search all settings, e.g. font, opacity, update", + )) + }); + subscriptions.push(cx.subscribe_in( + &settings_search_input, + window, + |_this: &mut Self, + _: &Entity, + event: &InputEvent, + _: &mut Window, + cx: &mut Context| { + if matches!(event, InputEvent::Change | InputEvent::Focus | InputEvent::Blur) { + cx.notify(); + } + }, + )); + Self { focus_handle: cx.focus_handle(), runtime, active_section: 0, - about_logo: Arc::new(Image::from_bytes( - ImageFormat::Png, - include_bytes!("../../../extra/logo/nebula.png").to_vec(), - )), about_update: AboutUpdateState::Idle, about_update_seq: 0, + about_last_checked: None, + settings_search_input, + settings_search_trigger_bounds: None, selects, shell_select, bg_picker_open: false, @@ -1445,12 +1504,18 @@ impl SettingsPane { ssh_port_input, ssh_label_input, ssh_password_input, + ssh_proxy_host_input, + ssh_proxy_port_input, + ssh_proxy_username_input, + ssh_proxy_password_input, + ssh_jump_host_input, ssh_username_picker_open: false, ssh_username_trigger_bounds: None, ssh_icon_picker_open: false, ssh_icon_filter_input, ssh_icon_trigger_bounds: None, ssh_editor: None, + ssh_editor_focus_handle: cx.focus_handle(), ssh_editor_seq: 0, ssh_test_seq: 0, ssh_status: None, @@ -1559,6 +1624,9 @@ impl SettingsPane { for (input, key) in [ (&self.ssh_label_input, "ssh_label"), (&self.ssh_password_input, "ssh_password"), + (&self.ssh_proxy_username_input, "ssh_proxy_username"), + (&self.ssh_proxy_password_input, "ssh_proxy_password"), + (&self.ssh_jump_host_input, "ssh_jump_host"), (&self.ssh_icon_filter_input, "ssh_icon_filter"), (&self.font_family_input, "font_family"), (&self.backup_pass_input, "backup_password"), @@ -1568,6 +1636,16 @@ impl SettingsPane { let placeholder = localized_input_placeholder(key, language); input.update(cx, |state, cx| state.set_placeholder(placeholder, window, cx)); } + self.settings_search_input.update(cx, |state, cx| { + state.set_placeholder( + language.pick( + "搜索全部设置,例如「字号」「透明度」「更新」", + "Search all settings, e.g. font, opacity, update", + ), + window, + cx, + ) + }); cx.notify(); } @@ -1651,6 +1729,7 @@ impl SettingsPane { self.persist( &[ ("theme", "Nebula".to_owned()), + ("app_icon", nebula_settings::AppIconName::default().settings_value().to_owned()), ("follow_system_theme", "0".to_owned()), ("opacity", "1".to_owned()), ("background", String::new()), @@ -2611,6 +2690,8 @@ impl SettingsPane { if pane.about_update_seq != sequence { return; } + pane.about_last_checked = + Some(chrono::Local::now().format("%Y-%m-%d %H:%M").to_string()); pane.about_update = match result { Ok(result) if result.update_available => { AboutUpdateState::Available(result.latest) @@ -2634,7 +2715,6 @@ impl SettingsPane { id: &'static str, icon: IconName, title: &'static str, - subtitle: &'static str, url: String, cx: &Context, ) -> gpui::AnyElement { @@ -2643,9 +2723,8 @@ impl SettingsPane { h_flex() .id(id) .w_full() - .min_h(px(52.0)) - .px_3() - .py_2() + .h(px(48.0)) + .px_1() .gap_3() .items_center() .rounded_md() @@ -2653,49 +2732,56 @@ impl SettingsPane { .hover(move |row| row.bg(hover)) .on_click(move |_, _, cx| cx.open_url(&url)) .child(Icon::new(icon).small().text_color(muted)) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap(px(2.0)) - .child(div().child(title)) - .child(div().text_xs().text_color(muted).truncate().child(subtitle)), - ) + .child(div().flex_1().min_w_0().child(title)) .child(Icon::new(IconName::ExternalLink).xsmall().text_color(muted)) .into_any_element() } + fn about_value_row( + label: &'static str, + value: impl IntoElement, + cx: &Context, + ) -> gpui::Div { + h_flex() + .w_full() + .min_h(px(48.0)) + .py_2() + .items_center() + .justify_between() + .gap_4() + .child(div().flex_1().min_w_0().text_color(cx.theme().foreground).child(label)) + .child(value) + } + fn section_home(&mut self, cx: &mut Context) -> gpui::Div { let language = crate::gpui_shell::config::ui_language(cx); let theme = cx.theme(); let muted = theme.muted_foreground; + let ink = theme.foreground; + let hairline = theme.border; + let success = theme.success; + let warning = theme.warning; + let danger = theme.danger; + let base_px = self.font_size_px(cx); let checking = matches!(self.about_update, AboutUpdateState::Checking); let (status, status_color): (SharedString, Hsla) = match &self.about_update { - AboutUpdateState::Idle => ( - language - .pick( - "通过 GitHub Releases 检查新版本。", - "Check GitHub Releases for a newer version.", - ) - .into(), - muted, - ), + AboutUpdateState::Idle => (language.pick("尚未检查", "Not checked yet").into(), muted), AboutUpdateState::Checking => { (language.pick("正在检查更新…", "Checking for updates...").into(), muted) }, AboutUpdateState::UpToDate(latest) => ( format!("{} (GitHub v{latest})", language.pick("已是最新版本", "Up to date")) .into(), - theme.success, + success, ), AboutUpdateState::Available(latest) => ( format!("{} v{latest}", language.pick("发现新版本", "New version available")) .into(), - theme.warning, + warning, ), AboutUpdateState::Failed(error) => ( format!("{}: {error}", language.pick("检查失败", "Update check failed")).into(), - theme.danger, + danger, ), }; let update_button = NebulaButton::new("about-check-updates") @@ -2704,128 +2790,164 @@ impl SettingsPane { } else { language.pick("检查更新", "Check for updates") }) - .outline() + .primary() .disabled(checking) .on_click(cx.listener(|this, _, window, cx| this.check_for_updates(window, cx))); - // 关于页做成一段终端输出,不是因为好看:这几行正是报 issue 时要贴的 - // 东西(版本、平台、构建方式、配置在哪)。做成可读又可整段复制的一 - // 块,比藏在"生成预填 Issue"按钮背后让人无从核对要诚实。 - // - // 这也是全页 mono 的另一处正当用途——它标记的仍然是机器可读的字面量。 - let mono: SharedString = cx.theme().mono_font_family.clone(); - let base_px = self.font_size_px(cx); - let ink = theme.foreground; - let accent = theme.primary; - let build = if cfg!(debug_assertions) { "debug" } else { "release" }; - let config_path = nebula_settings::settings_path().display().to_string(); - let facts: [(&'static str, String); 3] = [ - ("build", build.to_owned()), - ("platform", format!("{} {}", std::env::consts::OS, std::env::consts::ARCH)), - ("config", config_path), - ]; - // 复制走的文本与屏幕上逐字一致,粘进 issue 不用再改。 - let transcript = format!( - "Nebula {} -{}", - env!("CARGO_PKG_VERSION"), - facts.iter().map(|(key, value)| format!(" {key:<9}{value}")).collect::>().join( - " -" - ), - ); - let fact_rows = facts.into_iter().map(|(key, value)| { + + let status_badge = h_flex() + .min_w_0() + .max_w(px(360.0)) + .gap_1() + .items_center() + .text_size(px(base_px * 0.82)) + .text_color(status_color) + .when(matches!(self.about_update, AboutUpdateState::UpToDate(_)), |badge| { + badge.child(Icon::new(IconName::Check).xsmall()) + }) + .child(div().min_w_0().truncate().child(status)); + let identity = h_flex() - .gap_2() - .items_start() + .w_full() + .items_center() + .gap(px(24.0)) + .pb(px(64.0)) + .when_some(crate::app_icon::preview(crate::app_icon::selected()), |row, logo| { + row.child(img(logo).size(px(96.0)).flex_shrink_0()) + }) .child( - // 固定键列宽,值才对齐成一列——这是等宽字体在这里的全部 - // 意义,不然用 sans 就够了。 - div().w(px(76.0)).flex_shrink_0().text_color(muted).child(key), + v_flex() + .flex_1() + .min_w_0() + .gap(px(7.0)) + .child( + div() + .text_size(px(base_px * 2.15)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(ink) + .child("Nebula"), + ) + .child(div().text_color(muted).child( + language.pick( + "GPU 加速终端 · Windows", + "GPU-accelerated terminal · Windows", + ), + )) + .child( + h_flex() + .mt(px(12.0)) + .items_center() + .gap_4() + .child( + div() + .text_size(px(base_px * 0.88)) + .text_color(muted) + .child(format!("v{}", env!("CARGO_PKG_VERSION"))), + ) + .child(status_badge), + ), ) - .child(div().min_w_0().text_color(ink).child(value)) - .into_any_element() - }); - let banner = v_flex() - .id("about-transcript") - .group("about-transcript") - .relative() - .flex_1() - .min_w(px(300.0)) - .gap(px(3.0)) - .p_4() - .rounded(px(10.0)) - .border_1() - .border_color(crate::gpui_shell::theme::settings_hairline(cx)) - .bg(theme.background) - .font_family(mono) - .text_size(px(base_px * 0.92)) + .child(div().flex_shrink_0().child(update_button)); + + let auto_update_switch = + crate::gpui_shell::widgets::NebulaSwitch::new("auto-check-updates") + .checked(self.runtime.auto_check_updates) + .on_click(cx.listener(|this, checked: &bool, _, cx| { + this.persist(&[("auto_check_updates", (*checked as u8).to_string())], cx); + })); + let auto_update = h_flex() + .items_center() + .gap_2() + .child(div().text_size(px(base_px * 0.78)).text_color(muted).child( + if self.runtime.auto_check_updates { + language.pick("开启", "On") + } else { + language.pick("关闭", "Off") + }, + )) + .child(auto_update_switch); + let last_checked: SharedString = self + .about_last_checked + .clone() + .unwrap_or_else(|| language.pick("尚未检查", "Not checked yet").to_owned()) + .into(); + let section_title = |title: &'static str| { + div() + .h(px(30.0)) + .text_size(px(base_px * 0.85)) + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(muted) + .child(title) + }; + let version_overview = h_flex() + .w_full() + .items_end() + .justify_between() + .gap_4() + .pt(px(2.0)) + .pb_4() + .border_b_1() + .border_color(hairline) + .child( + v_flex() + .gap_2() + .child( + div() + .text_size(px(base_px * 2.0)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(ink) + .child(env!("CARGO_PKG_VERSION")), + ) + .child( + div() + .text_size(px(base_px * 0.72)) + .text_color(muted) + .child("Stable · Release"), + ), + ) .child( h_flex() - .gap_2() .items_center() - .child(div().text_color(accent).child("❯")) - .child(div().text_color(muted).child("nebula --version")), - ) - .child(div().mt_1().text_color(ink).child(format!( - "Nebula {}", - env!("CARGO_PKG_VERSION") - ))) - .children(fact_rows) - // 复制是动作,按需出现(与设置行的 ↶ 同一条规矩)。 - .child( - div() - .id("about-copy") - .absolute() - .top(px(8.0)) - .right(px(8.0)) - .size(px(24.0)) - .rounded_md() - .flex() - .items_center() - .justify_center() - .cursor_pointer() - .invisible() - .group_hover("about-transcript", |el| el.visible()) - .hover(|el| el.bg(theme.list_hover)) - .tooltip(|window, cx| { - gpui_component::tooltip::Tooltip::new( - crate::gpui_shell::config::ui_language(cx) - .pick("复制这段信息", "Copy this information"), - ) - .build(window, cx) - }) - .on_click(move |_, _, cx| { - cx.write_to_clipboard(gpui::ClipboardItem::new_string( - transcript.clone(), - )); - }) - .child(Icon::new(IconName::Copy).xsmall().text_color(muted)), + .gap_1() + .text_size(px(base_px * 0.75)) + .text_color(success) + .child(Icon::new(IconName::Check).xsmall()) + .child(language.pick("当前版本", "Current version")), ); - let identity = h_flex() - .w_full() - .gap(px(18.0)) - .items_start() - .child(img(self.about_logo.clone()).size(px(68.0)).rounded(px(12.0)).flex_shrink_0()) - .child(banner); + let update_column = v_flex() + .flex_1() + .min_w(px(280.0)) + .child(section_title(language.pick("版本与更新", "Version and updates"))) + .child(version_overview) + .child(Self::about_value_row( + language.pick("自动检查更新", "Automatically check for updates"), + auto_update, + cx, + )) + .child(Self::about_value_row( + language.pick("更新通道", "Update channel"), + div().text_color(muted).child("Stable"), + cx, + )) + .child(Self::about_value_row( + language.pick("上次检查", "Last checked"), + div().text_color(muted).child(last_checked), + cx, + )); let actions = v_flex() - .w_full() - .gap(px(2.0)) + .flex_1() + .min_w(px(280.0)) + .child(section_title(language.pick("项目与支持", "Project and support"))) .child(Self::about_action_row( "about-report-issue", IconName::TriangleAlert, language.pick("反馈问题", "Report an issue"), - language.pick( - "生成包含版本、平台与构建方式的预填 GitHub Issue", - "Open a prefilled GitHub issue with version, platform, and build details", - ), issue_url(), cx, )) .child(Self::about_action_row( "about-github", IconName::Github, - "GitHub", - language.pick("源代码", "Source code"), + language.pick("GitHub 仓库", "GitHub repository"), REPOSITORY_URL.to_owned(), cx, )) @@ -2833,28 +2955,19 @@ impl SettingsPane { "about-releases", IconName::BookOpen, language.pick("更新内容", "Release notes"), - language.pick( - "查看发布说明并下载最新版本", - "View release notes and download the latest version", - ), crate::update_check::RELEASES_PAGE.to_owned(), cx, )); - v_flex() - .w_full() - .gap(px(GROUP_GAP)) - .child(identity) - // 检查更新紧跟在版本信息下面:它回答的正是那一行提出的问题。 - .child( - h_flex() - .mt_5() - .gap_3() - .items_center() - .child(update_button) - .child(div().text_xs().text_color(status_color).child(status)), - ) - .child(actions) + v_flex().w_full().child(identity).child( + h_flex() + .w_full() + .flex_wrap() + .items_start() + .gap(px(64.0)) + .child(update_column) + .child(actions), + ) } fn section_appearance(&mut self, cx: &mut Context) -> gpui::Div { @@ -2977,6 +3090,7 @@ impl SettingsPane { .w_full() .gap(px(GROUP_GAP)) .child(preview) + .child(self.app_icon_previews(cx)) .child(themes) .child(theme_mode) .child(custom_background) @@ -3886,14 +4000,17 @@ impl SettingsPane { fn section_advanced(&mut self, cx: &mut Context) -> gpui::Div { let language = crate::gpui_shell::config::ui_language(cx); + // 平台没实现的开关整行不画:开着却没效果比没有更糟(见 + // `platform::capabilities` 的说明)。 + let caps = crate::platform::CAPABILITIES; self.group(language.pick("会话生命周期", "Session lifecycle"), cx) - .child(self.switch_row( + .when(caps.hide_window_on_close, |group| group.child(self.switch_row( "keep_session", language.pick("关窗后保留后台会话", "Keep sessions running after the window closes"), language.pick("开着时点 × 只是把窗口收走,里面的 shell 继续在常驻进程里跑、可以再附着回来;关掉则连 shell 一起杀,未保存的东西会丢。", "When enabled, closing the window leaves its shells running in the resident process so they can be reattached. When disabled, closing the window terminates them and unsaved work is lost."), self.runtime.keep_session, cx, - )) + ))) .child(self.switch_row( "restore_session", language.pick("启动时恢复上次标签", "Restore previous tabs at startup"), @@ -3908,13 +4025,13 @@ impl SettingsPane { self.runtime.resume_ai, cx, )) - .child(self.switch_row( + .when(caps.system_tray, |group| group.child(self.switch_row( "tray", language.pick("常驻系统托盘图标", "Keep an icon in the system tray"), language.pick("在通知区域留一个图标,正在跑的 AI 会话从那里能直接看到状态。", "Keeps an icon in the notification area where the status of running AI sessions is visible."), self.runtime.tray, cx, - )) + ))) } fn section_content(&mut self, window: &mut Window, cx: &mut Context) -> gpui::AnyElement { @@ -3943,9 +4060,11 @@ impl SettingsPane { // 指示,中间只隔着一条 hairline,用两种蓝会读成两套系统。 let active_bg = theme.sidebar_accent; let active_fg = theme.sidebar_accent_foreground; + let active_icon = theme.primary; + let foreground = theme.foreground; let hover_bg = crate::gpui_shell::theme::settings_hover_bg(cx, false); let hairline = crate::gpui_shell::theme::settings_hairline(cx); - let row_h = px(32.0); + let back_row_h = px(34.0); // 设置导航、内容和 workspace 左侧 tab 共享这一个主文字字号。 let main_text_px = self.font_size_px(cx); @@ -3953,7 +4072,7 @@ impl SettingsPane { // 不额外添加卡片或分组标题。 // 顶上不再画「设置」二字:右栏页头已经写着当前分区名,两者叠在同一 // 视线高度上就是同一件事说两遍;这个页面是不是设置页,窗口和 tab 早 - // 就说明了。省下的 72px 直接归还给导航项。 + // 就说明了。省下的标题块空间直接归还给导航项。 // // 分栏靠一条 hairline 而不是留白:留白只说明"这两块不挨着",线才说明 // "这是两个区"——导航是全局的,右栏是当前分区的,二者不是同一层。 @@ -3962,22 +4081,41 @@ impl SettingsPane { .h_full() .flex_shrink_0() .px_2() - .py(px(8.0)) + .pt(px(12.0)) + .pb(px(8.0)) .gap(px(2.0)) .border_r_1() - .border_color(hairline); + .border_color(hairline) + .child( + div() + .id("settings-back") + .mx_1() + .mb(px(24.0)) + .h(back_row_h) + .px_3() + .flex() + .items_center() + .gap_3() + .rounded_md() + .cursor_pointer() + .text_color(muted) + .hover(move |item| item.bg(hover_bg).text_color(foreground)) + .on_click(cx.listener(|_, _, _, cx| cx.emit(SettingsPaneEvent::Close))) + .child(Icon::new(IconName::ArrowLeft).small()) + .child(language.pick("返回工作区", "Back to workspace")), + ); for ix in visible_nav_sections() { let active = ix == self.active_section; nav = nav.child( div() .id(("settings-nav", ix)) - .px_2() - .ml_2() + .px_3() + .ml_1() .mr_1() - .h(row_h) + .h(px(40.0)) .flex() .items_center() - .gap_2() + .gap_3() .rounded_md() .cursor_pointer() .text_size(px(main_text_px)) @@ -3994,7 +4132,7 @@ impl SettingsPane { cx.notify(); })) .child(Icon::default().path(section_icon(ix)).small().text_color( - if active { active_fg } else { muted }, + if active { active_icon } else { muted }, )) .child(section_label(ix, language)), ); @@ -4028,6 +4166,59 @@ impl Render for SettingsPane { // 现有 reset 合同只覆盖外观键;其它页面显示同一个按钮会产生假承诺。 let show_reset = self.active_section == 1; let hairline = crate::gpui_shell::theme::settings_hairline(cx); + let search_hover = cx.theme().list_hover; + let reset_hover = cx.theme().list_hover; + let search_query = self.settings_search_input.read(cx).value().trim().to_lowercase(); + let search_results: Vec = if search_query.is_empty() { + Vec::new() + } else { + visible_nav_sections() + .filter(|index| { + SECTION_SEARCH_TERMS[*index].contains(&search_query) + || section_label(*index, language).to_lowercase().contains(&search_query) + }) + .take(6) + .collect() + }; + let search_focused = + self.settings_search_input.read(cx).focus_handle(cx).is_focused(window); + let search_panel = (search_focused && !search_results.is_empty()).then(|| { + v_flex() + .w(self + .settings_search_trigger_bounds + .map(|bounds| bounds.size.width) + .unwrap_or(px(680.0))) + .p_1() + .gap_1() + .rounded_md() + .border_1() + .border_color(hairline) + .bg(crate::gpui_shell::theme::settings_panel_bg(cx)) + .shadow_lg() + .occlude() + .children(search_results.into_iter().map(|index| { + h_flex() + .id(("settings-search-result", index)) + .h(px(36.0)) + .px_2() + .gap_2() + .rounded_md() + .cursor_pointer() + .hover(move |row| row.bg(search_hover)) + .on_click(cx.listener(move |this, _, window, cx| { + this.active_section = index; + this.settings_search_input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + cx.notify(); + })) + .child(Icon::default().path(section_icon(index)).small()) + .child(section_label(index, language)) + })) + }); + + let search_trigger = cx.entity().downgrade(); + let search_trigger_bounds = self.settings_search_trigger_bounds; div() .size_full() @@ -4083,7 +4274,7 @@ impl Render for SettingsPane { .flex_1() .min_w_0() .h_full() - // 旧壳标题栏固定 72px;正文单独滚动,滚动设置时仍能知道 + // 页头固定高度;正文单独滚动,滚动设置时仍能知道 // 自己在哪个分区,也不会让标题与首组的距离随内容变化。 .child( div() @@ -4101,10 +4292,11 @@ impl Render for SettingsPane { h_flex() .flex_1() .items_center() - .justify_between() - .gap_2() + .gap_3() .child( div() + .w(px(180.0)) + .flex_shrink_0() // 全页唯一放大的一处文字。层级本该 // 靠字重和位置做,但页头是页面唯一 // 的锚点,允许它比正文大一档。 @@ -4112,33 +4304,119 @@ impl Render for SettingsPane { .font_weight(gpui::FontWeight::SEMIBOLD) .child(section_label(self.active_section, language)), ) - .when(show_reset, |header| { - header.child( - div() - .id("settings-reset") - .size(px(24.0)) - .rounded_md() - .flex() - .items_center() - .justify_center() - .cursor_pointer() - .hover(|el| el.bg(cx.theme().list_hover)) - .tooltip(|window, cx| { - let language = crate::gpui_shell::config::ui_language(cx); - gpui_component::tooltip::Tooltip::new( - language.pick( - "还原外观为默认值", - "Restore appearance defaults", - ), + .child( + div() + .flex_1() + .min_w_0() + .flex() + .justify_center() + .child( + div() + .relative() + .w_full() + .max_w(px(680.0)) + .child( + Input::new(&self.settings_search_input) + .cleanable(true) + .prefix( + Icon::new(IconName::Search) + .xsmall() + .text_color( + cx.theme() + .muted_foreground, + ), + ) + .aria_label(language.pick( + "在全部设置中搜索", + "Search all settings", + )), ) - .build(window, cx) - }) - .on_click(cx.listener(|this, _, window, cx| { - this.reset_appearance(window, cx); - })) - .child(Icon::new(IconName::Undo2)), - ) - }), + .child( + gpui::canvas( + move |bounds, _, cx| { + let _ = search_trigger.update( + cx, + |pane, cx| { + if pane + .settings_search_trigger_bounds + == Some(bounds) + { + return; + } + pane.settings_search_trigger_bounds = + Some(bounds); + cx.notify(); + }, + ); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .when_some( + search_panel.zip(search_trigger_bounds), + |search, (panel, bounds)| { + search.child( + deferred( + anchored() + .anchor( + gpui::Anchor::TopLeft, + ) + .position( + bounds.bottom_left(), + ) + .offset(gpui::point( + px(0.0), + px(6.0), + )) + .snap_to_window_with_margin( + px(8.0), + ) + .child(panel), + ) + .with_priority(3), + ) + }, + ), + ), + ) + .child( + div() + .w(px(180.0)) + .flex_shrink_0() + .flex() + .justify_end() + .when(show_reset, |slot| { + slot.child( + div() + .id("settings-reset") + .size(px(24.0)) + .rounded_md() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(move |el| el.bg(reset_hover)) + .tooltip(|window, cx| { + let language = crate::gpui_shell::config::ui_language(cx); + gpui_component::tooltip::Tooltip::new( + language.pick( + "还原外观为默认值", + "Restore appearance defaults", + ), + ) + .build(window, cx) + }) + .on_click(cx.listener( + |this, _, window, cx| { + this.reset_appearance(window, cx); + }, + )) + .child(Icon::new(IconName::Undo2)), + ) + }), + ), ), ) .child( @@ -4181,7 +4459,13 @@ impl Render for SettingsPane { // 边。没有它时标签在最左、值在最右,1080px 宽的窗口 // 里眼睛要横跨一整屏才能把"这一项"和"它现在是什么" // 配上,扫到第三行就串行。窗口再宽只是两侧留白变多。 - .child(v_flex().w_full().child(content)), + .child( + div() + .w_full() + .flex() + .justify_center() + .child(v_flex().w_full().max_w(px(960.0)).child(content)), + ), ), ) .when_some(ssh_editor_modal, |root, modal| root.child(modal)) diff --git a/nebula_app/src/gpui_shell/settings_pane/app_icon.rs b/nebula_app/src/gpui_shell/settings_pane/app_icon.rs new file mode 100644 index 0000000..a0955ee --- /dev/null +++ b/nebula_app/src/gpui_shell/settings_pane/app_icon.rs @@ -0,0 +1,77 @@ +use super::*; +use nebula_settings::AppIconName; + +impl SettingsPane { + pub(super) fn app_icon_previews(&self, cx: &mut Context) -> gpui::Div { + let language = crate::gpui_shell::config::ui_language(cx); + let muted = cx.theme().muted_foreground; + let accent = cx.theme().primary; + let border = cx.theme().border; + let cards = h_flex().w_full().flex_wrap().gap(px(16.0)).children( + AppIconName::ALL.into_iter().map(|variant| { + let selected = crate::app_icon::selected() == variant; + let label = match variant { + AppIconName::SilverViolet => { + language.pick("银紫 · 默认", "Silver violet · Default") + }, + AppIconName::GraphiteViolet => language.pick("石墨紫", "Graphite violet"), + AppIconName::Titanium => language.pick("钛银", "Titanium"), + }; + let preview = h_flex().w_full().children([0xF1F3F6, 0x17191F].map(|background| { + h_flex() + .flex_1() + .h(px(68.0)) + .justify_center() + .items_center() + .bg(gpui::rgb(background)) + .when_some(crate::app_icon::preview(variant), |surface, logo| { + surface.child(img(logo).size(px(40.0)).flex_shrink_0()) + }) + })); + v_flex() + .w(px(190.0)) + .gap_2() + .child( + div() + .w_full() + .rounded_lg() + .overflow_hidden() + .border_1() + .border_color(if selected { accent } else { border }) + .child(preview), + ) + .child(div().text_sm().child(label)) + .child( + NebulaButton::new(format!("app-icon-{}", variant.settings_value())) + .label(if selected { + language.pick("已选择", "Selected") + } else { + language.pick("使用此图标", "Use this icon") + }) + .when(selected, |button| button.primary()) + .disabled(selected) + .on_click(cx.listener(move |this, _, _, cx| { + this.persist( + &[("app_icon", variant.settings_value().to_owned())], + cx, + ); + })), + ) + }), + ); + let description = if cfg!(windows) { + language.pick( + "即时切换窗口、Alt+Tab、托盘和应用内图标,并记住选择。已固定的快捷方式、EXE 和安装器仍使用默认主图标。图标不随终端主题自动变化。", + "Updates window, Alt+Tab, tray and in-app icons immediately and remembers your choice. Pinned shortcuts, the EXE and installer keep the default icon. The icon does not follow the terminal theme.", + ) + } else { + language.pick( + "切换应用内图标,并记住选择。当前不会更改系统 Dock 或应用启动器图标。", + "Changes the in-app icon and remembers your choice. System Dock and application launcher icons are not changed yet.", + ) + }; + self.group(language.pick("应用图标", "App icon"), cx) + .child(cards) + .child(div().mt_3().text_sm().text_color(muted).child(description)) + } +} diff --git a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs index e593abb..29b5f76 100644 --- a/nebula_app/src/gpui_shell/settings_pane/font_picker.rs +++ b/nebula_app/src/gpui_shell/settings_pane/font_picker.rs @@ -90,14 +90,14 @@ impl SettingsPane { if self.font_system.is_some() || self.font_loading { return; } - #[cfg(windows)] { self.font_loading = true; + let text_system = cx.text_system().clone(); let task = cx.background_executor().spawn(async move { - let system = crate::font_install::enumerate_system_font_families(); + let system = crate::platform::fonts::system_families(text_system); let imported: Vec = crate::font_install::imported_font_files() .iter() - .filter_map(|path| crate::font_install::probe_font_file_families(path).ok()) + .filter_map(|path| crate::platform::fonts::file_families(path).ok()) .flatten() .collect(); (system, imported) @@ -121,10 +121,6 @@ impl SettingsPane { }) .detach(); } - #[cfg(not(windows))] - { - self.font_system = Some(Vec::new()); - } } fn commit_font_family_input(&mut self, window: &mut Window, cx: &mut Context) { diff --git a/nebula_app/src/gpui_shell/settings_pane/keymap.rs b/nebula_app/src/gpui_shell/settings_pane/keymap.rs index 0558d6b..20c9c2b 100644 --- a/nebula_app/src/gpui_shell/settings_pane/keymap.rs +++ b/nebula_app/src/gpui_shell/settings_pane/keymap.rs @@ -40,7 +40,10 @@ impl SettingsPane { fn keymap_visible(&self, cx: &App) -> Vec { use crate::display::keymap; let query = self.keymap_search_input.read(cx).value().trim().to_lowercase(); + // 快速终端热键在没实现该功能的平台上不列出:改了也没有效果。 + let quick_terminal = crate::platform::CAPABILITIES.quick_terminal_hotkey; (0..keymap::editable_row_count()) + .filter(|flat| quick_terminal || *flat != keymap::QUICK_TERMINAL_ROW) .filter(|flat| query.is_empty() || self.keymap_row_haystack(*flat).contains(&query)) .collect() } diff --git a/nebula_app/src/gpui_shell/ssh_settings.rs b/nebula_app/src/gpui_shell/ssh_settings.rs index 2078fc2..ea0afb0 100644 --- a/nebula_app/src/gpui_shell/ssh_settings.rs +++ b/nebula_app/src/gpui_shell/ssh_settings.rs @@ -8,8 +8,8 @@ //! //! 与旧壳的行为对齐要点: //! - 文案合同(占位符/状态行)跟随旧壳 `ssh_editor_render`; -//! - 添加/编辑弹窗的高度与组内间距按旧壳 `editor_layout` 移植; -//! - 密钥模式是虚线空态框 + 路径框 +「+ 添加私钥」,文件对话框用旧壳 +//! - 添加/编辑弹窗采用基本与高级两页,正文独立滚动,底部操作始终可见; +//! - 密钥模式保留文件列表与「添加私钥」,文件对话框用旧壳 //! 同一套 `pem` / `key` / `ppk` / `id_*` 过滤器; //! - 系统口令弹窗修复在 `ssh_session`:解析失败不再误判为「缺口令」; //! - 删除主机先移出列表并开一个 8 秒撤销窗口,窗口结束才清理 Profile 与 @@ -27,30 +27,14 @@ use crate::gpui_shell::prelude::*; use crate::gpui_shell::settings_pane::{SettingsPane, SettingsPaneEvent, SshStatus}; use crate::gpui_shell::widgets::NebulaButton; +mod editor; +mod advanced; + /// 删除撤销窗口时长,旧壳 Undo 条同值。 const SSH_DELETE_UNDO_SECS: u64 = 8; -/// 对齐旧壳 `display/ssh_editor_render.rs` 的 `editor_layout` 几何。 -const SSH_EDITOR_W: f32 = 440.0; -/// 默认密码表单约 490px;多留一档呼吸,超出窗口时由 max-height 收缩正文。 -const SSH_EDITOR_H: f32 = 520.0; -const SSH_EDITOR_HEAD_H: f32 = 48.0; -const SSH_EDITOR_LABEL_W: f32 = 84.0; -const SSH_EDITOR_FIELD_GAP: f32 = 6.0; -const SSH_EDITOR_SPACE_XXS: f32 = 4.0; -const SSH_EDITOR_SPACE_XS: f32 = 8.0; -const SSH_EDITOR_SPACE_S: f32 = 12.0; -const SSH_EDITOR_SPACE_M: f32 = 16.0; -const SSH_EDITOR_SPACE_XL: f32 = 32.0; -const SSH_EDITOR_CTL_H: f32 = 32.0; -const SSH_EDITOR_USERNAME_W: f32 = 118.0; -const SSH_EDITOR_PORT_W: f32 = 76.0; -const SSH_EDITOR_KEY_ROW_H: f32 = 30.0; -const SSH_EDITOR_KEY_ROWS_MAX: usize = 4; -const SSH_EDITOR_AVATAR_H: f32 = 46.0; -const SSH_EDITOR_IDENT_NAME_H: f32 = 30.0; -const SSH_EDITOR_SAVE_H: f32 = 26.0; -const SSH_EDITOR_FOOTER_H: f32 = 56.0; +const SSH_EDITOR_CTL_H: f32 = 36.0; +const SSH_EDITOR_AVATAR_H: f32 = 44.0; const SSH_HOST_ROW_H: f32 = 58.0; const SSH_HOST_GAP: f32 = 8.0; @@ -122,6 +106,12 @@ pub(super) struct SshEditorState { /// 每次打开递增。文件选择器等异步回调只可写回发起它的编辑会话。 pub(super) id: u64, pub(super) original_destination: Option, + pub(super) advanced: bool, + pub(super) connection: crate::ssh_profiles::SshConnectionOptions, + pub(super) original_connection: crate::ssh_profiles::SshConnectionOptions, + pub(super) clear_proxy_password: bool, + pub(super) jump_picker_open: bool, + pub(super) jump_choices: Vec<(String, String)>, pub(super) auth: crate::ssh_profiles::SshAuthMode, pub(super) icon: Option, pub(super) private_keys: Vec, @@ -140,12 +130,18 @@ impl SshEditorState { Self { id, original_destination, + advanced: false, + connection: crate::ssh_profiles::SshConnectionOptions::default(), + original_connection: crate::ssh_profiles::SshConnectionOptions::default(), + clear_proxy_password: false, + jump_picker_open: false, + jump_choices: Vec::new(), // 与旧壳新增主机一致:第一次通常有密码,默认密码模式避免让 // 新用户先因 Auto 没有可用私钥而得到一次无意义的认证失败。 auth: crate::ssh_profiles::SshAuthMode::Password, icon: None, private_keys: Vec::new(), - save_password: true, + save_password: crate::platform::credentials::can_store(), show_password: false, username_suggestions: Vec::new(), revision: 0, @@ -191,6 +187,31 @@ impl SettingsPane { /// 二次确认后的删除:立刻移出列表并写盘,但 Profile 与凭据留到 8 秒 /// 撤销窗口结束(旧壳 Undo 合同——期内撤销可完整恢复)。 pub(super) fn delete_ssh_host(&mut self, host: &str, cx: &mut Context) { + let profile_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); + let profiles = match crate::ssh_profiles::SshProfiles::load(&profile_path) { + Ok(profiles) => profiles, + Err(error) => { + self.ssh_status = Some(SshStatus::ProfileLoadFailed(error.to_string())); + self.ssh_delete_confirm = None; + cx.notify(); + return; + }, + }; + let dependents = profiles.jump_dependents(host); + if !dependents.is_empty() { + let language = crate::gpui_shell::config::ui_language(cx); + self.ssh_status = Some(SshStatus::Error(format!( + "{}: {}", + language.pick( + "此主机仍被用作跳板,请先修改以下主机的高级设置后再删除", + "This host is still used as a jump host. Update these hosts' advanced settings before deleting it", + ), + dependents.join(", "), + ))); + self.ssh_delete_confirm = None; + cx.notify(); + return; + } // 一次只挂一个撤销窗口:新删除先提交上一个。 self.commit_pending_ssh_delete(); @@ -237,9 +258,14 @@ impl SettingsPane { let profile_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); match crate::ssh_profiles::SshProfiles::load(&profile_path) { Ok(mut profiles) => { + let proxy_credential = profiles.for_destination(&host).connection.proxy_credential_target(&host); profiles.remove(&host); if let Err(error) = profiles.save(&profile_path) { cleanup_errors.push(format!("Profile: {error}")); + } else if let Some(target) = proxy_credential { + if let Err(error) = crate::ssh_credentials::delete_generic_secret(&target) { + cleanup_errors.push(format!("Proxy credential: {error}")); + } } }, Err(error) => cleanup_errors.push(format!("Profile: {error}")), @@ -311,6 +337,14 @@ impl SettingsPane { self.ssh_editor_seq = self.ssh_editor_seq.wrapping_add(1).max(1); let mut editor = SshEditorState::new(self.ssh_editor_seq, destination); editor.username_suggestions = profiles.usernames(); + let labels = profiles.labels(); + editor.jump_choices = self.ssh_hosts.merged().into_iter() + .filter(|host| editor.original_destination.as_deref() != Some(host)) + .map(|host| { + let label = labels.get(&host).cloned().unwrap_or_else(|| host.clone()); + (host, label) + }) + .collect(); for host in self.ssh_hosts.merged() { let (address, _) = crate::display::split_destination_port(&host); let (username, _) = crate::display::split_destination_user(&address); @@ -320,14 +354,36 @@ impl SettingsPane { editor.auth = profile.auth; editor.icon = profile.icon.clone(); editor.private_keys = profile.private_keys.clone(); + editor.connection = profile.connection.clone(); + editor.original_connection = profile.connection.clone(); } + let connection = &editor.connection; + self.ssh_proxy_host_input.update(cx, |input, cx| input.set_value(connection.proxy_host.clone(), window, cx)); + self.ssh_proxy_port_input.update(cx, |input, cx| { + input.set_value(connection.proxy_port.map(|port| port.to_string()).unwrap_or_default(), window, cx); + input.set_placeholder(connection.effective_proxy_port().to_string(), window, cx); + }); + self.ssh_proxy_username_input.update(cx, |input, cx| input.set_value(connection.proxy_username.clone(), window, cx)); + self.ssh_proxy_password_input.update(cx, |input, cx| { + input.set_value("", window, cx); + input.set_masked(true, window, cx); + }); + self.ssh_jump_host_input.update(cx, |input, cx| input.set_value(connection.jump_host.clone(), window, cx)); let label = profile.and_then(|profile| profile.label).unwrap_or_default(); self.ssh_username_input.update(cx, |input, cx| input.set_value(username, window, cx)); self.ssh_destination_input.update(cx, |input, cx| input.set_value(address, window, cx)); self.ssh_port_input.update(cx, |input, cx| input.set_value(port, window, cx)); self.ssh_label_input.update(cx, |input, cx| input.set_value(label, window, cx)); // 存储的秘密绝不回填文本框;空值意味着编辑已有主机时保留原凭据。 - self.ssh_password_input.update(cx, |input, cx| input.set_value("", window, cx)); + self.ssh_password_input.update(cx, |input, cx| { + input.set_value("", window, cx); + let language = crate::gpui_shell::config::ui_language(cx); + input.set_placeholder(if adding { + language.pick("留空则连接时询问", "Leave empty to ask when connecting") + } else { + language.pick("留空则保留原有凭据", "Leave empty to keep existing credentials") + }, window, cx); + }); // 图标选择器每次开编辑器都从收起、无搜索词开始:上一台主机的搜索词 // 留在框里,下一台打开时列表看着像被莫名筛过。 self.ssh_icon_picker_open = false; @@ -341,7 +397,7 @@ impl SettingsPane { self.ssh_editor = Some(editor); self.ssh_status = None; self.set_ssh_editor_masking(window, cx); - self.ssh_username_input.update(cx, |input, cx| input.focus(window, cx)); + self.ssh_destination_input.update(cx, |input, cx| input.focus(window, cx)); cx.notify(); } @@ -350,6 +406,12 @@ impl SettingsPane { self.ssh_username_picker_open = false; self.ssh_username_trigger_bounds = None; self.ssh_password_input.update(cx, |input, cx| input.set_value("", window, cx)); + self.ssh_proxy_password_input.update(cx, |input, cx| { + input.set_value("", window, cx); + input.set_masked(true, window, cx); + }); + self.ssh_icon_picker_open = false; + self.ssh_icon_trigger_bounds = None; window.focus(&self.focus_handle, cx); cx.notify(); } @@ -429,11 +491,33 @@ impl SettingsPane { let destination = match self.ssh_destination_from_draft(cx) { Ok(destination) => destination, Err(error) => { + if let Some(editor) = self.ssh_editor.as_mut() { + editor.advanced = false; + } self.ssh_status = Some(SshStatus::Validation(error)); cx.notify(); return; }, }; + let connection = match self.ssh_connection_from_draft(&destination, cx) { + Ok(connection) => connection, + Err(error) => { + if let Some(editor) = self.ssh_editor.as_mut() { + editor.advanced = true; + } + self.ssh_status = Some(SshStatus::Error(error)); + cx.notify(); + return; + }, + }; + let (proxy_password, password) = match self.ssh_test_passwords(&destination, &connection, cx) { + Ok(passwords) => passwords, + Err(error) => { + self.ssh_status = Some(SshStatus::Error(error.to_string())); + cx.notify(); + return; + }, + }; let Some(editor) = self.ssh_editor.as_mut() else { return }; if editor.testing() { return; @@ -446,10 +530,9 @@ impl SettingsPane { destination: destination.clone(), auth: editor.auth, private_keys: editor.private_keys.clone(), - password: crate::display::auth_sections(editor.auth) - .0 - .then(|| self.ssh_password_input.read(cx).value().to_string()) - .filter(|password| !password.is_empty()), + connection, + proxy_password, + password, }; let receiver = match crate::ssh_session::start_test(request) { Ok(receiver) => receiver, @@ -486,11 +569,39 @@ impl SettingsPane { let destination = match self.ssh_destination_from_draft(cx) { Ok(destination) => destination, Err(error) => { + if let Some(editor) = self.ssh_editor.as_mut() { + editor.advanced = false; + } self.ssh_status = Some(SshStatus::Validation(error)); cx.notify(); return; }, }; + let connection = match self.ssh_connection_from_draft(&destination, cx) { + Ok(connection) => connection, + Err(error) => { + if let Some(editor) = self.ssh_editor.as_mut() { + editor.advanced = true; + } + self.ssh_status = Some(SshStatus::Error(error)); + cx.notify(); + return; + }, + }; + let proxy_password = self.ssh_proxy_password_from_draft(cx); + if proxy_password.as_deref().is_some_and(|password| !password.is_empty()) + && !crate::platform::credentials::can_store() + { + self.ssh_status = Some(SshStatus::Error(crate::gpui_shell::config::ui_language(cx).pick( + "系统凭据库不可用,无法安全保存代理密码。请先启用系统凭据库。", + "The system credential store is unavailable. Enable it before saving a proxy password.", + ).to_owned())); + if let Some(editor) = self.ssh_editor.as_mut() { + editor.advanced = true; + } + cx.notify(); + return; + } let Some(mut editor) = self.ssh_editor.take() else { return }; let original = editor.original_destination.clone(); let profile_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); @@ -503,6 +614,7 @@ impl SettingsPane { return; }, }; + let previous_connection = original.as_deref().map(|_| editor.original_connection.clone()); // 保存会改列表:未决删除的快照过期,先提交。 self.commit_pending_ssh_delete(); // 先在副本里计算新列表,直到 Profile 与 settings 两份数据都写成功 @@ -527,6 +639,7 @@ impl SettingsPane { private_keys: editor.private_keys.clone(), label: Some(label), icon: editor.icon.clone(), + connection: connection.clone(), }); if let Err(error) = profiles.save(&profile_path) { self.ssh_status = Some(SshStatus::ProfileSaveFailed(error.to_string())); @@ -542,12 +655,52 @@ impl SettingsPane { return; } self.ssh_hosts = hosts; - let password = self.ssh_password_input.read(cx).value().to_string(); + if let Err(error) = advanced::save_proxy_credential( + &destination, + &connection, + original.as_deref().zip(previous_connection.as_ref()), + proxy_password, + ) { + let language = crate::gpui_shell::config::ui_language(cx); + self.ssh_status = Some(SshStatus::Error(format!("{}: {error}", language.pick( + "主机信息已保存,但代理凭据更新失败,请重试", + "Host details were saved, but proxy credentials could not be updated; try again", + )))); + editor.advanced = true; + self.ssh_editor = Some(editor); + cx.notify(); + return; + } + let mut password = zeroize::Zeroizing::new(self.ssh_password_input.read(cx).value().to_string()); + if password.is_empty() && editor.save_password && crate::display::auth_sections(editor.auth).0 { + if let Some(original) = original.as_deref().filter(|old| *old != destination) { + match crate::ssh_credentials::load_stored_password(original) { + Ok(Some(secret)) => { + let secret = zeroize::Zeroizing::new(secret); + match std::str::from_utf8(&secret) { + Ok(secret) => *password = secret.to_owned(), + Err(error) => { + self.ssh_status = Some(SshStatus::CredentialSaveFailed(error.to_string())); + self.ssh_editor = Some(editor); + cx.notify(); + return; + }, + } + }, + Ok(None) => {}, + Err(error) => { + self.ssh_status = Some(SshStatus::CredentialSaveFailed(error.to_string())); + self.ssh_editor = Some(editor); + cx.notify(); + return; + }, + } + } + } if crate::display::auth_sections(editor.auth).0 && editor.save_password && !password.is_empty() { - #[cfg(windows)] if let Err(error) = crate::ssh_credentials::store_password(&destination, password.as_bytes()) { @@ -560,21 +713,19 @@ impl SettingsPane { // Credential Manager 以 destination 为键。重命名后旧键既不会被新 // 连接使用,也不应无限留下;与旧壳一致,在配置和列表都成功写入后 // 才删除它,避免前面的落盘失败反而丢掉仍可用的凭据。 - let credential_cleanup_error = - original.as_deref().filter(|old| *old != destination).and_then(|old| { - #[cfg(windows)] - { - crate::ssh_credentials::forget_password(old).err() - } - #[cfg(not(windows))] - { - let _ = old; - None - } - }); + let credential_cleanup_error = original + .as_deref() + .filter(|old| *old != destination) + .and_then(|old| crate::ssh_credentials::forget_password(old).err()); // 只在落盘流程结束后清掉明文;编辑已有主机且密码框为空时不触碰 // 当前 destination 的凭据。 self.ssh_password_input.update(cx, |input, cx| input.set_value("", window, cx)); + self.ssh_proxy_password_input.update(cx, |input, cx| { + input.set_value("", window, cx); + input.set_masked(true, window, cx); + }); + self.ssh_icon_picker_open = false; + self.ssh_username_picker_open = false; editor.private_keys.clear(); self.ssh_editor = None; self.ssh_status = credential_cleanup_error.map_or_else( @@ -592,618 +743,25 @@ impl SettingsPane { // ---- UI:添加/编辑弹窗 ---- - pub(super) fn ssh_editor_modal(&mut self, cx: &mut Context) -> Option { - let language = crate::gpui_shell::config::ui_language(cx); - let editor = self.ssh_editor.as_ref()?.clone(); - // 头像与它的弹层先建好:两者都要 `&mut Context`(listener / 实体 - // 弱引用),而下面的 `cx.theme()` 会把 cx 借成不可变直到函数结束。 - let icon = crate::display::ui::os_icons::resolve(editor.icon.as_deref()); - let avatar = self.ssh_avatar(icon, cx); - let icon_popup = self.ssh_icon_popup(cx); - let username_control = self.ssh_username_control(cx); - let username_popup = self.ssh_username_popup(cx); - let theme = cx.theme(); - let muted = theme.muted_foreground; - let danger = theme.danger; - let (shows_password, shows_keys) = crate::display::auth_sections(editor.auth); - let title = if editor.original_destination.is_some() { - language.pick("编辑 SSH 主机", "Edit SSH host") - } else { - language.pick("添加 SSH 主机", "Add SSH host") - }; - let dest_error = self.ssh_status.as_ref().and_then(|status| match status { - SshStatus::Validation(error) => Some(error.text(language).to_owned()), - _ => None, - }); - let status = editor - .test_status - .as_ref() - .map(|status| (status.text(language), status.is_error())) - .or_else(|| { - self.ssh_status.as_ref().and_then(|status| { - (!matches!(status, SshStatus::Validation(_))) - .then(|| (status.text(language), status.is_error())) - }) - }); - let destination_preview: SharedString = { - let username = self.ssh_username_input.read(cx).value(); - let address = self.ssh_destination_input.read(cx).value(); - let port = self.ssh_port_input.read(cx).value(); - if address.trim().is_empty() { - language.pick("未填地址", "No address entered").into() - } else { - let address = - crate::display::join_destination_user(username.trim(), address.trim()); - crate::display::join_destination_port(&address, port.trim()).into() - } - }; - let select_auth = |mode: crate::ssh_profiles::SshAuthMode| { - cx.listener(move |this, _, _, cx| { - if let Some(editor) = this.ssh_editor.as_mut() { - editor.auth = mode; - editor.revision = editor.revision.wrapping_add(1); - editor.test_request_id = None; - editor.test_status = None; - } - this.ssh_status = None; - cx.notify(); - }) - }; - let mode_chip = |id: &'static str, - label: &'static str, - mode: crate::ssh_profiles::SshAuthMode, - selected: bool| { - div() - .id(id) - .flex_1() - .h(px(SSH_EDITOR_CTL_H - 4.0)) - .flex() - .items_center() - .justify_center() - .rounded(px(4.0)) - .cursor_pointer() - .when(selected, |chip| { - chip.bg(theme.background).font_weight(gpui::FontWeight::MEDIUM) - }) - .when(!selected, |chip| { - chip.text_color(muted).hover(|chip| chip.bg(theme.list_hover)) - }) - .child(label) - .on_click(select_auth(mode)) - }; - let visible_start = editor.private_keys.len().saturating_sub(SSH_EDITOR_KEY_ROWS_MAX); - let key_rows = - editor.private_keys.iter().enumerate().skip(visible_start).map(|(index, path)| { - let shown: SharedString = ssh_key_path_tail(path, 36).into(); - h_flex() - .id(SharedString::from(format!("ssh-key-{index}"))) - .w_full() - .h(px(SSH_EDITOR_KEY_ROW_H)) - .px(px(SSH_EDITOR_SPACE_XS)) - .gap_2() - .items_center() - .rounded(px(6.0)) - .border_1() - .border_color(theme.border) - .bg(theme.input) - .child(div().flex_1().min_w_0().text_xs().truncate().child(shown)) - .child( - Button::new(SharedString::from(format!("ssh-key-remove-{index}"))) - .icon(IconName::Close) - .ghost() - .xsmall() - .tooltip(language.pick("移除私钥", "Remove private key")) - .on_click(cx.listener(move |this, _, _, cx| { - if let Some(editor) = this.ssh_editor.as_mut() { - if index < editor.private_keys.len() { - editor.private_keys.remove(index); - editor.revision = editor.revision.wrapping_add(1); - editor.test_request_id = None; - editor.test_status = None; - } - } - this.ssh_status = None; - cx.notify(); - })), - ) - }); - let empty_key_box = div() - .id("ssh-key-empty") - .w_full() - .h(px(SSH_EDITOR_KEY_ROW_H)) - .px(px(SSH_EDITOR_SPACE_XS)) - .flex() - .items_center() - .rounded(px(6.0)) - .border_1() - .border_dashed() - .border_color(theme.border) - .text_xs() - .text_color(muted) - .truncate() - .cursor_pointer() - .hover(|row| row.bg(theme.list_hover)) - .child(language.tr("settings.ssh.default_keys")) - .on_click(cx.listener(|this, _, window, cx| { - this.add_ssh_private_key(window, cx); - })) - .into_any_element(); - let add_key_link = div() - .id("ssh-add-private-key") - .h(px(SSH_EDITOR_CTL_H)) - .flex() - .items_center() - .text_xs() - .text_color(theme.primary) - .cursor_pointer() - .hover(|link| link.text_color(theme.foreground)) - .child(language.pick("+ 添加私钥", "+ Add private key")) - .on_click(cx.listener(|this, _, window, cx| { - this.add_ssh_private_key(window, cx); - })); - let field_row = |label: &'static str, control: gpui::AnyElement| { - h_flex() - .h(px(SSH_EDITOR_CTL_H)) - .gap(px(SSH_EDITOR_SPACE_S)) - .items_center() - .child(div().w(px(SSH_EDITOR_LABEL_W)).flex_shrink_0().text_sm().child(label)) - .child(div().flex_1().min_w_0().child(control)) - }; - let dest_hint = dest_error.as_deref().unwrap_or(language.tr("settings.ssh.address_hint")); - let dest_hint_color = if dest_error.is_some() { danger } else { muted }; - let auth_note = if editor.auth == crate::ssh_profiles::SshAuthMode::Auto { - language.tr("settings.ssh.auth_auto_description") - } else { - language.tr("settings.ssh.auth_interactive_description") - }; - - Some( - div() - .absolute() - .inset_0() - .occlude() - .bg(theme.overlay) - .on_mouse_down(MouseButton::Left, cx.listener(|this, _, window, cx| { - cx.stop_propagation(); - // 遮罩上的点击先收图标选择器:弹层开着时,点它外面的 - // 意思是「不选了」,不是「关掉整个编辑器」。 - if this.ssh_icon_picker_open { - this.toggle_ssh_icon_picker(window, cx); - } else if this.ssh_username_picker_open { - this.ssh_username_picker_open = false; - cx.notify(); - } - })) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { - if event.keystroke.key.eq_ignore_ascii_case("escape") { - cx.stop_propagation(); - // Esc 一层一层退:图标选择器开着就只收它。 - if this.ssh_icon_picker_open { - this.toggle_ssh_icon_picker(window, cx); - } else if this.ssh_username_picker_open { - this.ssh_username_picker_open = false; - cx.notify(); - } else { - this.close_ssh_editor(window, cx); - } - } - })) - .child( - div() - .absolute() - .inset_0() - .flex() - .items_center() - .justify_center() - .p(px(SSH_EDITOR_SPACE_XL)) - .child( - v_flex() - .w(px(SSH_EDITOR_W)) - .h(px(SSH_EDITOR_H)) - .max_w(gpui::relative(1.0)) - .max_h(gpui::relative(1.0)) - .flex_none() - .rounded(px(8.0)) - .border_1() - .border_color(theme.border) - .bg(theme.popover) - .shadow_md() - .overflow_hidden() - .child( - h_flex() - .h(px(SSH_EDITOR_HEAD_H)) - .flex_shrink_0() - .px(px(SSH_EDITOR_SPACE_M)) - .items_center() - .border_b_1() - .border_color(theme.border) - .child(div().flex_1().text_lg().child(title)) - .child( - Button::new("ssh-editor-close") - .icon(IconName::Close) - .ghost() - .tooltip(language.pick("关闭", "Close")) - .on_click(cx.listener(|this, _, window, cx| { - this.close_ssh_editor(window, cx); - })), - ), - ) - .child( - // 面板有明确高度后,正文才能可靠拿到标题栏与底栏 - // 之外的剩余空间;小窗口和长密钥列表只滚动这里。 - v_flex() - .flex_1() - .min_h_0() - .overflow_y_scrollbar() - .px(px(SSH_EDITOR_SPACE_M)) - .pt(px(SSH_EDITOR_SPACE_S)) - .pb(px(SSH_EDITOR_SPACE_S)) - .child( - h_flex() - .relative() - .gap(px(SSH_EDITOR_SPACE_S)) - .items_start() - .child(avatar) - .child( - v_flex() - .flex_1() - .min_w_0() - .h(px(SSH_EDITOR_AVATAR_H)) - .child( - div().h(px(SSH_EDITOR_IDENT_NAME_H)).w_full().child( - Input::new(&self.ssh_label_input) - .appearance(false) - .bordered(false) - .focus_bordered(false), - ), - ) - .child( - div() - .pt(px(2.0)) - .text_xs() - .text_color(muted) - .truncate() - .child(destination_preview), - ), - ) - .children(icon_popup), - ) - .child( - div() - .mt(px(SSH_EDITOR_SPACE_XS)) - .w_full() - .h(px(1.0)) - .bg(theme.border), - ) - .child( - v_flex() - .w_full() - .mt(px(SSH_EDITOR_SPACE_XS)) - .p(px(SSH_EDITOR_SPACE_S)) - .rounded(px(8.0)) - .border_1() - .border_color(theme.border) - .bg(theme.group_box) - .child( - div() - .text_sm() - .text_color(theme.group_box_foreground) - .child(language.pick("连接", "Connection")), - ) - .child( - h_flex() - .mt(px(SSH_EDITOR_SPACE_XS)) - .h(px(SSH_EDITOR_CTL_H)) - .gap(px(SSH_EDITOR_SPACE_S)) - .items_center() - .child( - h_flex() - .w(px(SSH_EDITOR_LABEL_W)) - .flex_shrink_0() - .text_sm() - .gap(px(2.0)) - .child(language.pick("地址", "Address")) - .child( - div().text_color(danger).child("*"), - ), - ) - .child( - div().flex_1().min_w_0().child( - h_flex() - .w_full() - .gap_2() - .items_center() - .child(username_control) - .child( - div() - .flex_shrink_0() - .text_sm() - .text_color(muted) - .child("@"), - ) - .child( - div().flex_1().min_w_0().child( - Input::new( - &self.ssh_destination_input, - ), - ), - ), - ), - ), - ) - .child( - div() - .mt(px(SSH_EDITOR_SPACE_XXS)) - .pl(px(SSH_EDITOR_LABEL_W + SSH_EDITOR_SPACE_S)) - .text_xs() - .text_color(dest_hint_color) - .child(dest_hint.to_owned()), - ) - .child( - h_flex() - .mt(px(SSH_EDITOR_FIELD_GAP)) - .h(px(SSH_EDITOR_CTL_H)) - .gap(px(SSH_EDITOR_SPACE_S)) - .items_center() - .child( - div() - .w(px(SSH_EDITOR_LABEL_W)) - .flex_shrink_0() - .text_sm() - .child(language.pick("端口", "Port")), - ) - .child( - div() - .w(px(SSH_EDITOR_PORT_W)) - .child(Input::new(&self.ssh_port_input)), - ) - .child( - div() - .text_xs() - .text_color(muted) - .child(language.pick("默认 22", "Default: 22")), - ), - ) - .children(username_popup), - ) - .child( - v_flex() - .w_full() - .mt(px(SSH_EDITOR_SPACE_S)) - .p(px(SSH_EDITOR_SPACE_S)) - .rounded(px(8.0)) - .border_1() - .border_color(theme.border) - .bg(theme.group_box) - .child( - div() - .text_sm() - .text_color(theme.group_box_foreground) - .child(language.pick("认证", "Authentication")), - ) - .child(field_row( - language.pick("方式", "Method"), - h_flex() - .w_full() - .h(px(SSH_EDITOR_CTL_H)) - .p(px(2.0)) - .rounded(px(6.0)) - .border_1() - .border_color(theme.border) - .bg(theme.input) - .child(mode_chip( - "ssh-auth-password", - language.pick("密码", "Password"), - crate::ssh_profiles::SshAuthMode::Password, - editor.auth - == crate::ssh_profiles::SshAuthMode::Password, - )) - .child(mode_chip( - "ssh-auth-key", - language.pick("密钥", "Key"), - crate::ssh_profiles::SshAuthMode::PublicKey, - editor.auth - == crate::ssh_profiles::SshAuthMode::PublicKey, - )) - .child(mode_chip( - "ssh-auth-auto", - language.pick("自动", "Auto"), - crate::ssh_profiles::SshAuthMode::Auto, - editor.auth - == crate::ssh_profiles::SshAuthMode::Auto, - )) - .child(mode_chip( - "ssh-auth-interactive", - language.pick("交互式", "Interactive"), - crate::ssh_profiles::SshAuthMode::KeyboardInteractive, - editor.auth - == crate::ssh_profiles::SshAuthMode::KeyboardInteractive, - )) - .into_any_element(), - ).mt(px(SSH_EDITOR_SPACE_XS))) - .when(shows_password, |section| { - section - .child( - h_flex() - .mt(px(SSH_EDITOR_FIELD_GAP)) - .h(px(SSH_EDITOR_CTL_H)) - .gap(px(SSH_EDITOR_SPACE_S)) - .items_center() - .child( - div() - .w(px(SSH_EDITOR_LABEL_W)) - .flex_shrink_0() - .text_sm() - .child(language.pick("密码", "Password")), - ) - .child( - div().flex_1().min_w_0().child( - Input::new(&self.ssh_password_input) - .mask_toggle(), - ), - ), - ) - .child( - h_flex() - .mt(px(SSH_EDITOR_SPACE_XS)) - .h(px(SSH_EDITOR_SAVE_H)) - .pl(px(SSH_EDITOR_LABEL_W + SSH_EDITOR_SPACE_S)) - .items_center() - .child( - Checkbox::new("ssh-save-password") - .small() - .checked(editor.save_password) - .label(language.tr( - "settings.ssh.save_password", - )) - .on_click(cx.listener( - |this, value: &bool, _, cx| { - if let Some(editor) = - this.ssh_editor.as_mut() - { - editor.save_password = *value; - editor.revision = editor - .revision - .wrapping_add(1); - editor.test_request_id = None; - editor.test_status = None; - } - this.ssh_status = None; - cx.notify(); - }, - )), - ), - ) - }) - .when(shows_keys, |section| { - section.child( - h_flex() - .mt(px(SSH_EDITOR_FIELD_GAP)) - .gap(px(SSH_EDITOR_SPACE_S)) - .items_start() - .child( - div() - .w(px(SSH_EDITOR_LABEL_W)) - .h(px(SSH_EDITOR_KEY_ROW_H)) - .flex_shrink_0() - .flex() - .items_center() - .text_sm() - .child(language.pick("私钥", "Private keys")), - ) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap(px(SSH_EDITOR_FIELD_GAP)) - .child(if editor.private_keys.is_empty() { - empty_key_box - } else { - v_flex() - .gap(px(SSH_EDITOR_FIELD_GAP)) - .children(key_rows) - .into_any_element() - }) - .child(add_key_link), - ), - ) - }) - .when(!shows_password && !shows_keys, |section| { - section.child( - div() - .mt(px(SSH_EDITOR_SPACE_XS)) - .text_xs() - .text_color(muted) - .child(auth_note), - ) - }), - ), - ) - .when_some(status, |card, (message, error)| { - card.child( - h_flex() - .flex_shrink_0() - .px(px(SSH_EDITOR_SPACE_M)) - .py(px(SSH_EDITOR_SPACE_XS)) - .gap_2() - .items_center() - .border_t_1() - .border_color(theme.border) - .text_color(if error { danger } else { theme.success }) - .child(if error { - Icon::new(IconName::CircleX).xsmall() - } else if editor.testing() { - Icon::new(IconName::Loader).xsmall() - } else { - Icon::new(IconName::CircleCheck).xsmall() - }) - .child(div().text_xs().child(message)), - ) - }) - .child( - h_flex() - .h(px(SSH_EDITOR_FOOTER_H)) - .flex_shrink_0() - .px(px(SSH_EDITOR_SPACE_M)) - .items_center() - .justify_between() - .border_t_1() - .border_color(theme.border) - .child( - NebulaButton::new("ssh-editor-test") - .label(if editor.testing() { - language.pick("测试中…", "Testing...") - } else { - language.pick("测试连接", "Test connection") - }) - .outline() - .disabled(editor.testing()) - .on_click(cx.listener(|this, _, _, cx| { - this.test_ssh_editor(cx) - })), - ) - .child( - h_flex() - .gap_2() - .child( - NebulaButton::new("ssh-editor-cancel") - .label(language.pick("取消", "Cancel")) - .outline() - .on_click(cx.listener(|this, _, window, cx| { - this.close_ssh_editor(window, cx); - })), - ) - .child( - NebulaButton::new("ssh-editor-save") - .label(language.pick("保存", "Save")) - .primary() - .on_click(cx.listener(|this, _, window, cx| { - this.save_ssh_editor(window, cx); - })), - ), - ), - ), - ), - ) - .into_any_element(), - ) - } - // ---- UI:可编辑用户名历史 ---- fn ssh_username_control(&self, cx: &mut Context) -> gpui::AnyElement { let pane = cx.entity().downgrade(); let open = self.ssh_username_picker_open; + let language = crate::gpui_shell::config::ui_language(cx); h_flex() .relative() - .w(px(SSH_EDITOR_USERNAME_W)) - .flex_shrink_0() + .w_full() // 输入框/下拉按钮都属于弹层触发器;阻止鼠标冒泡到模态遮罩, // 否则聚焦刚把候选打开,同一次点击又会被遮罩立即关闭。 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .child(div().flex_1().min_w_0().child(Input::new(&self.ssh_username_input))) .child( - Button::new("ssh-username-history") + Input::new(&self.ssh_username_input) + .h(px(SSH_EDITOR_CTL_H)) + .text_sm() + .aria_label(language.pick("用户名", "Username")) + .bg(cx.theme().popover) + .suffix(Button::new("ssh-username-history") .icon(if open { IconName::ChevronUp } else { IconName::ChevronDown }) .ghost() .xsmall() @@ -1213,7 +771,7 @@ impl SettingsPane { )) .on_click(cx.listener(|this, _, window, cx| { this.toggle_ssh_username_picker(window, cx); - })), + }))), ) .child( gpui::canvas( @@ -1294,7 +852,7 @@ impl SettingsPane { .collect(); let row_count = rows.len(); let panel = v_flex() - .w(px(SSH_EDITOR_USERNAME_W + 28.0)) + .w(trigger.size.width.max(px(200.0))) .p_2() .rounded_lg() .border_1() @@ -1355,12 +913,12 @@ impl SettingsPane { div() .id("ssh-icon-avatar") .relative() - .size(px(46.0)) + .size(px(SSH_EDITOR_AVATAR_H)) .flex_shrink_0() - .rounded(px(6.0)) + .rounded(px(10.0)) .border_1() .border_color(if open { theme.primary } else { theme.border }) - .bg(theme.input) + .bg(theme.group_box) .flex() .items_center() .justify_center() diff --git a/nebula_app/src/gpui_shell/ssh_settings/advanced.rs b/nebula_app/src/gpui_shell/ssh_settings/advanced.rs new file mode 100644 index 0000000..2134898 --- /dev/null +++ b/nebula_app/src/gpui_shell/ssh_settings/advanced.rs @@ -0,0 +1,529 @@ +use super::editor::{editor_field, editor_hint, editor_input}; +use super::*; +use crate::ssh_profiles::{SshConnectionOptions, SshHostJumpMode, SshHostProxyMode}; + +fn same_proxy_endpoint(previous: &SshConnectionOptions, connection: &SshConnectionOptions) -> bool { + previous.proxy_mode == connection.proxy_mode + && previous.normalized_proxy_host() == connection.normalized_proxy_host() + && previous.effective_proxy_port() == connection.effective_proxy_port() + && previous.proxy_username.trim() == connection.proxy_username.trim() +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::HashMap; + + use super::*; + + fn proxy() -> SshConnectionOptions { + SshConnectionOptions { + proxy_mode: SshHostProxyMode::Socks5, + proxy_host: "proxy.example".into(), + proxy_username: "alice".into(), + ..Default::default() + } + } + + #[test] + fn ssh_proxy_rename_preserves_credentials_for_equivalent_endpoints() { + let previous = proxy(); + let mut changed = previous.clone(); + changed.proxy_host = "PROXY.EXAMPLE".into(); + changed.proxy_username = " alice ".into(); + changed.proxy_port = Some(1080); + let old_key = previous.proxy_credential_target("old-host").unwrap(); + let new_key = changed.proxy_credential_target("new-host").unwrap(); + let secrets = RefCell::new(HashMap::from([(old_key.clone(), b"saved-secret".to_vec())])); + save_proxy_credential_with( + "new-host", + &changed, + Some(("old-host", &previous)), + None, + |target| Ok(secrets.borrow().get(target).cloned()), + |target, value| { + secrets.borrow_mut().insert(target.to_owned(), value.to_vec()); + Ok(()) + }, + |target| { + secrets.borrow_mut().remove(target); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(secrets.borrow().get(&new_key).unwrap(), b"saved-secret"); + assert!(!secrets.borrow().contains_key(&old_key)); + } + + #[test] + fn ssh_proxy_different_endpoint_never_receives_the_old_password() { + let previous = proxy(); + let mut changed = previous.clone(); + changed.proxy_host = "other.example".into(); + let deleted = RefCell::new(Vec::new()); + save_proxy_credential_with( + "new-host", + &changed, + Some(("old-host", &previous)), + None, + |_| panic!("must not read another endpoint's password"), + |_, _| panic!("must not copy another endpoint's password"), + |target| { + deleted.borrow_mut().push(target.to_owned()); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(*deleted.borrow(), vec![previous.proxy_credential_target("old-host").unwrap()]); + } + + #[test] + fn ssh_proxy_failed_migration_keeps_the_original_credential() { + let previous = proxy(); + save_proxy_credential_with( + "new-host", + &previous, + Some(("old-host", &previous)), + None, + |_| Ok(Some(b"saved-secret".to_vec())), + |_, _| Err(std::io::Error::other("credential store unavailable")), + |_| panic!("must not delete before the new credential was stored"), + ) + .unwrap_err(); + } + + #[test] + fn ssh_proxy_clear_does_not_reload_a_saved_password() { + let previous = proxy(); + let deleted = RefCell::new(Vec::new()); + save_proxy_credential_with( + "host", + &previous, + Some(("host", &previous)), + Some(String::new()), + |_| panic!("clear must not load a password"), + |_, _| panic!("clear must not store a password"), + |target| { + deleted.borrow_mut().push(target.to_owned()); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(*deleted.borrow(), vec![previous.proxy_credential_target("host").unwrap()]); + } +} + +pub(super) fn save_proxy_credential( + destination: &str, + connection: &SshConnectionOptions, + previous: Option<(&str, &SshConnectionOptions)>, + draft: Option, +) -> std::io::Result<()> { + save_proxy_credential_with( + destination, + connection, + previous, + draft, + crate::ssh_credentials::load_generic_secret, + crate::ssh_credentials::store_generic_secret, + crate::ssh_credentials::delete_generic_secret, + ) +} + +fn save_proxy_credential_with( + destination: &str, + connection: &SshConnectionOptions, + previous: Option<(&str, &SshConnectionOptions)>, + draft: Option, + mut load: impl FnMut(&str) -> std::io::Result>>, + mut store: impl FnMut(&str, &[u8]) -> std::io::Result<()>, + mut delete: impl FnMut(&str) -> std::io::Result<()>, +) -> std::io::Result<()> { + let target = connection.proxy_credential_target(destination); + let previous_target = previous + .and_then(|(destination, connection)| connection.proxy_credential_target(destination)); + if let Some(target) = target.as_deref() { + if let Some(password) = draft { + let password = zeroize::Zeroizing::new(password); + if password.is_empty() { + delete(target)?; + } else { + store(target, password.as_bytes())?; + } + } else if let Some((_, previous)) = previous { + if same_proxy_endpoint(previous, connection) + && previous_target.as_deref() != Some(target) + { + if let Some(previous_target) = previous_target.as_deref() { + if let Some(password) = load(previous_target)? { + let password = zeroize::Zeroizing::new(password); + store(target, &password)?; + } + } + } + } + } + if previous_target != target { + if let Some(previous_target) = previous_target { + delete(&previous_target)?; + } + } + Ok(()) +} + +impl SettingsPane { + pub(super) fn ssh_connection_from_draft( + &self, + destination: &str, + cx: &gpui::App, + ) -> Result { + let language = crate::gpui_shell::config::ui_language(cx); + let Some(editor) = self.ssh_editor.as_ref() else { + return Err(language.pick("SSH 编辑器已关闭", "The SSH editor is closed").to_owned()); + }; + let mut connection = editor.connection.clone(); + connection.proxy_host = self.ssh_proxy_host_input.read(cx).value().trim().to_owned(); + connection.proxy_username = + self.ssh_proxy_username_input.read(cx).value().trim().to_owned(); + connection.jump_host = self.ssh_jump_host_input.read(cx).value().trim().to_owned(); + let port = self.ssh_proxy_port_input.read(cx).value(); + connection.proxy_port = if port.trim().is_empty() { + None + } else { + match port.trim().parse::() { + Ok(port) if port > 0 => Some(port), + _ if matches!( + connection.proxy_mode, + SshHostProxyMode::Socks5 | SshHostProxyMode::Http + ) => + { + return Err(language + .pick( + "代理端口需要是 1–65535 之间的数字", + "The proxy port must be a number from 1 to 65535", + ) + .to_owned()); + }, + _ => None, + } + }; + connection.validate(destination)?; + let password = self.ssh_proxy_password_input.read(cx).value(); + if connection.has_custom_proxy() && !editor.clear_proxy_password && !password.is_empty() { + if connection.proxy_username.is_empty() { + return Err(language + .pick( + "填写代理密码时也需要填写代理用户名", + "Enter a proxy username when providing a proxy password", + ) + .to_owned()); + } + if connection.proxy_mode == SshHostProxyMode::Socks5 && password.len() > 255 { + return Err(language + .pick( + "SOCKS5 代理密码不能超过 255 字节", + "The SOCKS5 proxy password cannot exceed 255 bytes", + ) + .to_owned()); + } + } + Ok(connection) + } + + pub(super) fn ssh_test_passwords( + &self, + destination: &str, + connection: &SshConnectionOptions, + cx: &gpui::App, + ) -> std::io::Result<(Option, Option)> { + let Some(editor) = self.ssh_editor.as_ref() else { return Ok((None, None)) }; + let mut proxy_password = self.ssh_proxy_password_from_draft(cx); + let mut password = crate::display::auth_sections(editor.auth) + .0 + .then(|| self.ssh_password_input.read(cx).value().to_string()) + .filter(|password| !password.is_empty()); + if let Some(original) = + editor.original_destination.as_deref().filter(|old| *old != destination) + { + let decode = |secret: Vec| -> std::io::Result { + let secret = zeroize::Zeroizing::new(secret); + std::str::from_utf8(&secret) + .map(str::to_owned) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error)) + }; + if proxy_password.is_none() + && same_proxy_endpoint(&editor.original_connection, connection) + { + if let Some(target) = editor.original_connection.proxy_credential_target(original) { + proxy_password = crate::ssh_credentials::load_generic_secret(&target)? + .map(decode) + .transpose()?; + } + } + if password.is_none() + && editor.save_password + && crate::display::auth_sections(editor.auth).0 + { + password = crate::ssh_credentials::load_stored_password(original)? + .map(decode) + .transpose()?; + } + } + Ok((proxy_password, password)) + } + + pub(super) fn ssh_proxy_password_from_draft(&self, cx: &gpui::App) -> Option { + let editor = self.ssh_editor.as_ref()?; + if !matches!( + editor.connection.proxy_mode, + SshHostProxyMode::Socks5 | SshHostProxyMode::Http + ) { + return None; + } + if editor.clear_proxy_password { + return Some(String::new()); + } + let password = self.ssh_proxy_password_input.read(cx).value(); + (!password.is_empty()).then(|| password.to_string()) + } + + pub(super) fn ssh_editor_advanced(&self, cx: &mut Context) -> gpui::Div { + let language = crate::gpui_shell::config::ui_language(cx); + let editor = self.ssh_editor.as_ref().expect("open SSH editor"); + let theme = cx.theme(); + let custom_proxy = matches!( + editor.connection.proxy_mode, + SshHostProxyMode::Socks5 | SshHostProxyMode::Http + ); + let proxy_modes = [ + ("ssh-proxy-inherit", language.pick("跟随全局", "Inherit"), SshHostProxyMode::Inherit), + ("ssh-proxy-direct", language.pick("不使用", "None"), SshHostProxyMode::Direct), + ("ssh-proxy-socks", "SOCKS5", SshHostProxyMode::Socks5), + ("ssh-proxy-http", "HTTP", SshHostProxyMode::Http), + ]; + let proxy_buttons = proxy_modes.into_iter().map(|(id, label, mode)| { + Button::new(id) + .label(label) + .ghost() + .small() + .flex_1() + .min_w_0() + .h(px(30.0)) + .px_1() + .toggled(editor.connection.proxy_mode == mode) + .when(editor.connection.proxy_mode == mode, |button| { + button.bg(theme.popover).font_medium().shadow_sm() + }) + .when(editor.connection.proxy_mode != mode, |button| { + button.text_color(theme.muted_foreground) + }) + .on_click(cx.listener(move |this, _, window, cx| { + if let Some(editor) = this.ssh_editor.as_mut() { + editor.connection.proxy_mode = mode; + let placeholder = editor.connection.effective_proxy_port().to_string(); + this.ssh_proxy_port_input + .update(cx, |input, cx| input.set_placeholder(placeholder, window, cx)); + } + this.touch_ssh_editor(cx); + })) + }); + let jump_modes = [ + ( + "ssh-jump-inherit", + language.pick("跟随 SSH config", "SSH config"), + SshHostJumpMode::Inherit, + ), + ("ssh-jump-none", language.pick("不使用", "None"), SshHostJumpMode::None), + ("ssh-jump-host", language.pick("指定主机", "Use host"), SshHostJumpMode::Host), + ]; + let jump_buttons = jump_modes.into_iter().map(|(id, label, mode)| { + Button::new(id) + .label(label) + .ghost() + .small() + .flex_1() + .min_w_0() + .h(px(30.0)) + .px_1() + .toggled(editor.connection.jump_mode == mode) + .when(editor.connection.jump_mode == mode, |button| { + button.bg(theme.popover).font_medium().shadow_sm() + }) + .when(editor.connection.jump_mode != mode, |button| { + button.text_color(theme.muted_foreground) + }) + .on_click(cx.listener(move |this, _, _, cx| { + if let Some(editor) = this.ssh_editor.as_mut() { + editor.connection.jump_mode = mode; + editor.jump_picker_open = false; + } + this.touch_ssh_editor(cx); + })) + }); + let destination = self.ssh_destination_from_draft(cx).unwrap_or_default(); + let route = self.ssh_connection_from_draft(&destination, cx).ok().map(|connection| { + let mut steps = vec![language.pick("本机", "Local").to_owned()]; + match connection.proxy_mode { + SshHostProxyMode::Inherit => { + steps.push(language.pick("全局网络规则", "Global network rules").to_owned()) + }, + SshHostProxyMode::Direct => {}, + SshHostProxyMode::Socks5 | SshHostProxyMode::Http => steps.push(format!( + "{} {}:{}", + if connection.proxy_mode == SshHostProxyMode::Socks5 { + "SOCKS5" + } else { + "HTTP" + }, + connection.proxy_host, + connection.effective_proxy_port(), + )), + } + match connection.jump_mode { + SshHostJumpMode::Inherit => steps + .push(language.pick("SSH config 跳板规则", "SSH config jump rules").to_owned()), + SshHostJumpMode::None => {}, + SshHostJumpMode::Host => steps.push(connection.jump_host), + } + steps.push(if destination.is_empty() { + language.pick("目标主机", "Target host").to_owned() + } else { + destination.clone() + }); + steps.join(" → ") + }); + v_flex() + .w_full() + .child(div().text_xs().font_medium().child(language.pick("网络代理", "Network proxy"))) + .child(editor_hint(language.pick( + "仅覆盖这台主机,不改变全局网络设置。", + "Applies to this host without changing global network settings.", + ), cx).mt_1()) + .child(h_flex().mt_3().p(px(3.0)).gap(px(3.0)).rounded_lg().bg(theme.input).children(proxy_buttons)) + .when(custom_proxy, |body| { + body.child( + h_flex().mt_4().gap_3().items_start() + .child(div().flex_1().min_w_0().child(editor_field( + language.pick("代理地址", "Proxy host"), + editor_input(&self.ssh_proxy_host_input, language.pick("代理地址", "Proxy host"), cx), + ))) + .child(div().w(px(84.0)).flex_shrink_0().child(editor_field( + language.pick("端口", "Port"), + editor_input(&self.ssh_proxy_port_input, language.pick("代理端口", "Proxy port"), cx), + ))), + ) + .child( + h_flex().mt_4().gap_3().items_start() + .child(div().flex_1().min_w_0().child(editor_field( + language.pick("用户名(可选)", "Username (optional)"), + editor_input(&self.ssh_proxy_username_input, language.pick("代理用户名", "Proxy username"), cx), + ))) + .child(div().flex_1().min_w_0().child(editor_field( + language.pick("密码(可选)", "Password (optional)"), + editor_input(&self.ssh_proxy_password_input, language.pick("代理密码", "Proxy password"), cx) + .mask_toggle().disabled(editor.clear_proxy_password), + ))), + ) + .child(editor_hint(language.pick( + "代理密码仅保存在系统凭据库,不写入主机配置。", + "Proxy passwords stay in the system credential store, never in host configuration.", + ), cx).mt_2()) + .when(editor.original_destination.is_some(), |body| { + body.child( + div().mt_2().child( + Checkbox::new("ssh-clear-proxy-password").small() + .checked(editor.clear_proxy_password) + .label(language.pick("清除已保存的代理密码", "Remove the saved proxy password")) + .on_click(cx.listener(|this, value: &bool, window, cx| { + if let Some(editor) = this.ssh_editor.as_mut() { + editor.clear_proxy_password = *value; + } + if *value { + this.ssh_proxy_password_input.update(cx, |input, cx| input.set_value("", window, cx)); + } + this.touch_ssh_editor(cx); + })), + ), + ) + }) + .when(editor.connection.proxy_mode == SshHostProxyMode::Http, |body| { + body.child(editor_hint(language.pick( + "HTTP CONNECT 代理;代理认证本身不使用 TLS,请仅在可信网络中使用。", + "HTTP CONNECT; proxy authentication is not protected by TLS. Use a trusted network.", + ), cx).mt_2()) + }) + }) + .child( + v_flex().mt_5().pt_5().border_t_1().border_color(theme.border) + .child(div().text_xs().font_medium().child(language.pick("SSH 跳板机", "SSH jump host"))) + .child(h_flex().mt_3().p(px(3.0)).gap(px(3.0)).rounded_lg().bg(theme.input).children(jump_buttons)) + .when(editor.connection.jump_mode == SshHostJumpMode::Host, |section| { + section.child( + v_flex().mt_4().gap_2() + .child( + h_flex().justify_between().items_center() + .child(div().text_xs().font_medium().child(language.pick("跳板主机", "Jump host"))) + .child( + Button::new("ssh-jump-choose-saved").ghost().xsmall() + .label(language.pick("从已保存主机选择", "Choose saved host")) + .icon(if editor.jump_picker_open { IconName::ChevronUp } else { IconName::ChevronDown }) + .on_click(cx.listener(|this, _, _, cx| { + if let Some(editor) = this.ssh_editor.as_mut() { + editor.jump_picker_open = !editor.jump_picker_open; + } + cx.notify(); + })), + ), + ) + .child(editor_input(&self.ssh_jump_host_input, language.pick("跳板主机", "Jump host"), cx)) + .when(editor.jump_picker_open, |section| { + let choices = editor.jump_choices.iter().filter(|(host, _)| host != &destination).collect::>(); + if choices.is_empty() { + return section.child(editor_hint(language.pick( + "没有其他已保存主机;可直接填写地址或 SSH config 别名。", + "No other saved hosts. Enter an address or SSH config alias.", + ), cx)); + } + section.child( + v_flex().h(px((choices.len() as f32 * 42.0).min(168.0))) + .overflow_y_scrollbar().rounded_md().border_1().border_color(theme.border) + .child(v_flex().children(choices.into_iter().enumerate().map(|(index, (host, label))| { + let host = host.clone(); + let selected = host == self.ssh_jump_host_input.read(cx).value().as_ref(); + v_flex().id(SharedString::from(format!("ssh-jump-choice-{index}"))) + .w_full().h(px(42.0)).px_3().justify_center() + .cursor_pointer().hover(|row| row.bg(theme.list_hover)) + .when(selected, |row| row.bg(theme.list_active)) + .child(div().text_sm().truncate().child(label.clone())) + .child(div().text_xs().text_color(theme.muted_foreground).truncate().child(host.clone())) + .on_click(cx.listener(move |this, _, window, cx| { + this.ssh_jump_host_input.update(cx, |input, cx| input.set_value(host.clone(), window, cx)); + if let Some(editor) = this.ssh_editor.as_mut() { + editor.jump_picker_open = false; + } + this.touch_ssh_editor(cx); + })) + }))), + ) + }) + .child(editor_hint(language.pick( + "沿用跳板主机自己的用户名、密钥和已存凭据;不会使用目标主机的密码。", + "Uses the jump host's own username, keys and saved credentials, never the target password.", + ), cx)), + ) + }) + .child(editor_hint(language.pick( + "网络代理用于连接第一台 SSH 主机,再由跳板转发到目标。", + "The network proxy connects to the first SSH host; the jump host forwards to the target.", + ), cx).mt_3()), + ) + .child( + v_flex().mt_5().p_3().gap_2().rounded_md().bg(theme.group_box) + .child(div().text_xs().font_medium().child(language.pick("连接规则预览", "Connection rule preview"))) + .child(editor_hint(route.unwrap_or_else(|| language.pick( + "填写有效的代理和跳板信息后显示连接规则。", + "Enter valid proxy and jump host details to preview the connection rules.", + ).to_owned()), cx)), + ) + } +} diff --git a/nebula_app/src/gpui_shell/ssh_settings/editor.rs b/nebula_app/src/gpui_shell/ssh_settings/editor.rs new file mode 100644 index 0000000..334a2a4 --- /dev/null +++ b/nebula_app/src/gpui_shell/ssh_settings/editor.rs @@ -0,0 +1,549 @@ +use super::*; + +const EDITOR_WIDTH: f32 = 512.0; +const EDITOR_HEIGHT: f32 = 660.0; +const EDITOR_PADDING: f32 = 28.0; + +pub(super) fn editor_input( + state: &Entity, + label: &'static str, + cx: &gpui::App, +) -> Input { + Input::new(state).h(px(SSH_EDITOR_CTL_H)).text_sm().bg(cx.theme().popover).aria_label(label) +} + +pub(super) fn editor_field(label: &'static str, control: impl IntoElement) -> gpui::Div { + v_flex() + .w_full() + .min_w_0() + .gap(px(7.0)) + .child(div().text_xs().font_medium().child(label)) + .child(control) +} + +pub(super) fn editor_hint(text: impl Into, cx: &gpui::App) -> gpui::Div { + div() + .text_xs() + .line_height(gpui::relative(1.6)) + .text_color(cx.theme().muted_foreground) + .child(text.into()) +} + +impl SettingsPane { + pub(in crate::gpui_shell) fn ssh_editor_modal( + &mut self, + cx: &mut Context, + ) -> Option { + let language = crate::gpui_shell::config::ui_language(cx); + let editor = self.ssh_editor.as_ref()?.clone(); + let icon = crate::display::ui::os_icons::resolve(editor.icon.as_deref()); + let avatar = self.ssh_avatar(icon, cx); + let icon_popup = self.ssh_icon_popup(cx); + let username_popup = self.ssh_username_popup(cx); + let content = + if editor.advanced { self.ssh_editor_advanced(cx) } else { self.ssh_editor_basic(cx) }; + let theme = cx.theme(); + let title = if editor.original_destination.is_some() { + language.pick("编辑 SSH 主机", "Edit SSH host") + } else { + language.pick("添加 SSH 主机", "Add SSH host") + }; + let status = match self.ssh_status.as_ref() { + Some(SshStatus::Validation(_)) => None, + Some(status) => Some((status.text(language), status.is_error())), + None => { + editor.test_status.as_ref().map(|status| (status.text(language), status.is_error())) + }, + }; + let status_connecting = self.ssh_status.is_none() && editor.testing(); + let page_tab = |id, label, advanced| { + let selected = editor.advanced == advanced; + Button::new(id) + .label(label) + .ghost() + .small() + .h(px(38.0)) + .px_1() + .rounded_none() + .text_color(if selected { theme.foreground } else { theme.muted_foreground }) + .when(selected, |button| { + button.font_medium().border_b_2().border_color(theme.primary) + }) + .on_click(cx.listener(move |this, _, window, cx| { + if let Some(editor) = this.ssh_editor.as_mut() { + editor.advanced = advanced; + editor.jump_picker_open = false; + } + this.ssh_username_picker_open = false; + this.ssh_icon_picker_open = false; + window.focus(&this.ssh_editor_focus_handle, cx); + cx.notify(); + })) + }; + Some( + div() + .absolute() + .inset_0() + .track_focus(&self.ssh_editor_focus_handle) + .occlude() + .bg(theme.overlay) + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| { + cx.stop_propagation(); + if this.ssh_icon_picker_open { + this.toggle_ssh_icon_picker(window, cx); + } else if this.ssh_username_picker_open { + this.ssh_username_picker_open = false; + cx.notify(); + } + }), + ) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { + if !event.keystroke.key.eq_ignore_ascii_case("escape") { + return; + } + cx.stop_propagation(); + if this.ssh_icon_picker_open { + this.toggle_ssh_icon_picker(window, cx); + } else if this.ssh_username_picker_open { + this.ssh_username_picker_open = false; + cx.notify(); + } else if this.ssh_editor.as_ref().is_some_and(|editor| editor.jump_picker_open) + { + if let Some(editor) = this.ssh_editor.as_mut() { + editor.jump_picker_open = false; + } + cx.notify(); + } else { + this.close_ssh_editor(window, cx); + } + })) + .child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .p(px(20.0)) + .child( + v_flex() + .id("ssh-editor-dialog") + .debug_selector(|| "ssh-editor-dialog".to_owned()) + .w(px(EDITOR_WIDTH)) + .h(px(EDITOR_HEIGHT)) + .max_w(gpui::relative(1.0)) + .max_h(gpui::relative(1.0)) + .flex_none() + .rounded(px(12.0)) + .border_1() + .border_color(theme.border.opacity(0.7)) + .bg(theme.popover) + .text_color(theme.foreground) + .shadow_lg() + .overflow_hidden() + .child( + h_flex() + .h(px(84.0)) + .flex_shrink_0() + .px(px(EDITOR_PADDING)) + .gap_3() + .items_center() + .child(avatar) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child(div().text_lg().font_semibold().child(title)) + .child(editor_hint( + language.pick( + "配置连接信息,随时快速访问。", + "Save connection details for quick access.", + ), + cx, + )), + ) + .child( + Button::new("ssh-editor-close") + .icon(IconName::Close) + .ghost() + .small() + .tooltip(language.pick("关闭", "Close")) + .on_click(cx.listener(|this, _, window, cx| { + this.close_ssh_editor(window, cx); + })), + ), + ) + .child( + h_flex() + .h(px(39.0)) + .flex_shrink_0() + .px(px(EDITOR_PADDING)) + .gap_6() + .border_b_1() + .border_color(theme.border) + .child(page_tab( + "ssh-editor-basic", + language.pick("基本", "General"), + false, + )) + .child(page_tab( + "ssh-editor-advanced", + language.pick("高级", "Advanced"), + true, + )), + ) + .child( + v_flex() + .flex_1() + .min_h_0() + .overflow_y_scrollbar() + .px(px(EDITOR_PADDING)) + .py_5() + .child(content) + .when_some(status, |body, (message, error)| { + let color = if error { + theme.danger + } else if status_connecting { + theme.muted_foreground + } else { + theme.success + }; + body.child( + h_flex() + .mt_4() + .p_3() + .gap_2() + .items_start() + .rounded_md() + .bg(color.opacity(0.08)) + .text_color(color) + .child(if status_connecting { + Spinner::new().small().into_any_element() + } else { + Icon::new(if error { + IconName::CircleX + } else { + IconName::CircleCheck + }) + .xsmall() + .into_any_element() + }) + .child( + div() + .flex_1() + .min_w_0() + .text_xs() + .child(message), + ), + ) + }), + ) + .child( + h_flex() + .h(px(64.0)) + .flex_shrink_0() + .px(px(EDITOR_PADDING)) + .items_center() + .justify_between() + .border_t_1() + .border_color(theme.border) + .bg(theme.group_box) + .child( + Button::new("ssh-editor-test") + .debug_selector(|| "ssh-editor-test".to_owned()) + .label(if editor.testing() { + language.pick("测试中…", "Testing...") + } else { + language.pick("测试连接", "Test connection") + }) + .ghost() + .small() + .loading(editor.testing()) + .on_click(cx.listener(|this, _, _, cx| { + this.test_ssh_editor(cx) + })), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("ssh-editor-cancel") + .label(language.pick("取消", "Cancel")) + .ghost() + .small() + .h(px(SSH_EDITOR_CTL_H)) + .on_click(cx.listener( + |this, _, window, cx| { + this.close_ssh_editor(window, cx) + }, + )), + ) + .child( + Button::new("ssh-editor-save") + .debug_selector(|| { + "ssh-editor-save".to_owned() + }) + .label(language.pick("保存", "Save")) + .primary() + .small() + .h(px(SSH_EDITOR_CTL_H)) + .min_w(px(74.0)) + .on_click(cx.listener( + |this, _, window, cx| { + this.save_ssh_editor(window, cx) + }, + )), + ), + ), + ), + ), + ) + .children(icon_popup) + .children(username_popup) + .into_any_element(), + ) + } + + fn ssh_editor_basic(&self, cx: &mut Context) -> gpui::Div { + let language = crate::gpui_shell::config::ui_language(cx); + let username = self.ssh_username_control(cx); + let authentication = self.ssh_editor_authentication(cx); + let theme = cx.theme(); + let validation = self.ssh_status.as_ref().and_then(|status| match status { + SshStatus::Validation(error) => Some(*error), + _ => None, + }); + let port_error = matches!(validation, Some(SshValidationError::InvalidPort)); + let address_error = validation.is_some() && !port_error; + let destination = self.ssh_destination_from_draft(cx).ok(); + v_flex() + .w_full() + .child( + v_flex() + .gap(px(7.0)) + .child( + h_flex() + .gap_2() + .child( + div() + .text_xs() + .font_medium() + .child(language.pick("主机名称", "Host name")), + ) + .child(editor_hint(language.pick("可选", "Optional"), cx)), + ) + .child(editor_input( + &self.ssh_label_input, + language.pick("主机名称", "Host name"), + cx, + )), + ) + .child( + h_flex() + .mt_5() + .gap_3() + .items_start() + .child( + div().flex_1().min_w_0().child(editor_field( + language.pick("主机地址 *", "Host address *"), + editor_input( + &self.ssh_destination_input, + language.pick("主机地址", "Host address"), + cx, + ) + .when(address_error, |input| input.border_color(theme.danger)), + )), + ) + .child( + div().w(px(84.0)).flex_shrink_0().child(editor_field( + language.pick("端口", "Port"), + editor_input(&self.ssh_port_input, language.pick("端口", "Port"), cx) + .when(port_error, |input| input.border_color(theme.danger)), + )), + ), + ) + .child(editor_hint(language.tr("settings.ssh.address_hint"), cx).mt_1()) + .when_some(validation, |body, error| { + body.child( + div().mt_1().text_xs().text_color(theme.danger).child(error.text(language)), + ) + }) + .child(div().mt_4().child(editor_field(language.pick("用户名", "Username"), username))) + .when_some(destination, |body, destination| { + body.child(editor_hint(destination, cx).mt_2().truncate()) + }) + .child( + v_flex() + .mt_5() + .pt_5() + .border_t_1() + .border_color(theme.border) + .child(authentication), + ) + } + + fn ssh_editor_authentication(&self, cx: &mut Context) -> gpui::Div { + use crate::ssh_profiles::SshAuthMode; + + let language = crate::gpui_shell::config::ui_language(cx); + let editor = self.ssh_editor.as_ref().expect("open SSH editor"); + let theme = cx.theme(); + let (shows_password, shows_keys) = crate::display::auth_sections(editor.auth); + let modes = [ + ("ssh-auth-password", language.pick("密码", "Password"), SshAuthMode::Password), + ("ssh-auth-key", language.pick("密钥", "Key"), SshAuthMode::PublicKey), + ("ssh-auth-auto", language.pick("自动", "Auto"), SshAuthMode::Auto), + ( + "ssh-auth-interactive", + language.pick("交互式", "Interactive"), + SshAuthMode::KeyboardInteractive, + ), + ]; + let controls = modes.into_iter().map(|(id, label, mode)| { + Button::new(id) + .label(label) + .ghost() + .small() + .flex_1() + .min_w_0() + .h(px(30.0)) + .px_1() + .toggled(editor.auth == mode) + .when(editor.auth == mode, |button| { + button.bg(theme.popover).font_medium().shadow_sm() + }) + .when(editor.auth != mode, |button| button.text_color(theme.muted_foreground)) + .on_click(cx.listener(move |this, _, _, cx| { + if let Some(editor) = this.ssh_editor.as_mut() { + editor.auth = mode; + } + this.touch_ssh_editor(cx); + })) + }); + v_flex() + .w_full() + .child(div().text_xs().font_medium().child(language.pick("身份验证", "Authentication"))) + .child( + h_flex() + .mt_2() + .p(px(3.0)) + .gap(px(3.0)) + .rounded_lg() + .bg(theme.input) + .children(controls), + ) + .when(shows_password, |body| { + body.child( + div().mt_4().child(editor_field( + language.pick("密码", "Password"), + editor_input( + &self.ssh_password_input, + language.pick("密码", "Password"), + cx, + ) + .mask_toggle(), + )), + ) + .child( + div().mt_3().child( + Checkbox::new("ssh-save-password") + .small() + .disabled(!crate::platform::credentials::can_store()) + .checked(editor.save_password) + .label(if cfg!(windows) { + language.pick( + "保存到 Windows 凭据管理器", + "Save in Windows Credential Manager", + ) + } else { + language.pick("保存到系统凭据库", "Save in system credential store") + }) + .on_click(cx.listener(|this, value: &bool, _, cx| { + if let Some(editor) = this.ssh_editor.as_mut() { + editor.save_password = *value; + } + this.touch_ssh_editor(cx); + })), + ), + ) + }) + .when(shows_keys, |body| { + let rows = editor.private_keys.iter().enumerate().map(|(index, path)| { + h_flex() + .w_full() + .h(px(32.0)) + .px_2() + .gap_2() + .items_center() + .rounded_md() + .border_1() + .border_color(theme.border) + .child( + div() + .flex_1() + .min_w_0() + .text_xs() + .truncate() + .child(ssh_key_path_tail(path, 64)), + ) + .child( + Button::new(SharedString::from(format!("ssh-key-remove-{index}"))) + .icon(IconName::Close) + .ghost() + .xsmall() + .tooltip(language.pick("移除私钥", "Remove private key")) + .on_click(cx.listener(move |this, _, _, cx| { + if let Some(editor) = this.ssh_editor.as_mut() { + if index < editor.private_keys.len() { + editor.private_keys.remove(index); + } + } + this.touch_ssh_editor(cx); + })), + ) + }); + body.child( + v_flex() + .mt_4() + .gap_2() + .when(!editor.private_keys.is_empty(), |list| { + list.child( + v_flex() + .h(px((editor.private_keys.len() as f32 * 38.0).min(140.0))) + .overflow_y_scrollbar() + .child(v_flex().gap(px(6.0)).children(rows)), + ) + }) + .child( + Button::new("ssh-add-private-key") + .label(language.pick("选择私钥文件", "Choose private key")) + .icon(IconName::Plus) + .outline() + .small() + .w_full() + .h(px(40.0)) + .on_click(cx.listener(|this, _, window, cx| { + this.add_ssh_private_key(window, cx) + })), + ) + .child(editor_hint(language.tr("settings.ssh.default_keys"), cx)), + ) + }) + .when(!shows_password && !shows_keys, |body| { + body.child( + editor_hint( + language.tr(if editor.auth == SshAuthMode::Auto { + "settings.ssh.auth_auto_description" + } else { + "settings.ssh.auth_interactive_description" + }), + cx, + ) + .mt_4(), + ) + }) + } +} diff --git a/nebula_app/src/gpui_shell/terminal/answer_reader.rs b/nebula_app/src/gpui_shell/terminal/answer_reader.rs new file mode 100644 index 0000000..7eac986 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/answer_reader.rs @@ -0,0 +1,523 @@ +use std::collections::VecDeque; +use std::path::PathBuf; +use std::sync::Arc; + +use gpui::prelude::FluentBuilder as _; + +use gpui::{ + AnyElement, App, AppContext as _, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, + InteractiveElement as _, IntoElement, KeyDownEvent, ObjectFit, ParentElement as _, Render, + RenderImage, SharedString, StatefulInteractiveElement as _, Styled as _, StyledImage as _, + WeakEntity, Window, div, img, px, +}; +use gpui_component::text::{ + MarkdownExtensions, MarkdownNode, TextView, TextViewState, TextViewStyle, markdown_ast, +}; + +use crate::assistant_answer::AnswerSnapshot; +use crate::assistant_answer::document::{self, AnswerImage, IMAGE_LANGUAGE, ReaderDocument}; +use crate::gpui_shell::prelude::*; + +const IMAGE_BUDGET: usize = 64 * 1024 * 1024; +const LITERAL_BLOCK: &str = "nebula-answer-literal"; + +pub(super) enum ReaderEvent { + Close, +} + +enum ImageStatus { + Waiting, + Loading, + Ready(Arc), + Failed(String), +} + +struct ReaderImage { + spec: AnswerImage, + selected_path: Option, + status: ImageStatus, + revision: u64, + bytes: usize, +} + +pub(super) struct AnswerReader { + pub focus_handle: FocusHandle, + snapshot: AnswerSnapshot, + text: Entity, + raw_text: Entity, + raw_mode: bool, + raw_initialized: bool, + pending_markdown: Option, + extensions: MarkdownExtensions, + notice: Option, + images: Vec, + image_queue: VecDeque, + decoding: bool, + image_bytes: usize, + preview: Option>, + preparing: bool, + attention: bool, + newer_answer: bool, +} + +impl EventEmitter for AnswerReader {} + +impl AnswerReader { + pub fn new(snapshot: AnswerSnapshot, cx: &mut Context) -> Self { + let source = snapshot.content.source().cloned(); + let notice = snapshot.content.notice(); + let reader = Self { + focus_handle: cx.focus_handle(), + snapshot, + text: cx.new(|cx| TextViewState::markdown("", cx)), + raw_text: cx.new(|cx| TextViewState::markdown("", cx)), + raw_mode: false, + raw_initialized: false, + pending_markdown: None, + extensions: MarkdownExtensions::default(), + notice, + images: Vec::new(), + image_queue: VecDeque::new(), + decoding: false, + image_bytes: 0, + preview: None, + preparing: source.is_some(), + attention: false, + newer_answer: false, + }; + if let Some(source) = source { + cx.spawn(async move |reader, cx| { + let document = cx.background_spawn(async move { document::prepare(&source) }).await; + let _ = reader.update(cx, |reader, cx| reader.prepared(document, cx)); + }) + .detach(); + } + reader + } + + pub fn needs_attention(&mut self, cx: &mut Context) { + self.attention = true; + cx.notify(); + } + + pub fn answer_arrived(&mut self, cx: &mut Context) { + self.newer_answer = true; + cx.notify(); + } + + fn prepared(&mut self, document: ReaderDocument, cx: &mut Context) { + let starts = + document.images.iter().map(|image| image.placeholder_start).collect::>(); + self.images = document + .images + .into_iter() + .map(|spec| ReaderImage { + spec, + selected_path: None, + status: ImageStatus::Waiting, + revision: 0, + bytes: 0, + }) + .collect(); + if document.images_omitted > 0 { + self.notice = Some(format!( + "另有 {} 张图片超过本次阅读上限,原文未删减。", + document.images_omitted + )); + } + let weak = cx.entity().downgrade(); + self.extensions = MarkdownExtensions::default() + .block_parser(move |node, context| { + if contains_uncontrolled_media(node) { + let source = context.node_source(node)?.to_owned(); + return Some( + MarkdownNode::new(LITERAL_BLOCK, ()).text(source.clone()).markdown(source), + ); + } + let index = image_placeholder_index(node, context.offset(), &starts)?; + let source = context.node_source(node)?.to_owned(); + Some(MarkdownNode::new(IMAGE_LANGUAGE, index).text("[图片]").markdown(source)) + }) + .block_renderer(IMAGE_LANGUAGE, move |node, _, cx| match node.data::() { + Some(index) => render_image(&weak, *index, cx), + None => div().into_any_element(), + }) + .block_renderer(LITERAL_BLOCK, |node, _, _| div().child(node.as_text().to_owned())); + self.pending_markdown = Some(document.markdown); + self.preparing = false; + self.image_queue.extend(0..self.images.len()); + self.decode_next(cx); + cx.notify(); + } + + fn decode_next(&mut self, cx: &mut Context) { + if self.decoding { + return; + } + let Some(index) = self.image_queue.pop_front() else { return }; + let image = &mut self.images[index]; + image.status = ImageStatus::Loading; + let revision = image.revision; + let selected_path = image.selected_path.clone(); + let target = image.spec.target.clone(); + let base = self.snapshot.cwd.clone(); + self.decoding = true; + cx.spawn(async move |reader, cx| { + let result = cx + .background_spawn(async move { + let path = match selected_path { + Some(path) => path, + None => document::local_image_path( + &target, + base.as_deref().ok_or("回答未携带本地目录,请主动选择图片文件。")?, + )?, + }; + let bytes = document::read_image(&path)?; + super::inline_image::decode_bytes(&bytes) + }) + .await; + let _ = reader.update(cx, |reader, cx| { + reader.decoding = false; + if reader.images[index].revision == revision { + match result { + Ok((image, bytes)) + if reader.image_bytes.saturating_add(bytes) <= IMAGE_BUDGET => + { + reader.image_bytes += bytes; + reader.images[index].bytes = bytes; + reader.images[index].status = ImageStatus::Ready(image); + }, + Ok(_) => { + reader.images[index].status = ImageStatus::Failed( + "图片超过本次阅读的 64 MiB 总预算,未显示。".into(), + ) + }, + Err(error) => reader.images[index].status = ImageStatus::Failed(error), + } + } + reader.decode_next(cx); + cx.notify(); + }); + }) + .detach(); + } + + fn choose_image(&mut self, index: usize, cx: &mut Context) { + let picked = cx.prompt_for_paths(gpui::PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some("选择 PNG / JPEG 图片".into()), + }); + cx.spawn(async move |reader, cx| { + let Ok(Ok(Some(paths))) = picked.await else { return }; + let Some(path) = paths.into_iter().next() else { return }; + let _ = reader.update(cx, |reader, cx| { + let image = &mut reader.images[index]; + reader.image_bytes = reader.image_bytes.saturating_sub(image.bytes); + image.bytes = 0; + image.revision += 1; + image.selected_path = Some(path); + image.status = ImageStatus::Waiting; + reader.image_queue.retain(|queued| *queued != index); + reader.image_queue.push_back(index); + reader.decode_next(cx); + cx.notify(); + }); + }) + .detach(); + } + + fn on_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) { + if event.keystroke.key == "escape" { + if self.preview.take().is_none() { + cx.emit(ReaderEvent::Close); + } + cx.notify(); + cx.stop_propagation(); + } else if event.keystroke.modifiers.control + && event.keystroke.modifiers.shift + && event.keystroke.key == "c" + { + let text = if self.raw_mode { &self.raw_text } else { &self.text }; + let selected = text.read(cx).selected_text(); + if !selected.is_empty() { + cx.write_to_clipboard(ClipboardItem::new_string(selected)); + } + cx.stop_propagation(); + } + } +} + +fn contains_uncontrolled_media(node: &markdown_ast::Node) -> bool { + let mut pending = vec![node]; + while let Some(node) = pending.pop() { + if matches!( + node, + markdown_ast::Node::Image(_) + | markdown_ast::Node::ImageReference(_) + | markdown_ast::Node::Html(_) + ) { + return true; + } + if let Some(children) = node.children() { + pending.extend(children); + } + } + false +} + +fn image_placeholder_index( + node: &markdown_ast::Node, + offset: usize, + starts: &[usize], +) -> Option { + let markdown_ast::Node::Code(code) = node else { return None }; + if code.lang.as_deref() != Some(IMAGE_LANGUAGE) { + return None; + } + let index: usize = code.value.parse().ok()?; + let position = node.position()?; + (starts.get(index).copied()? == offset.checked_add(position.start.offset)?).then_some(index) +} + +fn render_image(reader: &WeakEntity, index: usize, cx: &mut App) -> AnyElement { + let Some(entity) = reader.upgrade() else { return div().into_any_element() }; + let state = entity.read(cx); + let Some(image) = state.images.get(index) else { return div().into_any_element() }; + let target: SharedString = image.spec.target.clone().into(); + let alt: SharedString = image.spec.alt.clone().into(); + let mut block = v_flex().w_full().gap_2().py_2(); + match &image.status { + ImageStatus::Ready(image) => { + let image = image.clone(); + let enlarged = image.clone(); + let weak = reader.clone(); + block = block + .child( + div() + .id(("answer-image", index)) + .w_full() + .h(px(320.0)) + .cursor_pointer() + .child(img(image).size_full().object_fit(ObjectFit::Contain)) + .on_click(move |_, _, cx| { + let _ = weak.update(cx, |reader, cx| { + reader.preview = Some(enlarged.clone()); + cx.notify(); + }); + }), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("点击放大 · Esc 返回"), + ); + }, + ImageStatus::Waiting | ImageStatus::Loading => { + block = block.child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("正在检查并加载本地图片…"), + ); + }, + ImageStatus::Failed(error) => { + let weak = reader.clone(); + block = block + .child(div().text_sm().text_color(cx.theme().muted_foreground).child(error.clone())) + .child( + Button::new(("answer-pick-image", index)) + .label("选择本地图片") + .ghost() + .small() + .on_click(move |_, _, cx| { + let _ = weak.update(cx, |reader, cx| reader.choose_image(index, cx)); + }), + ); + }, + } + block + .child(div().text_xs().text_color(cx.theme().muted_foreground).child(alt)) + .child(div().text_xs().text_color(cx.theme().muted_foreground).child(target)) + .into_any_element() +} + +impl Render for AnswerReader { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if let Some(markdown) = self.pending_markdown.take() { + cx.on_next_frame(window, move |reader, _, cx| { + reader.text.update(cx, |text, cx| text.push_str(&markdown, cx)); + }); + } + if self.raw_mode && !self.raw_initialized { + self.raw_initialized = true; + if let Some(source) = self.snapshot.content.source() { + let markdown = document::literal_markdown(source); + self.raw_text.update(cx, |text, cx| text.push_str(&markdown, cx)); + } + } + let provider = match self.snapshot.provider.as_str() { + "claude" => "Claude Code", + "codex" => "Codex", + _ => "Agent", + }; + let muted = cx.theme().muted_foreground; + let has_source = self.snapshot.content.source().is_some(); + let text = if self.raw_mode { &self.raw_text } else { &self.text }; + let extensions = + if self.raw_mode { MarkdownExtensions::default() } else { self.extensions.clone() }; + let mut root = v_flex() + .size_full() + .min_w_0() + .relative() + .overflow_hidden() + .track_focus(&self.focus_handle) + .on_key_down(cx.listener(Self::on_key_down)) + .child( + h_flex() + .h(px(32.0)) + .flex_shrink_0() + .px_2() + .gap_2() + .items_center() + .child( + Button::new("reader-return") + .label("终端") + .ghost() + .small() + .on_click(cx.listener(|_, _, _, cx| cx.emit(ReaderEvent::Close))), + ) + .child(div().text_sm().flex_1().child(format!("{provider} · 最近完整回答"))) + .child( + Button::new("reader-source") + .label(if self.raw_mode { "阅读" } else { "原文" }) + .ghost() + .small() + .disabled(!has_source) + .on_click(cx.listener(|reader, _, _, cx| { + reader.raw_mode = !reader.raw_mode; + cx.notify(); + })), + ) + .child( + Button::new("reader-copy") + .label("复制原文") + .ghost() + .small() + .disabled(!has_source) + .on_click(cx.listener(|reader, _, _, cx| { + if let Some(source) = reader.snapshot.content.source() { + cx.write_to_clipboard(ClipboardItem::new_string( + source.to_string(), + )); + } + })), + ), + ) + .when(self.attention, |root| { + root.child( + div() + .px_3() + .py_1() + .text_sm() + .text_color(cx.theme().warning) + .child("终端需要你处理输入或审批;点击「终端」返回。"), + ) + }) + .when(self.newer_answer, |root| { + root.child( + div() + .px_3() + .py_1() + .text_sm() + .text_color(muted) + .child("已收到新回答;本页不跳动,返回终端再点「阅读」查看。"), + ) + }) + .when_some(self.notice.clone(), |root, notice| { + root.child(div().px_3().py_2().text_sm().text_color(muted).child(notice)) + }) + .when(self.preparing, |root| { + root.child(div().p_3().text_sm().text_color(muted).child("正在整理完整回答…")) + }) + .child( + div().flex_1().min_h_0().min_w_0().px_3().py_2().child( + TextView::new(text) + .selectable(true) + .scrollable(true) + .markdown_extensions(extensions) + .on_link_click(|url, _, _, cx| { + if url.starts_with("https://") || url.starts_with("http://") { + cx.open_url(url); + } + }) + .style(TextViewStyle { + highlight_theme: cx.theme().highlight_theme.clone(), + is_dark: cx.theme().is_dark(), + ..TextViewStyle::default() + }), + ), + ); + if let Some(image) = self.preview.clone() { + root = root.child( + v_flex() + .absolute() + .inset_0() + .occlude() + .bg(cx.theme().background) + .p_3() + .gap_2() + .child( + h_flex().justify_between().child("图片预览").child( + Button::new("reader-close-preview") + .label("返回阅读 · Esc") + .ghost() + .small() + .on_click(cx.listener(|reader, _, _, cx| { + reader.preview = None; + cx.notify(); + })), + ), + ) + .child( + div() + .flex_1() + .min_h_0() + .child(img(image).size_full().object_fit(ObjectFit::Contain)), + ), + ); + } + root + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_generated_image_placeholders_can_load_files() { + let document = document::prepare("```nebula-answer-image\n0\n```\n\n![图](plot.png)"); + let starts = + document.images.iter().map(|image| image.placeholder_start).collect::>(); + let root = markdown::to_mdast(&document.markdown, &markdown::ParseOptions::gfm()).unwrap(); + let nodes = root.children().unwrap(); + assert_eq!(image_placeholder_index(&nodes[0], 0, &starts), None); + assert!(nodes.iter().any(|node| image_placeholder_index(node, 0, &starts) == Some(0))); + assert!(!contains_uncontrolled_media(&root)); + } + + #[test] + fn uncontrolled_media_falls_back_before_component_can_fetch_it() { + for source in [ + "![x](https://example.invalid/a)", + "", + "|图|\n|-|\n|![x](file:///private.png)|", + ] { + let root = markdown::to_mdast(source, &markdown::ParseOptions::gfm()).unwrap(); + assert!(contains_uncontrolled_media(&root)); + } + } +} diff --git a/nebula_app/src/gpui_shell/terminal/element.rs b/nebula_app/src/gpui_shell/terminal/element.rs index adbf0ff..369df79 100644 --- a/nebula_app/src/gpui_shell/terminal/element.rs +++ b/nebula_app/src/gpui_shell/terminal/element.rs @@ -15,9 +15,9 @@ use std::collections::HashSet; use gpui::{ - App, Bounds, CursorStyle, Element, ElementId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, - InspectorElementId, LayoutId, PathBuilder, Pixels, Rgba, SharedString, Style, TextRun, - UnderlineStyle, Window, fill, outline, point, px, relative, size, + App, Bounds, ContentMask, Corners, CursorStyle, Element, ElementId, GlobalElementId, Hitbox, + HitboxBehavior, Hsla, InspectorElementId, LayoutId, PathBuilder, Pixels, Rgba, SharedString, + Style, TextRun, UnderlineStyle, Window, fill, outline, point, px, relative, size, }; use gpui_component::ActiveTheme as _; use nebula_terminal::grid::Dimensions as _; @@ -57,7 +57,7 @@ impl TerminalElement { rows: usize, cols: usize, cx: &App, - ) -> Option<(RenderSnapshot, Option, usize, HashSet<(u16, u16)>)> { + ) -> Option<(RenderSnapshot, Option, usize, HashSet<(u16, u16)>, usize, i64)> { let view = self.view.read(cx); let session = view.session.as_ref()?; let hint_config = view.hint_config.clone(); @@ -84,7 +84,10 @@ impl TerminalElement { ); let history = term.history_size(); let dashed = super::osc_links::dashed_cells(&term, &hint_config, rows, cols); - Some((snapshot, prompt_line, history, dashed)) + let scrollback_floor = term.grid().scrolled_out(); + let image_anchor = scrollback_floor.saturating_add(history) as i64; + let viewport_top_abs = image_anchor + i64::from(term.viewport_origin_for(rows).0); + Some((snapshot, prompt_line, history, dashed, scrollback_floor, viewport_top_abs)) } /// 把**应用写死的**颜色按当前主题矫正,直接写回快照。 @@ -227,7 +230,7 @@ impl Element for TerminalElement { let focused = focus_handle.is_focused(window); // 旧壳只让光标本身参与闪烁;ghost、弹窗补齐和 IME 仍复用同一个坐标锚点。 let cursor_visible = self.view.read(cx).cursor_visible(); - let Some((mut snap, prompt_line, history, dashed)) = + let Some((mut snap, prompt_line, history, dashed, scrollback_floor, viewport_top_abs)) = self.snapshot(layout.rows, layout.cols, cx) else { return; @@ -275,6 +278,9 @@ impl Element for TerminalElement { self.view.update(cx, |view, _| { let size_info = super::math_overlay::grid_size_info(layout.cols, layout.rows, cell_w, line_h); + if view.uses_source_reader_math() { + return view.math.clear_frame(&size_info, math_pixels_per_point); + } let Some(term) = view.session.as_ref().map(|session| session.term.clone()) else { return view.math.clear_frame(&size_info, math_pixels_per_point); }; @@ -595,6 +601,46 @@ impl Element for TerminalElement { ); } + // Terminal images are ordinary scrollback content: the PTY reader + // reserved rows when it saw the protocol sequence, while this pass + // paints only images intersecting the current viewport. GPUI caches + // each RenderImage texture by ID, so steady-state scrolling is one + // clipped textured quad rather than a repeated decode/upload. + let inline_images = + self.view.update(cx, |view, _| view.inline_images.frame_images(scrollback_floor)); + if !inline_images.is_empty() { + let device_scale = window.scale_factor().max(0.1); + let viewport_top = bounds.origin.y.as_f32(); + let viewport_bottom = viewport_top + bounds.size.height.as_f32(); + for inline in inline_images { + let y = bounds.origin.y + + layout.line_height * (inline.abs_line as i64 - viewport_top_abs) as f32; + let mut width = inline.display_width / device_scale; + let mut height = inline.display_height / device_scale; + let fit = (bounds.size.width.as_f32() / width.max(1.0)).min(1.0); + width *= fit; + height *= fit; + let image_top = y.as_f32(); + if image_top + height <= viewport_top || image_top >= viewport_bottom { + continue; + } + let target = Bounds::new( + point(bounds.origin.x, y), + size(px(width.max(1.0)), px(height.max(1.0))), + ); + window.with_content_mask(Some(ContentMask { bounds }), |window| { + let _ = window.paint_image( + target, + target, + Corners::all(px(0.0)), + inline.image, + 0, + false, + ); + }); + } + } + if let Some(cursor) = &snap.cursor { let cursor_visual_col = visual_column(cursor.row, cursor.col); // 内联 ghost 余量与弹窗补全:结果由 view 在本帧 render 时算好 diff --git a/nebula_app/src/gpui_shell/terminal/inline_image.rs b/nebula_app/src/gpui_shell/terminal/inline_image.rs new file mode 100644 index 0000000..1203006 --- /dev/null +++ b/nebula_app/src/gpui_shell/terminal/inline_image.rs @@ -0,0 +1,312 @@ +//! Bounded decode/cache state for terminal image protocols. +//! +//! Encoded bytes arrive through the PTY, but decoding is serialized on the +//! background executor. This prevents a burst of compressed image bombs from +//! expanding concurrently on the UI thread or exhausting process memory. + +use std::collections::VecDeque; +use std::sync::Arc; + +use gpui::RenderImage; +use image::{Frame, ImageFormat}; + +const MAX_IMAGE_PIXELS: u64 = 16 * 1024 * 1024; +const MAX_IMAGE_ENCODED_BYTES: usize = 12 * 1024 * 1024; +const MAX_IMAGE_DECODED_BYTES: usize = 64 * 1024 * 1024; +const MAX_CACHED_IMAGES: usize = 16; +const MAX_CACHE_BYTES: usize = 128 * 1024 * 1024; +const MAX_QUEUED_IMAGES: usize = 16; +const MAX_QUEUED_BYTES: usize = 32 * 1024 * 1024; + +pub(super) struct PendingInlineImage { + sequence: u64, + data: Arc>, + abs_line: usize, + display_width: f32, + display_height: f32, + row_span: usize, +} + +#[derive(Clone)] +pub(super) struct InlineImage { + pub image: Arc, + pub abs_line: usize, + /// Display size reported by the terminal protocol, in device pixels. + pub display_width: f32, + pub display_height: f32, + pub row_span: usize, + decoded_bytes: usize, +} + +#[derive(Default)] +pub(super) struct InlineImageStore { + queued: VecDeque, + queued_bytes: usize, + decoding: bool, + next_sequence: u64, + images: VecDeque<(u64, InlineImage)>, + decoded_bytes: usize, +} + +impl InlineImageStore { + pub fn enqueue( + &mut self, + data: Arc>, + abs_line: usize, + display_width: f32, + display_height: f32, + row_span: usize, + ) -> Result<(), &'static str> { + if data.len() > MAX_IMAGE_ENCODED_BYTES { + return Err("encoded terminal image exceeds 12 MiB"); + } + if !display_width.is_finite() + || !display_height.is_finite() + || display_width <= 0.0 + || display_height <= 0.0 + { + return Err("terminal image has an invalid display size"); + } + if self.queued.len() >= MAX_QUEUED_IMAGES { + return Err("too many terminal images are waiting to decode"); + } + let next_bytes = self + .queued_bytes + .checked_add(data.len()) + .ok_or("terminal image queue size overflow")?; + if next_bytes > MAX_QUEUED_BYTES { + return Err("terminal image decode queue exceeds 32 MiB"); + } + + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.wrapping_add(1); + self.queued.push_back(PendingInlineImage { + sequence, + data, + abs_line, + display_width, + display_height, + row_span: row_span.max(1), + }); + self.queued_bytes = next_bytes; + Ok(()) + } + + pub fn start_next(&mut self) -> Option { + if self.decoding { + return None; + } + let pending = self.queued.pop_front()?; + self.queued_bytes = self.queued_bytes.saturating_sub(pending.data.len()); + self.decoding = true; + Some(pending) + } + + pub fn finish(&mut self, result: Result<(u64, InlineImage), String>) -> Result<(), String> { + self.decoding = false; + let (sequence, image) = result?; + if image.decoded_bytes > MAX_IMAGE_DECODED_BYTES { + return Err("decoded terminal image exceeds 64 MiB".to_owned()); + } + + while self.images.len() >= MAX_CACHED_IMAGES + || self.decoded_bytes.saturating_add(image.decoded_bytes) > MAX_CACHE_BYTES + { + let Some((_, evicted)) = self.images.pop_front() else { break }; + self.decoded_bytes = self.decoded_bytes.saturating_sub(evicted.decoded_bytes); + } + self.decoded_bytes += image.decoded_bytes; + self.images.push_back((sequence, image)); + Ok(()) + } + + /// Drop images whose reserved rows have left scrollback, then return cheap + /// Arc-backed clones for this frame. Terminal output is chronological, so + /// FIFO eviction is also the useful recency order here. + pub fn frame_images(&mut self, scrollback_floor: usize) -> Vec { + self.images + .retain(|(_, image)| image.abs_line.saturating_add(image.row_span) > scrollback_floor); + self.decoded_bytes = self.images.iter().map(|(_, image)| image.decoded_bytes).sum(); + self.images.iter().map(|(_, image)| image.clone()).collect() + } +} + +pub(super) fn decode(pending: PendingInlineImage) -> Result<(u64, InlineImage), String> { + let (render_image, decoded_bytes) = decode_bytes(pending.data.as_slice())?; + Ok(( + pending.sequence, + InlineImage { + image: render_image, + abs_line: pending.abs_line, + display_width: pending.display_width, + display_height: pending.display_height, + row_span: pending.row_span, + decoded_bytes, + }, + )) +} + +pub(super) fn decode_bytes(data: &[u8]) -> Result<(Arc, usize), String> { + if data.len() > MAX_IMAGE_ENCODED_BYTES { + return Err("encoded terminal image exceeds 12 MiB".to_owned()); + } + let format = image::guess_format(data) + .map_err(|error| format!("unsupported terminal image: {error}"))?; + if !matches!(format, ImageFormat::Png | ImageFormat::Jpeg | ImageFormat::Gif) { + return Err(format!("unsupported terminal image format: {format:?}")); + } + + let (encoded_width, encoded_height) = + image::ImageReader::with_format(std::io::Cursor::new(data), format) + .into_dimensions() + .map_err(|error| format!("failed to read terminal image dimensions: {error}"))?; + let pixels = u64::from(encoded_width) + .checked_mul(u64::from(encoded_height)) + .ok_or_else(|| "terminal image dimensions overflow".to_owned())?; + if pixels == 0 || pixels > MAX_IMAGE_PIXELS { + return Err("terminal image exceeds 16 megapixels".to_owned()); + } + let decoded_bytes = usize::try_from(pixels.checked_mul(4).unwrap_or(u64::MAX)) + .map_err(|_| "terminal image allocation size overflow".to_owned())?; + if decoded_bytes > MAX_IMAGE_DECODED_BYTES { + return Err("decoded terminal image exceeds 64 MiB".to_owned()); + } + + // `load_from_memory_with_format` decodes the first frame for animated + // formats. Animation is deliberately outside this first bounded slice. + let decoded = image::load_from_memory_with_format(data, format) + .map_err(|error| format!("failed to decode terminal image: {error}"))?; + let (actual_width, actual_height) = (decoded.width(), decoded.height()); + if actual_width != encoded_width || actual_height != encoded_height { + return Err("terminal image dimensions changed while decoding".to_owned()); + } + + let mut bgra = decoded.into_rgba8(); + for pixel in bgra.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + let render_image = Arc::new(RenderImage::new([Frame::new(bgra)])); + Ok((render_image, decoded_bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pending(data: Vec) -> PendingInlineImage { + PendingInlineImage { + sequence: 7, + data: Arc::new(data), + abs_line: 11, + display_width: 2.0, + display_height: 1.0, + row_span: 1, + } + } + + fn encoded_png() -> Vec { + let pixels = image::RgbaImage::from_pixel(2, 1, image::Rgba([10, 20, 30, 255])); + let mut output = std::io::Cursor::new(Vec::new()); + image::DynamicImage::ImageRgba8(pixels).write_to(&mut output, ImageFormat::Png).unwrap(); + output.into_inner() + } + + #[test] + fn queue_rejects_unbounded_encoded_backlog() { + let mut store = InlineImageStore::default(); + let chunk = Arc::new(vec![0; 3 * 1024 * 1024]); + for _ in 0..10 { + store.enqueue(chunk.clone(), 0, 1.0, 1.0, 1).unwrap(); + } + assert!(store.enqueue(chunk, 0, 1.0, 1.0, 1).is_err()); + } + + #[test] + fn decode_png_produces_one_bgra_frame() { + let (sequence, decoded) = decode(pending(encoded_png())).unwrap(); + assert_eq!(sequence, 7); + assert_eq!(decoded.image.frame_count(), 1); + assert_eq!(decoded.image.as_bytes(0), Some([30, 20, 10, 255, 30, 20, 10, 255].as_slice())); + } + + #[test] + fn decode_gif_keeps_only_the_first_frame() { + use image::codecs::gif::{GifEncoder, Repeat}; + + let mut encoded = Vec::new(); + { + let mut encoder = GifEncoder::new(&mut encoded); + encoder.set_repeat(Repeat::Infinite).unwrap(); + encoder + .encode_frame(Frame::new(image::RgbaImage::from_pixel( + 2, + 1, + image::Rgba([255, 0, 0, 255]), + ))) + .unwrap(); + encoder + .encode_frame(Frame::new(image::RgbaImage::from_pixel( + 2, + 1, + image::Rgba([0, 255, 0, 255]), + ))) + .unwrap(); + } + + let (_, decoded) = decode(pending(encoded)).unwrap(); + assert_eq!(decoded.image.frame_count(), 1); + assert_eq!(decoded.image.as_bytes(0), Some([0, 0, 255, 255, 0, 0, 255, 255].as_slice())); + } + + #[test] + fn decode_rejects_mp4_bytes() { + let Err(error) = decode(pending(b"\0\0\0\x18ftypmp42not-an-image".to_vec())) else { + panic!("MP4 input was accepted as a terminal image"); + }; + assert!(error.contains("unsupported terminal image")); + } + + #[test] + fn cache_is_bounded_by_count() { + let mut store = InlineImageStore::default(); + for sequence in 0..MAX_CACHED_IMAGES + 3 { + let rgba = image::RgbaImage::new(1, 1); + store + .finish(Ok(( + sequence as u64, + InlineImage { + image: Arc::new(RenderImage::new([Frame::new(rgba)])), + abs_line: sequence, + display_width: 1.0, + display_height: 1.0, + row_span: 1, + decoded_bytes: 4, + }, + ))) + .unwrap(); + } + assert_eq!(store.frame_images(0).len(), MAX_CACHED_IMAGES); + } + + #[test] + fn cache_drops_an_image_after_its_reserved_rows_leave_scrollback() { + let mut store = InlineImageStore::default(); + store + .finish(Ok(( + 0, + InlineImage { + image: Arc::new(RenderImage::new([Frame::new(image::RgbaImage::new(1, 1))])), + abs_line: 4, + display_width: 1.0, + display_height: 1.0, + row_span: 2, + decoded_bytes: 4, + }, + ))) + .unwrap(); + + assert_eq!(store.frame_images(5).len(), 1); + assert!(store.frame_images(6).is_empty()); + assert_eq!(store.decoded_bytes, 0); + } +} diff --git a/nebula_app/src/gpui_shell/terminal/mod.rs b/nebula_app/src/gpui_shell/terminal/mod.rs index d271fe2..2eb8c01 100644 --- a/nebula_app/src/gpui_shell/terminal/mod.rs +++ b/nebula_app/src/gpui_shell/terminal/mod.rs @@ -4,7 +4,9 @@ //! GPUI 类型。会话生命周期归 `session`,绘制归 `element`,交互归 `view`。 pub mod colors; +mod answer_reader; pub mod element; +mod inline_image; pub mod keymap; pub mod math_overlay; pub mod mouse_protocol; diff --git a/nebula_app/src/gpui_shell/terminal/osc_links.rs b/nebula_app/src/gpui_shell/terminal/osc_links.rs index 5393252..33a4a61 100644 --- a/nebula_app/src/gpui_shell/terminal/osc_links.rs +++ b/nebula_app/src/gpui_shell/terminal/osc_links.rs @@ -109,14 +109,14 @@ pub(super) fn open_hint(hint: &HintMatch, term: &Term, cx: let Some(text) = hint.text(term) else { return }; #[cfg(windows)] if let Some(path) = crate::file_uri::file_uri_to_local_path(&text) { - let _ = crate::daemon::spawn_daemon("explorer.exe", &[path.as_os_str()]); + let _ = crate::daemon::spawn_detached("explorer.exe", &[path.as_os_str()]); return; } match hint.action() { HintAction::Command(command) => { let mut args = command.args().to_vec(); args.push(text.into_owned()); - let _ = crate::daemon::spawn_daemon(command.program(), &args); + let _ = crate::daemon::spawn_detached(command.program(), &args); }, HintAction::Action(HintInternalAction::Copy) => { cx.write_to_clipboard(ClipboardItem::new_string(text.into_owned())); diff --git a/nebula_app/src/gpui_shell/terminal/session.rs b/nebula_app/src/gpui_shell/terminal/session.rs index 0593944..f45fbb5 100644 --- a/nebula_app/src/gpui_shell/terminal/session.rs +++ b/nebula_app/src/gpui_shell/terminal/session.rs @@ -87,6 +87,9 @@ pub(super) fn local_options( let mut options = tty::Options::default(); options.shell = shell; options.working_directory = cwd; + if let Err(error) = crate::platform::shell_integration::prepare(&mut options) { + log::warn!("Could not prepare shell integration: {error}"); + } #[cfg(windows)] if let Err(error) = tty::refresh_environment(&mut options) { log::warn!("Could not refresh the Windows environment for a new pane: {error}"); @@ -148,6 +151,7 @@ pub fn spawn( /// 业务层写进 grid,阶段流交给视图画连接横幅。 pub fn spawn_ssh( destination: String, + initial_remote_cwd: Option, window_size: WindowSize, term_config: Config, ) -> std::io::Result { @@ -160,8 +164,13 @@ pub fn spawn_ssh( screen_lines: window_size.num_lines as usize, }; let term = Arc::new(FairMutex::new(Term::new(term_config, &grid, proxy.clone()))); - let sender = - crate::ssh_session::spawn_session(destination, window_size, Arc::clone(&term), proxy)?; + let sender = crate::ssh_session::spawn_session_at( + destination, + initial_remote_cwd, + window_size, + Arc::clone(&term), + proxy, + )?; Ok((TerminalSession { term, notifier: Notifier(sender), shell_pid: 0 }, rx, stage_rx)) } diff --git a/nebula_app/src/gpui_shell/terminal/view.rs b/nebula_app/src/gpui_shell/terminal/view.rs index 730f96c..ab31c6b 100644 --- a/nebula_app/src/gpui_shell/terminal/view.rs +++ b/nebula_app/src/gpui_shell/terminal/view.rs @@ -193,6 +193,9 @@ pub enum TerminalLaunch { }, Ssh { destination: String, + /// Remote cwd inherited by duplicate-tab. `None` starts in the + /// account's ordinary login directory. + cwd: Option, }, } @@ -247,6 +250,9 @@ pub enum SidebarActivity { } pub struct TerminalView { + pub(super) answers: crate::assistant_answer::AnswerInbox, + pub(super) answer_reader: Option>, + preserve_agent_math_source: bool, pub pane_id: u64, pub session: Option, pub focus_handle: FocusHandle, @@ -326,6 +332,9 @@ pub struct TerminalView { pending_runtime_submit: Option, /// OSC 9 程序通知,攒到 render(那里才有 `Window` 判聚焦)再呈现。 pending_notify: Vec, + /// OSC 1337 图片的串行后台解码队列和有界像素缓存。图片不进入字符网格, + /// 只用事件携带的绝对行锚定到对应的 scrollback 位置。 + pub(super) inline_images: super::inline_image::InlineImageStore, /// SSH 直连目的地(`user@host[:port]`);本地会话为 None。 pub ssh_destination: Option, /// 创建本地 PTY 时冻结的受控环境,供独立 `pane.exec` child 复用。 @@ -627,7 +636,9 @@ impl TerminalView { TerminalLaunch::Local { cwd, .. } => { cwd.as_ref().map(|path| path.to_string_lossy().into_owned()).unwrap_or_default() }, - TerminalLaunch::Ssh { destination } => destination.clone(), + TerminalLaunch::Ssh { destination, cwd } => { + cwd.clone().unwrap_or_else(|| destination.clone()) + }, }; let (ssh_destination, initial_title, intro_shell_name, suggest_env, exec_context, spawned) = match launch { @@ -658,13 +669,13 @@ impl TerminalView { session::spawn(initial, term_config, options), ) }, - TerminalLaunch::Ssh { destination } => ( + TerminalLaunch::Ssh { destination, cwd } => ( Some(destination.clone()), destination.clone(), None, crate::display::SuggestEnv::Ssh { destination: destination.clone() }, None, - session::spawn_ssh(destination, initial, term_config), + session::spawn_ssh(destination, cwd, initial, term_config), ), }; let is_ssh = ssh_destination.is_some(); @@ -683,7 +694,9 @@ impl TerminalView { .or(runtime.shell.as_deref()) .unwrap_or_default() .to_ascii_lowercase(); - let intro_shell = if id.contains("wsl") || id.contains("bash") { + // Unix 没有 PowerShell 版欢迎脚本:一律走 fastfetch 回退链。 + let intro_shell = if !cfg!(windows) || id.contains("wsl") || id.contains("bash") + { crate::display::NebulaShell::Bash } else { crate::display::NebulaShell::PowerShell @@ -813,6 +826,9 @@ impl TerminalView { session, focus_handle, math: super::math_overlay::MathOverlay::default(), + answers: crate::assistant_answer::AnswerInbox::default(), + answer_reader: None, + preserve_agent_math_source: false, font: mono_font(&families[0], FontWeight::NORMAL, FontStyle::Normal), font_bold: mono_font(&families[1], FontWeight::BOLD, FontStyle::Normal), font_italic: mono_font(&families[2], FontWeight::NORMAL, FontStyle::Italic), @@ -846,6 +862,7 @@ impl TerminalView { agent_runtime_submit_pending: false, pending_runtime_submit: None, pending_notify: Vec::new(), + inline_images: super::inline_image::InlineImageStore::default(), ssh_destination, exec_context, ssh_stage: None, @@ -1007,7 +1024,16 @@ impl TerminalView { cx.notify(); } }, + TermEvent::InlineImage { data, abs_line, width, height } => { + let cell_height = f32::from(self.window_size.cell_height.max(1)); + let row_span = (height / cell_height).ceil().max(1.0) as usize; + match self.inline_images.enqueue(data, abs_line, width, height, row_span) { + Ok(()) => self.drive_inline_image_decode(cx), + Err(error) => log::warn!("terminal image dropped: {error}"), + } + }, TermEvent::CommandStart => { + self.answers.begin_command(); // 程序身份来自 Enter 时捕获的完整命令行;直接启动和 // npx/node/uvx 等包装启动都会归一成 Agent slug。它是 WSL // 看不见来宾进程时的主通道,hook 信封仍是更准的覆盖层。 @@ -1093,12 +1119,29 @@ impl TerminalView { } }, TermEvent::Bell => self.on_bell(cx), - // 仍未接线:内联图片(要图片管线把 abs_line 锚到网格)、OSC 1337 - // UserVar(AI 查询拦截)。 + // 仍未接线:OSC 1337 UserVar(AI 查询拦截)。 _ => {}, } } + fn drive_inline_image_decode(&mut self, cx: &mut Context) { + let Some(pending) = self.inline_images.start_next() else { return }; + let decode = cx + .background_executor() + .spawn(async move { super::inline_image::decode(pending) }); + cx.spawn(async move |this, cx| { + let result = decode.await; + let _ = this.update(cx, |view, cx| { + if let Err(error) = view.inline_images.finish(result) { + log::warn!("terminal image dropped: {error}"); + } + view.drive_inline_image_decode(cx); + cx.notify(); + }); + }) + .detach(); + } + /// `Exited` 只对宿主发一次;重复的退出信号(ChildExit 之后必然跟 Exit)只更新文案。 fn mark_exited(&mut self, message: String, cx: &mut Context) { self.pending_runtime_submit = None; @@ -1544,6 +1587,15 @@ impl TerminalView { path.is_dir().then_some(path) } + /// Absolute remote cwd reported by OSC 7/title integration. Unlike + /// [`Self::local_cwd`], this deliberately does not consult the host + /// filesystem; a POSIX path belongs to the SSH endpoint. + pub fn remote_cwd(&self) -> Option { + self.ssh_destination.as_ref()?; + let path = self.cwd.trim(); + (path.starts_with('/') && !path.chars().any(char::is_control)).then(|| path.to_owned()) + } + /// 这个 pane 的远端身份,仅当会话**已经就绪**时才给。 /// /// 两个条件都必须成立:既要是 SSH pane,又要已经握手认证完成。只看 @@ -1897,7 +1949,7 @@ impl TerminalView { } fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { - if self.exited.is_some() { + if self.exited.is_some() || self.answer_reader.is_some() { return; } // 旧壳 `keyboard.rs`:IME 组合中不编码、不拦截。GPUI Windows 在 @@ -2856,8 +2908,8 @@ impl Drop for TerminalView { } impl Focusable for TerminalView { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.answer_reader.as_ref().map_or_else(|| self.focus_handle.clone(), |reader| reader.read(cx).focus_handle.clone()) } } @@ -2906,6 +2958,9 @@ impl gpui::EntityInputHandler for TerminalView { _window: &mut Window, cx: &mut Context, ) { + if self.answer_reader.is_some() { + return; + } let had_marked_text = self.marked_text.take().is_some(); if !text.is_empty() && self.exited.is_none() { // 行镜像吃 IME 管道的字符(含中文提交与普通击键文本)。 @@ -2927,6 +2982,9 @@ impl gpui::EntityInputHandler for TerminalView { _window: &mut Window, cx: &mut Context, ) { + if self.answer_reader.is_some() { + return; + } let marked_text = if new_text.is_empty() { None } else { Some(new_text.to_string()) }; if self.marked_text != marked_text { self.marked_text = marked_text; @@ -2958,6 +3016,9 @@ impl gpui::EntityInputHandler for TerminalView { impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if let Some(reader) = &self.answer_reader { + return div().size_full().child(reader.clone()).into_any_element(); + } // OSC 9 程序通知:旧壳只在窗口没聚焦时递出去,聚焦时用户本来就在看这 // 个 pane。这里是最早能同时拿到 `Window` 和 view 可变引用的地方。用驻留 // 式 banner 而不是 toast——人不在看的时候弹一条 5 秒自动消失的提示等于 @@ -2979,7 +3040,6 @@ impl Render for TerminalView { } if let Some((title, body)) = self.pending_bell_notify.take() { if !window.is_window_active() { - #[cfg(windows)] crate::notify::toast(&title, &body); window.defer(cx, move |window, cx| { crate::gpui_shell::toast::banner( @@ -3096,7 +3156,47 @@ impl Render for TerminalView { ); } } - root + if let Some(answer) = self.answers.latest.clone() { + let provider = if answer.provider == "claude" { "Claude Code" } else { "Codex" }; + return div().size_full().relative() + .child(root) + .child(crate::gpui_shell::prelude::h_flex().absolute().top_0().right_2().px_2().gap_2().items_center() + .bg(cx.theme().background) + .child(div().text_xs().child(format!("{provider} · 回答"))) + .child(crate::gpui_shell::prelude::Button::new("answer-open") + .label("阅读").small() + .on_click(cx.listener(|view, _, window, cx| view.open_answer(window, cx))))) + .into_any_element(); + } + root.into_any_element() + } +} + +impl TerminalView { + fn open_answer(&mut self, window: &mut Window, cx: &mut Context) { + let Some(snapshot) = self.answers.latest.clone() else { return }; + let reader = cx.new(|cx| super::answer_reader::AnswerReader::new(snapshot, cx)); + if self.agent_status == crate::ai_agents::AgentStatus::Blocked { + reader.update(cx, |reader, cx| reader.needs_attention(cx)); + } + cx.subscribe_in(&reader, window, |view, _, _: &super::answer_reader::ReaderEvent, window, cx| { + view.answer_reader = None; + window.focus(&view.focus_handle, cx); + cx.notify(); + }).detach(); + let focus = reader.read(cx).focus_handle.clone(); + window.focus(&focus, cx); + self.answer_reader = Some(reader); + cx.notify(); + } + + pub(super) fn uses_source_reader_math(&mut self) -> bool { + self.preserve_agent_math_source |= self.running_program.as_deref().is_some_and(|program| { + matches!(program, "claude" | "codex") + }) || self.ai_session.as_ref().is_some_and(|session| { + matches!(session.source.as_str(), "claude" | "codex") + }); + self.preserve_agent_math_source } } diff --git a/nebula_app/src/gpui_shell/terminal/view/runtime.rs b/nebula_app/src/gpui_shell/terminal/view/runtime.rs index 8a4f734..730c0a0 100644 --- a/nebula_app/src/gpui_shell/terminal/view/runtime.rs +++ b/nebula_app/src/gpui_shell/terminal/view/runtime.rs @@ -32,6 +32,7 @@ impl TerminalView { /// the primary edge; the cached-prompt path calls the same reset so the two /// lifecycle routes cannot drift apart. pub(super) fn clear_foreground_agent_state(&mut self) -> bool { + self.answers.close(); let title_changed = self.running_program.take().is_some() || self.ai_session.take().is_some(); self.primary_agent_pid = None; @@ -700,6 +701,14 @@ impl TerminalView { // 指向一个早已结束的会话,真正活着的那个反而丢了。第一个报到的 agent // 进程就是主 agent(子代理必须由它 spawn,不可能先到)。 let from_primary_agent = self.claim_primary_agent(event); + if from_primary_agent && self.ssh_destination.is_none() && self.exited.is_none() { + if self.answers.observe(event, self.pane_id) && let Some(reader) = &self.answer_reader { + reader.update(cx, |reader, cx| reader.answer_arrived(cx)); + } + } + if event.kind == AiHookKind::NeedsAttention && let Some(reader) = &self.answer_reader { + reader.update(cx, |reader, cx| reader.needs_attention(cx)); + } if from_primary_agent && let Some(id) = event.session_id.as_deref() && let Err(error) = diff --git a/nebula_app/src/gpui_shell/theme.rs b/nebula_app/src/gpui_shell/theme.rs index 75d6659..a6bbf2f 100644 --- a/nebula_app/src/gpui_shell/theme.rs +++ b/nebula_app/src/gpui_shell/theme.rs @@ -375,8 +375,11 @@ pub fn settings_panel_bg(cx: &App) -> Hsla { solid(chrome_theme(effective_theme_name(cx)).skin().panel) } -/// 终端补齐浮层严格复用交互原型的中性色阶与语义色。语义色只落在图标、 -/// 右侧短码和选中竖线上,面板/选中底保持中性,密集候选不会变成彩色按钮墙。 +/// Terminal completion colors are derived from the active Nebula skin. The +/// popup used to switch between two hard-coded black/white palettes, which +/// made custom themes look as if a foreign window had been pasted on top. +/// Semantic colors still stay in the icon/tag column; panel and selection +/// surfaces now share the theme's own panel, ink, hairline, and accent ramp. pub(crate) struct CompletionColors { pub ghost: Hsla, pub panel_bg: Hsla, @@ -400,9 +403,6 @@ pub(crate) struct CompletionColors { pub(crate) fn completion_colors(cx: &App, _term_bg: GpuiRgba) -> CompletionColors { let sk = chrome_theme(effective_theme_name(cx)).skin(); - let opaque = |hex: u32| { - to_hsla(((hex >> 16) & 0xff) as u8, ((hex >> 8) & 0xff) as u8, (hex & 0xff) as u8) - }; let alpha = |hex: u32, a: f32| -> Hsla { GpuiRgba { r: f32::from(((hex >> 16) & 0xff) as u8) / 255.0, @@ -412,49 +412,26 @@ pub(crate) fn completion_colors(cx: &App, _term_bg: GpuiRgba) -> CompletionColor } .into() }; - - if sk.is_light { - CompletionColors { - ghost: opaque(0x8b938e), - panel_bg: opaque(0xffffff), - panel_border: alpha(0x2c5c48, 0.27), - panel_shadow: alpha(0x18221c, 0.20), - row_bg: opaque(0xffffff), - row_fg: opaque(0x5f6962), - match_fg: opaque(0x8b938e), - selected_bg: opaque(0xe6f0eb), - selected_fg: opaque(0x1d2520), - scroll_track: alpha(0x2c5c48, 0.18), - scroll_thumb: alpha(0x5f6962, 0.55), - history: opaque(0x916c2d), - command: opaque(0x247f68), - directory: opaque(0x3e7f49), - rust_file: opaque(0xa75935), - toml: opaque(0x8a567c), - markdown: opaque(0x39749b), - file: opaque(0x5f6962), - } - } else { - CompletionColors { - ghost: opaque(0x6f7872), - panel_bg: opaque(0x181b19), - panel_border: alpha(0xb0c9be, 0.23), - panel_shadow: alpha(0x000000, 0.46), - row_bg: opaque(0x181b19), - row_fg: opaque(0xa4ada7), - match_fg: opaque(0x6f7872), - selected_bg: opaque(0x29302c), - selected_fg: opaque(0xe9eeea), - scroll_track: alpha(0xb0c9be, 0.23), - scroll_thumb: alpha(0x6f7872, 0.72), - history: opaque(0xd7b06d), - command: opaque(0x79c9b4), - directory: opaque(0x88c58d), - rust_file: opaque(0xe59770), - toml: opaque(0xc596ba), - markdown: opaque(0x78afd5), - file: opaque(0xa4ada7), - } + let selected = crate::display::ui::surface::over(sk.accent_soft, sk.panel); + CompletionColors { + ghost: ink(sk.ink_faint), + panel_bg: solid(sk.panel), + panel_border: wash(sk.hairline), + panel_shadow: if sk.is_light { alpha(0x101820, 0.18) } else { alpha(0x000000, 0.46) }, + row_bg: solid(sk.panel), + row_fg: ink(sk.ink_dim), + match_fg: ink(sk.ink_faint), + selected_bg: solid(selected), + selected_fg: ink(sk.ink_strong), + scroll_track: wash(sk.track_off), + scroll_thumb: wash_scaled(sk.track_off, if sk.is_light { 1.8 } else { 2.2 }), + history: solid(sk.warn), + command: ink(sk.accent), + directory: solid(sk.ok), + rust_file: solid(sk.danger), + toml: ink(sk.accent), + markdown: ink(sk.accent), + file: ink(sk.ink_dim), } } diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 7c7654c..80ece57 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -56,6 +56,7 @@ mod remote_files; mod residency; mod send_to_chat; mod sidebar; +mod ssh_dialog; mod tab_drag; mod tab_menu; mod tab_scroll; @@ -283,6 +284,45 @@ pub fn init(cx: &mut App) { KeyBinding::new("alt-enter", ToggleFullscreen, None), KeyBinding::new("ctrl-shift-o", OpenQuickJump, None), ]); + #[cfg(target_os = "macos")] + bind_macos_command_keys(cx); +} + +/// macOS 的原生修饰键是 ⌘:在 Ctrl 绑定之外**追加**一套 ⌘ 绑定,不替换。 +/// 追加而非替换有两个原因:Ctrl+Shift 组合在 Mac 终端里没有别的含义,留着 +/// 不碍事;而 ⌘C/⌘V 必须存在,否则 Mac 用户第一反应就是「复制粘贴坏了」。 +/// 终端里的 Ctrl+C 仍然是 SIGINT——这里只绑 ⌘,不碰 Ctrl 的语义。 +#[cfg(target_os = "macos")] +fn bind_macos_command_keys(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("cmd-t", NewTerminal, None), + KeyBinding::new("cmd-n", NewWindow, None), + KeyBinding::new("cmd-w", CloseActiveTerminal, None), + KeyBinding::new("cmd-b", ToggleSidebar, None), + KeyBinding::new("cmd-,", OpenSettings, None), + KeyBinding::new("cmd-shift-p", ToggleCommandPalette, None), + KeyBinding::new("cmd-k", ToggleShellPicker, None), + KeyBinding::new("cmd-shift-f", ToggleFileTree, None), + KeyBinding::new("cmd-d", SplitRight, None), + KeyBinding::new("cmd-shift-d", SplitDown, None), + KeyBinding::new("cmd-shift-enter", ToggleZoom, None), + KeyBinding::new("cmd-alt-left", FocusPaneLeft, None), + KeyBinding::new("cmd-alt-right", FocusPaneRight, None), + KeyBinding::new("cmd-alt-up", FocusPaneUp, None), + KeyBinding::new("cmd-alt-down", FocusPaneDown, None), + KeyBinding::new("cmd-shift-]", SelectNextTab, None), + KeyBinding::new("cmd-shift-[", SelectPreviousTab, None), + KeyBinding::new("cmd-shift-g", ToggleGitPanel, None), + KeyBinding::new("cmd-=", IncreaseFontSize, None), + KeyBinding::new("cmd-+", IncreaseFontSize, None), + KeyBinding::new("cmd--", DecreaseFontSize, None), + KeyBinding::new("cmd-0", ResetFontSize, None), + KeyBinding::new("cmd-c", CopySelection, Some(crate::gpui_shell::terminal::KEY_CONTEXT)), + KeyBinding::new("cmd-v", PasteClipboard, Some(crate::gpui_shell::terminal::KEY_CONTEXT)), + KeyBinding::new("cmd-v", gpui_component::input::Paste, Some("Input")), + KeyBinding::new("cmd-ctrl-f", ToggleFullscreen, None), + KeyBinding::new("cmd-shift-o", OpenQuickJump, None), + ]); } /// 一个终端 pane:视图实体 + 宿主订阅。id 即 `TerminalView::pane_id` @@ -818,6 +858,10 @@ fn new_tab_insert_index( } } +fn settings_should_fold_sidebar(tabs_position: nebula_settings::TabsPositionName) -> bool { + tabs_position == nebula_settings::TabsPositionName::Sidebar +} + /// 新建终端弹窗的行:已检测 shell + SSH 主机,分组对照旧壳 /// `CommandPalette::open_profiles`(推荐 / 所有 Shell / SSH 主机)。 /// 三点菜单与 Ctrl+K 打开的是这份列表,不是通用命令面板。 @@ -990,6 +1034,14 @@ pub struct NebulaWorkspace { tab_rename: Option, next_pane_id: u64, active: usize, + /// Window-level Settings surface. It intentionally lives outside `tabs`: + /// opening preferences must not alter tab order, session state, or PTYs. + settings_surface: Option<(Entity, Subscription)>, + settings_open: bool, + /// Sidebar state sampled on entry. `None` means top-tab mode, where there + /// is no left rail to fold and the setting must remain untouched. + settings_restore_sidebar_collapsed: Option, + settings_restore_side_panel_open: bool, sidebar_collapsed: bool, /// 只折叠 TABS 分区,不影响整个左栏;与旧壳分区标题的 chevron 同义。 tabs_section_collapsed: bool, @@ -1184,6 +1236,17 @@ impl NebulaWorkspace { // 文件变化拆成互相矛盾的 restore/resume 状态。 let runtime = nebula_settings::RuntimeSettings::load(); let sidebar_width = runtime.sidebar_width; + #[cfg(windows)] + { + let mut icon_scale = window.scale_factor(); + cx.observe_window_bounds(window, move |_, window, _| { + if icon_scale != window.scale_factor() { + icon_scale = window.scale_factor(); + crate::gpui_shell::set_native_window_icon(window); + } + }) + .detach(); + } let initial_grid = Self::prepare_initial_grid( window, cx, @@ -1274,6 +1337,10 @@ impl NebulaWorkspace { .saturating_mul(1u64 << 32) .saturating_add(1), active: 0, + settings_surface: None, + settings_open: false, + settings_restore_sidebar_collapsed: None, + settings_restore_side_panel_open: false, sidebar_collapsed: false, tabs_section_collapsed: false, tabs_position: runtime.tabs_position, @@ -1447,7 +1514,7 @@ impl NebulaWorkspace { /// 重新读取设置,只会让右侧短标漂成新的默认值,和仍在运行/恢复出来的 /// 进程身份相互矛盾。 fn default_shell_tag() -> SharedString { - crate::shell_detect::shell_short_tag("powershell").into() + crate::shell_detect::shell_short_tag(&crate::platform::shell::default_shell_id()).into() } /// 冻结“新建这一刻”的默认 Shell 为共享 v4 launch 身份。 @@ -1527,7 +1594,9 @@ impl NebulaWorkspace { shell_name: profile.shell_id.clone().or_else(|| Some(profile.name.clone())), } }, - LaunchSession::Ssh { host } => TerminalLaunch::Ssh { destination: host.clone() }, + LaunchSession::Ssh { host } => { + TerminalLaunch::Ssh { destination: host.clone(), cwd: None } + }, } } @@ -1598,6 +1667,7 @@ impl NebulaWorkspace { } crate::gpui_shell::theme::apply_chrome_theme(cx); let runtime = nebula_settings::RuntimeSettings::load(); + crate::gpui_shell::apply_app_icon(runtime.app_icon, cx); self.sidebar_width = runtime.sidebar_width; self.tabs_position = runtime.tabs_position; self.sidebar_resizing = false; @@ -1687,6 +1757,9 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context, ) -> u64 { + if self.settings_open { + self.close_settings(window, cx); + } let shell_tag = Self::launch_shell_tag(&launch_session); let grid = self.inherited_grid(cx); let launch = Self::terminal_launch_from_session(&launch_session, cwd); @@ -1723,6 +1796,19 @@ impl NebulaWorkspace { window: &mut Window, cx: &mut Context, ) { + self.add_ssh_terminal_at(destination, None, window, cx); + } + + fn add_ssh_terminal_at( + &mut self, + destination: String, + remote_cwd: Option, + window: &mut Window, + cx: &mut Context, + ) { + if self.settings_open { + self.close_settings(window, cx); + } { let mut lists = crate::gpui_shell::ssh_hosts::SshHostLists::load(); if !lists.is_from_config(&destination) { @@ -1735,6 +1821,7 @@ impl NebulaWorkspace { let grid = self.inherited_grid(cx); let launch = crate::gpui_shell::terminal::view::TerminalLaunch::Ssh { destination: destination.clone(), + cwd: remote_cwd, }; let pane = self.new_pane(grid, launch, None, window, cx); let focused = pane.id; @@ -1786,6 +1873,7 @@ impl NebulaWorkspace { let launch = crate::gpui_shell::terminal::view::TerminalLaunch::Ssh { destination: destination.clone(), + cwd: None, }; let replacement = self.new_pane(grid, launch, None, window, cx); let replacement_id = replacement.id; @@ -2360,19 +2448,76 @@ impl NebulaWorkspace { .detach(); } - /// 设置页是单例 tab(旧壳同形态):已开则激活,未开则新建。 + /// Open Settings as a window-level page while preserving the active tab. + /// Side-tab mode folds its real left rail; top-tab mode has no such rail, + /// so touching `sidebar_collapsed` there would only create hidden state. fn open_settings(&mut self, window: &mut Window, cx: &mut Context) { - if let Some(ix) = self.tabs.iter().position(WorkspaceTab::is_settings) { - self.activate_tab(ix, window, cx); + if self.settings_open { + if let Some((view, _)) = self.settings_surface.as_ref() { + let focus = view.read(cx).focus_handle(cx); + window.defer(cx, move |window, cx| window.focus(&focus, cx)); + } return; } - let view = cx.new(|cx| SettingsPane::new(window, cx)); - let subscription = cx.subscribe_in(&view, window, Self::on_settings_event); - self.insert_new_tab(WorkspaceTab::Settings { view, _subscription: subscription }); + + if self.settings_surface.is_none() { + let view = cx.new(|cx| SettingsPane::new(window, cx)); + let subscription = cx.subscribe_in(&view, window, Self::on_settings_event); + self.settings_surface = Some((view, subscription)); + } + + self.settings_restore_sidebar_collapsed = + settings_should_fold_sidebar(self.tabs_position).then_some(self.sidebar_collapsed); + if self.settings_restore_sidebar_collapsed == Some(false) { + self.sidebar_collapsed = true; + self.sidebar_fold_armed = true; + } + + self.settings_restore_side_panel_open = self.side_panel.open; + if self.side_panel.open { + self.side_panel.open = false; + self.side_panel_anim_armed = true; + self.file_tree_menu = None; + } + + self.settings_open = true; + if let Some((view, _)) = self.settings_surface.as_ref() { + let focus = view.read(cx).focus_handle(cx); + window.defer(cx, move |window, cx| window.focus(&focus, cx)); + } + cx.notify(); + } + + fn close_settings(&mut self, window: &mut Window, cx: &mut Context) { + if !self.settings_open { + return; + } + self.settings_open = false; + + if let Some(collapsed) = self.settings_restore_sidebar_collapsed.take() { + if self.sidebar_collapsed != collapsed { + self.sidebar_collapsed = collapsed; + self.sidebar_fold_armed = true; + } + } + + let restore_side_panel = std::mem::take(&mut self.settings_restore_side_panel_open); + if restore_side_panel && !self.side_panel.open { + let view = self.side_panel.view; + self.toggle_side_panel(view, cx); + } self.focus_active(window, cx); cx.notify(); } + fn toggle_settings(&mut self, window: &mut Window, cx: &mut Context) { + if self.settings_open { + self.close_settings(window, cx); + } else { + self.open_settings(window, cx); + } + } + /// 调试/验收后门:`NEBULA_GPUI_OPEN_DOC=路径` 时启动即打开该文档, /// 与文件树双击同一条路由(公式渲染等文档 UI 的免点击验收)。 pub fn open_document_at_startup( @@ -2616,7 +2761,6 @@ impl NebulaWorkspace { format!("{} · {body}", attention.source), ); if !window.is_window_active() { - #[cfg(windows)] crate::notify::toast(&attention.source, &body); } cx.notify(); @@ -2674,6 +2818,7 @@ impl NebulaWorkspace { cx: &mut Context, ) { match event { + SettingsPaneEvent::Close => self.close_settings(window, cx), SettingsPaneEvent::Changed => { self.apply_runtime_settings(cx); // 键位编辑器可能改了 keybind= 表:注入/撤销随之热更新。 @@ -2722,6 +2867,9 @@ impl NebulaWorkspace { } fn activate_tab(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { + if self.settings_open { + self.close_settings(window, cx); + } if ix < self.tabs.len() && ix != self.active { self.active = ix; if let Some(meta) = self.tab_meta.get_mut(ix) { @@ -2737,6 +2885,10 @@ impl NebulaWorkspace { /// ctrl+shift+w(对齐旧壳 CloseTab 语义):tab 有分屏时关聚焦 pane, /// 单 pane 时关整个 tab;设置 tab 直接关 tab。 fn close_active(&mut self, window: &mut Window, cx: &mut Context) { + if self.settings_open { + self.close_settings(window, cx); + return; + } match self.tabs.get(self.active) { Some(WorkspaceTab::Terminal { panes, focused, .. }) if panes.len() > 1 => { let (tab_ix, pane_id) = (self.active, *focused); @@ -2751,9 +2903,16 @@ impl NebulaWorkspace { } fn focus_active(&self, window: &mut Window, cx: &mut Context) { + if self.settings_open { + if let Some((view, _)) = self.settings_surface.as_ref() { + let focus = view.read(cx).focus_handle(cx); + window.defer(cx, move |window, cx| window.focus(&focus, cx)); + } + return; + } let focus = match self.tabs.get(self.active) { Some(tab @ WorkspaceTab::Terminal { .. }) => match tab.focused_view() { - Some(view) => view.read(cx).focus_handle.clone(), + Some(view) => view.read(cx).focus_handle(cx), None => return, }, Some(WorkspaceTab::Settings { view, .. }) => view.read(cx).focus_handle(cx), @@ -3988,13 +4147,16 @@ impl NebulaWorkspace { fn duplicate_tab(&mut self, ix: usize, window: &mut Window, cx: &mut Context) { let Some(tab) = self.tabs.get(ix) else { return }; let Some(view) = tab.focused_view() else { return }; - let (ssh, cwd) = { + let (ssh, cwd, remote_cwd, pane_id) = { let view = view.read(cx); - (view.ssh_destination.clone(), view.local_cwd()) + (view.ssh_destination.clone(), view.local_cwd(), view.remote_cwd(), view.pane_id) }; let meta = self.meta(ix); if let Some(destination) = ssh { - self.add_ssh_terminal(destination, window, cx); + let remote_cwd = remote_cwd.or_else(|| { + self.remote_browser.path_for(pane_id, &destination).map(ToOwned::to_owned) + }); + self.add_ssh_terminal_at(destination, remote_cwd, window, cx); } else { let launch = match meta.launch { None | Some(crate::session::LaunchSession::Default) => { @@ -4145,7 +4307,8 @@ impl NebulaWorkspace { } }) .collect(); - let directory = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let directory = + crate::platform::dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); let prompt = cx.prompt_for_new_path(&directory, Some(&format!("{stem}.nebula-workspace.json"))); cx.spawn_in(window, async move |this, cx| { @@ -4638,28 +4801,35 @@ impl Render for NebulaWorkspace { self.sidebar_logo_images = sidebar_logo_images(sidebar_logo_target_px); self.sidebar_logo_target_px = sidebar_logo_target_px; } - let content: Option = match self.tabs.get(self.active) { - Some(WorkspaceTab::Terminal { .. }) => Some(self.render_terminal_tab(self.active, cx)), - Some(WorkspaceTab::Settings { view, .. }) => { - Some(gpui::IntoElement::into_any_element(view.clone())) - }, - Some(WorkspaceTab::Image { view }) => { - Some(gpui::IntoElement::into_any_element(view.clone())) - }, - Some(WorkspaceTab::Document { view, .. }) => { - Some(gpui::IntoElement::into_any_element(view.clone())) - }, - Some(WorkspaceTab::Code { view, .. }) => { - Some(gpui::IntoElement::into_any_element(view.clone())) - }, - None => None, + let content: Option = if self.settings_open { + self.settings_surface + .as_ref() + .map(|(view, _)| gpui::IntoElement::into_any_element(view.clone())) + } else { + match self.tabs.get(self.active) { + Some(WorkspaceTab::Terminal { .. }) => { + Some(self.render_terminal_tab(self.active, cx)) + }, + Some(WorkspaceTab::Settings { view, .. }) => { + Some(gpui::IntoElement::into_any_element(view.clone())) + }, + Some(WorkspaceTab::Image { view }) => { + Some(gpui::IntoElement::into_any_element(view.clone())) + }, + Some(WorkspaceTab::Document { view, .. }) => { + Some(gpui::IntoElement::into_any_element(view.clone())) + }, + Some(WorkspaceTab::Code { view, .. }) => { + Some(gpui::IntoElement::into_any_element(view.clone())) + }, + None => None, + } }; let files_active = self.side_panel.open && self.side_panel.view == crate::display::side_panel::PanelView::Files; let git_active = self.side_panel.open && self.side_panel.view == crate::display::side_panel::PanelView::Git; - let settings_active = - matches!(self.tabs.get(self.active), Some(WorkspaceTab::Settings { .. })); + let settings_active = self.settings_open; let top_tabs = self.tabs_position == nebula_settings::TabsPositionName::Top; // dock 预览:被拖 tab 悬于终端区时高亮目标半区(松手即挂到那侧)。 let dock_preview = self @@ -4758,7 +4928,7 @@ impl Render for NebulaWorkspace { cx.notify(); })) .on_action(cx.listener(|this, _: &OpenSettings, window, cx| { - this.open_settings(window, cx); + this.toggle_settings(window, cx); })) .on_action(cx.listener(|this, _: &ToggleCommandPalette, window, cx| { this.toggle_command_palette(window, cx); diff --git a/nebula_app/src/gpui_shell/workspace/quick_terminal.rs b/nebula_app/src/gpui_shell/workspace/quick_terminal.rs index aa93991..2b2a3d9 100644 --- a/nebula_app/src/gpui_shell/workspace/quick_terminal.rs +++ b/nebula_app/src/gpui_shell/workspace/quick_terminal.rs @@ -182,7 +182,11 @@ impl Global for QuickTerminalHotkey {} impl NebulaWorkspace { /// 注册快速终端热键并启动事件泵。只应由初始窗口调用一次。 pub(super) fn start_quick_terminal_hotkey(cx: &mut Context) { - if cx.has_global::() { + // 窗口显隐/贴边几何只有 Windows 实现;其余平台先不注册热键, + // 免得热键响应了却没有窗口出来。 + if !crate::platform::CAPABILITIES.quick_terminal_hotkey + || cx.has_global::() + { return; } let manager = match GlobalHotKeyManager::new() { diff --git a/nebula_app/src/gpui_shell/workspace/remote_files.rs b/nebula_app/src/gpui_shell/workspace/remote_files.rs index 6285fb7..b706406 100644 --- a/nebula_app/src/gpui_shell/workspace/remote_files.rs +++ b/nebula_app/src/gpui_shell/workspace/remote_files.rs @@ -32,8 +32,8 @@ use gpui::{ use crate::gpui_shell::prelude::*; use crate::ssh_sftp::{ - SftpConflictPolicy, SftpController, SftpEntry, SftpEntryKind, SftpPhase, SftpSnapshot, - SftpTransferOptions, + SftpBrowseSession, SftpConflictPolicy, SftpController, SftpEntry, SftpEntryKind, SftpPhase, + SftpSnapshot, SftpTransferOptions, }; use super::NebulaWorkspace; @@ -65,6 +65,9 @@ pub(super) struct RemoteBrowser { /// 每个 pane 最近一次已确认的列表。不能只记路径:切回来再按路径列一次, /// 本质上仍是 tab 激活触发刷新,也会丢掉选择和瞬时滚动上下文。 snapshots: HashMap, + /// One interactive SFTP subsystem per SSH pane. Bulk transfers deliberately + /// use `transfer` below so a large copy cannot head-of-line block browsing. + browse_sessions: HashMap, /// uniform_list 的 handle 内含滚动位置;每个 pane 各持一个,避免 A 主机的 /// 长目录把 B 主机顶到同一个偏移。 scrolls: HashMap, @@ -141,6 +144,13 @@ impl RemoteBrowser { self.destination = destination.clone(); self.loading = false; self.scrolls.entry(pane).or_insert_with(gpui::UniformListScrollHandle::new); + let replace_session = self + .browse_sessions + .get(&pane) + .is_none_or(|session| session.destination() != destination); + if replace_session { + self.browse_sessions.insert(pane, SftpBrowseSession::new(destination.clone())); + } let snapshot = self .snapshots @@ -171,6 +181,7 @@ impl RemoteBrowser { pub(super) fn forget(&mut self, pane: u64) { self.snapshots.remove(&pane); self.scrolls.remove(&pane); + self.browse_sessions.remove(&pane); if self.pane == Some(pane) { self.pane = None; self.destination.clear(); @@ -197,6 +208,18 @@ impl RemoteBrowser { // 世代号往前走一格:已经在路上的响应回来时会发现自己过期了。 self.generation = self.generation.wrapping_add(1); } + + /// Last confirmed remote path for duplicate-tab. It remains available + /// while the drawer is closed because `park_active` stores it per pane. + pub(super) fn path_for(&self, pane: u64, destination: &str) -> Option<&str> { + if self.pane == Some(pane) && self.destination == destination && !self.path.is_empty() { + return Some(&self.path); + } + self.snapshots + .get(&pane) + .filter(|snapshot| snapshot.destination == destination && !snapshot.path.is_empty()) + .map(|snapshot| snapshot.path.as_str()) + } } impl NebulaWorkspace { @@ -304,18 +327,19 @@ impl NebulaWorkspace { /// 列出一个远端目录并显示它。 pub(super) fn navigate_remote(&mut self, path: String, cx: &mut Context<'_, Self>) { let Some(pane) = self.remote_browser.pane else { return }; - let destination = self.remote_browser.destination.clone(); + let Some(session) = self.remote_browser.browse_sessions.get(&pane).cloned() else { + self.remote_browser.loading = false; + self.remote_browser.error = Some("远端浏览会话不可用,请重新打开文件抽屉".to_owned()); + cx.notify(); + return; + }; let generation = self.bump_remote_generation(); self.remote_browser.loading = true; cx.notify(); let target = path.clone(); cx.spawn(async move |this, cx| { - let listed = - remote_call( - || async move { crate::ssh_sftp::list_dir(&destination, &target).await }, - ) - .await; + let listed = remote_call(|| async move { session.list_dir(&target).await }).await; let _ = this.update(cx, |workspace, cx| { // 世代号和 pane 都要对:前者防乱序,后者防用户在等待期间切到 // 别的 pane(那时这份结果属于另一台机器)。 @@ -1399,6 +1423,7 @@ mod tests { assert_eq!(browser.path, "/srv/alpha"); assert_eq!(browser.entries, vec![entry("alpha.txt", "/srv/alpha/alpha.txt")]); assert_eq!(browser.selected.as_deref(), Some("/srv/alpha/alpha.txt")); + assert_eq!(browser.path_for(11, "alice@alpha"), Some("/srv/alpha")); assert!(!browser.loading); } @@ -1413,6 +1438,7 @@ mod tests { assert!(browser.path.is_empty()); assert!(browser.entries.is_empty()); assert!(browser.selected.is_none()); + assert_eq!(browser.path_for(7, "root@old-host"), None); } #[test] diff --git a/nebula_app/src/gpui_shell/workspace/residency.rs b/nebula_app/src/gpui_shell/workspace/residency.rs index aaed215..05ac3ed 100644 --- a/nebula_app/src/gpui_shell/workspace/residency.rs +++ b/nebula_app/src/gpui_shell/workspace/residency.rs @@ -85,7 +85,7 @@ impl NebulaWorkspace { }, // 更新通知由进程级 windowing dispatcher 选择 MRU 窗口;这个 // 旧的 workspace-local 分发器没有 Window,不能在此打开 Dialog。 - GpuiShellEvent::UpdateAvailable(_) => {}, + GpuiShellEvent::UpdateAvailable(_) | GpuiShellEvent::SshPrompt(_) => {}, } } } @@ -988,8 +988,11 @@ impl NebulaWorkspace { cx: &mut Context, ) -> bool { let runtime = nebula_settings::RuntimeSettings::load(); + // 平台藏不了窗口就没有「驻留」可言:不拦关闭,否则 Unix 上会变成 + // 既不关也不藏(设置页那一行也按同一能力位隐藏)。 + let can_hide = crate::platform::CAPABILITIES.hide_window_on_close; if residency_close_action( - runtime.keep_session, + runtime.keep_session && can_hide, self.has_live_terminal_panes(), runtime.tray, ) != ResidencyCloseAction::Hide diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs index 47e0bb4..a98e14b 100644 --- a/nebula_app/src/gpui_shell/workspace/sidebar.rs +++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs @@ -959,7 +959,7 @@ impl NebulaWorkspace { }) .tooltip("设置 (Ctrl+,)") .on_click(cx.listener(|this, _, window, cx| { - this.open_settings(window, cx); + this.toggle_settings(window, cx); })), ), ) diff --git a/nebula_app/src/gpui_shell/workspace/ssh_dialog.rs b/nebula_app/src/gpui_shell/workspace/ssh_dialog.rs new file mode 100644 index 0000000..0969f7d --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/ssh_dialog.rs @@ -0,0 +1,164 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::sync::Arc; + +use gpui::{App, AppContext as _, ParentElement as _, Styled as _, Window, div}; + +use crate::gpui_shell::prelude::*; +use crate::ssh_prompt::{Prompt, PromptKind, PromptResponse}; + +pub(super) fn show(request: Arc, window: &mut Window, cx: &mut App) { + if !request.is_pending() { + return; + } + let language = crate::gpui_shell::config::ui_language(cx); + let (title, description, is_secret, allow_save) = match &request.kind { + PromptKind::HostKey { host, port, fingerprint } => ( + language.pick("验证 SSH 主机", "Verify SSH host"), + format!("{host}:{port}\n\n{fingerprint}\n\n{}", language.pick( + "请通过可信渠道核对指纹。仅在确认主机身份后信任并保存。", + "Verify this fingerprint through a trusted channel before trusting and saving it.", + )), + false, + false, + ), + PromptKind::Secret { label, allow_save } => { + (language.pick("SSH 身份验证", "SSH authentication"), label.clone(), true, *allow_save) + }, + }; + let input = cx.new(|cx| InputState::new(window, cx).masked(true)); + let remember = Rc::new(Cell::new(false)); + let focus_input = input.clone(); + window.open_dialog(cx, move |dialog, window, _cx| { + let submitted = request.clone(); + let closed = request.clone(); + let submit_input = input.clone(); + let clear_input = input.clone(); + let save = remember.clone(); + let toggle_save = remember.clone(); + let mut body = div().w_full().flex().flex_col().gap_3(); + if is_secret { + body = body.child(Input::new(&input).mask_toggle().w_full()); + } + if allow_save { + body = body.child( + Checkbox::new("ssh-remember-secret") + .label(language.pick("保存在系统凭据库", "Save in system credential store")) + .checked(remember.get()) + .on_click(move |value, window, _| { + toggle_save.set(*value); + window.refresh(); + }), + ); + } + confirm_dialog( + dialog, + window, + title, + description.clone(), + if is_secret { + language.pick("继续", "Continue") + } else { + language.pick("信任并连接", "Trust and connect") + }, + language.pick("取消", "Cancel"), + ButtonVariant::Primary, + ) + .child(body) + .on_ok(move |_, window, cx| { + let response = if is_secret { + PromptResponse::Secret { + value: zeroize::Zeroizing::new( + submit_input.read(cx).value().as_bytes().to_vec(), + ), + save: allow_save && save.get(), + } + } else { + PromptResponse::Trust + }; + submitted.respond(response); + submit_input.update(cx, |input, cx| input.set_value("", window, cx)); + true + }) + .on_close(move |_, window, cx| { + closed.respond(PromptResponse::Cancel); + clear_input.update(cx, |input, cx| input.set_value("", window, cx)); + }) + }); + if is_secret { + focus_input.update(cx, |input, cx| input.focus(window, cx)); + } +} + +#[cfg(all(test, feature = "gpui-test-support"))] +mod tests { + use super::*; + use gpui::{Context, IntoElement, Modifiers, Render, TestAppContext, point}; + use gpui_component::Root; + + struct DialogProbe; + + impl Render for DialogProbe { + fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement { + div().size_full().children(Root::render_dialog_layer(window, cx)) + } + } + + fn click(cx: &mut gpui::VisualTestContext, selector: &'static str) { + let bounds = cx.debug_bounds(selector).expect("SSH dialog button is visible"); + cx.simulate_click( + point( + bounds.origin.x + bounds.size.width * 0.5, + bounds.origin.y + bounds.size.height * 0.5, + ), + Modifiers::default(), + ); + } + + #[gpui::test] + fn ssh_dialog_cancel_does_not_accept_an_unknown_host(cx: &mut TestAppContext) { + cx.update(gpui_component::init); + let (_, cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|_| DialogProbe); + Root::new(view, window, cx) + }); + let (request, mut response) = Prompt::for_test(PromptKind::HostKey { + host: "example.test".into(), + port: 22, + fingerprint: "SHA256:verify-me".into(), + }); + cx.update(|window, cx| { + show(request, window, cx); + let _ = window.draw(cx); + }); + click(cx, "confirm-dialog-cancel"); + assert!(matches!(response.try_recv(), Ok(PromptResponse::Cancel))); + } + + #[gpui::test] + fn ssh_dialog_password_is_delivered_only_on_confirmation(cx: &mut TestAppContext) { + cx.update(gpui_component::init); + let (_, cx) = cx.add_window_view(|window, cx| { + let view = cx.new(|_| DialogProbe); + Root::new(view, window, cx) + }); + let (request, mut response) = Prompt::for_test(PromptKind::Secret { + label: "Password for example.test".into(), + allow_save: true, + }); + cx.update(|window, cx| { + show(request, window, cx); + let _ = window.draw(cx); + }); + cx.simulate_input("test-secret"); + assert!(response.try_recv().is_err()); + click(cx, "confirm-dialog-ok"); + match response.try_recv() { + Ok(PromptResponse::Secret { value, save }) => { + assert_eq!(&*value, b"test-secret"); + assert!(!save); + }, + _ => panic!("confirm must deliver the entered password"), + } + } +} diff --git a/nebula_app/src/gpui_shell/workspace/tests.rs b/nebula_app/src/gpui_shell/workspace/tests.rs index bc1ac76..5f04b80 100644 --- a/nebula_app/src/gpui_shell/workspace/tests.rs +++ b/nebula_app/src/gpui_shell/workspace/tests.rs @@ -2,6 +2,12 @@ use super::*; +#[test] +fn settings_only_fold_the_sidebar_in_sidebar_tab_mode() { + assert!(settings_should_fold_sidebar(nebula_settings::TabsPositionName::Sidebar)); + assert!(!settings_should_fold_sidebar(nebula_settings::TabsPositionName::Top)); +} + #[cfg(feature = "gpui-test-support")] mod title_bar_panel_control_tests { use super::*; diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index aa80dbb..fe71050 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -648,7 +648,7 @@ impl NebulaWorkspace { .on_click(move |_, window, cx| { if let Some(workspace) = settings.upgrade() { workspace.update(cx, |this, cx| { - this.open_settings(window, cx); + this.toggle_settings(window, cx); }); } }), diff --git a/nebula_app/src/gpui_shell/workspace/update_dialog.rs b/nebula_app/src/gpui_shell/workspace/update_dialog.rs index 6ca57f2..2123431 100644 --- a/nebula_app/src/gpui_shell/workspace/update_dialog.rs +++ b/nebula_app/src/gpui_shell/workspace/update_dialog.rs @@ -233,6 +233,14 @@ pub(crate) fn open_update_dialog( "Nebula will download and verify the installer, then wait for your confirmation before installing.", ) .into(), + // 非 Windows 目前没有自动安装路径(能力表 `self_update_install`): + // 不说「缺 Windows 安装包」,那对 Mac/Linux 用户是句错话。 + _ if !crate::platform::CAPABILITIES.self_update_install => language + .pick( + "此平台暂不支持应用内自动更新;请到发布页下载对应的安装包。", + "In-app automatic updates are not available on this platform yet. Download the matching package from the Releases page.", + ) + .into(), _ => language .pick( "此 release 没有可验证的 Windows x64 安装包,已禁用自动执行;可打开发布页手动处理。", diff --git a/nebula_app/src/gpui_shell/workspace/windowing.rs b/nebula_app/src/gpui_shell/workspace/windowing.rs index a6613b5..52aad79 100644 --- a/nebula_app/src/gpui_shell/workspace/windowing.rs +++ b/nebula_app/src/gpui_shell/workspace/windowing.rs @@ -663,6 +663,18 @@ pub(crate) fn dispatch_shell_events(events: Vec, cx: &mut App) { super::show_update_notification(result, window, cx); }); }, + GpuiShellEvent::SshPrompt(request) => { + let Some(entry) = entries_by_mru(cx).into_iter().next() else { + request.respond(crate::ssh_prompt::PromptResponse::Cancel); + continue; + }; + let pending = request.clone(); + if entry.handle.update(cx, move |_, window, cx| { + super::ssh_dialog::show(pending, window, cx); + }).is_err() { + request.respond(crate::ssh_prompt::PromptResponse::Cancel); + } + }, } } publish_runtime_snapshot(cx); diff --git a/nebula_app/src/main.rs b/nebula_app/src/main.rs index 569e0a6..238951e 100644 --- a/nebula_app/src/main.rs +++ b/nebula_app/src/main.rs @@ -41,6 +41,8 @@ mod ai_assistant; mod ai_hook; mod ai_providers; mod ai_sessions; +mod app_icon; +mod assistant_answer; mod atomic_file; mod backup_remote; mod cli; @@ -83,7 +85,7 @@ mod notify; #[cfg(windows)] mod panic; mod platform; -#[cfg(unix)] +#[cfg(all(unix, feature = "legacy-shell"))] mod polling; mod process_tree; mod provider_test; @@ -104,6 +106,7 @@ mod shell_detect; mod ssh; mod ssh_credentials; mod ssh_profiles; +mod ssh_prompt; mod ssh_proxy; mod ssh_session; mod ssh_sftp; @@ -143,7 +146,9 @@ use crate::config::monitor::ConfigMonitor; use crate::event::{Event, Processor}; #[cfg(target_os = "macos")] use crate::macos::locale; -#[cfg(unix)] +// 旧壳的 Unix socket IPC 与信号轮询;GPUI 壳的控制面走 runtime_api 自己的 +// 传输,不再依赖这套 winit `Event` 代理。 +#[cfg(all(unix, feature = "legacy-shell"))] use crate::polling::{IoListener, ipc}; fn main() -> Result<(), Box> { @@ -205,16 +210,18 @@ fn main() -> Result<(), Box> { // (ATTACH),不能再拉一套 PTY。 #[cfg(feature = "gpui-shell")] if wants_gpui_shell(&options) { + let initial_cwd = options + .window_options + .terminal_options + .resolved_working_directory() + .filter(|path| path.is_dir()) + .and_then(|path| std::path::absolute(path).ok()); + platform::startup::prepare_gui(); let _log_file = logging::initialize(&options).expect("Unable to initialize logger"); #[cfg(windows)] if try_hand_over_to_resident(&options) { return Ok(()); } - let initial_cwd = options - .window_options - .terminal_options - .resolved_working_directory() - .filter(|path| path.is_dir()); gpui_shell::run_shell(initial_cwd); return Ok(()); } @@ -228,7 +235,7 @@ fn main() -> Result<(), Box> { Some(Subcommands::Tab(options)) => runtime_api::shortcuts::tab(options)?, Some(Subcommands::Pane(options)) => runtime_api::shortcuts::pane(options)?, Some(Subcommands::Agent(options)) => runtime_api::shortcuts::agent(options)?, - #[cfg(unix)] + #[cfg(all(unix, feature = "legacy-shell"))] Some(Subcommands::Msg(options)) => msg(options)?, Some(Subcommands::Migrate(options)) => migrate::migrate(options), Some(Subcommands::Config(options)) => std::process::exit(config_cli::run(options)), @@ -252,7 +259,7 @@ fn main() -> Result<(), Box> { } /// `msg` subcommand entrypoint. -#[cfg(unix)] +#[cfg(all(unix, feature = "legacy-shell"))] #[allow(unused_mut)] fn msg(mut options: MessageOptions) -> Result<(), Box> { #[cfg(not(any(target_os = "macos", windows)))] diff --git a/nebula_app/src/math/compile.rs b/nebula_app/src/math/compile.rs index 370838b..e467350 100644 --- a/nebula_app/src/math/compile.rs +++ b/nebula_app/src/math/compile.rs @@ -7,7 +7,7 @@ use std::borrow::Cow; use super::layout::{MathLayout, layout_formula}; -use super::parser::parse_formula; +use super::parser::{parse_formula, parse_formula_source}; use super::{MathError, MathErrorKind, MathLimits}; const MAX_ENTITY_DECODE_PASSES: usize = 4; @@ -26,6 +26,17 @@ pub(crate) fn compile_formula( layout_formula(&formula, pixel_size * super::OPTICAL_SCALE, pixels_per_point, limits) } +pub(crate) fn compile_formula_source( + source: &str, + display: bool, + pixel_size: f32, + pixels_per_point: f32, + limits: MathLimits, +) -> Result { + let formula = parse_formula_source(source, display, limits)?; + layout_formula(&formula, pixel_size * super::OPTICAL_SCALE, pixels_per_point, limits) +} + /// Normalize transport-level text without inferring mathematical structure. /// /// HTML named/numeric references are decoded generically, CRLF is normalized, @@ -76,7 +87,7 @@ pub(super) fn normalize_formula_source<'a>( /// `pulldown-latex` requires braces around `\frac` arguments, while TeX also /// accepts a single token (`\frac13`, `\frac\pi2`). Canonicalize that standard /// shorthand before parsing without changing already-grouped arguments. -fn brace_unbraced_fraction_arguments(source: &str) -> Option { +pub(super) fn brace_unbraced_fraction_arguments(source: &str) -> Option { let mut normalized = String::with_capacity(source.len()); let mut copied_until = 0usize; let mut offset = 0usize; @@ -283,13 +294,63 @@ fn is_tex_dimension(source: &str) -> bool { #[cfg(test)] mod tests { - use super::{compile_formula, normalize_formula_source}; + use super::{compile_formula, compile_formula_source, normalize_formula_source}; use crate::math::DEFAULT_LIMITS; const QUADRATIC: &str = r"x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}"; const CASES: &str = r"f(x)=\begin{cases}x^2,&x\geq 0\\-x,&x<0\end{cases}"; const MATRIX: &str = r"A=\begin{pmatrix}1&2&3\\4&5&6\\7&8&9\end{pmatrix}"; + #[test] + fn original_multiline_formulas_keep_operators_and_alignment_rows() { + let integral = + "\\widehat{f}(\\xi)\n=\n+ \\int_{-\\infty}^{\\infty} f(x)e^{-2\\pi i x\\xi}\\,dx"; + let aligned = "\\begin{aligned}\na&=b+c\\\\\nd&=e-f\n\\end{aligned}"; + for source in [QUADRATIC, CASES, MATRIX, integral, aligned, r"\frac13+\frac\pi2"] { + let original = compile_formula_source(source, true, 18.0, 1.0, DEFAULT_LIMITS) + .unwrap_or_else(|error| panic!("original formula failed: {source:?}: {error:?}")); + let expected = compile_formula(source, true, 18.0, 1.0, DEFAULT_LIMITS).unwrap(); + assert_eq!(original, expected); + assert!(!original.glyphs.is_empty()); + } + } + + #[test] + fn original_source_never_decodes_entities_or_guesses_missing_row_breaks() { + let encoded = r"\begin{pmatrix}a&b\\c&d\end{pmatrix}"; + let expected = compile_formula( + r"\begin{pmatrix}a&b\\c&d\end{pmatrix}", + true, + 18.0, + 1.0, + DEFAULT_LIMITS, + ) + .unwrap(); + if let Ok(original) = compile_formula_source(encoded, true, 18.0, 1.0, DEFAULT_LIMITS) { + assert_ne!(original, expected); + } + let damaged = "\\begin{aligned}\na&=b \\\nc&=d\n\\end{aligned}"; + let repaired = compile_formula(damaged, true, 18.0, 1.0, DEFAULT_LIMITS).unwrap(); + if let Ok(original) = compile_formula_source(damaged, true, 18.0, 1.0, DEFAULT_LIMITS) { + assert_ne!(original, repaired); + } + } + + #[test] + fn original_source_does_not_silently_rewrite_arrows_or_drop_commands() { + let original = compile_formula_source("a->b", true, 18.0, 1.0, DEFAULT_LIMITS).unwrap(); + let substituted = compile_formula("a->b", true, 18.0, 1.0, DEFAULT_LIMITS).unwrap(); + assert_ne!(original, substituted); + assert!( + compile_formula_source(r"\unsupportedNebulaMacro{x}", true, 18.0, 1.0, DEFAULT_LIMITS) + .is_err() + ); + assert!( + compile_formula_source(r"\input{private.tex}", true, 18.0, 1.0, DEFAULT_LIMITS) + .is_err() + ); + } + #[test] fn screenshot_formulas_share_one_successful_compile_path() { for source in [QUADRATIC, CASES, MATRIX] { diff --git a/nebula_app/src/math/mod.rs b/nebula_app/src/math/mod.rs index ea220b7..2db128e 100644 --- a/nebula_app/src/math/mod.rs +++ b/nebula_app/src/math/mod.rs @@ -12,7 +12,7 @@ pub(crate) mod rasterizer; pub(crate) mod spacing; pub(crate) mod validate; -pub(crate) use compile::compile_formula; +pub(crate) use compile::{compile_formula, compile_formula_source}; pub(crate) use parser::parse_formula; pub(crate) use validate::validate; diff --git a/nebula_app/src/math/parser.rs b/nebula_app/src/math/parser.rs index 5e1ff22..5895ccb 100644 --- a/nebula_app/src/math/parser.rs +++ b/nebula_app/src/math/parser.rs @@ -28,15 +28,37 @@ pub(crate) fn parse_formula( let style = if display { MathStyle::Display } else { MathStyle::Text }; let arrows = normalize_ascii_math_arrows(source.as_ref()); let normalized_source = substitute_unsupported_presentation(arrows.as_ref()); + parse_normalized_formula(normalized_source.as_ref(), display, style, limits) +} + +pub(crate) fn parse_formula_source( + source: &str, + display: bool, + limits: MathLimits, +) -> Result { + validate(source, limits)?; + let source = super::compile::brace_unbraced_fraction_arguments(source) + .map(Cow::Owned) + .unwrap_or(Cow::Borrowed(source)); + let style = if display { MathStyle::Display } else { MathStyle::Text }; + parse_normalized_formula(source.as_ref(), display, style, limits) +} + +fn parse_normalized_formula( + source: &str, + display: bool, + style: MathStyle, + limits: MathLimits, +) -> Result { // pulldown-latex intentionally rejects `\\` outside an alignment // environment. Markdown math blocks commonly use it directly, so wrap // only formulas that contain an environment-external line break in a // one-column gathered environment. Existing matrix/align rows retain // their own scope and are not double-wrapped. - let parser_source = if display && has_unscoped_line_break(normalized_source.as_ref()) { - Cow::Owned(format!(r"\begin{{gathered}}{}\end{{gathered}}", normalized_source.as_ref())) + let parser_source = if display && has_unscoped_line_break(source) { + Cow::Owned(format!(r"\begin{{gathered}}{}\end{{gathered}}", source)) } else { - Cow::Borrowed(normalized_source.as_ref()) + Cow::Borrowed(source) }; let storage = Storage::new(); let mut builder = Builder::new(style, limits); @@ -67,18 +89,28 @@ fn normalize_ascii_math_arrows(source: &str) -> Cow<'_, str> { /// /// An unknown primitive aborts the whole parse, so a single `\boxed{…}` — the /// shape reasoning models put their final answer in — leaves the entire -/// equation on screen as raw TeX. Both rewrites keep the mathematics and drop +/// equation on screen as raw TeX. These rewrites keep the mathematics and drop /// only decoration: /// /// * `\boxed{X}` -> `X`. A real frame needs a layout node we do not have; an /// unframed equation beats an unrendered one. -/// * `\tag{n}` -> `\quad(n)`. The number stays beside the equation instead of -/// being pushed to the margin, which would need equation-level numbering. +/// * `\tag{n}` -> `\quad(n)` and `\tag*{n}` -> `\quad n`. The number stays +/// beside the equation instead of being pushed to the margin, which would +/// need equation-level numbering. +/// * `\label{name}` disappears, while unresolved `\ref{name}` and +/// `\eqref{name}` remain readable as `name` and `(name)`. Resolving labels +/// across terminal output would require document-level state; preserving a +/// useful formula is preferable to rejecting the entire expression. +/// * `\leftroot` and `\uproot` (amsmath's cosmetic root-index offsets) and +/// their numeric argument disappear. The root itself remains renderable. /// /// Anything else passes through untouched, so an unsupported command still /// fails the parse and keeps its source literal. fn substitute_unsupported_presentation(source: &str) -> Cow<'_, str> { - if !source.contains(r"\boxed") && !source.contains(r"\tag") { + if ![r"\boxed", r"\tag", r"\label", r"\ref", r"\eqref", r"\leftroot", r"\uproot"] + .iter() + .any(|command| source.contains(command)) + { return Cow::Borrowed(source); } @@ -91,9 +123,22 @@ fn substitute_unsupported_presentation(source: &str) -> Cow<'_, str> { + source[name_start..] .find(|character: char| !character.is_ascii_alphabetic()) .unwrap_or(source.len() - name_start); - let (prefix, suffix) = match &source[name_start..name_end] { - "boxed" => ("", ""), - "tag" => (r"\quad(", ")"), + let name = &source[name_start..name_end]; + if matches!(name, "leftroot" | "uproot") { + output.push_str(&source[cursor..command]); + cursor = root_adjustment_end(source, name_end); + continue; + } + + let starred = name == "tag" && source[name_end..].starts_with('*'); + let argument_at = name_end + usize::from(starred); + let (prefix, suffix, keep_argument) = match name { + "boxed" => ("", "", true), + "tag" if starred => (r"\quad ", "", true), + "tag" => (r"\quad(", ")", true), + "label" => ("", "", false), + "ref" => (r"\text{", "}", true), + "eqref" => (r"\text{(", ")}", true), // Every other command passes through. An empty name is an escaped // character whose second `char` must be consumed here, or `\\` // would be rescanned as the start of a command. @@ -111,16 +156,18 @@ fn substitute_unsupported_presentation(source: &str) -> Cow<'_, str> { continue; }, }; - let Some(argument) = brace_group(source, name_end) else { - output.push_str(&source[cursor..name_end]); - cursor = name_end; + let Some(argument) = brace_group(source, argument_at) else { + output.push_str(&source[cursor..argument_at]); + cursor = argument_at; continue; }; output.push_str(&source[cursor..command]); output.push_str(prefix); - // The argument may hold another one of these commands; recursion depth - // is bounded by the group depth `validate` already enforces. - output.push_str(&substitute_unsupported_presentation(&source[argument.clone()])); + if keep_argument { + // The argument may hold another one of these commands; recursion + // depth is bounded by the group depth `validate` already enforces. + output.push_str(&substitute_unsupported_presentation(&source[argument.clone()])); + } output.push_str(suffix); cursor = argument.end + 1; } @@ -128,6 +175,44 @@ fn substitute_unsupported_presentation(source: &str) -> Cow<'_, str> { Cow::Owned(output) } +/// Consume the optional signed integer accepted by amsmath's root-index +/// positioning commands. Non-numeric content is left in place so compatibility +/// rewriting cannot silently discard arbitrary formula source. +fn root_adjustment_end(source: &str, command_end: usize) -> usize { + let Some(relative) = source[command_end..].find(|character: char| !character.is_whitespace()) + else { + return command_end; + }; + let argument_start = command_end + relative; + + if source[argument_start..].starts_with('{') { + if let Some(argument) = brace_group(source, argument_start) { + let value = source[argument.clone()].trim(); + if !value.is_empty() + && value + .strip_prefix(['+', '-']) + .unwrap_or(value) + .chars() + .all(|character| character.is_ascii_digit()) + { + return argument.end + 1; + } + } + return command_end; + } + + let bytes = source.as_bytes(); + let mut end = argument_start; + if matches!(bytes.get(end), Some(b'+') | Some(b'-')) { + end += 1; + } + let digits_start = end; + while bytes.get(end).is_some_and(u8::is_ascii_digit) { + end += 1; + } + if end > digits_start { end } else { command_end } +} + /// Byte range of the contents of the `{…}` group at or after `at`, skipping /// whitespace only. An escaped brace neither opens nor closes a group. fn brace_group(source: &str, at: usize) -> Option> { @@ -876,6 +961,7 @@ mod tests { // an entire answer as raw TeX on screen. assert_eq!(substitute_unsupported_presentation(r"\boxed{E=mc^2}").as_ref(), "E=mc^2"); assert_eq!(substitute_unsupported_presentation(r"x=1 \tag{3}").as_ref(), r"x=1 \quad(3)"); + assert_eq!(substitute_unsupported_presentation(r"x=1 \tag*{A}").as_ref(), r"x=1 \quad A"); assert_eq!( substitute_unsupported_presentation(r"\boxed{\boxed{\frac{1}{2}}}").as_ref(), r"\frac{1}{2}", @@ -897,6 +983,36 @@ mod tests { assert!(parse_formula(numbered, true, DEFAULT_LIMITS).is_ok()); } + #[test] + fn cross_references_and_root_offsets_degrade_without_losing_the_formula() { + assert_eq!( + substitute_unsupported_presentation( + r"y=Hx.\label{eq:conv} \quad \eqref{eq:conv}=\ref{eq:conv}" + ) + .as_ref(), + r"y=Hx. \quad \text{(eq:conv)}=\text{eq:conv}" + ); + assert_eq!( + substitute_unsupported_presentation( + r"\sqrt[\leftroot-2\uproot{2} n]{x} + \leftroot{x}" + ) + .as_ref(), + r"\sqrt[ n]{x} + {x}", + "only numeric root adjustments are consumed" + ); + + for source in [ + r"y=Hx.\label{eq:conv} \tag{5}", + r"\eqref{eq:conv}=\ref{eq:conv}", + r"\sqrt[\leftroot-2\uproot2 n]{x}", + ] { + assert!( + parse_formula(source, true, DEFAULT_LIMITS).is_ok(), + "compatibility source should render: {source:?}" + ); + } + } + #[test] fn parses_atoms_fraction_radical_and_scripts_into_arena() { let formula = parse(r"x_i^2+\frac{1}{\sqrt{y}}"); diff --git a/nebula_app/src/notify.rs b/nebula_app/src/notify.rs index 3581f7d..ffcfc0a 100644 --- a/nebula_app/src/notify.rs +++ b/nebula_app/src/notify.rs @@ -228,10 +228,14 @@ fn toast_clickable( } #[cfg(not(windows))] -pub(crate) fn toast(_title: &str, _body: &str) {} +pub(crate) fn toast(title: &str, body: &str) { + crate::platform::notifications::show(title, body); +} #[cfg(all(not(windows), feature = "legacy-shell"))] -fn toast_clickable(_title: &str, _body: &str, _focus: Option<(WindowId, Option)>) {} +fn toast_clickable(title: &str, body: &str, _focus: Option<(WindowId, Option)>) { + toast(title, body); +} /// `nebula notify-test` entrypoint: run the full toast pipeline synchronously /// (registration + show), printing per-step diagnostics to the console. Skips @@ -295,7 +299,7 @@ pub fn notify_test() -> i32 { #[cfg(windows)] mod win { use std::path::PathBuf; - use std::sync::OnceLock; + use std::sync::Mutex; use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, REG_SZ, RegSetKeyValueW}; @@ -304,32 +308,33 @@ mod win { /// so the system shows "Nebula" instead of "PowerShell" / "cmd.exe". pub const AUMID: &str = "com.nebula.terminal"; - /// Embedded toast icon. `IconUri` must point at a real file on disk, so - /// this is materialized to the Nebula data dir on first registration — - /// works for a portable exe with no installer. (Some terminals solve this - /// problem with an installer-created Start-menu shortcut; we don't have - /// an installer to lean on.) - const ICON_PNG: &[u8] = include_bytes!("../../extra/logo/nebula.png"); - /// 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 DONE: OnceLock<()> = OnceLock::new(); - DONE.get_or_init(|| { - if let Err(err) = register_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", "Nebula")?; - match ensure_icon_file() { + 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"), } @@ -338,19 +343,17 @@ mod win { /// The materialized icon path, for diagnostics (`nebula notify-test`). pub fn icon_path() -> Option { - ensure_icon_file() + ensure_icon_file(crate::app_icon::selected()) } - /// Write the embedded logo to `toast_icon.png` in the user data dir - /// (idempotent; refreshed when the embedded bytes change size, e.g. after a - /// logo swap). - fn ensure_icon_file() -> Option { - let path = crate::platform::dirs::data_dir().join("toast_icon.png"); - let stale = std::fs::metadata(&path) - .map(|meta| meta.len() != ICON_PNG.len() as u64) - .unwrap_or(true); + 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); if stale { - std::fs::write(&path, ICON_PNG).ok()?; + std::fs::create_dir_all(directory).ok()?; + std::fs::write(&path, bytes).ok()?; } Some(path) } diff --git a/nebula_app/src/platform/capabilities.rs b/nebula_app/src/platform/capabilities.rs new file mode 100644 index 0000000..64bb7d8 --- /dev/null +++ b/nebula_app/src/platform/capabilities.rs @@ -0,0 +1,102 @@ +//! 平台能力表。 +//! +//! UI 层只问「这台机器能不能」,不问「是不是 Windows」。一个功能在某平台 +//! 尚未实现时,对应字段为 `false`,设置页整行不渲染(不是灰掉——灰掉会引来 +//! 「为什么灰」的 issue),调用方也不会走进一个静默 no-op。 +//! +//! 新增平台差异先在这里加字段,再在各平台实现里对号入座;把 `cfg` 留在 +//! `platform/` 之内是 `scripts/check_platform_cfg.py` 卡住的预算规则。 + +/// 编译期常量的能力集合。字段顺序按用户可见程度排列。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Capabilities { + /// 关窗后进程驻留并隐藏窗口(Windows `SW_HIDE`)。Unix 尚无实现: + /// `hide_native_window` 在那里是 no-op,开着会导致「既不关也不藏」。 + pub hide_window_on_close: bool, + /// 系统托盘图标(`crate::tray`)。 + pub system_tray: bool, + /// 系统通知后端已实现;实际投递仍受系统通知权限控制。 + pub system_notifications: bool, + /// 系统提示音(`platform::beep`)。 + pub system_bell: bool, + /// 下载并静默运行安装包完成更新;其余平台只能打开 Releases 页。 + pub self_update_install: bool, + /// 自动往 Claude / Codex 配置里写入 hook 与 skill(`crate::ai_hook`)。 + pub ai_hook_server: bool, + /// 全局热键呼出快速终端。 + pub quick_terminal_hotkey: bool, + /// 枚举系统字体族供字体选择器使用。 + pub system_font_enumeration: bool, + /// 系统凭据后端已实现;Linux 还需 credentials::can_store 检查运行依赖。 + pub credential_store: bool, + /// 安装版可把「在此处打开 Nebula」挂进资源管理器右键菜单。 + pub shell_context_menu: bool, +} + +pub const CAPABILITIES: Capabilities = { + #[cfg(windows)] + { + Capabilities { + hide_window_on_close: true, + system_tray: true, + system_notifications: true, + system_bell: true, + self_update_install: true, + ai_hook_server: true, + quick_terminal_hotkey: true, + system_font_enumeration: true, + credential_store: true, + shell_context_menu: true, + } + } + #[cfg(not(windows))] + { + Capabilities { + hide_window_on_close: false, + system_tray: false, + system_notifications: true, + system_bell: false, + self_update_install: false, + ai_hook_server: false, + quick_terminal_hotkey: false, + system_font_enumeration: true, + credential_store: true, + shell_context_menu: false, + } + } +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_integrations_never_advertise_unimplemented_features() { + let all = [ + CAPABILITIES.hide_window_on_close, + CAPABILITIES.system_tray, + CAPABILITIES.system_notifications, + CAPABILITIES.system_bell, + CAPABILITIES.self_update_install, + CAPABILITIES.ai_hook_server, + CAPABILITIES.quick_terminal_hotkey, + CAPABILITIES.system_font_enumeration, + CAPABILITIES.credential_store, + CAPABILITIES.shell_context_menu, + ]; + if cfg!(windows) { + assert!(all.iter().all(|flag| *flag)); + } else { + assert!(CAPABILITIES.system_notifications); + assert!(CAPABILITIES.system_font_enumeration); + assert!(CAPABILITIES.credential_store); + assert!(!CAPABILITIES.hide_window_on_close); + assert!(!CAPABILITIES.system_tray); + assert!(!CAPABILITIES.system_bell); + assert!(!CAPABILITIES.self_update_install); + assert!(!CAPABILITIES.ai_hook_server); + assert!(!CAPABILITIES.quick_terminal_hotkey); + assert!(!CAPABILITIES.shell_context_menu); + } + } +} diff --git a/nebula_app/src/platform/credentials.rs b/nebula_app/src/platform/credentials.rs new file mode 100644 index 0000000..e813635 --- /dev/null +++ b/nebula_app/src/platform/credentials.rs @@ -0,0 +1,175 @@ +use std::io; + +pub fn can_store() -> bool { + #[cfg(any(windows, target_os = "macos"))] + { + true + } + #[cfg(not(any(windows, target_os = "macos")))] + { + secret_tool().is_some() + } +} + +#[cfg(windows)] +pub fn load(target: &str) -> io::Result>> { + crate::ssh_credentials::windows_store::load_secret(target) +} + +#[cfg(windows)] +pub fn store(target: &str, secret: &[u8]) -> io::Result<()> { + store_with_username(target, "Nebula", secret) +} + +#[cfg(windows)] +pub fn store_with_username(target: &str, username: &str, secret: &[u8]) -> io::Result<()> { + crate::ssh_credentials::windows_store::save_secret(target, username, secret) +} + +#[cfg(not(windows))] +pub fn store_with_username(target: &str, _username: &str, secret: &[u8]) -> io::Result<()> { + store(target, secret) +} + +#[cfg(windows)] +pub fn delete(target: &str) -> io::Result<()> { + crate::ssh_credentials::windows_store::delete_secret(target) +} + +#[cfg(target_os = "macos")] +pub fn load(target: &str) -> io::Result>> { + match security_framework::passwords::get_generic_password("Nebula", target) { + Ok(secret) => Ok(Some(secret)), + Err(error) if error.code() == -25300 => Ok(None), + Err(error) => Err(io::Error::other(format!("Keychain: {error}"))), + } +} + +#[cfg(target_os = "macos")] +pub fn store(target: &str, secret: &[u8]) -> io::Result<()> { + security_framework::passwords::set_generic_password("Nebula", target, secret) + .map_err(|error| io::Error::other(format!("Keychain: {error}"))) +} + +#[cfg(target_os = "macos")] +pub fn delete(target: &str) -> io::Result<()> { + match security_framework::passwords::delete_generic_password("Nebula", target) { + Ok(()) => Ok(()), + Err(error) if error.code() == -25300 => Ok(()), + Err(error) => Err(io::Error::other(format!("Keychain: {error}"))), + } +} + +#[cfg(not(any(windows, target_os = "macos")))] +fn secret_tool() -> Option { + ["/usr/bin/secret-tool", "/bin/secret-tool"] + .into_iter() + .map(std::path::PathBuf::from) + .find(|path| path.is_file()) +} + +#[cfg(not(any(windows, target_os = "macos")))] +fn invoke( + operation: &str, + target: &str, + secret: Option<&[u8]>, +) -> io::Result { + use std::io::Write as _; + use std::process::{Command, Stdio}; + use std::time::{Duration, Instant}; + + let program = secret_tool().ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "Install libsecret-tools and enable a Secret Service keyring to save credentials", + ) + })?; + let mut command = Command::new(program); + command.arg(operation); + if operation == "store" { + command.arg("--label=Nebula SSH"); + } + let mut child = command + .args(["application", "Nebula", "target", target]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + if let Some(secret) = secret { + if let Err(error) = child.stdin.take().unwrap().write_all(secret) { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + } else { + drop(child.stdin.take()); + } + let deadline = Instant::now() + Duration::from_secs(5); + while child.try_wait()?.is_none() { + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(io::Error::new(io::ErrorKind::TimedOut, "Secret Service did not respond")); + } + std::thread::sleep(Duration::from_millis(20)); + } + child.wait_with_output() +} + +#[cfg(not(any(windows, target_os = "macos")))] +pub fn load(target: &str) -> io::Result>> { + if !can_store() { + return Ok(None); + } + let output = invoke("lookup", target, None)?; + if !output.status.success() { + return Ok(None); + } + let mut value = output.stdout; + if value.last() == Some(&b'\n') { + value.pop(); + } + Ok(Some(value)) +} + +#[cfg(not(any(windows, target_os = "macos")))] +pub fn store(target: &str, secret: &[u8]) -> io::Result<()> { + if !valid_secret(secret) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Credentials must be at most 4096 bytes and contain no NUL or line breaks", + )); + } + if invoke("store", target, Some(secret))?.status.success() { + Ok(()) + } else { + Err(io::Error::other("Secret Service could not save the credential")) + } +} + +#[cfg(not(any(windows, target_os = "macos")))] +pub fn delete(target: &str) -> io::Result<()> { + let output = invoke("clear", target, None)?; + if output.status.success() || output.status.code() == Some(1) { + Ok(()) + } else { + Err(io::Error::other("Secret Service could not remove the credential")) + } +} + +#[cfg(not(any(windows, target_os = "macos")))] +fn valid_secret(secret: &[u8]) -> bool { + secret.len() <= 4096 && !secret.iter().any(|byte| matches!(*byte, 0 | b'\n' | b'\r')) +} + +#[cfg(all(test, not(any(windows, target_os = "macos"))))] +mod tests { + #[test] + fn secret_tool_never_silently_truncates_a_credential() { + assert!(super::valid_secret("口令 with spaces".as_bytes())); + for secret in [&b"line\nbreak"[..], &b"carriage\rreturn"[..], &b"nul\0byte"[..]] { + assert!(!super::valid_secret(secret)); + } + assert!(!super::valid_secret(&vec![b'x'; 4097])); + } +} diff --git a/nebula_app/src/platform/dirs.rs b/nebula_app/src/platform/dirs.rs index 29c0fde..f85ce81 100644 --- a/nebula_app/src/platform/dirs.rs +++ b/nebula_app/src/platform/dirs.rs @@ -65,41 +65,11 @@ pub fn data_dir() -> &'static Path { } fn resolve_data_dir() -> PathBuf { - if let Some(raw) = std::env::var_os(DIR_OVERRIDE_ENV) { - let path = PathBuf::from(raw); - if !path.as_os_str().is_empty() { - return path; - } - } - - #[cfg(windows)] - { - // 与改造前逐字节一致:`%APPDATA%\Nebula`。 - std::env::var_os("APPDATA") - .map(PathBuf::from) - .or_else(home_dir) - .unwrap_or_else(std::env::temp_dir) - .join("Nebula") - } - - #[cfg(target_os = "macos")] - { - home_dir() - .map(|home| home.join("Library").join("Application Support")) - .unwrap_or_else(std::env::temp_dir) - .join("Nebula") - } - - #[cfg(all(unix, not(target_os = "macos")))] - { - // XDG 规定:相对路径的 XDG_CONFIG_HOME 必须当作未设置。 - std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .filter(|path| path.is_absolute()) - .or_else(|| home_dir().map(|home| home.join(".config"))) - .unwrap_or_else(std::env::temp_dir) - .join("nebula") - } + // `nebula_settings.txt`, runtime discovery, sessions, history, and imported + // fonts must share one directory. The zero-dependency settings crate owns + // the environment/OS resolution so consumers outside `nebula_app` cannot + // accidentally revive the old Linux/macOS paths. + nebula_settings::settings_dir() } #[cfg(test)] diff --git a/nebula_app/src/platform/fonts.rs b/nebula_app/src/platform/fonts.rs new file mode 100644 index 0000000..fb28f8e --- /dev/null +++ b/nebula_app/src/platform/fonts.rs @@ -0,0 +1,74 @@ +use std::path::Path; +use std::sync::Arc; + +use crate::font_install::SystemFontFamily; + +pub fn system_families(text: Arc) -> Vec { + #[cfg(windows)] + { + let _ = text; + crate::font_install::enumerate_system_font_families() + } + #[cfg(not(windows))] + { + text.all_font_names() + .into_iter() + .filter(|name| !name.starts_with('.')) + .map(|name| { + let font = text.resolve_font(&gpui::font(name.clone())); + let widths: Option> = (' '..='~') + .map(|character| { + text.advance(font, gpui::px(16.0), character) + .ok() + .map(|size| f32::from(size.width)) + }) + .collect(); + let monospaced = widths.is_some_and(|widths| { + widths[0] > 0.0 && widths.iter().all(|width| (width - widths[0]).abs() < 0.01) + }); + SystemFontFamily { name, monospaced } + }) + .collect() + } +} + +pub fn file_families(path: &Path) -> Result, String> { + #[cfg(windows)] + { + crate::font_install::probe_font_file_families(path) + } + #[cfg(not(windows))] + { + let bytes = std::fs::read(path).map_err(|error| error.to_string())?; + let count = ttf_parser::fonts_in_collection(&bytes).unwrap_or(1); + let mut families = Vec::new(); + for index in 0..count { + let face = ttf_parser::Face::parse(&bytes, index).map_err(|error| error.to_string())?; + for name in face.names() { + if matches!( + name.name_id, + ttf_parser::name_id::FAMILY | ttf_parser::name_id::TYPOGRAPHIC_FAMILY + ) { + if let Some(name) = name.to_string() { + families.push(name); + } + } + } + } + families.sort(); + families.dedup(); + if families.is_empty() { Err("No font family names in file".into()) } else { Ok(families) } + } +} + +#[cfg(test)] +mod tests { + #[test] + fn bundled_font_can_be_identified_without_installing_it() { + let directory = tempfile::tempdir().unwrap(); + let font = directory.path().join("bundled.ttf"); + std::fs::write(&font, crate::font_install::REQUIRED_FONT_BYTES).unwrap(); + let families = super::file_families(&font).unwrap(); + assert!(families.iter().any(|name| name == crate::font_install::REQUIRED_FONT_FAMILY)); + } +} diff --git a/nebula_app/src/platform/mod.rs b/nebula_app/src/platform/mod.rs index 4aa4a77..698a7ba 100644 --- a/nebula_app/src/platform/mod.rs +++ b/nebula_app/src/platform/mod.rs @@ -9,7 +9,17 @@ //! 切模块 + 模块内 `#[cfg]` 分派」,只保留真正值钱的两点:单一入口,以及 //! 能力探测(让 UI 隐藏入口,而不是让功能在别的平台报错)。 +pub mod capabilities; +pub mod credentials; pub mod dirs; +#[cfg(feature = "gpui-shell")] +pub mod fonts; +pub mod notifications; +pub mod shell; +pub mod shell_integration; +pub mod startup; + +pub use capabilities::CAPABILITIES; /// Play the system's default notification sound. /// diff --git a/nebula_app/src/platform/notifications.rs b/nebula_app/src/platform/notifications.rs new file mode 100644 index 0000000..89ca2d3 --- /dev/null +++ b/nebula_app/src/platform/notifications.rs @@ -0,0 +1,33 @@ +#[cfg(target_os = "macos")] +static MACOS_READY: std::sync::OnceLock = std::sync::OnceLock::new(); + +pub fn prepare() { + #[cfg(target_os = "macos")] + { + MACOS_READY.get_or_init(|| { + let Some(identifier) = objc2_foundation::NSBundle::mainBundle().bundleIdentifier() + else { + return false; + }; + notify_rust::set_application(&identifier.to_string()).is_ok() + }); + } +} + +#[cfg(not(windows))] +pub fn show(title: &str, body: &str) { + #[cfg(target_os = "macos")] + if !MACOS_READY.get().copied().unwrap_or(false) { + log::warn!("System notifications require a registered Nebula application bundle"); + return; + } + let title = title.to_owned(); + let body = body.to_owned(); + let _ = std::thread::Builder::new().name("nebula-notify".into()).spawn(move || { + if let Err(error) = + notify_rust::Notification::new().appname("Nebula").summary(&title).body(&body).show() + { + log::warn!("System notification unavailable: {error}"); + } + }); +} diff --git a/nebula_app/src/platform/shell.rs b/nebula_app/src/platform/shell.rs new file mode 100644 index 0000000..2380e2f --- /dev/null +++ b/nebula_app/src/platform/shell.rs @@ -0,0 +1,98 @@ +//! Platform-owned shell defaults and legacy integration boundaries. +//! +//! Shell discovery stays in `crate::shell_detect`; this module only answers +//! questions whose result depends on the host OS. Keeping those branches here +//! prevents tab labels and saved shell ids from drifting away from the PTY +//! backend's actual default. + +#[cfg(unix)] +use std::path::Path; + +/// Stable id for the shell the PTY backend starts when no override is set. +pub fn default_shell_id() -> String { + #[cfg(windows)] + { + "powershell".to_owned() + } + #[cfg(unix)] + { + let shell = nebula_terminal::tty::default_shell_program().unwrap_or_default(); + Path::new(&shell).file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .unwrap_or(default_unix_shell_id()) + .to_owned() + } +} + +pub fn interactive_args(id: &str) -> Vec { + if cfg!(target_os = "macos") && matches!(id, "zsh" | "bash" | "fish") { + vec!["-l".to_owned()] + } else { + Vec::new() + } +} + +#[cfg(target_os = "macos")] +const fn default_unix_shell_id() -> &'static str { + "zsh" +} + +#[cfg(all(unix, not(target_os = "macos")))] +const fn default_unix_shell_id() -> &'static str { + "sh" +} + +/// The historic `bash` id means Git Bash on Windows and system Bash on Unix. +pub const fn bash_display_name() -> &'static str { + #[cfg(windows)] + { + "Git Bash" + } + #[cfg(unix)] + { + "Bash" + } +} + +/// Whether an id must fall through to the Windows PTY bootstrap. +/// +/// Unix shells are launched directly. Treating `bash` as integrated there +/// makes an explicit `shell=bash` silently fall back to the user's login shell. +pub fn uses_legacy_pty_bootstrap(id: &str) -> bool { + #[cfg(windows)] + { + matches!( + id.trim().to_ascii_lowercase().as_str(), + "powershell" | "ps" | "bash" | "git-bash" | "gitbash" + ) + } + #[cfg(unix)] + { + let _ = id; + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_shell_id_is_never_empty() { + assert!(!default_shell_id().is_empty()); + } + + #[test] + fn bash_bootstrap_matches_the_host_contract() { + assert_eq!(uses_legacy_pty_bootstrap("bash"), cfg!(windows)); + } + + #[test] + fn mac_shell_picker_preserves_login_startup() { + for shell in ["zsh", "bash", "fish"] { + assert_eq!(interactive_args(shell) == ["-l"], cfg!(target_os = "macos")); + } + assert!(interactive_args("nu").is_empty()); + } +} diff --git a/nebula_app/src/platform/shell_integration.rs b/nebula_app/src/platform/shell_integration.rs new file mode 100644 index 0000000..0930d39 --- /dev/null +++ b/nebula_app/src/platform/shell_integration.rs @@ -0,0 +1,90 @@ +use nebula_terminal::tty; + +pub fn prepare(options: &mut tty::Options) -> std::io::Result<()> { + #[cfg(unix)] + { + prepare_unix(options) + } + #[cfg(not(unix))] + { + let _ = options; + Ok(()) + } +} + +#[cfg(unix)] +fn prepare_unix(options: &mut tty::Options) -> std::io::Result<()> { + use std::path::Path; + + let program = match &options.shell { + Some(shell) => shell.program().to_owned(), + None => tty::default_shell_program()?, + }; + let args = options.shell.as_ref().map(|shell| shell.args()).unwrap_or_default(); + let name = Path::new(&program).file_name().and_then(|name| name.to_str()).unwrap_or_default(); + if !supports(name, args) { + return Ok(()); + } + let root = super::dirs::data_dir().join("shell-integration"); + match name { + "zsh" => { + let directory = root.join("zsh"); + std::fs::create_dir_all(&directory)?; + for (name, content) in [ + (".zshenv", include_str!("../../res/shell/zshenv")), + (".zprofile", include_str!("../../res/shell/zprofile")), + (".zshrc", include_str!("../../res/shell/zshrc")), + ] { + crate::atomic_file::write(&directory.join(name), content.as_bytes())?; + } + if let Some(original) = std::env::var_os("ZDOTDIR") { + options.env.insert( + "NEBULA_ORIGINAL_ZDOTDIR".into(), + original.to_string_lossy().into_owned(), + ); + options.env.insert("NEBULA_ZDOTDIR_WAS_SET".into(), "1".into()); + } else { + options.env.insert("NEBULA_ZDOTDIR_WAS_SET".into(), "0".into()); + } + let directory = directory.to_string_lossy().into_owned(); + options.env.insert("NEBULA_ZSH_INTEGRATION".into(), directory.clone()); + options.env.insert("ZDOTDIR".into(), directory); + }, + "bash" => { + std::fs::create_dir_all(&root)?; + let init = root.join("bashrc"); + crate::atomic_file::write(&init, include_bytes!("../../res/shell/bashrc"))?; + options.shell = Some(tty::Shell::new( + program, + vec!["--rcfile".into(), init.to_string_lossy().into_owned(), "-i".into()], + )); + }, + _ => {}, + } + Ok(()) +} + +#[cfg(unix)] +fn supports(name: &str, args: &[String]) -> bool { + match name { + "zsh" => args.iter().all(|arg| matches!(arg.as_str(), "-l" | "--login" | "-i")), + "bash" => !cfg!(target_os = "macos") && args.is_empty(), + _ => false, + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn custom_commands_and_rcfiles_are_never_rewritten() { + for name in ["bash", "zsh", "fish"] { + assert!(!supports(name, &["-c".into(), "echo test".into()])); + assert!(!supports(name, &["--norc".into()])); + assert!(!supports(name, &["--rcfile".into(), "custom".into()])); + } + assert!(supports("zsh", &["-l".into()])); + assert_eq!(supports("bash", &[]), !cfg!(target_os = "macos")); + } +} diff --git a/nebula_app/src/platform/startup.rs b/nebula_app/src/platform/startup.rs new file mode 100644 index 0000000..60da486 --- /dev/null +++ b/nebula_app/src/platform/startup.rs @@ -0,0 +1,15 @@ +pub fn prepare_gui() { + #[cfg(target_os = "macos")] + { + crate::macos::locale::set_locale_environment(); + crate::macos::disable_autofill(); + if std::env::current_dir().ok().as_deref() == Some(std::path::Path::new("/")) { + if let Some(home) = home::home_dir() { + if let Err(error) = std::env::set_current_dir(home) { + eprintln!("Could not use the home directory: {error}"); + } + } + } + } + super::notifications::prepare(); +} diff --git a/nebula_app/src/process_tree.rs b/nebula_app/src/process_tree.rs index 7466996..65d3ce2 100644 --- a/nebula_app/src/process_tree.rs +++ b/nebula_app/src/process_tree.rs @@ -86,7 +86,8 @@ fn is_shell_executable(path: &str) -> bool { /// (`busy_process_in` 优先用 pane 已知的 `running_program`);这里只负责 /// 没有那份身份信息时把名字擦干净。 pub fn display_name(exe: &str) -> String { - exe.strip_suffix(".exe").or_else(|| exe.strip_suffix(".EXE")).unwrap_or(exe).to_owned() + let name = exe.rsplit(['/', '\\']).next().unwrap_or(exe); + name.strip_suffix(".exe").or_else(|| name.strip_suffix(".EXE")).unwrap_or(name).to_owned() } #[derive(Debug, Clone, PartialEq, Eq)] @@ -97,6 +98,36 @@ pub struct ProcessEntry { pub depth: u32, } +fn is_stateless_process(executable: &str) -> bool { + let name = executable.rsplit(['/', '\\']).next().unwrap_or(executable).to_ascii_lowercase(); + if STATELESS.contains(&name.as_str()) { + return true; + } + let stem = name.strip_suffix(".exe").unwrap_or(&name); + INTERACTIVE_SHELLS.contains(&stem) || stem == "git" +} + +/// Parse the stable three-column `ps` output used by Unix snapshots. The +/// command column is the remainder so executable paths containing spaces are +/// not split into a fake fourth field. +#[allow(dead_code)] +fn parse_ps_line(line: &str) -> Option<(u32, u32, String)> { + fn field(input: &str) -> Option<(&str, &str)> { + let input = input.trim_start(); + let end = input.find(char::is_whitespace).unwrap_or(input.len()); + let value = &input[..end]; + (!value.is_empty()).then(|| (value, &input[end..])) + } + + let (pid, rest) = field(line)?; + let (parent_pid, executable) = field(rest)?; + let executable = executable.trim(); + if executable.is_empty() { + return None; + } + Some((pid.parse().ok()?, parent_pid.parse().ok()?, executable.to_owned())) +} + /// One Toolhelp pass over every process on the machine: `pid -> (parent, exe)`. /// /// Shared by the descendant walk and the ancestry check so a caller never pays @@ -143,11 +174,39 @@ fn snapshot() -> Result, String> { Ok(processes) } +/// Unix has no Toolhelp equivalent shared by Linux and macOS. `ps` is part of +/// both base systems and this path only runs for explicit process inspection +/// or a throttled identity check, never on the render loop. +#[cfg(not(windows))] +fn snapshot() -> Result, String> { + let output = std::process::Command::new("ps") + .args(["-axo", "pid=,ppid=,comm="]) + .output() + .map_err(|error| format!("could not launch ps: {error}"))?; + if !output.status.success() { + return Err(format!( + "ps exited with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let mut processes = std::collections::HashMap::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some((pid, parent_pid, executable)) = parse_ps_line(line) { + processes.insert(pid, (parent_pid, executable)); + } + } + if processes.is_empty() { + return Err("ps returned no process rows".to_owned()); + } + Ok(processes) +} + /// Snapshot the real local descendants owned by one terminal shell. The root /// is included at depth zero so clients can distinguish the PTY owner from the /// commands below it. Toolhelp is intentionally sampled on demand; running it /// on the 1 Hz UI state pump would scan the whole machine continuously. -#[cfg(windows)] pub fn descendants(root_pid: u32) -> Result, String> { use std::collections::{HashMap, HashSet, VecDeque}; @@ -193,26 +252,14 @@ pub fn descendants(root_pid: u32) -> Result, String> { Ok(result) } -#[cfg(not(windows))] -pub fn descendants(_root_pid: u32) -> Result, String> { - Err("pane.procs is not implemented on this platform".to_owned()) -} - /// 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. -#[cfg(windows)] pub fn busy_child(root_pid: u32) -> Option { descendants(root_pid).ok()?.into_iter().skip(1).find_map(|process| { - let lower = process.executable.to_ascii_lowercase(); - (!STATELESS.contains(&lower.as_str())).then_some(process.executable) + (!is_stateless_process(&process.executable)).then_some(process.executable) }) } -#[cfg(not(windows))] -pub fn busy_child(_root_pid: u32) -> Option { - None -} - /// First descendant that `AgentKind` recognizes as an AI CLI, as its slug. /// /// `busy_child` walks BFS level order, so the first non-stateless hit is always @@ -225,21 +272,15 @@ pub fn busy_child(_root_pid: u32) -> Option { /// 输入框,但 `running_program` 是 None,于是 1 Hz 屏幕看门狗在入口处早退, /// 没有任何机制去看那块屏幕,转圈一直挂着。命令行首 token 是脆弱推断(会话 /// 恢复、别名、`npx codex` 都会落空),进程树才是客观事实。 -#[cfg(windows)] pub fn agent_child(root_pid: u32) -> Option { descendants(root_pid).ok()?.into_iter().skip(1).find_map(|process| { - crate::ai_agents::AgentKind::parse(&process.executable).map(|kind| kind.slug().to_owned()) + crate::ai_agents::AgentKind::parse(&display_name(&process.executable)) + .map(|kind| kind.slug().to_owned()) }) } -#[cfg(not(windows))] -pub fn agent_child(_root_pid: u32) -> Option { - None -} - /// 上溯上限。真实的 pane 进程链是 shell → 解释器 → agent → 子进程,个位数 /// 深度;给足余量同时保证快照里的父指针成环时一定会停。 -#[cfg_attr(not(windows), allow(dead_code))] const MAX_ANCESTRY_DEPTH: u32 = 64; /// `pid` 是否落在 `root_pid` 的进程树内(含 `root_pid` 自身)。 @@ -247,7 +288,6 @@ const MAX_ANCESTRY_DEPTH: u32 = 64; /// 纯函数形态,便于用构造出来的进程表覆盖分叉、成环和缺链三种情况。 /// `None` 的含义是「拿不到证据」,绝不能当成校验失败——见 /// [`is_within_tree`] 的说明。 -#[cfg_attr(not(windows), allow(dead_code))] fn resolve_within_tree( processes: &std::collections::HashMap, pid: u32, @@ -283,7 +323,6 @@ fn resolve_within_tree( /// 带 TTL 的全机进程快照,供路由校验和祖先链查询共用。 /// /// 用闭包而不是把表交出去:调用方只需要读,克隆整张进程表纯属浪费。 -#[cfg(windows)] fn with_cached_snapshot( read: impl FnOnce(&std::collections::HashMap) -> T, ) -> Option { @@ -316,21 +355,14 @@ fn with_cached_snapshot( /// 快照在 [`SNAPSHOT_TTL`] 内复用:一个回合里 hook 事件是成批到达的(每个 /// tool call 一条),逐条扫描全机进程表会把校验本身变成开销。过期的表只会让 /// 刚起的 helper 查不到而返回 `None`(放行),不会误判成伪造。 -#[cfg(windows)] pub fn is_within_tree(pid: u32, root_pid: u32) -> Option { with_cached_snapshot(|processes| resolve_within_tree(processes, pid, root_pid)).flatten() } /// 快照复用窗口。取值只需覆盖「同一回合内成批到达的 hook」,不需要更长: /// 表越旧,查不到新 helper 的概率越高,校验就越容易退化成放行。 -#[cfg(windows)] const SNAPSHOT_TTL: std::time::Duration = std::time::Duration::from_millis(250); -#[cfg(not(windows))] -pub fn is_within_tree(_pid: u32, _root_pid: u32) -> Option { - None -} - /// 沿 `pid` 的祖先链找最近的 AI CLI 进程,返回它的 pid 与 slug。 /// /// hook helper 不一定是 agent 直接 spawn 的:Windows 上 claude 通过 shell 执行 @@ -340,18 +372,11 @@ pub fn is_within_tree(_pid: u32, _root_pid: u32) -> Option { /// 这个 pid 是 agent 的**进程身份**,比 session id 稳:嵌套 `claude -p` 子代理 /// 有自己的 pid,但它的 session id 是短命的,一旦被当成 pane 的会话身份,就会 /// 把真正活着的那个顶掉(`claude --resume` 之后指向一个已经结束的会话)。 -#[cfg(windows)] pub fn nearest_agent_ancestor(pid: u32) -> Option<(u32, String)> { with_cached_snapshot(|processes| resolve_agent_ancestor(processes, pid)).flatten() } -#[cfg(not(windows))] -pub fn nearest_agent_ancestor(_pid: u32) -> Option<(u32, String)> { - None -} - /// [`nearest_agent_ancestor`] 的纯函数内核。 -#[cfg_attr(not(windows), allow(dead_code))] fn resolve_agent_ancestor( processes: &std::collections::HashMap, pid: u32, @@ -359,7 +384,7 @@ fn resolve_agent_ancestor( let mut current = pid; for _ in 0..MAX_ANCESTRY_DEPTH { let (parent, executable) = processes.get(¤t)?; - if let Some(kind) = crate::ai_agents::AgentKind::parse(executable) { + if let Some(kind) = crate::ai_agents::AgentKind::parse(&display_name(executable)) { return Some((current, kind.slug().to_owned())); } if *parent == 0 || *parent == current { @@ -375,9 +400,19 @@ mod tests { use std::collections::HashMap; use super::{ - display_name, is_interactive_shell_command, resolve_agent_ancestor, resolve_within_tree, + display_name, is_interactive_shell_command, parse_ps_line, resolve_agent_ancestor, + resolve_within_tree, }; + #[test] + fn unix_process_rows_keep_the_complete_command_column() { + assert_eq!( + parse_ps_line(" 42 7 /Applications/Nebula Preview/bin/zsh"), + Some((42, 7, "/Applications/Nebula Preview/bin/zsh".to_owned())) + ); + assert_eq!(parse_ps_line("header"), None); + } + fn table(rows: &[(u32, u32)]) -> HashMap { rows.iter().map(|&(pid, parent)| (pid, (parent, format!("p{pid}.exe")))).collect() } @@ -423,6 +458,7 @@ mod tests { fn display_name_drops_exe_suffix() { assert_eq!(display_name("node.exe"), "node"); assert_eq!(display_name("CARGO.EXE"), "CARGO"); + assert_eq!(display_name("/bin/bash"), "bash"); // Unix names and dotted names that are not suffixes stay intact. assert_eq!(display_name("cargo"), "cargo"); assert_eq!(display_name("python3.11"), "python3.11"); diff --git a/nebula_app/src/runtime_api/command.rs b/nebula_app/src/runtime_api/command.rs index 9dcf56a..ea2d45a 100644 --- a/nebula_app/src/runtime_api/command.rs +++ b/nebula_app/src/runtime_api/command.rs @@ -481,8 +481,10 @@ pub(crate) fn capture_process_tree( let processes = entries .into_iter() .map(|entry| { - let agent_kind = crate::ai_agents::AgentKind::parse(&entry.executable) - .map(|kind| kind.slug().to_owned()); + let agent_kind = crate::ai_agents::AgentKind::parse( + &crate::process_tree::display_name(&entry.executable), + ) + .map(|kind| kind.slug().to_owned()); RuntimeProcess { pid: entry.pid, parent_pid: (entry.pid != root_pid).then_some(entry.parent_pid), diff --git a/nebula_app/src/shell_detect.rs b/nebula_app/src/shell_detect.rs index 1df5a4d..994f2f1 100644 --- a/nebula_app/src/shell_detect.rs +++ b/nebula_app/src/shell_detect.rs @@ -102,9 +102,9 @@ pub fn icon_for_id(id: &str) -> &'static str { match id.as_str() { "pwsh" | "powershell" => "\u{ebc7}", // codicon terminal-powershell "cmd" => "\u{ebc4}", // codicon terminal-cmd - "bash" | "git-bash" | "gitbash" => "\u{e795}", // devicon bash/terminal - "nu" => "\u{f489}", // generic terminal glyph - _ => "\u{ea85}", // codicon terminal (fallback) + "bash" | "git-bash" | "gitbash" | "zsh" | "sh" | "dash" => "\u{e795}", // devicon bash/terminal + "fish" | "nu" => "\u{f489}", // generic terminal glyph + _ => "\u{ea85}", // codicon terminal (fallback) } } @@ -186,7 +186,11 @@ pub fn display_name_for_id(id: &str) -> String { "pwsh" => "PowerShell 7".into(), "powershell" | "ps" => "PowerShell".into(), "cmd" => "CMD".into(), - "bash" | "git-bash" | "gitbash" => "Git Bash".into(), + // Windows 上 `bash` id 只可能是 Git Bash;Unix 上就是系统 Bash。 + "bash" => crate::platform::shell::bash_display_name().into(), + "git-bash" | "gitbash" => "Git Bash".into(), + "zsh" => "Zsh".into(), + "fish" => "Fish".into(), "nu" => "Nushell".into(), _ => trimmed.to_owned(), } @@ -200,8 +204,7 @@ fn env_path(var: &str) -> Option { std::env::var_os(var).map(PathBuf::from) } -/// Detect every installed shell, menu order. Non-Windows builds return an -/// empty list (the dropdown then shows only config profiles). +/// Detect every installed shell, menu order. pub fn detect_shells() -> Vec { #[cfg(windows)] { @@ -209,7 +212,56 @@ pub fn detect_shells() -> Vec { } #[cfg(not(windows))] { - Vec::new() + detect_unix() + } +} + +/// Unix:`$SHELL` 排第一(它就是用户的登录 shell,也是没选择时的默认), +/// 其余按 `/etc/shells` 的顺序去重列出。id 取可执行文件名(`zsh`、`bash`、 +/// `fish`),与 Windows 侧的 `bash` id 同名以便设置文件跨平台复用。 +/// 这里只负责「有什么」,不注入 rcfile——Unix 的 OSC 133 集成另行实现。 +#[cfg(not(windows))] +fn detect_unix() -> Vec { + let mut shells: Vec = Vec::new(); + let mut push = |program: PathBuf| { + let Some(id) = program.file_name().and_then(|name| name.to_str()) else { return }; + if !program.is_file() || shells.iter().any(|known| known.id == id) { + return; + } + shells.push(DetectedShell { + name: unix_shell_label(id), + id: id.to_owned(), + program: program.display().to_string(), + args: crate::platform::shell::interactive_args(id), + }); + }; + if let Ok(login) = nebula_terminal::tty::default_shell_program() { + push(PathBuf::from(login)); + } + if let Ok(list) = std::fs::read_to_string("/etc/shells") { + for line in list.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + push(PathBuf::from(line)); + } + } + shells +} + +#[cfg(not(windows))] +fn unix_shell_label(id: &str) -> String { + match id { + "zsh" => "Zsh".to_owned(), + "bash" => "Bash".to_owned(), + "fish" => "Fish".to_owned(), + "nu" => "Nushell".to_owned(), + "sh" => "sh".to_owned(), + "dash" => "Dash".to_owned(), + "tcsh" | "csh" => id.to_owned(), + "pwsh" => "PowerShell 7".to_owned(), + other => other.to_owned(), } } @@ -222,17 +274,14 @@ pub fn resolve_id(id: &str) -> Option { return None; } let lower = id.to_ascii_lowercase(); - if is_pty_integrated_id(&lower) { + if crate::platform::shell::uses_legacy_pty_bootstrap(&lower) { return None; } detect_shells().into_iter().find(|shell| shell.id.eq_ignore_ascii_case(id)) } pub fn is_pty_integrated_id(id: &str) -> bool { - matches!( - id.trim().to_ascii_lowercase().as_str(), - "powershell" | "ps" | "bash" | "git-bash" | "gitbash" - ) + crate::platform::shell::uses_legacy_pty_bootstrap(id) } #[cfg(windows)] @@ -842,17 +891,25 @@ mod tests { #[test] fn resolve_rejects_pty_integrated_ids() { - // These two ids belong to the PTY layer's executor bootstrap; the - // resolver must never shadow them with a raw spawn. - assert_eq!(resolve_id("powershell"), None); - assert_eq!(resolve_id("bash"), None); + // Only Windows owns the legacy PowerShell/Git Bash bootstrap. Unix + // must resolve an explicit `shell=bash` instead of silently launching + // the login shell. + if crate::platform::shell::uses_legacy_pty_bootstrap("bash") { + assert_eq!(resolve_id("powershell"), None); + assert_eq!(resolve_id("bash"), None); + } else if std::path::Path::new("/bin/bash").is_file() + || std::path::Path::new("/usr/bin/bash").is_file() + { + assert_eq!(resolve_id("bash").as_ref().map(|shell| shell.id.as_str()), Some("bash")); + } assert_eq!(resolve_id(""), None); } #[test] fn powershell_seven_is_not_the_windows_powershell_integration() { - assert!(is_pty_integrated_id("powershell")); - assert!(is_pty_integrated_id("bash")); + let legacy = cfg!(windows); + assert_eq!(is_pty_integrated_id("powershell"), legacy); + assert_eq!(is_pty_integrated_id("bash"), legacy); assert!(!is_pty_integrated_id("pwsh")); assert!(!is_pty_integrated_id("cmd")); assert!(!is_pty_integrated_id("nu")); diff --git a/nebula_app/src/ssh.rs b/nebula_app/src/ssh.rs index 9bfdc47..636ef63 100644 --- a/nebula_app/src/ssh.rs +++ b/nebula_app/src/ssh.rs @@ -393,7 +393,7 @@ fn already_has_tty(args: &[String]) -> bool { /// you can click to connect to. Missing/unreadable config → empty list /// (the section simply doesn't render). pub fn ssh_config_hosts() -> Vec { - let Some(home) = dirs::home_dir() else { return Vec::new() }; + let Some(home) = crate::platform::dirs::home_dir() else { return Vec::new() }; let Ok(data) = std::fs::read_to_string(home.join(".ssh").join("config")) else { return Vec::new(); }; diff --git a/nebula_app/src/ssh_credentials.rs b/nebula_app/src/ssh_credentials.rs index 14c6984..56dd9be 100644 --- a/nebula_app/src/ssh_credentials.rs +++ b/nebula_app/src/ssh_credentials.rs @@ -383,30 +383,6 @@ pub fn store_password(destination: &str, password: &[u8]) -> std::io::Result<()> windows_store::save_password(destination, password) } -#[cfg(windows)] -pub fn load_stored_password(destination: &str) -> std::io::Result>> { - windows_store::load_password(destination) -} - -#[cfg(windows)] -pub fn store_private_key_passphrase(private_key: &[u8], passphrase: &[u8]) -> std::io::Result<()> { - windows_store::save_secret( - &private_key_credential_target(private_key), - "private-key", - passphrase, - ) -} - -#[cfg(windows)] -pub fn load_private_key_passphrase(private_key: &[u8]) -> std::io::Result>> { - windows_store::load_secret(&private_key_credential_target(private_key)) -} - -#[cfg(windows)] -pub fn forget_private_key_passphrase(private_key: &[u8]) -> std::io::Result<()> { - windows_store::delete_secret(&private_key_credential_target(private_key)) -} - #[cfg(windows)] pub fn prompt_password( destination: &str, @@ -416,32 +392,60 @@ pub fn prompt_password( windows_store::prompt_password(destination, initial, allow_save) } -#[cfg(not(windows))] -pub fn load_stored_password(_destination: &str) -> std::io::Result>> { - Ok(None) +pub fn load_stored_password(destination: &str) -> std::io::Result>> { + crate::platform::credentials::load(&credential_target(destination)) +} + +#[cfg(not(windows))] +pub fn store_password(destination: &str, password: &[u8]) -> std::io::Result<()> { + crate::platform::credentials::store(&credential_target(destination), password) } -#[cfg(windows)] pub fn forget_password(destination: &str) -> std::io::Result<()> { - windows_store::delete_password(destination) + crate::platform::credentials::delete(&credential_target(destination)) +} + +pub fn store_private_key_passphrase( + private_key: &[u8], + passphrase: &[u8], +) -> std::io::Result<()> { + crate::platform::credentials::store_with_username( + &private_key_credential_target(private_key), + "private-key", + passphrase, + ) +} + +pub fn load_private_key_passphrase(private_key: &[u8]) -> std::io::Result>> { + crate::platform::credentials::load(&private_key_credential_target(private_key)) +} + +pub fn forget_private_key_passphrase(private_key: &[u8]) -> std::io::Result<()> { + crate::platform::credentials::delete(&private_key_credential_target(private_key)) +} + +#[cfg(not(windows))] +pub fn prompt_password( + _destination: &str, + _initial: Option<&[u8]>, + _allow_save: bool, +) -> std::io::Result, bool)>> { + Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "SSH application window unavailable")) } /// Generic credential-manager primitives used by non-SSH features. Keeping /// the target namespace at the caller makes the secret store reusable without /// duplicating Windows API code or ever exposing the value to UI state. -#[cfg(windows)] pub fn store_generic_secret(target: &str, secret: &[u8]) -> std::io::Result<()> { - windows_store::save_secret(target, "Nebula", secret) + crate::platform::credentials::store(target, secret) } -#[cfg(windows)] pub fn load_generic_secret(target: &str) -> std::io::Result>> { - windows_store::load_secret(target) + crate::platform::credentials::load(target) } -#[cfg(windows)] pub fn delete_generic_secret(target: &str) -> std::io::Result<()> { - windows_store::delete_secret(target) + crate::platform::credentials::delete(target) } #[cfg(windows)] diff --git a/nebula_app/src/ssh_profiles.rs b/nebula_app/src/ssh_profiles.rs index 597cd97..4ff8c2e 100644 --- a/nebula_app/src/ssh_profiles.rs +++ b/nebula_app/src/ssh_profiles.rs @@ -2,9 +2,10 @@ use std::io; use std::path::{Path, PathBuf}; use serde::{Deserialize, Deserializer, Serialize}; -use windows_sys::Win32::Storage::FileSystem::{ - MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, MoveFileExW, -}; + +mod connection; +pub use connection::{SshConnectionOptions, SshHostJumpMode, SshHostProxyMode}; +pub(crate) use connection::validate_ssh_destination; const PROFILE_VERSION: u32 = 1; const USERNAME_HISTORY_CAP: usize = 12; @@ -52,6 +53,8 @@ pub struct SshProfileAuth { /// 码位——码位属于某一个字体文件,换字体就作废;id 是我们自己的稳定键。 #[serde(default, skip_serializing_if = "Option::is_none")] pub icon: Option, + #[serde(default, skip_serializing_if = "SshConnectionOptions::is_default")] + pub connection: SshConnectionOptions, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -114,6 +117,7 @@ impl SshProfiles { private_keys: Vec::new(), label: None, icon: None, + connection: SshConnectionOptions::default(), }) } @@ -201,15 +205,33 @@ impl SshProfiles { self.profiles.retain(|profile| profile.destination != destination); } + pub fn jump_dependents(&self, destination: &str) -> Vec { + self.profiles + .iter() + .filter(|profile| { + profile.destination != destination + && profile.connection.jump_mode == SshHostJumpMode::Host + && profile.connection.jump_host.trim() == destination + }) + .map(|profile| profile.destination.clone()) + .collect() + } + pub fn rename(&mut self, old: &str, new: &str) { - let Some(mut profile) = + if let Some(mut profile) = self.profiles.iter().find(|profile| profile.destination == old).cloned() - else { - return; - }; - self.remove(old); - profile.destination = new.to_owned(); - self.upsert(profile); + { + self.remove(old); + profile.destination = new.to_owned(); + self.upsert(profile); + } + for profile in &mut self.profiles { + if profile.connection.jump_mode == SshHostJumpMode::Host + && profile.connection.jump_host.trim() == old + { + profile.connection.jump_host = new.to_owned(); + } + } } } @@ -258,22 +280,10 @@ fn deduplicate_key_paths(paths: &mut Vec) { }); } +/// 原子替换走 [`crate::atomic_file::replace`]:Windows `MoveFileExW`、Unix +/// `rename`,两端语义同为「目标要么旧要么新」。此前这里复制了一份 Windows 版。 fn replace_file(source: &Path, destination: &Path) -> io::Result<()> { - let source = wide_path(source); - let destination = wide_path(destination); - let ok = unsafe { - MoveFileExW( - source.as_ptr(), - destination.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - if ok == 0 { Err(io::Error::last_os_error()) } else { Ok(()) } -} - -fn wide_path(path: &Path) -> Vec { - use std::os::windows::ffi::OsStrExt; - path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() + crate::atomic_file::replace(source, destination) } #[cfg(test)] @@ -318,6 +328,7 @@ mod tests { private_keys: Vec::new(), label: None, icon, + connection: Default::default(), }); } let json = serde_json::to_string(&profiles).expect("serialize"); @@ -343,6 +354,7 @@ mod tests { private_keys: Vec::new(), label: Some(label), icon: None, + connection: Default::default(), }); } profiles.remove("root@10.0.0.1"); @@ -361,6 +373,7 @@ mod tests { private_keys: Vec::new(), label: Some("生产数据库".to_owned()), icon: None, + connection: Default::default(), }); assert_eq!(profiles.next_default_label("主机"), "主机 1"); @@ -377,6 +390,7 @@ mod tests { private_keys: vec![PathBuf::from(r"C:\Keys\first"), PathBuf::from(r"C:\Keys\second")], label: None, icon: None, + connection: Default::default(), }); profiles.save(&path).unwrap(); @@ -402,6 +416,7 @@ mod tests { ], label: None, icon: None, + connection: Default::default(), }); assert_eq!( @@ -419,6 +434,7 @@ mod tests { private_keys: vec![PathBuf::from(r"C:\Keys\id_ed25519")], label: None, icon: None, + connection: Default::default(), }); profiles.rename("old@example.com", "new@example.com"); diff --git a/nebula_app/src/ssh_profiles/connection.rs b/nebula_app/src/ssh_profiles/connection.rs new file mode 100644 index 0000000..2924eeb --- /dev/null +++ b/nebula_app/src/ssh_profiles/connection.rs @@ -0,0 +1,189 @@ +use std::net::Ipv6Addr; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SshHostProxyMode { + #[default] + Inherit, + Direct, + Socks5, + Http, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SshHostJumpMode { + #[default] + Inherit, + None, + Host, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct SshConnectionOptions { + pub proxy_mode: SshHostProxyMode, + pub proxy_host: String, + pub proxy_port: Option, + pub proxy_username: String, + pub jump_mode: SshHostJumpMode, + pub jump_host: String, +} + +impl SshConnectionOptions { + pub fn is_default(&self) -> bool { + self == &Self::default() + } + + pub fn effective_proxy_port(&self) -> u16 { + self.proxy_port.unwrap_or(match self.proxy_mode { + SshHostProxyMode::Http => 8080, + _ => 1080, + }) + } + + pub fn has_custom_proxy(&self) -> bool { + matches!(self.proxy_mode, SshHostProxyMode::Socks5 | SshHostProxyMode::Http) + } + + pub fn normalized_proxy_host(&self) -> String { + let host = self.proxy_host.trim(); + host.strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host) + .to_ascii_lowercase() + } + + pub fn validate(&self, destination: &str) -> Result<(), String> { + validate_ssh_destination(destination)?; + if self.has_custom_proxy() { + validate_host(&self.normalized_proxy_host()).map_err(|_| { + "代理地址无效,请只填写主机名或 IP,不要包含协议、端口或密码".to_owned() + })?; + if self.effective_proxy_port() == 0 { + return Err("代理端口必须在 1–65535 之间".to_owned()); + } + let username = self.proxy_username.trim(); + if username.chars().any(char::is_control) { + return Err("代理用户名不能包含控制字符".to_owned()); + } + if self.proxy_mode == SshHostProxyMode::Socks5 && username.len() > 255 { + return Err("SOCKS5 用户名不能超过 255 字节".to_owned()); + } + if self.proxy_mode == SshHostProxyMode::Http && username.contains(':') { + return Err("HTTP 代理用户名不能包含冒号".to_owned()); + } + } + if self.jump_mode == SshHostJumpMode::Host { + validate_ssh_destination(&self.jump_host) + .map_err(|_| "跳板地址无效,请填写单个 SSH 别名或 user@host:port".to_owned())?; + if normalized_destination(&self.jump_host) == normalized_destination(destination) { + return Err("不能将目标主机自身设为跳板".to_owned()); + } + } + Ok(()) + } + + pub fn proxy_credential_target(&self, destination: &str) -> Option { + if !self.has_custom_proxy() || self.proxy_username.trim().is_empty() { + return None; + } + use sha2::{Digest, Sha256}; + use std::fmt::Write as _; + + let mode = match self.proxy_mode { + SshHostProxyMode::Socks5 => "socks5", + SshHostProxyMode::Http => "http", + _ => return None, + }; + let mut digest = Sha256::new(); + for field in [ + destination.trim().to_owned(), + mode.to_owned(), + self.normalized_proxy_host(), + self.effective_proxy_port().to_string(), + self.proxy_username.trim().to_owned(), + ] { + digest.update((field.len() as u64).to_be_bytes()); + digest.update(field.as_bytes()); + } + let mut fingerprint = String::with_capacity(64); + for byte in digest.finalize() { + let _ = write!(fingerprint, "{byte:02x}"); + } + Some(format!("Nebula/SSH/Proxy/{fingerprint}")) + } +} + +pub(crate) fn validate_ssh_destination(value: &str) -> Result<(), String> { + let value = value.trim(); + if value.is_empty() + || value.chars().any(|character| { + character.is_whitespace() + || character.is_control() + || ",;&|<>\"'`\\?#".contains(character) + }) + { + return Err("SSH 地址为空或包含不允许的字符".to_owned()); + } + let address = value.strip_prefix("ssh://").unwrap_or(value); + if address.starts_with('-') { + return Err("SSH 地址不能以选项前缀开头".to_owned()); + } + let host_port = if let Some((username, host)) = address.rsplit_once('@') { + if username.is_empty() || username.contains(['@', ':', '/', '[', ']']) { + return Err("SSH 用户名无效".to_owned()); + } + host + } else { + address + }; + let (host, port) = if let Some(rest) = host_port.strip_prefix('[') { + let (host, suffix) = rest.split_once(']').ok_or("IPv6 地址缺少右方括号")?; + if host.parse::().is_err() { + return Err("IPv6 地址无效".to_owned()); + } + let port = if suffix.is_empty() { + None + } else { + Some(suffix.strip_prefix(':').ok_or("SSH 端口格式无效")?) + }; + (host, port) + } else if let Some((host, port)) = host_port.rsplit_once(':') { + if host.contains(':') { (host_port, None) } else { (host, Some(port)) } + } else { + (host_port, None) + }; + validate_host(host)?; + if port.is_some_and(|port| port.parse::().map_or(true, |port| port == 0)) { + return Err("SSH 端口必须在 1–65535 之间".to_owned()); + } + Ok(()) +} + +fn validate_host(host: &str) -> Result<(), String> { + if host.is_empty() || host.starts_with('-') || host.len() > 253 { + return Err("主机名无效".to_owned()); + } + if host.contains(':') { + host.parse::().map_err(|_| "IPv6 地址无效".to_owned())?; + } else if host + .chars() + .any(|character| !character.is_alphanumeric() && !matches!(character, '.' | '-' | '_')) + { + return Err("主机名包含不允许的字符".to_owned()); + } + Ok(()) +} + +fn normalized_destination(destination: &str) -> String { + let destination = destination.trim().strip_prefix("ssh://").unwrap_or(destination.trim()); + let (username, host) = destination.rsplit_once('@').unwrap_or(("", destination)); + let host = host.strip_suffix(":22").unwrap_or(host).to_ascii_lowercase(); + format!("{username}@{host}") +} + +#[cfg(test)] +mod tests; diff --git a/nebula_app/src/ssh_profiles/connection/tests.rs b/nebula_app/src/ssh_profiles/connection/tests.rs new file mode 100644 index 0000000..0e31999 --- /dev/null +++ b/nebula_app/src/ssh_profiles/connection/tests.rs @@ -0,0 +1,209 @@ +use super::{SshConnectionOptions, SshHostJumpMode, SshHostProxyMode, validate_ssh_destination}; +use crate::ssh_profiles::{SshAuthMode, SshProfiles}; + +fn proxy() -> SshConnectionOptions { + SshConnectionOptions { + proxy_mode: SshHostProxyMode::Socks5, + proxy_host: "proxy.internal".to_owned(), + proxy_username: "alice".to_owned(), + ..Default::default() + } +} + +#[test] +fn old_profiles_inherit_both_connection_settings_without_serialized_noise() { + let profiles: SshProfiles = serde_json::from_str( + r#"{"version":1,"profiles":[{"destination":"root@host","auth":"password"}]}"#, + ) + .unwrap(); + assert!(profiles.for_destination("root@host").connection.is_default()); + let serialized = serde_json::to_string(&profiles).unwrap(); + assert!(!serialized.contains("connection")); + assert!(!serialized.contains("proxy_password")); +} + +#[test] +fn explicit_direct_and_disabled_jump_survive_profile_round_trip() { + let mut profiles = SshProfiles::default(); + let mut profile = profiles.for_destination("root@host"); + profile.connection.proxy_mode = SshHostProxyMode::Direct; + profile.connection.jump_mode = SshHostJumpMode::None; + profiles.upsert(profile.clone()); + let serialized = serde_json::to_string(&profiles).unwrap(); + let restored: SshProfiles = serde_json::from_str(&serialized).unwrap(); + assert_eq!(restored.for_destination("root@host"), profile); +} + +#[test] +fn proxy_defaults_are_protocol_specific() { + let mut options = proxy(); + assert_eq!(options.effective_proxy_port(), 1080); + options.proxy_mode = SshHostProxyMode::Http; + assert_eq!(options.effective_proxy_port(), 8080); + options.proxy_port = Some(3128); + assert_eq!(options.effective_proxy_port(), 3128); + options.proxy_port = Some(0); + assert!(options.validate("root@host").is_err()); +} + +#[test] +fn invalid_proxy_endpoints_and_authentication_are_rejected() { + for host in ["", "http://proxy", "user:password@proxy", "proxy:8080", "proxy\r\nHeader"] { + let options = SshConnectionOptions { proxy_host: host.to_owned(), ..proxy() }; + assert!(options.validate("root@host").is_err(), "{host:?}"); + } + let mut options = proxy(); + options.proxy_username = "字".repeat(86); + assert!(options.validate("root@host").is_err()); + options.proxy_mode = SshHostProxyMode::Http; + options.proxy_username = "user:password".to_owned(); + assert!(options.validate("root@host").is_err()); +} + +#[test] +fn proxy_credential_keys_are_stable_and_scoped_to_endpoint_and_target() { + let options = proxy(); + let key = options.proxy_credential_target("root@host").unwrap(); + assert!(key.starts_with("Nebula/SSH/Proxy/")); + assert!(!key.contains("alice")); + assert!(!key.contains("proxy.internal")); + assert_eq!(key, options.proxy_credential_target("root@host").unwrap()); + assert_ne!(key, options.proxy_credential_target("root@other").unwrap()); + for changed in [ + SshConnectionOptions { proxy_host: "other-proxy".into(), ..options.clone() }, + SshConnectionOptions { proxy_port: Some(9000), ..options.clone() }, + SshConnectionOptions { proxy_username: "bob".into(), ..options.clone() }, + SshConnectionOptions { proxy_mode: SshHostProxyMode::Http, ..options.clone() }, + ] { + assert_ne!(key, changed.proxy_credential_target("root@host").unwrap()); + } + let disabled = SshConnectionOptions { proxy_mode: SshHostProxyMode::Direct, ..options }; + assert!(disabled.proxy_credential_target("root@host").is_none()); +} + +#[test] +fn ssh_specs_allow_aliases_and_ipv6_but_not_lists_commands_or_passwords() { + for spec in ["office", "dev@office:2200", "ssh://dev@[::1]:2222", "dev@2001:db8::1"] { + assert!(validate_ssh_destination(spec).is_ok(), "{spec}"); + } + for spec in [ + "", + "host1,host2", + "-oProxyCommand=exec", + "user:secret@host", + "bad host", + "host:0", + "host:65536", + "[::1]junk", + "host;echo", + "user@@host", + "https://host", + ] { + assert!(validate_ssh_destination(spec).is_err(), "{spec}"); + } +} + +#[test] +fn self_jump_is_rejected_before_resolution() { + let options = SshConnectionOptions { + jump_mode: SshHostJumpMode::Host, + jump_host: "ssh://root@HOST:22".to_owned(), + ..Default::default() + }; + assert!(options.validate("root@host").unwrap_err().contains("自身")); +} + +#[test] +fn unknown_connection_modes_do_not_fall_back_to_direct_or_inherit() { + assert!(serde_json::from_str::(r#"{"proxy_mode":"socks4"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"jump_mode":"missing"}"#).is_err()); +} + +#[test] +fn renaming_saved_jump_host_updates_all_enabled_exact_references() { + let mut profiles = SshProfiles::default(); + let mut bastion = profiles.for_destination("jump@old-bastion"); + bastion.auth = SshAuthMode::PublicKey; + bastion.private_keys = vec!["bastion-identity".into()]; + profiles.upsert(bastion); + for (destination, jump_host) in + [("root@first", "jump@old-bastion"), ("root@second", " jump@old-bastion ")] + { + let mut profile = profiles.for_destination(destination); + profile.connection.jump_mode = SshHostJumpMode::Host; + profile.connection.jump_host = jump_host.to_owned(); + profiles.upsert(profile); + } + + profiles.rename("jump@old-bastion", "jump@new-bastion"); + + for destination in ["root@first", "root@second"] { + assert_eq!(profiles.for_destination(destination).connection.jump_host, "jump@new-bastion",); + } + assert_eq!(profiles.for_destination("jump@new-bastion").auth, SshAuthMode::PublicKey); + assert_eq!( + profiles.for_destination("jump@new-bastion").private_keys, + vec![std::path::PathBuf::from("bastion-identity")], + ); +} + +#[test] +fn renaming_jump_host_keeps_different_aliases_and_inactive_drafts_unchanged() { + let mut profiles = SshProfiles::default(); + let mut unchanged = Vec::new(); + for (destination, mode, jump_host) in [ + ("root@alias", SshHostJumpMode::Host, "bastion-alias"), + ("root@uri", SshHostJumpMode::Host, "ssh://jump@old-bastion"), + ("root@case", SshHostJumpMode::Host, "jump@OLD-bastion"), + ("root@disabled", SshHostJumpMode::None, "jump@old-bastion"), + ("root@inherited", SshHostJumpMode::Inherit, "jump@old-bastion"), + ] { + let mut profile = profiles.for_destination(destination); + profile.connection.jump_mode = mode; + profile.connection.jump_host = jump_host.to_owned(); + unchanged.push(profile.clone()); + profiles.upsert(profile); + } + + profiles.rename("jump@old-bastion", "jump@new-bastion"); + + for profile in unchanged { + assert_eq!(profiles.for_destination(&profile.destination), profile); + } +} + +#[test] +fn renaming_imported_jump_without_profile_still_updates_exact_references() { + let mut profiles = SshProfiles::default(); + let mut target = profiles.for_destination("root@target"); + target.connection.jump_mode = SshHostJumpMode::Host; + target.connection.jump_host = "imported-bastion".to_owned(); + profiles.upsert(target); + + profiles.rename("imported-bastion", "renamed-bastion"); + + assert_eq!(profiles.for_destination("root@target").connection.jump_host, "renamed-bastion",); + let serialized = serde_json::to_value(&profiles).unwrap(); + assert_eq!(serialized["profiles"].as_array().unwrap().len(), 1); +} + +#[test] +fn jump_dependents_preserves_order_and_excludes_self_aliases_and_inactive_drafts() { + let mut profiles = SshProfiles::default(); + for (destination, mode, jump_host) in [ + ("root@second", SshHostJumpMode::Host, "bastion"), + ("bastion", SshHostJumpMode::Host, "bastion"), + ("root@alias", SshHostJumpMode::Host, "another-alias"), + ("root@first", SshHostJumpMode::Host, " bastion "), + ("root@disabled", SshHostJumpMode::None, "bastion"), + ("root@inherited", SshHostJumpMode::Inherit, "bastion"), + ] { + let mut profile = profiles.for_destination(destination); + profile.connection.jump_mode = mode; + profile.connection.jump_host = jump_host.to_owned(); + profiles.upsert(profile); + } + + assert_eq!(profiles.jump_dependents("bastion"), vec!["root@second", "root@first"]); + assert!(profiles.jump_dependents("missing").is_empty()); +} diff --git a/nebula_app/src/ssh_prompt.rs b/nebula_app/src/ssh_prompt.rs new file mode 100644 index 0000000..0ecd4f1 --- /dev/null +++ b/nebula_app/src/ssh_prompt.rs @@ -0,0 +1,190 @@ +use std::io; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use tokio::sync::oneshot; +use zeroize::Zeroizing; + +pub enum PromptKind { + HostKey { host: String, port: u16, fingerprint: String }, + Secret { label: String, allow_save: bool }, +} + +pub enum PromptResponse { + Trust, + Secret { value: Zeroizing>, save: bool }, + Cancel, +} + +pub struct Prompt { + pub kind: PromptKind, + reply: Mutex>>, +} + +impl Prompt { + fn channel(kind: PromptKind) -> (Arc, oneshot::Receiver) { + let (sender, receiver) = oneshot::channel(); + (Arc::new(Self { kind, reply: Mutex::new(Some(sender)) }), receiver) + } + + #[cfg(test)] + pub(crate) fn for_test(kind: PromptKind) -> (Arc, oneshot::Receiver) { + Self::channel(kind) + } + + pub fn respond(&self, response: PromptResponse) -> bool { + let response = match (&self.kind, response) { + (PromptKind::HostKey { .. }, PromptResponse::Trust) => PromptResponse::Trust, + (PromptKind::Secret { allow_save, .. }, PromptResponse::Secret { value, save }) => { + PromptResponse::Secret { value, save: save && *allow_save } + }, + _ => PromptResponse::Cancel, + }; + self.reply + .lock() + .unwrap_or_else(|error| error.into_inner()) + .take() + .is_some_and(|sender| sender.send(response).is_ok()) + } + + pub fn is_pending(&self) -> bool { + self.reply + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + .is_some_and(|sender| !sender.is_closed()) + } +} + +type Dispatcher = Box) -> bool + Send + Sync>; +static DISPATCHER: OnceLock = OnceLock::new(); +static PROMPT_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +pub fn install(dispatch: impl Fn(Arc) -> bool + Send + Sync + 'static) { + let _ = DISPATCHER.set(Box::new(dispatch)); +} + +pub fn available() -> bool { + DISPATCHER.get().is_some() +} + +async fn request(kind: PromptKind) -> io::Result { + let dispatch = DISPATCHER.get().ok_or_else(|| { + io::Error::new(io::ErrorKind::NotConnected, "SSH user interface unavailable") + })?; + let _serial = PROMPT_LOCK.lock().await; + request_with(kind, |prompt| dispatch(prompt), Duration::from_secs(300)).await +} + +async fn request_with( + kind: PromptKind, + dispatch: impl FnOnce(Arc) -> bool, + timeout: Duration, +) -> io::Result { + let (prompt, receiver) = Prompt::channel(kind); + if !dispatch(prompt) { + return Err(io::Error::new(io::ErrorKind::NotConnected, "SSH window is closed")); + } + match tokio::time::timeout(timeout, receiver).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(_)) => Ok(PromptResponse::Cancel), + Err(_) => Err(io::Error::new(io::ErrorKind::TimedOut, "SSH confirmation timed out")), + } +} + +pub async fn confirm_host(host: &str, port: u16, fingerprint: String) -> io::Result { + let response = + request(PromptKind::HostKey { host: host.to_owned(), port, fingerprint }).await?; + Ok(matches!(response, PromptResponse::Trust)) +} + +pub async fn secret( + label: String, + allow_save: bool, +) -> io::Result>, bool)>> { + match request(PromptKind::Secret { label, allow_save }).await? { + PromptResponse::Secret { value, save } => Ok(Some((value, save && allow_save))), + _ => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keyboard_interactive_secrets_cannot_request_storage() { + let (prompt, mut receiver) = Prompt::channel(PromptKind::Secret { + label: "Verification code".into(), + allow_save: false, + }); + prompt.respond(PromptResponse::Secret { + value: Zeroizing::new(b"123456".to_vec()), + save: true, + }); + assert!(matches!(receiver.try_recv(), Ok(PromptResponse::Secret { save: false, .. }))); + } + + #[test] + fn a_secret_response_cannot_trust_a_host() { + let (prompt, mut receiver) = Prompt::channel(PromptKind::HostKey { + host: "example.test".into(), + port: 22, + fingerprint: "SHA256:test".into(), + }); + prompt.respond(PromptResponse::Secret { value: Zeroizing::new(Vec::new()), save: true }); + assert!(matches!(receiver.try_recv(), Ok(PromptResponse::Cancel))); + } + + #[test] + fn absent_window_or_expired_prompt_never_succeeds() { + let runtime = tokio::runtime::Builder::new_current_thread().enable_time().build().unwrap(); + runtime.block_on(async { + let kind = || PromptKind::Secret { label: "Password".into(), allow_save: false }; + let response = request_with(kind(), |_| false, Duration::from_millis(1)).await; + assert!(matches!(response, Err(error) if error.kind() == io::ErrorKind::NotConnected)); + let mut pending = None; + let response = request_with( + kind(), + |prompt| { + pending = Some(prompt); + true + }, + Duration::from_millis(1), + ) + .await; + assert!(matches!(response, Err(error) if error.kind() == io::ErrorKind::TimedOut)); + assert!(!pending.unwrap().is_pending()); + }); + } + + #[test] + fn confirmation_is_one_shot_and_cancel_is_not_trust() { + let (sender, mut receiver) = oneshot::channel(); + let prompt = Prompt { + kind: PromptKind::HostKey { + host: "example.test".into(), + port: 22, + fingerprint: "SHA256:test".into(), + }, + reply: Mutex::new(Some(sender)), + }; + assert!(prompt.is_pending()); + assert!(prompt.respond(PromptResponse::Cancel)); + assert!(!prompt.respond(PromptResponse::Trust)); + assert!(matches!(receiver.try_recv(), Ok(PromptResponse::Cancel))); + assert!(!prompt.is_pending()); + } + + #[test] + fn expired_request_cannot_accept_credentials() { + let (sender, receiver) = oneshot::channel(); + let prompt = Prompt { + kind: PromptKind::Secret { label: "Password".into(), allow_save: false }, + reply: Mutex::new(Some(sender)), + }; + drop(receiver); + assert!(!prompt.is_pending()); + assert!(!prompt.respond(PromptResponse::Trust)); + } +} diff --git a/nebula_app/src/ssh_proxy.rs b/nebula_app/src/ssh_proxy.rs index 9a061d0..fba4678 100644 --- a/nebula_app/src/ssh_proxy.rs +++ b/nebula_app/src/ssh_proxy.rs @@ -345,7 +345,7 @@ pub enum ProxyScheme { } /// 一台已解析好的代理服务器。密码只活在内存里,不进连接池 key。 -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct ProxyServer { pub scheme: ProxyScheme, pub host: String, @@ -354,6 +354,19 @@ pub struct ProxyServer { pub password: Option, } +impl std::fmt::Debug for ProxyServer { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProxyServer") + .field("scheme", &self.scheme) + .field("host", &self.host) + .field("port", &self.port) + .field("username", &self.username) + .field("password", &self.password.as_ref().map(|_| "[redacted]")) + .finish() + } +} + impl ProxyServer { /// 解析 `socks5://[user:pass@]host[:port]` / `http://[user:pass@]host[:port]`。 /// `socks5h` 与 `socks5` 等价——我们本来就把域名交给代理解析。 @@ -361,7 +374,7 @@ impl ProxyServer { let url = url.trim(); let (scheme, rest) = url .split_once("://") - .ok_or_else(|| format!("代理地址缺少协议前缀(socks5:// 或 http://): {url}"))?; + .ok_or_else(|| "代理地址缺少协议前缀(socks5:// 或 http://)".to_owned())?; let (scheme, default_port) = match scheme.to_ascii_lowercase().as_str() { "socks5" | "socks5h" | "socks" => (ProxyScheme::Socks5, 1080), "http" => (ProxyScheme::HttpConnect, 8080), @@ -384,7 +397,7 @@ impl ProxyServer { }; let (host, port) = split_host_port(host_port, default_port)?; if host.is_empty() { - return Err(format!("代理地址缺少主机名: {url}")); + return Err("代理地址缺少主机名".to_owned()); } Ok(Self { scheme, host, port, username, password }) } @@ -637,6 +650,11 @@ pub async fn connect( target_host: &str, target_port: u16, ) -> io::Result { + if proxy.port == 0 || target_port == 0 { + return Err(proxy_err("代理和目标端口必须在 1–65535 之间")); + } + crate::ssh_profiles::validate_ssh_destination(target_host) + .map_err(|_| proxy_err("代理目标主机名无效"))?; tokio::time::timeout(PROXY_CONNECT_TIMEOUT, async { let mut stream = TcpStream::connect((proxy.host.as_str(), proxy.port)).await?; // russh 的 connect 会给自己的 socket 设 nodelay;走 connect_stream @@ -680,7 +698,7 @@ async fn socks5_handshake( } match reply[1] { 0x00 => {}, - 0x02 => { + 0x02 if has_auth => { let username = proxy.username.as_deref().unwrap_or_default().as_bytes(); let password = proxy.password.as_deref().unwrap_or_default().as_bytes(); if username.len() > 255 || password.len() > 255 { @@ -695,7 +713,7 @@ async fn socks5_handshake( stream.write_all(&request).await?; let mut auth_reply = [0u8; 2]; stream.read_exact(&mut auth_reply).await?; - if auth_reply[1] != 0x00 { + if auth_reply[0] != 0x01 || auth_reply[1] != 0x00 { return Err(proxy_err("SOCKS5 代理拒绝了用户名/密码")); } }, @@ -728,6 +746,9 @@ async fn socks5_handshake( let mut head = [0u8; 4]; stream.read_exact(&mut head).await?; + if head[0] != 0x05 || head[2] != 0x00 { + return Err(proxy_err("SOCKS5 CONNECT 应答格式无效")); + } if head[1] != 0x00 { return Err(proxy_err(socks5_reply_message(head[1]))); } @@ -796,12 +817,17 @@ async fn http_connect_handshake( } let head = String::from_utf8_lossy(&response); let status_line = head.lines().next().unwrap_or_default(); - let status = status_line.split_whitespace().nth(1).and_then(|code| code.parse::().ok()); + let mut status_parts = status_line.split_whitespace(); + let protocol = status_parts.next(); + let status = status_parts.next().and_then(|code| code.parse::().ok()); + if !matches!(protocol, Some("HTTP/1.0" | "HTTP/1.1")) { + return Err(proxy_err("HTTP 代理应答协议无效")); + } match status { Some(200..=299) => Ok(()), Some(407) => Err(proxy_err("HTTP 代理要求认证(407),请检查用户名/密码")), Some(code) => Err(proxy_err(&format!("HTTP 代理拒绝建立隧道({code})"))), - None => Err(proxy_err(&format!("HTTP 代理应答无法解析: {status_line}"))), + None => Err(proxy_err("HTTP 代理应答无法解析")), } } diff --git a/nebula_app/src/ssh_session.rs b/nebula_app/src/ssh_session.rs index 2999923..a3bb0ca 100644 --- a/nebula_app/src/ssh_session.rs +++ b/nebula_app/src/ssh_session.rs @@ -28,6 +28,9 @@ use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use crate::event::EventProxy; use crate::proxy_test::{ProxyTestFailure, ProxyTestOutcome, ProxyTestResult, ProxyTestRoute}; +mod route; +use route::{ResolvedRoute, RouteTransport}; + type SessionError = Box; type ClientSession = client::Handle; type SharedSession = Arc>; @@ -36,6 +39,12 @@ struct AcquiredSession { key: String, session: SharedSession, reused: bool, + jump_sessions: Vec, +} + +struct OpenedTransport { + session: ClientSession, + jump_sessions: Vec, } /// 直连 SSH 会话的宿主回调抽象:终端事件泵([`EventListener`])+ 连接 @@ -203,6 +212,8 @@ impl SshDestination { /// 这能保持用户现有 `~/.ssh/config` 行为,同时网络连接仍完全由 Rust 传输层承担。 fn resolve(value: &str) -> io::Result { let original = value.trim().to_owned(); + crate::ssh_profiles::validate_ssh_destination(&original) + .map_err(|message| io::Error::new(io::ErrorKind::InvalidInput, message))?; if original.is_empty() { return Err(io::Error::new(io::ErrorKind::InvalidInput, "SSH 地址为空")); } @@ -212,8 +223,7 @@ impl SshDestination { // WSAHOST_NOT_FOUND。只规范化配置探测参数,保留 original 作为列表、 // Profile 与凭据的稳定身份。 let config_target = ssh_config_probe_target(&original); - let output = - Command::new(find_ssh()).arg("-G").arg("--").arg(config_target.as_ref()).output(); + let output = ssh_config_output(config_target.as_ref()); if let Ok(output) = output { if output.status.success() { let text = String::from_utf8_lossy(&output.stdout); @@ -338,6 +348,24 @@ fn find_ssh() -> PathBuf { PathBuf::from("ssh") } +/// Run OpenSSH's config-only probe without attaching a console window. +/// +/// Nebula is a Windows GUI process (`windows_subsystem = "windows"`). Starting +/// `ssh.exe -G` without `CREATE_NO_WINDOW` briefly creates a conhost window; +/// SFTP used to run this probe on every directory change, which made the black +/// flash particularly visible. Other platforms keep the ordinary process +/// flags. +fn ssh_config_output(target: &str) -> io::Result { + let mut command = Command::new(find_ssh()); + command.arg("-G").arg("--").arg(target); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW); + } + command.output() +} + fn expand_home(value: &str) -> PathBuf { let value = value.trim_matches('"'); if let Some(rest) = value.strip_prefix("~/").or_else(|| value.strip_prefix("~\\")) { @@ -362,6 +390,21 @@ fn default_identity_files() -> Vec { struct ClientHandler { host: String, port: u16, + allow_prompt: bool, + #[cfg(test)] + known_hosts_path: Option, +} + +impl ClientHandler { + fn verify_host_key(&self, key: &ssh_key::PublicKey) -> Result { + #[cfg(test)] + if let Some(path) = self.known_hosts_path.as_deref() { + return russh::keys::known_hosts::check_known_hosts_path( + &self.host, self.port, key, path, + ); + } + russh::keys::known_hosts::check_known_hosts(&self.host, self.port, key) + } } impl client::Handler for ClientHandler { @@ -371,11 +414,12 @@ impl client::Handler for ClientHandler { &mut self, server_public_key: &ssh_key::PublicKey, ) -> Result { - match russh::keys::known_hosts::check_known_hosts(&self.host, self.port, server_public_key) - { + match self.verify_host_key(server_public_key) { Ok(true) => Ok(true), Ok(false) => { - if confirm_new_host(&self.host, self.port, server_public_key) { + if self.allow_prompt + && confirm_new_host(&self.host, self.port, server_public_key).await + { if let Err(err) = russh::keys::known_hosts::learn_known_hosts( &self.host, self.port, @@ -390,7 +434,9 @@ impl client::Handler for ClientHandler { }, Err(err) => { warn!("SSH 主机密钥验证失败: {err}"); - show_host_key_changed(&self.host, self.port, &err.to_string()); + if self.allow_prompt { + show_host_key_changed(&self.host, self.port, &err.to_string()); + } Ok(false) }, } @@ -425,6 +471,19 @@ pub fn spawn_session( initial_size: WindowSize, terminal: Arc>>, event_proxy: H, +) -> io::Result { + spawn_session_at(destination, None, initial_size, terminal, event_proxy) +} + +/// Start an SSH pane and optionally move its fresh interactive shell to a +/// known remote working directory. The directory is sent only after the shell +/// channel is ready; it never participates in address parsing or authentication. +pub fn spawn_session_at( + destination: String, + initial_remote_cwd: Option, + initial_size: WindowSize, + terminal: Arc>>, + event_proxy: H, ) -> io::Result { let (sender, receiver) = EventLoopSender::standalone()?; let profiles_path = crate::display::nebula_data_dir().join("ssh_profiles.json"); @@ -435,13 +494,7 @@ pub fn spawn_session( let raw = destination.clone(); let resolved = tokio::task::spawn_blocking(move || { let resolved = SshDestination::resolve(&raw)?; - let profiles = match crate::ssh_profiles::SshProfiles::load(&profiles_path) { - Ok(profiles) => profiles, - Err(err) => { - warn!("加载 SSH Profile 失败,使用自动认证: {err}"); - crate::ssh_profiles::SshProfiles::default() - }, - }; + let profiles = crate::ssh_profiles::SshProfiles::load(&profiles_path)?; Ok::<_, io::Error>((resolved, profiles.for_destination(&raw))) }) .await; @@ -459,6 +512,7 @@ pub fn spawn_session( event_proxy.clone(), receiver, ready.clone(), + initial_remote_cwd, ) .await }, @@ -498,11 +552,13 @@ async fn run_session_async( event_proxy: H, receiver: Receiver, ready: Arc, + initial_remote_cwd: Option, ) -> Result<(), SessionError> { let mut acquired = authenticated_session_at(&destination, &profile, Some(&event_proxy), 0).await?; report_stage(Some(&event_proxy), SshStage::OpenShell); - let shell = open_shell_channel(&acquired.session, initial_size).await; + let shell = + open_shell_channel(&acquired.session, initial_size, initial_remote_cwd.as_deref()).await; let (mut channel, hook_token) = match shell { Ok(shell) => shell, Err(first_error) if acquired.reused => { @@ -514,7 +570,9 @@ async fn run_session_async( acquired = authenticated_session_at(&destination, &profile, Some(&event_proxy), 0).await?; report_stage(Some(&event_proxy), SshStage::OpenShell); - match open_shell_channel(&acquired.session, initial_size).await { + match open_shell_channel(&acquired.session, initial_size, initial_remote_cwd.as_deref()) + .await + { Ok(shell) => shell, Err(retry_error) => { evict_pooled_session(&acquired.key, &acquired.session).await; @@ -597,6 +655,7 @@ async fn run_session_async( async fn open_shell_channel( session: &SharedSession, initial_size: WindowSize, + initial_remote_cwd: Option<&str>, ) -> Result<(russh::Channel, String), SessionError> { let channel = { let session = session.lock().await; @@ -618,9 +677,24 @@ async fn open_shell_channel( // 远端 pane 的自声明是安全护栏而非连接前提;AcceptEnv 未放行时忽略。 let _ = channel.set_env(false, "NEBULA_PANE_REMOTE", "1").await; channel.request_shell(true).await?; + if let Some(command) = initial_remote_cd_command(initial_remote_cwd) { + channel.data_bytes(command).await?; + } Ok((channel, hook_token)) } +/// Build one shell-safe `cd` command for a path learned from OSC 7 or SFTP. +/// Relative and control-character-bearing values are rejected: a duplicated +/// tab must never turn terminal metadata into an arbitrary command stream. +fn initial_remote_cd_command(path: Option<&str>) -> Option> { + let path = path?.trim(); + if !path.starts_with('/') || path.len() > 16 * 1024 || path.chars().any(char::is_control) { + return None; + } + let quoted = path.replace('\'', "'\\''"); + Some(format!("cd '{quoted}'\r").into_bytes()) +} + async fn evict_pooled_session(key: &str, session: &SharedSession) -> bool { let mut pool = connection_pool().lock().await; let matches = pool.get(key).is_some_and(|pooled| Arc::ptr_eq(pooled, session)); @@ -644,33 +718,71 @@ async fn authenticated_session( Ok(authenticated_session_at(destination, profile, progress, 0).await?.session) } -/// [`authenticated_session`] 的带深度版本。`depth` 是当前跳板层级:目标连接 -/// 为 0,它的跳板为 1……跳板递归([`open_transport`] 的 `Jump` 分支)经 -/// [`authenticated_session_boxed`] 回到这里,深度上限在那边把关。 async fn authenticated_session_at( destination: &SshDestination, profile: &crate::ssh_profiles::SshProfileAuth, progress: Option<&H>, depth: u8, ) -> Result { - // 代理决策先于连接池查找:pool key 必须带上代理身份,否则改完代理设置 - // 还在复用旧代理建立的连接,表现为「改了设置没生效」。配置写错在这里 - // 直接报错——静默直连会把配置问题伪装成网络问题。 - let global = tokio::task::spawn_blocking(crate::ssh_proxy::SshProxyConfig::load_global) - .await - .map_err(|err| format!("读取代理配置任务失败: {err}"))?; - let proxy = resolve_network_proxy(&global, destination) - .map_err(|err| format!("SSH 代理配置无效: {err}"))?; - let key = match &proxy { - Some(link) => format!("{}|{}", destination.pool_key(), link.identity()), - None => destination.pool_key(), + let route = resolve_connection_route(destination, profile, depth, None).await?; + authenticated_route(&route, progress, false).await +} + +async fn resolve_connection_route( + destination: &SshDestination, + profile: &crate::ssh_profiles::SshProfileAuth, + depth: u8, + proxy_password: Option, +) -> Result { + let destination = destination.clone(); + let profile = profile.clone(); + tokio::task::spawn_blocking(move || { + let global = crate::ssh_proxy::SshProxyConfig::load_global(); + let path = crate::display::nebula_data_dir().join("ssh_profiles.json"); + let profiles = crate::ssh_profiles::SshProfiles::load(&path) + .map_err(|err| format!("读取 SSH 主机配置失败: {err}"))?; + route::resolve_route_with( + destination, + profile, + &global, + depth, + proxy_password.as_deref(), + &mut |spec| { + let destination = SshDestination::resolve(spec) + .map_err(|err| format!("解析跳板地址失败: {err}"))?; + Ok((destination, profiles.for_destination(spec))) + }, + &mut |target| { + crate::ssh_credentials::load_generic_secret(target) + .map_err(|err| format!("读取代理凭据失败,请重新保存代理密码: {err}")) + }, + ) + }) + .await + .map_err(|err| format!("解析 SSH 连接路径任务失败: {err}"))? + .map_err(Into::into) +} + +async fn authenticated_route( + route: &ResolvedRoute, + progress: Option<&H>, + unattended: bool, +) -> Result { + let key = route.pool_key(); + let existing = if unattended { + None + } else { + connection_pool().lock().await.get(&key).cloned() }; - if let Some(existing) = connection_pool().lock().await.get(&key).cloned() { + if let Some(existing) = existing { if !existing.lock().await.is_closed() { info!("复用已认证 SSH 连接: {key}"); - // 复用不上报 Connect/Authenticate:这条路径是瞬时的,交给 - // 350ms 门槛把卡片整个吃掉,用户看到的就是直接出 prompt。 - return Ok(AcquiredSession { key, session: existing, reused: true }); + return Ok(AcquiredSession { + key, + session: existing, + reused: true, + jump_sessions: Vec::new(), + }); } connection_pool().lock().await.remove(&key); } @@ -681,25 +793,49 @@ async fn authenticated_session_at( keepalive_max: 3, ..Default::default() }); - let handler = ClientHandler { host: destination.host.clone(), port: destination.port }; report_stage(progress, SshStage::Connect); - let mut session = open_transport(proxy.as_ref(), config, destination, handler, depth).await?; + let mut transport = open_transport(route, config, unattended).await?; report_stage(progress, SshStage::Authenticate); - authenticate(&mut session, destination, profile).await?; + if unattended { + let request = SshTestRequest { + request_id: 0, + destination: route.destination.original.clone(), + auth: route.profile.auth, + private_keys: route.profile.private_keys.clone(), + password: None, + connection: route.profile.connection.clone(), + proxy_password: None, + }; + test_authenticate(&mut transport.session, &route.destination, &request).await?; + } else { + authenticate(&mut transport.session, &route.destination, &route.profile).await?; + } - let session = Arc::new(tokio::sync::Mutex::new(session)); + let session = Arc::new(tokio::sync::Mutex::new(transport.session)); + if unattended { + return Ok(AcquiredSession { + key, + session, + reused: false, + jump_sessions: transport.jump_sessions, + }); + } let mut pool = connection_pool().lock().await; if let Some(existing) = pool.get(&key).cloned() { if !existing.lock().await.is_closed() { - return Ok(AcquiredSession { key, session: existing, reused: true }); + return Ok(AcquiredSession { + key, + session: existing, + reused: true, + jump_sessions: Vec::new(), + }); } } pool.insert(key.clone(), session.clone()); - Ok(AcquiredSession { key, session, reused: false }) + Ok(AcquiredSession { key, session, reused: false, jump_sessions: transport.jump_sessions }) } -/// SSH/SFTP 的唯一 Nebula 代理决策入口。主机编辑器不再维护第二套代理; -/// OpenSSH `ProxyJump` 是目标解析的一部分,仍需先于全局网络设置生效。 +#[cfg(test)] fn resolve_network_proxy( global: &crate::ssh_proxy::SshProxyConfig, destination: &SshDestination, @@ -707,77 +843,40 @@ fn resolve_network_proxy( global.resolve(destination.proxy_jump.as_deref(), &destination.host) } -/// 跳板递归需要的类型擦除:async fn 相互递归时 future 类型无限展开, -/// `Box` 在这里切断它。 -fn authenticated_session_boxed<'a, H: SshEventHost>( - destination: &'a SshDestination, - profile: &'a crate::ssh_profiles::SshProfileAuth, - progress: Option<&'a H>, - depth: u8, -) -> std::pin::Pin< - Box> + Send + 'a>, -> { - Box::pin(authenticated_session_at(destination, profile, progress, depth)) -} - -/// 建立 SSH 传输层。直连交给 russh 自己开 TCP;代理服务器先完成隧道握手, -/// 再把裸流交给 `connect_stream`;跳板则先对跳板主机走一遍完整的 -/// 连接 + 认证(沿用它自己的 profile / 密钥 / 代理配置,会话进连接池可 -/// 复用),再在其上开 direct-tcpip 通道当传输层——三条路返回同一种句柄。 async fn open_transport( - proxy: Option<&crate::ssh_proxy::ProxyLink>, + route: &ResolvedRoute, config: Arc, - destination: &SshDestination, - handler: ClientHandler, - depth: u8, -) -> Result { - use crate::ssh_proxy::ProxyLink; - match proxy { - Some(ProxyLink::Server(server)) => { + unattended: bool, +) -> Result { + let destination = &route.destination; + let handler = ClientHandler { + host: destination.host.clone(), + port: destination.port, + allow_prompt: !unattended, + #[cfg(test)] + known_hosts_path: route.known_hosts_path.clone(), + }; + let mut jump_sessions = Vec::new(); + let session = match &route.transport { + RouteTransport::Server(server) => { info!("经代理 {} 连接 {}:{}", server.display(), destination.host, destination.port); let stream = crate::ssh_proxy::connect(server, &destination.host, destination.port) .await .map_err(|err| format!("经代理 {} 连接失败: {err}", server.display()))?; - Ok(client::connect_stream(config, stream, handler).await?) + client::connect_stream(config, stream, handler).await? }, - Some(ProxyLink::Jump(spec)) => { - // 上限 2 级:target → 跳板 → 跳板的跳板。成环配置(a jump b、 - // b jump a)也在这里截断,报错而不是栈溢出。 - if depth >= 2 { - return Err(format!("跳板链过深或存在循环(经 {spec},最多 2 级跳板)").into()); - } + RouteTransport::Jump(jump) => { + let spec = &jump.destination.original; info!("经跳板 {spec} 连接 {}:{}", destination.host, destination.port); - let (jump_destination, jump_profile) = tokio::task::spawn_blocking({ - let spec = spec.clone(); - move || { - let jump_destination = SshDestination::resolve(&spec)?; - let path = crate::display::nebula_data_dir().join("ssh_profiles.json"); - let profile = match crate::ssh_profiles::SshProfiles::load(&path) { - Ok(profiles) => profiles.for_destination(&spec), - Err(err) => { - warn!("加载跳板 SSH Profile 失败,使用自动认证: {err}"); - crate::ssh_profiles::SshProfiles::default().for_destination(&spec) - }, - }; - Ok::<_, io::Error>((jump_destination, profile)) - } - }) - .await - .map_err(|err| format!("解析跳板地址任务失败: {err}"))? - .map_err(|err| format!("解析跳板 {spec} 失败: {err}"))?; - // 跳板阶段不上报进度:连接卡片的阶段属于目标主机,来回横跳 - // 只会让用户以为连接在抽风。 - let jump_session = authenticated_session_boxed( - &jump_destination, - &jump_profile, + let acquired = Box::pin(authenticated_route( + jump, None::<&NoopSshEventHost>, - depth + 1, - ) + unattended, + )) .await - .map_err(|err| format!("连接跳板 {spec} 失败: {err}"))? - .session; + .map_err(|err| format!("连接跳板 {spec} 失败: {err}"))?; let channel = { - let session = jump_session.lock().await; + let session = acquired.session.lock().await; session .channel_open_direct_tcpip( destination.host.clone(), @@ -793,21 +892,24 @@ async fn open_transport( ) })? }; - Ok(client::connect_stream(config, channel.into_stream(), handler).await?) + jump_sessions = acquired.jump_sessions; + jump_sessions.push(acquired.session); + client::connect_stream(config, channel.into_stream(), handler).await? }, - Some(ProxyLink::Command(command)) => { + RouteTransport::Command(command) => { info!("经自定义命令连接 {}:{}", destination.host, destination.port); let stream = crate::ssh_proxy::connect_command(command, &destination.host, destination.port) .await .map_err(|err| format!("自定义代理命令启动失败: {err}"))?; - Ok(client::connect_stream(config, stream, handler).await?) + client::connect_stream(config, stream, handler).await? }, - None => { - Ok(client::connect(config, (destination.host.as_str(), destination.port), handler) - .await?) + RouteTransport::Direct => { + client::connect(config, (destination.host.as_str(), destination.port), handler) + .await? }, - } + }; + Ok(OpenedTransport { session, jump_sessions }) } async fn authenticate( @@ -846,7 +948,11 @@ async fn authenticate( AuthMethod::StoredPassword => { if !loaded_stored_password { reusable_password = - crate::ssh_credentials::load_stored_password(&destination.original)?; + crate::ssh_credentials::load_stored_password(&destination.original) + .unwrap_or_else(|error| { + warn!("Could not read saved SSH password; prompting instead: {error}"); + None + }); loaded_stored_password = true; stored_password_was_present = reusable_password.is_some(); } @@ -901,7 +1007,9 @@ async fn authenticate( } if stored_password_was_present { - crate::ssh_credentials::forget_password(&destination.original)?; + if let Err(error) = crate::ssh_credentials::forget_password(&destination.original) { + warn!("Could not remove rejected SSH password: {error}"); + } } clear_secret(&mut reusable_password); Err(auth_failure(profile.auth, key_count, &local_key_errors).into()) @@ -985,13 +1093,7 @@ pub(crate) async fn open_sftp( let raw = raw_destination.to_owned(); let (destination, profile) = tokio::task::spawn_blocking(move || { let destination = SshDestination::resolve(&raw)?; - let profiles = match crate::ssh_profiles::SshProfiles::load(&profiles_path) { - Ok(profiles) => profiles, - Err(err) => { - warn!("加载 SSH Profile 失败,SFTP 使用自动认证: {err}"); - crate::ssh_profiles::SshProfiles::default() - }, - }; + let profiles = crate::ssh_profiles::SshProfiles::load(&profiles_path)?; Ok::<_, io::Error>((destination, profiles.for_destination(&raw))) }) .await @@ -1022,7 +1124,7 @@ pub(crate) async fn open_sftp( /// 编辑器草稿的连通性测试请求。带草稿密码/密钥而不是磁盘 profile—— /// 测试要回答「保存后能不能连上」,不是「上次保存的配置行不行」。 -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct SshTestRequest { /// Window-local monotonic id. The editor may be changed and tested again /// while an older network task is still finishing, so address equality is @@ -1033,6 +1135,23 @@ pub struct SshTestRequest { pub private_keys: Vec, /// 密码框里的未保存草稿;`None`/空 = 只用密钥与已存凭据。 pub password: Option, + pub connection: crate::ssh_profiles::SshConnectionOptions, + pub proxy_password: Option, +} + +impl std::fmt::Debug for SshTestRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SshTestRequest") + .field("request_id", &self.request_id) + .field("destination", &self.destination) + .field("auth", &self.auth) + .field("private_keys", &self.private_keys) + .field("connection", &self.connection) + .field("password", &self.password.as_ref().map(|_| "[redacted]")) + .field("proxy_password", &self.proxy_password.as_ref().map(|_| "[redacted]")) + .finish() + } } const TEST_TIMEOUT: Duration = Duration::from_secs(12); @@ -1056,23 +1175,32 @@ async fn run_test(request: SshTestRequest) -> SshTestResult { let request_id = request.request_id; let raw = request.destination.clone(); let outcome = tokio::time::timeout(TEST_TIMEOUT, async { - let (resolved, global) = tokio::task::spawn_blocking({ + let resolved = tokio::task::spawn_blocking({ let raw = raw.clone(); move || { - let resolved = SshDestination::resolve(&raw)?; - let global = crate::ssh_proxy::SshProxyConfig::load_global(); - Ok::<_, io::Error>((resolved, global)) + SshDestination::resolve(&raw) } }) .await .map_err(|err| -> SessionError { format!("SSH 地址解析任务失败: {err}").into() })??; - // SSH 编辑器不再维护重复的每主机代理;测试与真连都只读取网络页 - // 的当前配置。`ProxyJump` 属于 OpenSSH 目标解析,仍按其原语义保留。 - let proxy = resolve_network_proxy(&global, &resolved) - .map_err(|err| -> SessionError { format!("SSH 代理配置无效: {err}").into() })?; - test_connect(&resolved, &request, proxy.as_ref()).await + let profile = crate::ssh_profiles::SshProfileAuth { + destination: request.destination.clone(), + auth: request.auth, + private_keys: request.private_keys.clone(), + label: None, + icon: None, + connection: request.connection.clone(), + }; + let route = resolve_connection_route( + &resolved, + &profile, + 0, + request.proxy_password.clone(), + ) + .await?; + test_connect(&route, &request).await }) .await; let (ok, message) = match outcome { @@ -1134,6 +1262,45 @@ const NETWORK_TEST_PORT: u16 = 80; trait NetworkTestStream: AsyncRead + AsyncWrite + Unpin + Send {} impl NetworkTestStream for T {} +struct RetainedNetworkStream { + stream: Box, + _jump_sessions: Vec, +} + +impl AsyncRead for RetainedNetworkStream { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + context: &mut std::task::Context<'_>, + buffer: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(self.stream.as_mut()).poll_read(context, buffer) + } +} + +impl AsyncWrite for RetainedNetworkStream { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + context: &mut std::task::Context<'_>, + buffer: &[u8], + ) -> std::task::Poll> { + std::pin::Pin::new(self.stream.as_mut()).poll_write(context, buffer) + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + context: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(self.stream.as_mut()).poll_flush(context) + } + + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + context: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::pin::Pin::new(self.stream.as_mut()).poll_shutdown(context) + } +} + /// 一次出网测试的完成数据。旧 winit 壳经事件投递;GPUI 设置页走 oneshot, /// 握手与 HTTP 探测仍是 [`proxy_test_once`] 这一条路径。 fn proxy_test_pair( @@ -1272,25 +1439,28 @@ async fn proxy_test_stream( })?; let path = crate::display::nebula_data_dir().join("ssh_profiles.json"); let profile = crate::ssh_profiles::SshProfiles::load(&path) - .unwrap_or_default() + .map_err(|error| ProxyTestFailure::JumpResolve { + target: spec.clone(), + error: format!("读取 SSH 主机配置失败: {error}"), + })? .for_destination(&spec); Ok::<_, ProxyTestFailure>((destination, profile)) } }) .await .map_err(|error| ProxyTestFailure::JumpTask(error.to_string()))??; - let jump = - authenticated_session_at::( - &jump_destination, - &jump_profile, - None, - 1, - ) - .await - .map_err(|error| ProxyTestFailure::JumpConnect { - target: spec.clone(), - error: error.to_string(), - })?; + let route = resolve_connection_route(&jump_destination, &jump_profile, 1, None) + .await + .map_err(|error| ProxyTestFailure::JumpConnect { + target: spec.clone(), + error: error.to_string(), + })?; + let jump = authenticated_route(&route, None::<&NoopSshEventHost>, true) + .await + .map_err(|error| ProxyTestFailure::JumpConnect { + target: spec.clone(), + error: error.to_string(), + })?; let channel = { let session = jump.session.lock().await; session @@ -1306,7 +1476,12 @@ async fn proxy_test_stream( error: error.to_string(), })? }; - Ok(Box::new(channel.into_stream())) + let mut sessions = jump.jump_sessions; + sessions.push(jump.session); + Ok(Box::new(RetainedNetworkStream { + stream: Box::new(channel.into_stream()), + _jump_sessions: sessions, + })) }, None => tokio::net::TcpStream::connect((NETWORK_TEST_HOST, NETWORK_TEST_PORT)) .await @@ -1315,12 +1490,9 @@ async fn proxy_test_stream( } } -/// 一次性连接(不进连接池——池里的旧连接不能代表新草稿),认证完即 drop。 -/// 跳板路径例外:跳板本身的会话仍走池(它不承载草稿,复用是安全的)。 async fn test_connect( - destination: &SshDestination, + route: &ResolvedRoute, request: &SshTestRequest, - proxy: Option<&crate::ssh_proxy::ProxyLink>, ) -> Result<(), SessionError> { let config = Arc::new(client::Config { inactivity_timeout: None, @@ -1328,9 +1500,8 @@ async fn test_connect( keepalive_max: 3, ..Default::default() }); - let handler = ClientHandler { host: destination.host.clone(), port: destination.port }; - let mut session = open_transport(proxy, config, destination, handler, 0).await?; - test_authenticate(&mut session, destination, request).await + let mut transport = open_transport(route, config, true).await?; + test_authenticate(&mut transport.session, &route.destination, request).await } /// 无人值守版认证:none → 草稿密码 → 密钥/已存密码计划。明确的 @@ -1504,13 +1675,19 @@ async fn try_private_key( }; if key.is_none() { - let mut stored = crate::ssh_credentials::load_private_key_passphrase(&private_key)?; + let mut stored = crate::ssh_credentials::load_private_key_passphrase(&private_key) + .unwrap_or_else(|error| { + warn!("Could not read saved SSH key passphrase; prompting instead: {error}"); + None + }); key = stored .as_deref() .and_then(|bytes| std::str::from_utf8(bytes).ok()) .and_then(|passphrase| russh::keys::load_secret_key(path, Some(passphrase)).ok()); - if key.is_none() { - crate::ssh_credentials::forget_private_key_passphrase(&private_key)?; + if key.is_none() && stored.is_some() { + if let Err(error) = crate::ssh_credentials::forget_private_key_passphrase(&private_key) { + warn!("Could not remove rejected SSH key passphrase: {error}"); + } } clear_secret(&mut stored); } @@ -1522,7 +1699,7 @@ async fn try_private_key( } let prompt = format!("密钥口令: {}", path.display()); if let Some((mut passphrase, save)) = prompt_secret(prompt, None, true).await? { - let text = String::from_utf8_lossy(&passphrase).into_owned(); + let text = zeroize::Zeroizing::new(String::from_utf8_lossy(&passphrase).into_owned()); key = russh::keys::load_secret_key(path, Some(&text)).ok(); if key.is_some() && save { crate::ssh_credentials::store_private_key_passphrase(&private_key, &passphrase)?; @@ -1628,9 +1805,17 @@ async fn prompt_secret( destination: String, initial: Option>, allow_save: bool, -) -> io::Result, bool)>> { +) -> io::Result>, bool)>> { + if crate::ssh_prompt::available() { + let _initial = zeroize::Zeroizing::new(initial.unwrap_or_default()); + return crate::ssh_prompt::secret( + destination, allow_save && crate::platform::credentials::can_store(), + ).await; + } tokio::task::spawn_blocking(move || { - crate::ssh_credentials::prompt_password(&destination, initial.as_deref(), allow_save) + let initial = zeroize::Zeroizing::new(initial.unwrap_or_default()); + crate::ssh_credentials::prompt_password(&destination, Some(&initial), allow_save) + .map(|response| response.map(|(value, save)| (zeroize::Zeroizing::new(value), save))) }) .await .map_err(|err| io::Error::other(format!("凭据输入任务失败: {err}")))? @@ -1666,8 +1851,31 @@ fn render_error( event_proxy.send_event(TerminalEvent::Wakeup); } +async fn confirm_new_host(host: &str, port: u16, key: &ssh_key::PublicKey) -> bool { + if crate::ssh_prompt::available() { + return crate::ssh_prompt::confirm_host( + host, port, key.fingerprint(ssh_key::HashAlg::Sha256).to_string(), + ).await.unwrap_or_else(|error| { + warn!("SSH host confirmation failed: {error}"); + false + }); + } + confirm_new_host_legacy(host, port, key) +} + +#[cfg(not(windows))] +fn confirm_new_host_legacy(host: &str, port: u16, _key: &ssh_key::PublicKey) -> bool { + warn!("SSH host {host}:{port} is untrusted and no confirmation window is available"); + false +} + +#[cfg(not(windows))] +fn show_host_key_changed(host: &str, port: u16, detail: &str) { + log::error!("{host}:{port} 的主机密钥与已保存记录不一致,连接已终止: {detail}"); +} + #[cfg(windows)] -fn confirm_new_host(host: &str, port: u16, key: &ssh_key::PublicKey) -> bool { +fn confirm_new_host_legacy(host: &str, port: u16, key: &ssh_key::PublicKey) -> bool { use std::ptr::null_mut; use windows_sys::Win32::UI::WindowsAndMessaging::{ IDYES, MB_ICONQUESTION, MB_SETFOREGROUND, MB_YESNO, MessageBoxW, @@ -1716,8 +1924,8 @@ fn wide(value: &str) -> Vec { #[cfg(test)] mod tests { use super::{ - AuthMethod, SshDestination, authentication_plan, is_password_prompt, parse_resolved_config, - resolve_network_proxy, ssh_config_probe_target, + AuthMethod, SshDestination, authentication_plan, initial_remote_cd_command, + is_password_prompt, parse_resolved_config, resolve_network_proxy, ssh_config_probe_target, }; use crate::ssh_profiles::SshAuthMode; use crate::ssh_proxy::{ProxyLink, ProxyMode, SshProxyConfig}; @@ -1773,6 +1981,16 @@ mod tests { assert_eq!(ssh_config_probe_target("root@2001:db8::1"), "root@2001:db8::1"); } + #[test] + fn duplicated_ssh_tabs_quote_their_remote_working_directory() { + assert_eq!( + initial_remote_cd_command(Some("/srv/Team's App")), + Some(b"cd '/srv/Team'\\''s App'\r".to_vec()) + ); + assert_eq!(initial_remote_cd_command(Some("relative/path")), None); + assert_eq!(initial_remote_cd_command(Some("/srv/app\nwhoami")), None); + } + #[test] fn proxy_test_timeout_copy_matches_shared_budget() { // 353bc17 把这条文案搬进 i18n 目录,秒数改由 `ProxyTestFailure` 携带 diff --git a/nebula_app/src/ssh_session/route.rs b/nebula_app/src/ssh_session/route.rs new file mode 100644 index 0000000..480bf6b --- /dev/null +++ b/nebula_app/src/ssh_session/route.rs @@ -0,0 +1,215 @@ +use crate::ssh_profiles::{SshHostJumpMode, SshHostProxyMode, SshProfileAuth}; +use crate::ssh_proxy::{ProxyLink, ProxyMode, ProxyScheme, ProxyServer, SshProxyConfig}; + +use super::SshDestination; + +pub(super) struct ResolvedRoute { + pub destination: SshDestination, + pub profile: SshProfileAuth, + pub transport: RouteTransport, + #[cfg(test)] + pub known_hosts_path: Option, +} + +pub(super) enum RouteTransport { + Direct, + Server(ProxyServer), + Command(String), + Jump(Box), +} + +#[derive(Clone)] +enum NetworkOverride { + Direct, + Server(ProxyServer), +} + +impl ResolvedRoute { + pub fn pool_key(&self) -> String { + use sha2::{Digest, Sha256}; + use std::fmt::Write as _; + + let mut digest = Sha256::new(); + let mut current = self; + loop { + let metadata = serde_json::to_vec(&( + ¤t.destination.original, + current.destination.pool_key(), + current.profile.auth, + ¤t.profile.private_keys, + ¤t.destination.identity_files, + )) + .expect("SSH route metadata is serializable"); + digest.update((metadata.len() as u64).to_be_bytes()); + digest.update(metadata); + match ¤t.transport { + RouteTransport::Jump(jump) => { + digest.update(b"jump"); + current = jump; + }, + RouteTransport::Direct => { + digest.update(b"direct"); + break; + }, + RouteTransport::Server(server) => { + digest.update(b"proxy"); + digest.update(server.identity()); + break; + }, + RouteTransport::Command(command) => { + digest.update(b"command"); + digest.update(command); + break; + }, + } + } + let mut fingerprint = String::with_capacity(64); + for byte in digest.finalize() { + let _ = write!(fingerprint, "{byte:02x}"); + } + format!("{}|route:{fingerprint}", self.destination.pool_key()) + } +} + +pub(super) fn resolve_route_with( + destination: SshDestination, + profile: SshProfileAuth, + global: &SshProxyConfig, + depth: u8, + proxy_password: Option<&str>, + resolve_host: &mut impl FnMut(&str) -> Result<(SshDestination, SshProfileAuth), String>, + load_secret: &mut impl FnMut(&str) -> Result>, String>, +) -> Result { + build_route( + destination, + profile, + global, + depth, + proxy_password, + None, + &mut Vec::new(), + resolve_host, + load_secret, + ) +} + +fn build_route( + destination: SshDestination, + profile: SshProfileAuth, + global: &SshProxyConfig, + depth: u8, + proxy_password: Option<&str>, + inherited_proxy: Option, + ancestors: &mut Vec<(String, u16)>, + resolve_host: &mut impl FnMut(&str) -> Result<(SshDestination, SshProfileAuth), String>, + load_secret: &mut impl FnMut(&str) -> Result>, String>, +) -> Result { + profile.connection.validate(&destination.original)?; + let endpoint = (destination.host.to_ascii_lowercase(), destination.port); + if ancestors.contains(&endpoint) { + return Err("跳板链存在循环或将目标主机自身用作跳板".to_owned()); + } + ancestors.push(endpoint); + let options = &profile.connection; + let network = match inherited_proxy { + Some(network) => Some(network), + None => match options.proxy_mode { + SshHostProxyMode::Inherit => None, + SshHostProxyMode::Direct => Some(NetworkOverride::Direct), + SshHostProxyMode::Socks5 | SshHostProxyMode::Http => { + let username = options.proxy_username.trim(); + let password = if username.is_empty() { + None + } else if let Some(password) = proxy_password { + Some(password.to_owned()) + } else if let Some(target) = options.proxy_credential_target(&destination.original) + { + let secret = load_secret(&target)?; + match secret { + Some(mut secret) => { + let password = std::str::from_utf8(&secret) + .map(str::to_owned) + .map_err(|_| "代理密码无法读取,请重新填写并保存".to_owned()); + secret.fill(0); + Some(password?) + }, + None => None, + } + } else { + None + }; + if options.proxy_mode == SshHostProxyMode::Socks5 + && password.as_ref().is_some_and(|password| password.len() > 255) + { + return Err("SOCKS5 代理密码不能超过 255 字节".to_owned()); + } + Some(NetworkOverride::Server(ProxyServer { + scheme: if options.proxy_mode == SshHostProxyMode::Socks5 { + ProxyScheme::Socks5 + } else { + ProxyScheme::HttpConnect + }, + host: options.normalized_proxy_host(), + port: options.effective_proxy_port(), + username: (!username.is_empty()).then(|| username.to_owned()), + password, + })) + }, + }, + }; + let jump = match options.jump_mode { + SshHostJumpMode::Host => Some(options.jump_host.trim().to_owned()), + SshHostJumpMode::None => None, + SshHostJumpMode::Inherit => destination + .proxy_jump + .as_deref() + .map(str::trim) + .filter(|jump| !jump.is_empty() && !jump.eq_ignore_ascii_case("none")) + .map(str::to_owned) + .or_else(|| { + (depth == 0 && global.mode == ProxyMode::Custom) + .then(|| crate::ssh_proxy::jump_target(&global.url).map(str::to_owned)) + .flatten() + }), + }; + let transport = if let Some(spec) = jump { + crate::ssh_profiles::validate_ssh_destination(&spec) + .map_err(|_| "跳板地址无效,仅支持单个 SSH 别名或 user@host:port".to_owned())?; + if depth >= 2 { + return Err("跳板链过深,最多支持 2 级跳板".to_owned()); + } + let (jump_destination, jump_profile) = resolve_host(&spec)?; + RouteTransport::Jump(Box::new(build_route( + jump_destination, + jump_profile, + global, + depth + 1, + None, + network, + ancestors, + resolve_host, + load_secret, + )?)) + } else { + match network { + Some(NetworkOverride::Direct) => RouteTransport::Direct, + Some(NetworkOverride::Server(server)) => RouteTransport::Server(server), + None => match global.resolve(None, &destination.host)? { + Some(ProxyLink::Server(server)) => RouteTransport::Server(server), + Some(ProxyLink::Command(command)) => RouteTransport::Command(command), + Some(ProxyLink::Jump(_)) | None => RouteTransport::Direct, + }, + } + }; + ancestors.pop(); + Ok(ResolvedRoute { + destination, + profile, + transport, + #[cfg(test)] + known_hosts_path: None, + }) +} + +#[cfg(test)] +mod tests; diff --git a/nebula_app/src/ssh_session/route/tests.rs b/nebula_app/src/ssh_session/route/tests.rs new file mode 100644 index 0000000..b025bcb --- /dev/null +++ b/nebula_app/src/ssh_session/route/tests.rs @@ -0,0 +1,253 @@ +use std::collections::HashMap; + +use super::{ResolvedRoute, RouteTransport, resolve_route_with}; +use crate::ssh_profiles::{ + SshAuthMode, SshHostJumpMode, SshHostProxyMode, SshProfileAuth, SshProfiles, +}; +use crate::ssh_proxy::{ProxyMode, ProxyScheme, SshProxyConfig}; +use crate::ssh_session::{SshDestination, SshTestRequest}; + +mod loopback; + +fn host(spec: &str) -> (SshDestination, SshProfileAuth) { + (SshDestination::parse(spec).unwrap(), SshProfiles::default().for_destination(spec)) +} + +fn global_proxy() -> SshProxyConfig { + SshProxyConfig { + mode: ProxyMode::Custom, + url: "socks5://global-proxy:1080".to_owned(), + no_proxy: Vec::new(), + } +} + +fn resolve( + destination: SshDestination, + profile: SshProfileAuth, + global: &SshProxyConfig, + hosts: &HashMap, +) -> Result { + resolve_route_with( + destination, + profile, + global, + 0, + None, + &mut |spec| hosts.get(spec).cloned().ok_or_else(|| "missing host".to_owned()), + &mut |_| Ok(None), + ) +} + +fn custom_proxy(profile: &mut SshProfileAuth, mode: SshHostProxyMode, host: &str) { + profile.connection.proxy_mode = mode; + profile.connection.proxy_host = host.to_owned(); +} + +fn jump(profile: &mut SshProfileAuth, target: &str) { + profile.connection.jump_mode = SshHostJumpMode::Host; + profile.connection.jump_host = target.to_owned(); +} + +#[test] +fn defaults_follow_global_proxy_and_openssh_jump() { + let (mut destination, profile) = host("root@target"); + let global = global_proxy(); + let route = resolve(destination.clone(), profile.clone(), &global, &HashMap::new()).unwrap(); + assert!(matches!(route.transport, RouteTransport::Server(_))); + destination.proxy_jump = Some("jump-alias".to_owned()); + let hosts = HashMap::from([("jump-alias".to_owned(), host("bastion@first-hop"))]); + let route = resolve(destination, profile, &global, &hosts).unwrap(); + let RouteTransport::Jump(jump) = route.transport else { panic!("expected jump") }; + assert_eq!(jump.destination.user, "bastion"); + let RouteTransport::Server(server) = jump.transport else { panic!("expected proxy") }; + assert_eq!(server.host, "global-proxy"); +} + +#[test] +fn explicit_network_proxy_overrides_jump_proxy_only_at_first_network_hop() { + let (destination, mut profile) = host("root@target"); + custom_proxy(&mut profile, SshHostProxyMode::Http, "target-proxy"); + jump(&mut profile, "bastion@hop"); + let (jump_destination, mut jump_profile) = host("bastion@hop"); + custom_proxy(&mut jump_profile, SshHostProxyMode::Socks5, "jump-proxy"); + jump_profile.auth = SshAuthMode::PublicKey; + jump_profile.private_keys = vec!["bastion-key".into()]; + let hosts = HashMap::from([("bastion@hop".to_owned(), (jump_destination, jump_profile))]); + let route = resolve(destination, profile, &global_proxy(), &hosts).unwrap(); + let RouteTransport::Jump(jump) = route.transport else { panic!("expected jump") }; + assert_eq!(jump.profile.auth, SshAuthMode::PublicKey); + assert_eq!(jump.profile.private_keys, vec![std::path::PathBuf::from("bastion-key")]); + let RouteTransport::Server(server) = jump.transport else { panic!("expected proxy") }; + assert_eq!(server.scheme, ProxyScheme::HttpConnect); + assert_eq!(server.host, "target-proxy"); +} + +#[test] +fn direct_disables_network_proxy_but_keeps_jump() { + let (destination, mut profile) = host("root@target"); + profile.connection.proxy_mode = SshHostProxyMode::Direct; + jump(&mut profile, "bastion@hop"); + let (jump_destination, mut jump_profile) = host("bastion@hop"); + custom_proxy(&mut jump_profile, SshHostProxyMode::Http, "ignored-proxy"); + let hosts = HashMap::from([("bastion@hop".to_owned(), (jump_destination, jump_profile))]); + let route = resolve(destination, profile, &global_proxy(), &hosts).unwrap(); + let RouteTransport::Jump(jump) = route.transport else { panic!("expected jump") }; + assert!(matches!(jump.transport, RouteTransport::Direct)); +} + +#[test] +fn inherited_network_uses_jump_profiles_own_proxy() { + let (destination, mut profile) = host("root@target"); + jump(&mut profile, "bastion@hop"); + let (jump_destination, mut jump_profile) = host("bastion@hop"); + custom_proxy(&mut jump_profile, SshHostProxyMode::Http, "jump-proxy"); + let hosts = HashMap::from([("bastion@hop".to_owned(), (jump_destination, jump_profile))]); + let route = resolve(destination, profile, &global_proxy(), &hosts).unwrap(); + let RouteTransport::Jump(jump) = route.transport else { panic!("expected jump") }; + let RouteTransport::Server(server) = jump.transport else { panic!("expected proxy") }; + assert_eq!(server.host, "jump-proxy"); +} + +#[test] +fn disabled_jump_suppresses_ssh_config_without_disabling_network_proxy() { + let (mut destination, mut profile) = host("root@target"); + destination.proxy_jump = Some("bad,chain".to_owned()); + profile.connection.jump_mode = SshHostJumpMode::None; + let route = resolve(destination, profile, &global_proxy(), &HashMap::new()).unwrap(); + assert!(matches!(route.transport, RouteTransport::Server(_))); +} + +#[test] +fn global_jump_is_applied_once_instead_of_recursing_into_itself() { + let (destination, profile) = host("root@target"); + let global = SshProxyConfig { + mode: ProxyMode::Custom, + url: "jump:bastion@hop".into(), + no_proxy: Vec::new(), + }; + let hosts = HashMap::from([("bastion@hop".to_owned(), host("bastion@hop"))]); + let route = resolve(destination, profile, &global, &hosts).unwrap(); + let RouteTransport::Jump(jump) = route.transport else { panic!("expected jump") }; + assert!(matches!(jump.transport, RouteTransport::Direct)); +} + +#[test] +fn invalid_proxy_or_unresolved_jump_never_falls_back_to_direct() { + let (destination, mut profile) = host("root@target"); + custom_proxy(&mut profile, SshHostProxyMode::Socks5, "http://invalid"); + assert!( + resolve(destination.clone(), profile.clone(), &global_proxy(), &HashMap::new()).is_err() + ); + profile.connection.proxy_mode = SshHostProxyMode::Direct; + jump(&mut profile, "bastion@missing"); + assert!(resolve(destination, profile, &global_proxy(), &HashMap::new()).is_err()); +} + +#[test] +fn resolved_alias_cycles_are_rejected_before_opening_any_transport() { + let (destination, mut profile) = host("root@target"); + jump(&mut profile, "target-alias"); + let hosts = HashMap::from([("target-alias".to_owned(), host("another-user@TARGET"))]); + let error = resolve(destination, profile, &global_proxy(), &hosts).err().unwrap(); + assert!(error.contains("循环")); +} + +#[test] +fn two_hops_are_supported_and_a_third_is_rejected() { + let (destination, mut profile) = host("root@target"); + jump(&mut profile, "jump@near-target"); + let (first_destination, mut first_profile) = host("jump@near-target"); + jump(&mut first_profile, "jump@near-client"); + let (second_destination, second_profile) = host("jump@near-client"); + let mut hosts = HashMap::from([ + ("jump@near-target".to_owned(), (first_destination, first_profile)), + ("jump@near-client".to_owned(), (second_destination, second_profile)), + ]); + assert!(resolve(destination.clone(), profile.clone(), &global_proxy(), &hosts).is_ok()); + jump(&mut hosts.get_mut("jump@near-client").unwrap().1, "jump@third"); + let error = resolve(destination, profile, &global_proxy(), &hosts).err().unwrap(); + assert!(error.contains("2 级")); +} + +#[test] +fn pool_identity_changes_when_any_hop_network_path_or_auth_changes() { + let (destination, mut profile) = host("root@target"); + jump(&mut profile, "bastion@hop"); + let mut hosts = HashMap::from([("bastion@hop".to_owned(), host("bastion@hop"))]); + let initial = resolve(destination.clone(), profile.clone(), &global_proxy(), &hosts).unwrap(); + let initial_key = initial.pool_key(); + custom_proxy( + &mut hosts.get_mut("bastion@hop").unwrap().1, + SshHostProxyMode::Http, + "other-proxy", + ); + let changed = resolve(destination.clone(), profile.clone(), &global_proxy(), &hosts).unwrap(); + assert_ne!(initial_key, changed.pool_key()); + hosts.get_mut("bastion@hop").unwrap().1.auth = SshAuthMode::Password; + let changed_auth = resolve(destination, profile, &global_proxy(), &hosts).unwrap(); + assert_ne!(changed.pool_key(), changed_auth.pool_key()); +} + +#[test] +fn proxy_draft_secret_wins_and_is_not_used_as_jump_ssh_password() { + let (destination, mut profile) = host("root@target"); + custom_proxy(&mut profile, SshHostProxyMode::Socks5, "draft-proxy"); + profile.connection.proxy_username = "proxy-user".to_owned(); + jump(&mut profile, "bastion@hop"); + let route = resolve_route_with( + destination, + profile, + &global_proxy(), + 0, + Some("draft-secret"), + &mut |_| Ok(host("bastion@hop")), + &mut |_| panic!("must not read an older credential when a draft was supplied"), + ) + .unwrap(); + assert!(!route.pool_key().contains("draft-secret")); + let RouteTransport::Jump(jump) = route.transport else { panic!("expected jump") }; + assert_eq!(jump.profile.destination, "bastion@hop"); + let RouteTransport::Server(server) = jump.transport else { panic!("expected proxy") }; + assert_eq!(server.password.as_deref(), Some("draft-secret")); + assert!(!format!("{server:?}").contains("draft-secret")); +} + +#[test] +fn proxy_password_load_uses_its_own_destination_scoped_credential_target() { + let (destination, mut profile) = host("root@target"); + custom_proxy(&mut profile, SshHostProxyMode::Http, "stored-proxy"); + profile.connection.proxy_username = "proxy-user".to_owned(); + let expected = profile.connection.proxy_credential_target("root@target").unwrap(); + let route = resolve_route_with( + destination, + profile, + &global_proxy(), + 0, + None, + &mut |_| panic!("no jump configured"), + &mut |target| { + assert_eq!(target, expected); + Ok(Some(b"stored-secret".to_vec())) + }, + ) + .unwrap(); + let RouteTransport::Server(server) = route.transport else { panic!("expected proxy") }; + assert_eq!(server.password.as_deref(), Some("stored-secret")); +} + +#[test] +fn test_request_debug_redacts_both_password_fields() { + let request = SshTestRequest { + request_id: 1, + destination: "root@target".to_owned(), + auth: SshAuthMode::Password, + private_keys: Vec::new(), + password: Some("ssh-secret".to_owned()), + connection: Default::default(), + proxy_password: Some("proxy-secret".to_owned()), + }; + let debug = format!("{request:?}"); + assert!(!debug.contains("ssh-secret")); + assert!(!debug.contains("proxy-secret")); + assert_eq!(debug.matches("[redacted]").count(), 2); +} diff --git a/nebula_app/src/ssh_session/route/tests/loopback.rs b/nebula_app/src/ssh_session/route/tests/loopback.rs new file mode 100644 index 0000000..b5a4204 --- /dev/null +++ b/nebula_app/src/ssh_session/route/tests/loopback.rs @@ -0,0 +1,488 @@ +use std::io; +use std::net::SocketAddr; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use base64::Engine as _; +use russh::Channel; +use russh::keys::ssh_key::{Algorithm, LineEnding, PrivateKey, PublicKey}; +use russh::server::{self, Auth, ChannelOpenHandle, Msg, Session}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +use super::{ResolvedRoute, RouteTransport, custom_proxy, host, jump, resolve_route_with}; +use crate::ssh_profiles::{SshAuthMode, SshHostProxyMode}; +use crate::ssh_proxy::{ProxyScheme, SshProxyConfig}; +use crate::ssh_session::{SshTestRequest, test_connect}; + +const PROXY_USER: &str = "fixture-proxy-user"; +const PROXY_PASSWORD: &str = "fixture-proxy-draft-password"; +const BASTION_USER: &str = "fixture-bastion-user"; +const TARGET_USER: &str = "fixture-target-user"; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Failure { + None, + ProxyPassword, + BastionIdentity, + TargetIdentity, + BastionHostKey, + TargetHostKey, +} + +#[derive(Default)] +struct Observations { + proxy_authenticated: Option, + proxy_destination: Option, + ssh_authentication: Vec<(&'static str, String, bool)>, + forwards: Vec<(String, u32)>, +} + +struct LoopbackSshServer { + username: &'static str, + public_key: PublicKey, + forward_destination: Option, + observations: Arc>, +} + +impl server::Handler for LoopbackSshServer { + type Error = russh::Error; + + async fn auth_publickey( + &mut self, + username: &str, + public_key: &PublicKey, + ) -> Result { + let accepted = username == self.username && public_key == &self.public_key; + self.observations.lock().unwrap().ssh_authentication.push(( + self.username, + username.to_owned(), + accepted, + )); + Ok(if accepted { Auth::Accept } else { Auth::reject() }) + } + + async fn auth_password( + &mut self, + username: &str, + _password: &str, + ) -> Result { + self.observations.lock().unwrap().ssh_authentication.push(( + "unexpected-password-auth", + username.to_owned(), + false, + )); + Ok(Auth::reject()) + } + + async fn channel_open_direct_tcpip( + &mut self, + channel: Channel, + host_to_connect: &str, + port_to_connect: u32, + _originator_address: &str, + _originator_port: u32, + reply: ChannelOpenHandle, + _session: &mut Session, + ) -> Result<(), Self::Error> { + self.observations + .lock() + .unwrap() + .forwards + .push((host_to_connect.to_owned(), port_to_connect)); + let Some(destination) = self.forward_destination else { + reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await; + return Ok(()); + }; + if host_to_connect != destination.ip().to_string() + || port_to_connect != u32::from(destination.port()) + { + reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await; + return Ok(()); + } + let mut stream = TcpStream::connect(destination).await?; + stream.set_nodelay(true)?; + reply.accept().await; + tokio::spawn(async move { + let mut channel = channel.into_stream(); + let _ = tokio::io::copy_bidirectional(&mut channel, &mut stream).await; + }); + Ok(()) + } +} + +fn generate_key() -> PrivateKey { + PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap() +} + +fn write_key(path: &Path, key: &PrivateKey) { + std::fs::write(path, key.to_openssh(LineEnding::LF).unwrap().as_bytes()).unwrap(); +} + +fn configure_known_hosts(route: &mut ResolvedRoute, path: &Path) { + route.known_hosts_path = Some(path.to_owned()); + if let RouteTransport::Jump(jump) = &mut route.transport { + configure_known_hosts(jump, path); + } +} + +fn spawn_ssh_server( + listener: TcpListener, + host_key: PrivateKey, + handler: LoopbackSshServer, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + stream.set_nodelay(true).unwrap(); + let config = Arc::new(server::Config { + keys: vec![host_key], + auth_rejection_time: Duration::ZERO, + auth_rejection_time_initial: Some(Duration::ZERO), + inactivity_timeout: Some(Duration::from_secs(10)), + ..Default::default() + }); + if let Ok(session) = server::run_stream(config, stream, handler).await { + let _ = session.await; + } + }) +} + +async fn send_fragmented(stream: &mut TcpStream, response: &[u8]) -> io::Result<()> { + for byte in response { + stream.write_all(&[*byte]).await?; + tokio::task::yield_now().await; + } + Ok(()) +} + +async fn read_sized_bytes(stream: &mut TcpStream) -> io::Result> { + let length = stream.read_u8().await?; + let mut data = vec![0; usize::from(length)]; + stream.read_exact(&mut data).await?; + Ok(data) +} + +async fn socks_proxy_handshake( + stream: &mut TcpStream, + destination: SocketAddr, + observations: &Arc>, +) -> io::Result { + let mut greeting = [0; 4]; + stream.read_exact(&mut greeting).await?; + assert_eq!(greeting, [5, 2, 0, 2]); + send_fragmented(stream, &[5, 2]).await?; + assert_eq!(stream.read_u8().await?, 1); + let username = read_sized_bytes(stream).await?; + let password = read_sized_bytes(stream).await?; + let accepted = username == PROXY_USER.as_bytes() && password == PROXY_PASSWORD.as_bytes(); + observations.lock().unwrap().proxy_authenticated = Some(accepted); + send_fragmented(stream, &[1, u8::from(!accepted)]).await?; + if !accepted { + return Ok(false); + } + let mut request = [0; 4]; + stream.read_exact(&mut request).await?; + assert_eq!(request, [5, 1, 0, 1]); + let mut address = [0; 4]; + stream.read_exact(&mut address).await?; + let port = stream.read_u16().await?; + let requested = SocketAddr::from((address, port)); + assert_eq!(requested, destination, "the network proxy must connect to the bastion"); + observations.lock().unwrap().proxy_destination = Some(requested); + Ok(true) +} + +async fn http_proxy_handshake( + stream: &mut TcpStream, + destination: SocketAddr, + observations: &Arc>, +) -> io::Result { + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + assert!(request.len() < 16 * 1024); + request.push(stream.read_u8().await?); + } + let request = String::from_utf8(request).unwrap(); + let authority = destination.to_string(); + assert!(request.starts_with(&format!("CONNECT {authority} HTTP/1.1\r\n"))); + assert!(request.contains(&format!("\r\nHost: {authority}\r\n"))); + let authentication = + base64::engine::general_purpose::STANDARD.encode(format!("{PROXY_USER}:{PROXY_PASSWORD}")); + let accepted = + request.contains(&format!("\r\nProxy-Authorization: Basic {authentication}\r\n")); + observations.lock().unwrap().proxy_authenticated = Some(accepted); + if !accepted { + send_fragmented(stream, b"HTTP/1.1 407 Proxy Authentication Required\r\n\r\n").await?; + return Ok(false); + } + observations.lock().unwrap().proxy_destination = Some(destination); + Ok(true) +} + +fn spawn_proxy( + listener: TcpListener, + scheme: ProxyScheme, + destination: SocketAddr, + observations: Arc>, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let (mut client, _) = listener.accept().await.unwrap(); + client.set_nodelay(true).unwrap(); + let accepted = match scheme { + ProxyScheme::Socks5 => { + socks_proxy_handshake(&mut client, destination, &observations).await + }, + ProxyScheme::HttpConnect => { + http_proxy_handshake(&mut client, destination, &observations).await + }, + } + .unwrap(); + if !accepted { + return; + } + let mut server = TcpStream::connect(destination).await.unwrap(); + server.set_nodelay(true).unwrap(); + match scheme { + ProxyScheme::Socks5 => { + send_fragmented(&mut client, &[5, 0, 0, 1, 127, 0, 0, 1, 0, 0]).await.unwrap(); + }, + ProxyScheme::HttpConnect => { + send_fragmented(&mut client, b"HTTP/1.1 200 Connection established\r\n\r\n") + .await + .unwrap(); + }, + } + let _ = tokio::io::copy_bidirectional(&mut client, &mut server).await; + }) +} + +async fn verify_loopback_route(scheme: ProxyScheme, failure: Failure) { + let temporary = tempfile::tempdir().unwrap(); + let known_hosts = temporary.path().join("known_hosts"); + let bastion_key_path = temporary.path().join("bastion-identity"); + let target_key_path = temporary.path().join("target-identity"); + let bastion_identity = generate_key(); + let target_identity = generate_key(); + let bastion_host_key = generate_key(); + let target_host_key = generate_key(); + assert_ne!(bastion_identity.public_key(), target_identity.public_key()); + assert_ne!(bastion_host_key.public_key(), target_host_key.public_key()); + write_key(&bastion_key_path, &bastion_identity); + write_key(&target_key_path, &target_identity); + + let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let target_address = target_listener.local_addr().unwrap(); + let bastion_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bastion_address = bastion_listener.local_addr().unwrap(); + let proxy_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = proxy_listener.local_addr().unwrap(); + let wrong_host_key = generate_key(); + for (address, key) in [ + ( + bastion_address, + if failure == Failure::BastionHostKey { &wrong_host_key } else { &bastion_host_key }, + ), + ( + target_address, + if failure == Failure::TargetHostKey { &wrong_host_key } else { &target_host_key }, + ), + ] { + russh::keys::known_hosts::learn_known_hosts_path( + &address.ip().to_string(), + address.port(), + key.public_key(), + &known_hosts, + ) + .unwrap(); + } + let initial_known_hosts = std::fs::read(&known_hosts).unwrap(); + let observations = Arc::new(Mutex::new(Observations::default())); + let target_task = spawn_ssh_server( + target_listener, + target_host_key, + LoopbackSshServer { + username: TARGET_USER, + public_key: target_identity.public_key().clone(), + forward_destination: None, + observations: observations.clone(), + }, + ); + let bastion_task = spawn_ssh_server( + bastion_listener, + bastion_host_key, + LoopbackSshServer { + username: BASTION_USER, + public_key: bastion_identity.public_key().clone(), + forward_destination: Some(target_address), + observations: observations.clone(), + }, + ); + let proxy_task = spawn_proxy(proxy_listener, scheme, bastion_address, observations.clone()); + + let (destination, mut profile) = host(&format!("{TARGET_USER}@{target_address}")); + profile.auth = SshAuthMode::PublicKey; + profile.private_keys = vec![if failure == Failure::TargetIdentity { + bastion_key_path.clone() + } else { + target_key_path.clone() + }]; + custom_proxy( + &mut profile, + if scheme == ProxyScheme::Socks5 { + SshHostProxyMode::Socks5 + } else { + SshHostProxyMode::Http + }, + "127.0.0.1", + ); + profile.connection.proxy_port = Some(proxy_address.port()); + profile.connection.proxy_username = PROXY_USER.to_owned(); + jump(&mut profile, "loopback-bastion"); + let (bastion_destination, mut bastion_profile) = + host(&format!("{BASTION_USER}@{bastion_address}")); + bastion_profile.auth = SshAuthMode::PublicKey; + bastion_profile.private_keys = + vec![if failure == Failure::BastionIdentity { target_key_path } else { bastion_key_path }]; + let proxy_draft = if failure == Failure::ProxyPassword { + "incorrect-fixture-proxy-draft" + } else { + PROXY_PASSWORD + }; + let request = SshTestRequest { + request_id: 1, + destination: destination.original.clone(), + auth: profile.auth, + private_keys: profile.private_keys.clone(), + password: None, + connection: profile.connection.clone(), + proxy_password: Some(proxy_draft.to_owned()), + }; + let mut route = resolve_route_with( + destination, + profile, + &SshProxyConfig::default(), + 0, + request.proxy_password.as_deref(), + &mut |spec| { + assert_eq!(spec, "loopback-bastion"); + Ok((bastion_destination.clone(), bastion_profile.clone())) + }, + &mut |_| panic!("a loopback fixture must never read stored credentials"), + ) + .unwrap(); + configure_known_hosts(&mut route, &known_hosts); + assert!(!route.pool_key().contains(proxy_draft)); + let outcome = test_connect(&route, &request).await; + if failure == Failure::None { + outcome.expect("real proxy -> SSH bastion -> SSH target should authenticate"); + } else { + assert!(outcome.is_err(), "the invalid credential or host key must be rejected"); + } + assert_eq!(std::fs::read(&known_hosts).unwrap(), initial_known_hosts); + { + let observed = observations.lock().unwrap(); + assert_eq!(observed.proxy_authenticated, Some(failure != Failure::ProxyPassword)); + if failure == Failure::ProxyPassword { + assert!(observed.ssh_authentication.is_empty()); + assert!(observed.forwards.is_empty()); + } else { + assert_eq!(observed.proxy_destination, Some(bastion_address)); + if failure == Failure::BastionHostKey { + assert!(observed.ssh_authentication.is_empty()); + assert!(observed.forwards.is_empty()); + } else { + assert!(observed.ssh_authentication.contains(&( + BASTION_USER, + BASTION_USER.to_owned(), + failure != Failure::BastionIdentity, + ))); + if failure == Failure::BastionIdentity { + assert!(observed.forwards.is_empty()); + assert!( + !observed + .ssh_authentication + .iter() + .any(|(role, _, _)| *role == TARGET_USER) + ); + } else { + assert_eq!( + observed.forwards, + vec![("127.0.0.1".to_owned(), u32::from(target_address.port()))] + ); + if failure == Failure::TargetHostKey { + assert!( + !observed + .ssh_authentication + .iter() + .any(|(role, _, _)| *role == TARGET_USER) + ); + } else { + assert!(observed.ssh_authentication.contains(&( + TARGET_USER, + TARGET_USER.to_owned(), + failure != Failure::TargetIdentity, + ))); + } + } + } + } + assert!( + !observed + .ssh_authentication + .iter() + .any(|(role, _, _)| *role == "unexpected-password-auth") + ); + } + for task in [proxy_task, bastion_task, target_task] { + if task.is_finished() { + task.await.expect("loopback protocol task should not panic"); + } else { + task.abort(); + let _ = task.await; + } + } +} + +fn run_loopback(scheme: ProxyScheme, failure: Failure) { + tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + tokio::time::timeout(Duration::from_secs(15), verify_loopback_route(scheme, failure)) + .await + .expect("the isolated SSH route fixture must finish within its deadline"); + }); +} + +#[test] +fn socks5_bastion_target_uses_real_ssh_and_independent_identities() { + run_loopback(ProxyScheme::Socks5, Failure::None); +} + +#[test] +fn http_connect_bastion_target_uses_real_ssh_and_independent_identities() { + run_loopback(ProxyScheme::HttpConnect, Failure::None); +} + +#[test] +fn wrong_proxy_draft_does_not_fall_back_to_saved_credentials_or_direct() { + run_loopback(ProxyScheme::HttpConnect, Failure::ProxyPassword); +} + +#[test] +fn target_private_key_is_not_accepted_as_bastion_identity() { + run_loopback(ProxyScheme::Socks5, Failure::BastionIdentity); +} + +#[test] +fn bastion_private_key_is_not_accepted_as_target_identity() { + run_loopback(ProxyScheme::HttpConnect, Failure::TargetIdentity); +} + +#[test] +fn changed_bastion_host_key_stops_the_route_before_authentication() { + run_loopback(ProxyScheme::Socks5, Failure::BastionHostKey); +} + +#[test] +fn changed_target_host_key_stops_forwarded_ssh_before_authentication() { + run_loopback(ProxyScheme::HttpConnect, Failure::TargetHostKey); +} diff --git a/nebula_app/src/ssh_sftp.rs b/nebula_app/src/ssh_sftp.rs index 388319d..2261a8e 100644 --- a/nebula_app/src/ssh_sftp.rs +++ b/nebula_app/src/ssh_sftp.rs @@ -124,6 +124,17 @@ pub struct SftpController { upload_debounce_active: Arc, } +/// Long-lived SFTP channel used only by interactive directory browsing. +/// +/// Each browser session owns a stable channel, separate from bulk transfers. +/// This handle is cheap to clone into UI jobs, serializes directory requests +/// on one SFTP subsystem, and is never shared with upload/download workers. +#[derive(Clone)] +pub(crate) struct SftpBrowseSession { + destination: Arc, + session: Arc>>, +} + #[derive(Clone)] struct TaskContext { state: Arc>, @@ -411,6 +422,47 @@ impl SftpController { } } +impl SftpBrowseSession { + pub(crate) fn new(destination: impl Into) -> Self { + Self { + destination: Arc::from(destination.into()), + session: Arc::new(tokio::sync::Mutex::new(None)), + } + } + + pub(crate) fn destination(&self) -> &str { + &self.destination + } + + /// List an absolute browser path on the existing subsystem. Browser paths + /// already come from an absolute cwd, a returned entry, or normalized `..`; + /// calling `canonicalize` before every `readdir` adds a full network RTT + /// without changing the result. + pub(crate) async fn list_dir(&self, path: &str) -> Result, String> { + let path = normalize_remote_path("/", path); + let mut slot = self.session.lock().await; + if slot.is_none() { + *slot = Some( + crate::ssh_session::open_sftp(&self.destination) + .await + .map_err(|err| format!("无法连接 {}:{err}", self.destination))?, + ); + } + + let result = + read_remote_dir(slot.as_ref().expect("SFTP browser session initialized"), &path) + .await + .map_err(|err| format!("无法读取目录 {path}:{err}")); + if result.is_err() { + // A dead subsystem must not poison every later click. Permission + // and missing-path errors also clear the channel; the next user + // action reopens it through the already pooled SSH transport. + *slot = None; + } + result + } +} + impl TransferObserver for TaskContext { fn advance(&self, bytes: u64) { if !self.is_current() { diff --git a/nebula_app/src/sync.rs b/nebula_app/src/sync.rs index 5d54214..2db2912 100644 --- a/nebula_app/src/sync.rs +++ b/nebula_app/src/sync.rs @@ -34,6 +34,7 @@ const HISTORY_SYNC_CAP: usize = 2000; const SYNC_KEYS: &[&str] = &[ "language", "theme", + "app_icon", "follow_system_theme", "ghost", "accept", diff --git a/nebula_app/src/tray.rs b/nebula_app/src/tray.rs index 98dd28e..88dac65 100644 --- a/nebula_app/src/tray.rs +++ b/nebula_app/src/tray.rs @@ -46,7 +46,7 @@ pub use win::init; #[cfg(all(windows, feature = "gpui-shell"))] pub use win::init_gpui; #[cfg(windows)] -pub use win::{set_enabled, shutdown, update}; +pub use win::{refresh_app_icon, set_enabled, shutdown, update}; #[cfg(all(not(windows), feature = "legacy-shell"))] pub fn init(_proxy: winit::event_loop::EventLoopProxy) {} @@ -58,6 +58,8 @@ pub fn set_enabled(_enabled: bool) {} pub fn update(_agents: Vec) {} #[cfg(not(windows))] pub fn shutdown() {} +#[cfg(not(windows))] +pub fn refresh_app_icon() {} #[cfg(windows)] mod win { @@ -72,9 +74,6 @@ mod win { #[cfg(feature = "legacy-shell")] use crate::event::{Event, EventType}; - /// 与 notify.rs 的 toast 图标同一份官方 logo,托盘不会出现第二个品牌形态。 - const LOGO_PNG: &[u8] = include_bytes!("../../extra/logo/nebula.png"); - /// 托盘回调(lParam 携带鼠标消息;未 SETVERSION 的经典 v0 语义, /// Win7 至今行为一致,不依赖 v4 的坐标打包)。 const WM_APP_TRAY: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 1; @@ -84,6 +83,7 @@ mod win { const WM_APP_SET: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 3; /// 进程收尾:删图标 + 退出消息泵(不删的话图标要等用户悬停才消失)。 const WM_APP_SHUTDOWN: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 4; + const WM_APP_ICON: u32 = windows_sys::Win32::UI::WindowsAndMessaging::WM_APP + 5; /// 菜单命令 id 的起点;`MENU_AGENT_BASE + i` = 聚焦第 i 个 agent。 const MENU_AGENT_BASE: usize = 1000; @@ -151,6 +151,10 @@ mod win { post(WM_APP_SHUTDOWN); } + pub fn refresh_app_icon() { + post(WM_APP_ICON); + } + fn post(message: u32) { let hwnd = HWND.load(Ordering::Acquire); if hwnd != 0 { @@ -182,6 +186,15 @@ mod win { added: bool, } + impl Drop for Icons { + fn drop(&mut self) { + unsafe { + windows_sys::Win32::UI::WindowsAndMessaging::DestroyIcon(self.normal as _); + windows_sys::Win32::UI::WindowsAndMessaging::DestroyIcon(self.attention as _); + } + } + } + thread_local! { static ICONS: RefCell> = const { RefCell::new(None) }; } @@ -279,6 +292,10 @@ mod win { apply_enabled(hwnd); 0 }, + WM_APP_ICON => { + replace_app_icon(hwnd); + 0 + }, WM_APP_SHUTDOWN => { remove_icon(hwnd); // SAFETY: 本线程的消息泵,退出后线程结束。 @@ -491,6 +508,18 @@ mod win { // ─── 图标像素 ──────────────────────────────────────────────────────── + fn replace_app_icon(hwnd: windows_sys::Win32::Foundation::HWND) { + let (normal, attention) = build_icons(); + let replacement = + Icons { normal: normal as isize, attention: attention as isize, added: false }; + if normal.is_null() || attention.is_null() { + return; + } + remove_icon(hwnd); + ICONS.with(|slot| *slot.borrow_mut() = Some(replacement)); + apply_enabled(hwnd); + } + /// 从内嵌 logo 造两个 HICON:常态 + attention(右下角橙点)。 /// 失败退化为空句柄——Shell_NotifyIconW 会用系统默认图标,托盘仍可用。 fn build_icons() -> ( @@ -501,7 +530,8 @@ mod win { // SAFETY: GetSystemMetrics 无前置条件。 let size = unsafe { GetSystemMetrics(SM_CXSMICON) }.clamp(16, 64) as u32; - let Ok(decoded) = image::load_from_memory(LOGO_PNG) else { + let bytes = crate::app_icon::png(crate::app_icon::selected(), size).unwrap_or_default(); + let Ok(decoded) = image::load_from_memory(bytes) else { log::warn!("tray: embedded logo failed to decode"); return (std::ptr::null_mut(), std::ptr::null_mut()); }; diff --git a/nebula_app/src/update_check.rs b/nebula_app/src/update_check.rs index 0300899..da40d6f 100644 --- a/nebula_app/src/update_check.rs +++ b/nebula_app/src/update_check.rs @@ -153,6 +153,10 @@ pub fn spawn_once(proxy: EventLoopProxy) { /// 轻通知,再由用户决定是否打开更新详情弹窗。 #[cfg(feature = "gpui-shell")] pub fn spawn_gpui_once(sender: std::sync::mpsc::Sender) { + if !nebula_settings::RuntimeSettings::load().auto_check_updates { + log::debug!("update-check: automatic checks disabled in settings"); + return; + } static STARTED: AtomicBool = AtomicBool::new(false); if STARTED.swap(true, Ordering::SeqCst) { return; diff --git a/nebula_app/windows/nebula-dark.ico b/nebula_app/windows/nebula-dark.ico new file mode 100644 index 0000000000000000000000000000000000000000..f2e1ce9f1ad8f044aa33517b158a27c824be3a69 GIT binary patch literal 20266 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%0D6#8~*+$o=t9i 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 B@$vuw literal 0 HcmV?d00001 diff --git a/nebula_app/windows/nebula-titanium.ico b/nebula_app/windows/nebula-titanium.ico new file mode 100644 index 0000000000000000000000000000000000000000..9a6253eb1b5ef3af85be10820579afa45b7488ce GIT binary patch literal 20545 zcmagFWmKC@7dCn)xKrGvIK|xwTA;W?aVQSOU4j;hLy=Nk3dLL8gA^!Ead&rj_;}vu zd(W?P&YQKeCVTD7+%uVLU)P?=3;=)tQ~(BQOAb2LJ#M?_YcN#fFCi03xD) zZG)H3iwFQfMfI=k2oC^)C;-67_^(a*;w!=c01l3SZAL@@=)nO12;^V800{t&UfMD- z`PYWM)J=S83kv<$#(Al$&J6tL^-q5mU%qR>4*-#>%5O1HNl;%L81iz`>M!L>j{$7t zm-Ye5^Jh~40Mp4!OK5t{nOi`EhZ{+~CutE_W(5TCh@T(K&0yfCYK}+qo6Gy95aE$n zE2~fbph(W(h{yZ5H&rg@tQ*_gKg~@o%}h)zEMTZl&sI0**OXmFD#n=p<`xm|ykh~~ z$iHUwb!?)Fi>t`tP*#S%U4LTBXxbZUu({qfkrDjKYj0mh^f{JH_|M7K(0h*gKGkdq zWo;6+>;3JI;l7@f&76+nPEDE;vUyRFUy0dncTP3JQIf1WdAlGv?fgUp$-49||2#ls0Jmj-1xSLUxN(SH~6qN^oP zqrXcnH@&%f6b=V*=$P=XoQ&b3S8uaf%Dsy;k55WAwcg+9<>>BHjmxFtCvOH(1!fBe zMs^JMBvK1;rR6rYk<*YZ7?2a2y_TvXt+Kc!2{&xM4tW%`8XE$y? z@VnnKH;s>bbZ-cv>I(ghz#oBvQpyz%GHUbo-5wh744{%CmRRE1#5ZPOV%q9~fV}P= zHY?^+v*Eee4BN+brDS%|v9S2H!#*yxnG1M6;dEV+))Te)-w#tUFm$aP?e$T;W(mzZ zI!~=cXHdz~`LIyCZPC;aeS?na!H3?(XFu0d<=CZNMo{?g#KLaH10T%1PS-vlu1|^f z{`&Bt@OxEtE{TBE5>B|(+t~Gs*ZhPGQm*Fs3x(|gkCt8+H#V5saH%ZDT~&710l}+V zTYG)jxWvKB5Y;sNPkp z10p@g-U@&8I7-o=s6YLGIJ_DJO)Z4Klua+TCe1G>o0T`D6${gabyZujdu9jv%u^cp zmCw7VY8XgbCq-0Mp$dk#dY>N+pA%;LN%@sK!lKmTbHS2+xHn$j3%YuG#A(Q1s-B-8 zt!^&2`MXk8j6K`EuUWAyh!i3*z}!}jTX$U#h6-8|OSk-8#sq$dzqq)c8~v|Wm$LoP zRiZjOS7wm`bPfIY5_})T{s*vM2o)gu53pA}MQ;EAj^_UXHe9K8xtZ^Evc}WMb7jl( z6X#D3Y*1R-)8ncM{!IZ(*Ar}P>=f%484Z`!Skoe(S@46Pq@-V+V)USTv|@C3+Q`ZC za$vZ4;FCz;{c6{$XLg%XG=`H{xM|0hCif49 z0v7%HMOi65Q#x@T3?8fGueD)g@)VA2=tFT7;vcM!53FwP4uzLiuwT8hw6-aeJpGeexl9zBC14dh z4vrtN+n+}!!Gz;{VQ0sR*zWhEOT4ehmSn?y(3?Gu8LWf8H!(|n@CG>(kcs(#$&Nhu zQqY;?Ii19)tE`ta*I3dxyy?3d&r@LsHD3e}N#D5Uu~5$iHx$?lkZwiQe_ZCoOPNxi z1}kCkhWG#S3qM|N=l&-$G?L3}5da)1vFA5Mmn`42hATO;KpJ3Q|a(M6QBLH7mvPX!&|9q-i$cr~-6JBR3R~)(2Jh)D z@CC_sZw{7@0%B8E4lh9+%s{UPbksnoiFL67Eh$g~qxjSyQvn4vPODQ)d~)(zsbF2y z=8wyP90v=vajOT7XeR;yNOcSDHx^^3a}d_CxM;i6eiQ5Z)8oBf8@CgihITw!p{29` zamV^(wy}6LypBK=|3R&KN~!@33l)`oChGS3x$kv0;#-79vQIi+n=!Ml*0%DOXonK*&`{*$uhwS34IFB&o97ie-;URK3z|^K5oAV zF^H|z0)a6wFj!DYEYFVH{kN^2u2YLG2aM^r#0?A-DG|N~h$Ew+-AAz?0jEAiVF?Y+ z!v7<-s9yY3|A{SEAD@et%!&8Eu?2(MI$j<*Jsoxpu=F6P5I25)vZ4Z2IdOA8J>G85 zBjNvv!-$O)clu%8O)ucwqptNz?Nrs#i0=&_0iTw(_LO+w^V5gdzd?h8gU z)7kSc0}{BqIBcGjVKVhZ7Bcz02-%HT&)jSO{B{ERwwcfVUe^2~Llg^qdf1)H z?2S>AVDooEg02~8X!z&1WPG8ABZqap9eD{D=U#qx(KWUi(7WlQAAhGbZ40$CaKXy~ z4<-#Ro67F?#qUXJ7)wze%#2D4zpu%}n*JkG|B!iFd2WS5O&clyvE%dYt~anAIy=xk zsrd!-&6~a-iDtXuG4n*jScL30}a@SQ$v#8;-&EtU(7*E?7F??>#37})g- zi*t)xw3KBNi*JmZ%vigI4ew7%R=3tH`bLy`NngKkd4tE}Fgaz<)9wI14{x$3WB)+$ zdQAPXIMJN&{Y$ptBdN(4L^igd8^%pbA>!k)KG!;*B`us&mz!xlU<#X>3#R53OmRVC z(Q6Agoj!WBD7=w5|E|6T{fBUB+?7rKZ}^+(*7;#{<^b+C*r->?QVE}J~_tJq@53~Q57JiuSIz<;Kr(aG({zFt* zO(+4|?@w`ACb~`b7;2H27B>0{B1QueD}Tlbrz;kl*QMOt(G{@v`Rx&e5q08`5WPZN zQ8)o3a{hzmk=*AfoAQ)&W;F~P)$*I2G4t@Zqo3uKl@SdTaZ8@ZI%@ap>z|*_xPUp@ zd9A|oACmR-v(qn=_Cnys#P}NvAP>lLh8FW&iNZJhjRKyXRVY$i)>4DVb&98{`h}=h zwcqBQ=oUPeYn=>lux+bY?{ ztv4LY^FyyGJ*Vv#vtCo125G9PQDd+%7ow1G^KTy9) z;1Hw0B()?U(8BbnTMm4BqA7Rn@4qFUY1+{AO}Wt*WiUqua1yk3=eHichO-NtQxytii}i0_lUGP38Fkb#GHh6LHKC)*3@ z{%YV{w51ZjwKCva%QH(kBiRY%nxU79R~pI)IdQb`NR7> zH9bB$9zSlh9NqdmuWW?BcdXWBycq4PHKo8)sTeC*mZeUz5z1Uz%p$~zgs)Y|C+=BC zj{7p+{|!FQ>NqeSrlyDC)u@@ab-12zxV|}ba(0!2g91<0&;YEQUNv@G!?$(-KCQ3l z@lj_(U2GwOg-i-6(JXrSIe#oVQ?>Y!AFW56`NXB%jiWeX#Z!KKlI=+^Aon!C@oRII zhrsI?jWeQKa%06ubn_1JmMbHXFRuA4hBVr?HIG{YYh#J zNurf{ostIoKv_D6eajMV023pwzp49gS0iy`JqJW$@*_f!Fj2M!qeU~Nm;eu$BxY;( z7$6wmvr;V8q)HU&Q;D3vM?k!h`d}z;Ek_k6{dyLj?kK7AQ208hww!z-ow!=m^A{;R z{Ed{0nTV=Cf5o16wLWW&`{#E{N;9dSLE4kNyhuNZnhlZROJNpgC=Hqh;MYGbw63jM zvcVr)>Ri%EF%WlfE|(P|)|@zCzxFW#k5NrOHR_%w-<%H{s~R~Eb(+UQd5EG%=a$Y@ z6#S&ql%U9%-ope^)Fa}__4W;i;2I>y;drD326D^@=8NvsFb^G}v$e3}VIhhW+GidS z5;DZ5W$hI@^6xR{rX0_;h!Gm{a3qhW=Ad}X6cv+GI1>Sv{5jd-WrdIU{K;h)7cpRpiJgrc(tzL zl%H$k50r+rLu#v*><@>gje{<(y=E>Co3B^aK6KpS|B-|dN=dEcV{sFPN%^{o&jnxc zpqD$4TiPuX`eRtsW5j~&Z(4~J-c;y?dT@@QRN(177_((V7rX_d&+1k*v)1oc%w!}B z?}8QS>l+BYok$lKLW=LUeL1$R2||zNc4uV$?*_fGFXa!PJ|CEHu2!!L#!?cD4(fPq zwHP`T^JWUaud+Cref;r0EozD=-N+^OgZXWJq8^~nT{Q=eJY(e+b&weuqSO`krDJ`I z*;YIMJ%JKm$z4fw3Z?^|CmpW6Gce;kL#ud7r|h%L%84%*=gD2bi!8Lv$IDc%-mT)X z(ipeh!N=GrBELNzy-;$dV_<~p$;_{j^$vIBsx`_zP#x)7N}?g^YECN7ZeYfA*iXGv zc6K^Gc$DJgt73oi=G}Swg_>IS+DMM^jP#|U&aSxuy-pMzRy9{rem_fh{;$2BITJn_ z+t%*`~5`oYw2=YleK=kcxYo-v-Ya2 zDLFCuN5$v>VarLJlfpBhEW$9qiQakF=D4`j%F;KWcyxPxqbZ{>Mn`a_5th&*t}5gA z4cw&96os~1YX-x5ba`m-V9&-WUVEC!R69R2udJ3Jxq2OGhJ*;_%fSO|ich}985vFT zTuMuCCvB7ZkI}E#MLmb#;;EJ4{||b6*?|H${zH#C;M-3CfN=aTJx+eLQPt4F>m5Op zlnXYEB8Y^~l!r<~>aZsz18xMn1&qzxtgx5`FYZPA=7{i)BB-4vqjoM(7;J}~f(*$iES)LyA^VN3x z@2=zjh~z{-gl1Ih@S)r+JhFJ|NOsy$^sn5vRF5t#1>FmyZNKe!7w;Dvig;f zCZ3t~tkwB9`}VnOV1GaAGH1-j(J8OOgw+~9 zL4zsTzCPF=WyG1@{*~#uwoBrR?L*VX$n7}&Sb^Usss{uvH8}VR#(Qg`f7rZ6G(f}n zin^aP9sim_?=EG+blyQ-bW}%nD3YI{PctjWKq$fCcg&zBW;g45bXT#j4j!AoXOV)g zwy3yg)^HP9luaao;&a9Mi&`1#j_Y$!8|6s%j}m9O`^DvN+VeCQI_8~u(7T$5TvP%!FLgWuU%eC)S?^KxxjBJ){5-*ltprqZ}7 z$ZgEOQ+xc2;U7|N-aFhV6ysW%c2yBg@LW->VV#Xs)cxC?_)~KcolX=OVIj;d+yToI zKYZ2kuy7`3_k8{^XXhoEt7?S<=y|ktcTN62 z^s$bI^`>;EYH3f`iP?QiPH!lyeRfZZ)8p=z!8J&WEd)J@4C^u!J@eb+DHQvu&KGOC zhhY$4ee@k=C5Spa{EDk0*eo!Vv9Y$f8R~9_E+G{3V5fQMv$=6 z(7=#y&*&P_*Qd&$C;F#nzB)OOZ&H9dwSO_1KiRo&Wcc=cyiOT4JlWlY8S_Gm)HVEl zm@bR+(a(C{cAcVY6M;FE93gdY3$S&QuSNQi4lADU0h56BQH~Po$Tf#o@1b}ONZIIk z9uEC~=mg}&5BZNyl-M{g0RWQSzdCXF&HEpn_@p8yH-jrTYWqq$rK-5NxM6CHAhRSn zB1ab=p&Nq`hHu+Dg;Cqj>}X#hgGXdgs_meqNroo_|N5vyyBuE53`a85tpZbl1C-c; zMCQE@`RPEF*TjhEEZ*U>p%*nIQO|DDhx zRFNJOrih3dw(y;XAz>o8ppKwGo#{bGP49ed(~c=YnwelSqCOl^=}#vfMAD^4RrEFI z?}ZG@-!;dM_Gb1I)LnX1c%bG3XWlq=FExfFL^jNmfpH>OMXTUIF;5b<@Vw*V56kcR zoRy{CV6ThN^RjgI2xE?MVSNYS&n|X4$JPqTo&t$Fa97SDD`D!lJVw=u?LT)>7% zS1`fK!AuwATeY9fY(9zO2X}?h2}*)G5&+e=iOwOcF1964ZpEdPZ#a9CeEc;ubfYXc zCDZ!t-IdEx$-B#^smwL2WUUq^G&p}p9yBUW)+Es!WA^WLv@KH%vSJ&{Y)pDYD~Ba` zK*@QEn@>sex2T_NC2bG$*ClTnTXKGPw9TS|4g|d(!hI=IgUD-#?aFYMkjFF|I6V>V zMj3$k7CP+9qv*SyE;JkP79lt$An1EsVzlxzzv@4?J+kb$GaZz7!0>oOdb1H_jFi~sF_$4&!Wp1ZWP+x zKF9>MX$k_qnB=}(W_^Ic-EGEt(aT<36ct`1@0~E;ls5KHE=6TDE$wso~3eVi9cmciKq;=e352wvr9@bZL?{&ww{YnpGiD6Y7% zi9tXbg04y-)v`m2S)En;*^)FI%1|h`EnW5`7O1GUUxj+Wjk;YV_;cHH3}R-mU8A!j z4=W-?=n~Hb0A{!zQXPlQ*&^B8^OWHjr!aEKW-#Pypi!TC;qg59ALBDS(DI=@8mU(R(?E=#Rr+o#s%91 zB#R>(Q4=+ zrD@PAJZ}4P6orw?9e4l+q~ z`~zkpxr{7xPCG=WDNT@D>92j?5*8%J19SByvkB$iW|MSu%u*N!3+^9mL^Q5rTm|^H z>;%X`;~uWO{^qV18Dt*x{$;HarwaNG|IHAQ?tlyX%?yvTiwivBdh5EEz@nSUQL@-z zx^T!QO*VWIcc*<>lWZf$A!ANgcx>O7)aPcuKV=Tz#>f7vwgp#6T zg0%5knAsFb!YSKFTWh9fc}czS5>(iohk1YZJI3kJZM^7_sQBp26AQy+T%N@$cnslR zqON;g`^<_BvG!JoF3H_8a$9 zU{@?46C?g8!ToK$vu1klm+oKy)sHn455(ITERBn{**Dxwy9{;T(uwXHFWvI&_WRJJ z^Wge3K1~^<&o%N3M=S8YH?y+)AeEnPqtxkpQIpX{h+NNv>YEHu&1a?Wg5X;l3((1(6R@<*O{(LSCl_fpfivHjS8?;wK{|0Mpu#a{Q#)uqU?oX7^^4Hs?PF1uRiis%a-rG9=(>9FOa$1+Nx3XevzSko9l5?2GK%oi0ETG+22 z&RH>9#Ud`u`t^^TN>o2W&AKh2o5$rTTdX;@S168BRcU9Q{ECrdbk`7sk4%V>OVQ_MZ{LoUwvD;LNNkqYU=Ovuv8aN9ZS&9NexAY z7GK6tp0mY@n{yGc<72Ei`UF*K7{-06h|t?(atLaP2`J~|`)IQivNxIvagU*FCW}pw zRjD>1gPxVFhapRqlU&MC;+tYLEgPu&bzlK@2%QZFzHu9+DM_k<0hJ&QNdi}!pMMyD zcgeL3OwK+ENB*u_rSiq!RoFXXg1)tWDEwXB0(Sb8RdSy&j^WQsE0e`%gb>w^%lV0^ zCW35lBKvW_A!e2c=#AT{aTeE9xENm?M}p%cb@RNI)bV6T$CzzDyDyLyrKMN?G%?o}gIdA)F=rnuj#Y$EEPVE>xJ+l&eB^ z0!&o37~0>}{%NZ2D*mb6!hfdjH_IFNJy4C|5isy1MNNFU3We}OHI65{#>{z5J^j1M zlPipI4S^CPaa1c3@jUuHF41NQWUYKK;LyXMpg7T0kvUK2DKi1e8M|@BFW1GCd6Oc; z2BgOvFVVEz^|Q%afp`Iu;VfK9rMVA&cW>j{Z*u#<5FBgYtCUZy6X??62Z}RSetH`K z)sHYj<0V!-F$O##uNAF73#v09GXHb<3?V}#g~Tt8jAcL{o9DIB2x0e>NF&bK8377+ z{?$hF=)?mPFF^5lq`rE|L;=fM))m?Eqkw+u*AI1guh@?W;rfPukLCTa zc*RQpGP5~CivH`DaQ%o?x3}58hF(hF_+Y`ef||$pco?wMW6zKGfwiurJ8nX@)L!2T zKLZdx1B>;|cR#8VSHsFJZK)XvfXMso*&7YDkwW5NGtiB`UoyPJPoTRY8$uOo$55^d zupiFQ7^I=}vl)c1&|7S;PnypX96R?D)Trp$M?7R{Q{e#};LzIQ+%d*N)YT zc(JT-g0Ijl5oZoPW~xpF+ec{fjr&KiLdhV9f5cJVM!S3l7TP9R=2UR|Ht#V}>$&|R zGvJcWO_=aqfDNU@g%}*V%bib7nMmwg8K)#HyC`EP;r;w&8IRmKWM&uNlB&h%%~1$qO<&$yxXd_=c?l*#-G&y=`U;^pYnwq7R+kBuDfLvX85x{ zdd!k{S-t&~9RL(>?iVVH!)vxJiK3b5@)3^(|THb!_sO13FM#f23F!a%j<&Wbcuk_k#;z>`yod{PuUkVwI;7|4qsefqg`xR|! zS~1r*lpQMTsKiRvds_a&y5|p~#%^z~Wk;4^hlXEK*mgfRwIK)PJHr`G3JMq9r zpVm+Eb~KN?MSe6aJEczZs5{~fl71UL=vJFqK0180w`lz~bR?StIyR=p!|z5PetfWd zZ!BdMI2Ky~EZoC8#l+8^aM;*nea-qVADdX4dt4o0{rvHrEBXn@+H5+krto&A1_5Xf zfl@7x$o)k#9xqagNfYDQ;Mm+MXU};KSGswPgN`+bsj?du559Sht{O(docf6@I(+Vj zdizzj%SD5pp=#r&aw$S4n=&;bC1P{eD&`x()F|8~xGs)XvYwA{Xm5KgdAl%8T6{^^ z;JXJAqqlo)c~C^Qk_2O)n{a?C7S|XF#pQ?rcO>j4Anh1lX+gN7>1#ZkY9Uw^RE$&f z42s+o+Ql~tTTM;4HT z^LVq$WrMII!dr%X9x0TpgsPR-bif51?u`TfKVtT9{~Dn@G-csZQV z@NmXTq&V`@mhT_@{ntr%_`fChPFHxoY4`R0C~=>x%<&EBD8P;oTVLj}*PcXa1S0$A zWYuEDt*Lj7F_PnO)@uw-4egFok%w!8 z`OKEp=kR&m`0VOGMF8W^w)RRqWRpZBZYx^Y#GkQF{rqCI)VWnR^QcXfFlG|$v~6WM zDn}yml(c*1p9NiI0Y?XT4eV|iRq?4U5)S_P<+jE7>Lzt_3PHu(ZR+EJp{HVQ2FQb1 zyi(mhlwXoCtn^oU_(fA%Ag|9SFEAtelLJv>D0w-->Xx(+2$Ys(4es*#TW($MSPG$= z8WYEmmI}mxCqRJndbM9gW_`V%@T^sNf@g*b=ESj_oB^W)qMHZaaIQ+#$8j2$`FV(d zBy&RHP_@Ff1ZOFba*7K&Sw9F>^ron@Hh}*YLsD zydJ9tApt$tId$s01QDxr8B9=B&m%kMF0Q+njz3rt_1NMVnp}U=Rc1l%Cfmv+46R0tWrWp6VYm#5TUw{3#F@y)m$nZiG)N1fVuOn z`{2Xxz3EWG9j=Y-XipMg-*SYBY`uRljV6}1mrE;KvuB`K`tN}Q!7LJN?lLNqk2n#R z68mj}IWKP@sC=6pL}&4-#pOo_6A=A-vtd4!SwZ~pOK5}I%i$V%S7a#beQ(xt=iuAl zuO^_F-^6|%yqfue7!FqTgF&Epkq5?6q9Eph+kt3IpsFUoxeB+)r?%ur0@)XZnsjl-8jw(EZS*Z>zHrm6lb+28v&KoO!e~ zM!j2~|B~?qU~ku@0-UsFs(X`SX{Q^KeLsUQHR-1BU^YB}0e z#ePyG?uZL4kY117uv}kxqrdFqsd}HleXvqt?>G8I^egp24p=GM?OB^=fX=W#A} zeQrKxdXCkhW9aD`U?+wLidjD%5IH+y#TcJFFb`>=-7n2C0WcfG^>59xej``Q?R*Ea zs3X|X7p-n^uyn$}b&gZl8g;KQ7-=NcI6x-qLH7B&cC0vhsKfFtic zz-Pa(rrAvRwj%K7K@sM1FDd*|F?`w!QofIrB&!I!(_^Y&d*##gu>d=FobU~=%OzG~ z)Z_JlfZGRst@{W4U`Rb6pmDbg0xyDddLB!|U}{P7zzQ6Y@#9ex0W=^S-2@MKK4!gt zd=uxNwHjtcE$Y5uM=bt0Xsd=$1qQPI)_RwY(PZOuJD`>s*IRaL{POknU7ek@lc9v- z10uDD`_ZRCr{jI_K)$_SixKQ7(SJ}I)U<#GJHr})w31R@-hIF9zaGN}-q(@j{9L{deAPDNMFM^fCwo9ru}O!4E1U2l-B_K+M<9CMqM-urT# zVCwR#gso6e*X6^a{EbL*Q0Xp@PyT~B0l@1E+Tr@S9q2hIa*-=9$PX#cn3Zypt#!vR zc?dawKfmKXdhhj4@dz<*K+{znsUkf-9(!kZ*ZIBx`=Lx{>JR28CGzqo9n|`UnrfyB zEFfnCNJG2P_o{fpB4AbBjC}sF967JQ)DcNmzPqWz!2$$Sm33yC48N8|grs%cB9XqAcGRQWf~qe|}%A zH46D0@N$uj=UuNfX1J$4az%g=NP*t;AZ1m&+Bhms;}F{hOth+QJ?9&Tk#xDmPCPOp zS=5kxQX{WnhHLnjpyNaKNIlqOCR@=Q!_^bFJljfGFk;R4)IGYy$6F8J;Zs`ELB3bJ zt;=l=o6y~-EpWq*D2$ru(^kLP&pDFGGffPXy1e{}1=}h&=cHX3B!8+J zR8shCwZ&Ie(LXug`;it1!|M4B@mp|XYoppi^t!}qi8FaWk5KQ5AoBj@W`KmC8m%ZR zf>&x3K*oVhC8I$vBhfDvX%Juv54^3xMr;A_#p?Kf@v>Snhr%PhnLdn*{EFJq(V@uu zQKT^aPIjl^FA82U%QicAEIP#I0}JQ1Ka)HT%b*{8=*_h0c##R;w;+X}>jCfCavE?B zq^h|k7CY^??Q6z@*Pt2=FeVn1_f_=&r+x?m3UAaSzh+UJwfWlo?lc{Kp;vHZ(l z4mF%Jox4|(O-PS$W-nLxN0HWY~xc3U72n!B>Xui8rtMQgpU7 z1@HK~XR+$tk(}eG%pKEVpSMLlFVTo9pny+%+9id?a3GOmsV^c-a zj6m=1hn#-Zf!f-});ab)v4BCOyL-h3H9S#H@L#JT?6=v41#yz1@bH#GpYKaRRg&0f zTWvy$sIS0^=wS>ei~W@j)-@j<(k==yr%z6a0Fy-)F!%4-4i0oAvUAS) z`ld(6c)9PbLs@E|l~{(I~nS~m;7w3Fs`Nv~&In^}GJf7Z2tHVhPhL~%*?&rLTAxr_P*U zc>Hj2-wF&iBgc9oRDKUe-e5dyx8BV-X5bnMT{3Arwn=5%8`=+&Z zy@adhLlT2os}884$O`JrJU~FAs>>@KjTJKbayzGy)i|Cua40oNx9#f?oadU*UQZ0g zPRmlc#ib`Pd5Ay0To9>Y`QcZPiQKUXz@gID{a3B*I#*pmNZbq@$f%()&W!<69dQyy zgU%>~DZaT{)&C^L$p!lMhF{P45NxA3XY_?PuW{T(G_f#;TUSnT%y ze?T_jw@sh*?2`!2#H{%hP;wghZD*{9O#&J zwneF+ja7NgqrVyLCL9}EhIyl_+RJI`QAUm-=SS*Br&#V7V~1fHrj?<9oJ&j17!IlN zf)ADqF8H%}%Vx8)(B^RGe0=D7{P~&pHrrdCksb) z>jx+`mu(IrxWZ)~->v8%k4cU_D?+Nv~YEygqwW^~wIqnJ#P0g(`uim@2W)kMyIlu(r}h z^6T2}h(?G9GS$+Gv4$Qap8i|gq}YHJW6l7wm_|1|v9B-}Oh5nlwwdPRPtv0m$jqng zY!$(P8R;drOpcvlfm?Qzw@&m3B$+}^V*aPco!Q4IeUWGpRyo#$pQ?q5e0BZ51H{4!akm| z(p9{I8lBK=B2{GJ(8_~TP*?1AjHeL~@5GRv2Or1^9$>s0!4ABat2ir%JwG1bl^IZD zPa!_n^e6^nKLXhnSe=t301&K;h$z-5zuBtO9@wLL>9ubcWAlmx)O>*8>?GP#F zN7Uu%S}AHE$cE zbunWdUzfkZ?Pt?u>8T%r&(CvhGl;2>ZMy3l-VM^$>fC1R&| z1OYuTJ?NGfXBMhK%1#Mn4Xj(;rpVa;cXS)o@)4QT58%Ld#3@<2^Hd_B(!M7oyiKHs zW`$=T=8l~1w&M20bqOOvI}T=>*$-+=f75(t_)GOA9;gWu7po7P8Whod1aW|j@MoIJ zwOx-PW(XY(2s!+BBYoMQ-w6X82_c{6it7SW>Csf}KtofgIW4~=u@~|EqB|*psT@{h zX6eeC*57EXxDb``5gnpOP?aYEb~eD1FFx{!W;p^AhJ(sze!MNyVWnDPp|S++#5`Hw zfnfS>-$K^L8f$P2~bb({!Nvlvn>CQH&=X5M|?NQyBie zHCJWww&nM@5-xP<@ABoeQJ8xmkO+13G>MLp_=y4;zY=zlZt|%R2dD&3TEU+%>oW-< z(T5$JFJM;M)G1K4JmLHt2+*chndRhIF0ZW(pc6v*9(z&R0a{8&tS9Sp24@wxJVJ=~ zCaOe@$PLk3e-IVYTz(f`Uhm`58on%Ajy|?w)CDEbSOh{Hvuv+#fT@DMBEbe zf_7UsgYj`lAD8kZrQmZ6pR;*gg<|g!A+K+4PRi%&KFNA$J_2JX5Dnm=Xuq=O%@GHQ z=W#BL!`t1y1irax7pRbs(2x9lsYicfbjmDcn((61_K`j!DLAA8wX1Fv9upYQPZZ_< zTKjaF(tly!nI{Y;_6)59AtONU$A7hJVcevoz}1xgl*jPlCQjuH|5aUWt$qdHpOq#9 zSLI`YY8nP(BniGFfLbB|Zv$tsC}ss$>})+%&CPK$4|WvymKhjBA9e+8zXQ?K55-k& zI^aBP$T2>2@Mr4Zg2>eM$vTPAX~)WX4A;v1S@En$elEk)1V_}9Tp0es<6-jq)x z^mogw*#c+FNUtCO1c`0OVde(J%fr{Wwk*sf9Muwa1c?E_u!!fL_s322CsM0R!M9=J zMUB&hDh88OeC9Y@2F4o%zzt+ zKB1)DZzXLlD`6JuIhOf_pZt+g-|P@E6rj!8Zm+rG9dEuk7(A?JgM9(OP6XtcMLR?F zOEOKAbkl=vp9z-zg^iGrVNXumMz?c12}ixi{Me#rRg}KyRKR`g9VuU1Xf>W>hpB`lfFFDDfz(P;&d-1}v+RD{1C_EKVU--ay@wIm$doXuC(3u+^ z0QC_+lx8s{`$SdXo;>bR=v<*dfW(n&!5g(reo99IpR>~s74zZ%$fOO+XJxa4K}Y1D zhad+`z)=CnR46T{SAk~!;(tUgZ@8xIDS`o7v!Q{?{J(KO0C?yM(x31mkjP6nr~vqO zv{kJj@rNg--eW>2fc-!P)){G17u81x|6{Rj-Y$#p?kv5uDStwp*^-adH}v4m|M$UL zJmjVJI!s917K9Cbx0>IqEv6Ap2-XAOSNKNfKQ6PZh(BGBy^N$A%j0sEwCIQ+6ksu% ztS&Y5miVNxvSRfzAZ*BB%hOczUZx*x>EUG}1uBS;L22mO3jZ9u>@o?nWD*sO# zR~`;!*T&C01~X-L(!Oaw+;deJ5ptyW;E@8PWcV zc=pp|wiOXgBU<14`D83m0IMcgwF7Mlu!kNuDS2==)~(hB9ZAqIGuthN2(Z=^)dB}1 z>S+Fs7zi;uQ7HN@$c$J^SV&`OZrv&@{!6z;mw1*rduA(VGbHe@=)*^>N< zqV3Sl3i-?QjhrI`JpwG(xzS`xPv_hJ0kZ~tRDjo)jSzcAU;7;S z4>B_3=8#C9ous+r>?*6oKHg)rb7haMVsYye1!S6sGb(Zc!ZQw-LYvg=N{6&x8sW2t}CAzE;9CL&*^#6wn%A(M^FS*k;)(1g;{dqYf_2zN@LQA#ZRm{L`=759 zK@o)R7d*&D{qIORRLfxP$Qul-oxF>ms9n=%UzM(XaC-RqC*N2>#5iRyx_o_c5N+Xf zAZcad+EQF}a#Xxh>UB2-MDs|ngp$1=w)rq!tKIMl1oF4Bb+)}ySNX|~Zs<1EW0 z5NCiU1hpm1o$21i4wY@Z+grmZ%`V=XW!CIovl50J0@dY>&zDM>*@`fMl<67cb5KPN zzRqu<>ZE$#Tbj|lqy(j(q&71CD2va{sq>q;7K81r2`_LPr6|L)jUOH1_%Cmtd$Mxb zk_vZ4kH!4SM*5?43eL7R@uHk`E0;8J`<@^95pz_4L3JPz!E?8LdJU_|_o#2)yt#o- zZW#te!|Qyg$LUf!D3*D|qrvd!6}wgLG4 zt-k(Tm^nViL2=oSy7To+HL`ffZW++4XM@-AaYg|JzqO=sZ$6TQYva8)SVN$3Xs-pJ zg%&WE29Pa@XAmsn_Fq4(ur9V`NAc@^8EDq&yw}sY(16d=Zx8T`WX75I&&SMc3C2ea}F3t0T+RdrU`S# ziwY%lfVdC-Qw5PUkHAq91RelAqNrAH&|CS^!#*Zb=~ufQ>_kXr^w_wF(lFl_C^H$s zm)nLUAX?txk}*+(A7nrSQ(mm@Z^QhUWDF3GmX9=fgM(l+9MVM@yKU6o)+vL0`Cw&M z5j*@kdT9>Py-l` zU-aFFHJBlT$TTCu_4)*VLsO$ zhr}d_ZNqG3VDrd>7WLO@C6#`(IVG?Jk%&MSbqN7R%r`}9kGqA1zRcOE-KaV*|1FZi z;qwbpOvA8;oS67~OxOc=KVo%gryR=vC42C}NO^0hSmN^cO8w>gMKUm0ZZm9*MikEeye}t9h8toTOiWs?msk7YzY7MjIlzDV#xAC zVM^9{Va0uhNU(N99-NXG9vZGzVT7mnP6l@z4e`GzV4h;05K&uQg%AnnF~s1@6YXWD z+fa4hr8>}yWd^eur(c%x&T#%np<3#bCwtz63i$NOBR=Vm#aqf#>J_J7h7Kjn{9J_Q z)2De?E83`NQka0fP)ACCf9v_>d$U!DCu_Hr_yc317o>~iSDYz-e^D^WxQ8=1@+(CA zple%HyZ)*Gd0e+SR?IKvq9OIklP#l9w;R8?m8|&1Us#+R?d~K`yCtwXKlv!^@qFxy zXR;SxbP98p+a;Rg#+GSI{_j{T<5$CJVAuoe`h{P~skmrwke*_ihp)INq)T-+PVCYu#d zkEhSAjJt-=!0CE)836# zhx+&N<$bO16dFXzO~HO_i<~8k5ab0ze07r}ra(H$OzWswa7dYv0;t7)<++8Or`W|r z>Q1PhL}mYu&Qr4nB+;bfm?&PxouB%M*2idCP*u0k8Iiw9pl+}C`tCyBN_VgS_U?D? zsG?VpHe!mt&mcexUaIm~AOD!vm|V(1mtEs$G&qC0Nk;jun%fiC=cklfz)}-XkP?7* zQkA$;6H}#mAT|emg&zcz4p=CoXng|8cldh?^3Qhxun!(fZi%dY+sCaO=LB_t*d`=1 z2Vj%1*W4=OO%%Kom~Ix7=o13;=-77Np@^S5ZNS%E_;UJmh~*Arj)G7MkadWkbb4n~ zf;-MtUmUxp4d3-51(!RAk*7|Ci9mGO*urG=s}+PJvR_KXRr0QmrVvYI3=ApSmqi7M zC{F9#v7NiM2pDQ6FGGsgD@Jb<&X+Xyl`)lSjqfKFT**y6fC3-kT3+ef;lY-PV!SYq z`DwaTey*uB9CB7~ILWmdxLt=k6S<^bsTpc7J-+`p@vsF^b->(^#ola=jMM>Hwjp(Z z5`OGFaRvoscg#7JF<+aa=4(hJ(=+{se9oeP*ptw0wOjKZ#j@f6Wssk=N7zczxrY1| zMFSv4pxbm#hCgG+x~Dd5%LY00+5Eu)H8d>pe)ewg*s@hMH{v82!hT7xO@x!`xNbf3?6hc?h>N9;eKY@08L1!xK};**Bcj8$#Q#BtQR^L|*J zo|{<~L@<{7KF@q<_Aqs#z@%FeN>@Y8yxUDzA;l1kv3kofjH8J^ugR_3NrAmn5@jVH ziE~Owv}LURnbnnitT9U$x#0f63VcSkCGUN0(Z|%E1!>psaxAK!%ucdVdYjMu6!yn}b*;MWV zH%h-6%|!|C?lg++aw_4O>*#Rr6G;AU(9DBV0W;aUKIYI3s{XgIu(hU<2i&Hc>u+#y zpOXH;@^1?GV(DmWAO^IcjcECuEzL8xe(ve%&Nt)AY>LawW%5TmqK|6h?8=2YBZzro z^Xh7qE}Ce&GWOThnPA1!s^;);?CoVjnKkI8 zUQFx}vv6g&y}qe9q#u{2d*aNP5TgS#()FX_= z5z&}kA7sqswAU|-!?(;ERNfr1)GQLafe^LB_=Zf(A;swg$BC1(t&uAwj*Js=l(fvn zBpWPovlzMx`{a2fqMWKYg^NUZRUvd=k?>NVn~UJj2W2el&E@ViR*H7JKvxi& za8SdTjI*28lT?egF)_%zE5*&$p0SZSnLp9)2m-fbG?QxVE&bETK;_0t;l{#h^_Hx^ zj=T1^TY*3;O-dE~S37L`Nj#3FLI#1VW;chpOhecGL-l=gzRusyQCgxG3zK&_5XkP^ zFx#k->K=!F6yA?!h6rOK^7!7BpBO2@Zn=2pZhogS#^Xw*VoyySw}4J?H!G zue~Dt9JLQ+Ee}1Q$5`P00K|}2n2X-DFItJ04M|i01xlKa^0(p1qJ{jqW{W< zulL;u06F0LW1QfRXXPGU2N(0RsRyIQ}csAOb)c4giRY|5x@x0)VmCu}n?> zE7!mFRd^i>3jMDv`r7w}3HZi4k;OnIL48$V$jeD+*EpvWwr~G z6Kvli(DoryM^SWE!utCgVnUJ@qQb&b{C}Q_@jjuyyl`_pKHMa&4ewNj5E1cP)Yk`- za|YP!XlN`_v;21I7l(6yIiAes>wC}l-){Y!)UN0yk(^Kl^ zF|m05!%xpoA!}!O){YTHsw;03V(^a4t*n9+MZKQCn=L;cp`R@KNOF!ZjYf1WjpB{G znk&{Fo>VzZq?*x^!9>R@8W|RbQqf%GMyFDggclNMe15Vb_MOjZc~pjTwLjVfeUufA z_z`SR%{VI`uhdH~HbP#u7N*LldaD`Dj5~h!lxPn4ZPCg^q$ahlb^UZQ}CfmWiDx1aT z1D@_b#zj?je+E(ETM-E&omSdDnG1hj*Qv_Kvy*Iby&>*iYE>o96!q$+r0p5b&SmM% z&MOZxr(~oO@wy*L_&inmBs@QFw*Vh}>maWGIuWssBW*=S_6TmGZf zy?3(znr=ix!#R!nJwmgzg2G~KNXWu>|FcdWUa;@Wo!iA&CB=@@im$gY+n=Yma_E5B z7h+cG$W_wU5CY_7RHQ2;O#}Wv0_}Aw0C@iiw8*eGuki`z_1{1%h!<1BRVVBBW3Stq z4~!KukUI|jt_9H}g2xGs_s=I(mOc>QHxIyMhTwJ==Q)1oKkc9E6-0(FjEfJlquhH( zf?ilqXBLxadi1k_49hl+TX)U8Km^h;>DAfh?b~_t5VFdC_J~?ClSdy*JO7b5!T&oX z)-|^7Wb9)@09n6$>C^7&RYha+uh5Afs5C{Bwl!LowE{4r3v)QJy**p>VsQi=2P$jE zp+&$tCRy(Lq-{xMZ7nA>-f4PTL-SpI9r#bq(NRyx=*edW1HD_%t=}?zPF&c%?I-zb z3;T#cH0rQh!m0>_mZNfU%>;xKT9odjv0oT+5J{}EchfV7fzLz{SvR0DG?_QeM(yJh z)A`F-m|Tz-Y`@UN#Od!u4OWLH*$+!_w?X2A>?A1Qh=`_ta@;8__Py)fGXC7PS+E0R z0ZylHUT+uoTT3ytT&^E`Nt-tI5fbZRw>>m++z$QZ~!GH+V-Fj zzc%JdiwNayp!nprn1Dz`#K!#80K6Hili&5_@t`)QtT_j#z>^6+8$j^MK1&SQv9X9Y z!u1QVW5Zrm5@~FA8Gn7|YvgakQ!%eMje32&VWG?6DLfWuZ#?XkTG({KiuBo6q|Sr8 zvx9(8YIG`bNI$ir?MuT|J*i4TEMH3t8`2a)Ya5vzZ|qj3T-;#J%mF}^kMh$}zy#UW z&B@ov>7^R(?d5(|`WVX1V5-Gisx{ucKug_~RRXfLKf zox|}D(avSpF_AIXW!L5Cz%D5vsVpdEwT=&QU?8}b+4nN?3eZ@&k$`GIQ1N!4AWtqU`yrfM=Dm^bM z3^FRJtOVxoH?kGV)gw;b-L=-Y@Ck_`t*Vq+r8D}Q$;J=Hpwj6LTS77ur(Z`3`;_Rz zRSHA}pYsb*b2=(q!Wqb;2}Ps5#mF@&>n;lEEsV}F;xqn) z{0=bMhGWHAhzZq5K_M8{GXgam!QH2FkHCxSGfXre)X8R-o=dxvVU4<7jlgG$9t548 z*?!d3#Shep8boVO(yWiNuJM8fo)cc$FXNvi@fZ89}Hvvl>BTo#R`a`Mcl? z2AYA!_#YJ&*ugwBOE~4T9_^pTt%;=wn?EB_F(z=uLj{$;DSa;LcF0OjW`}ut1=^=O zd3oGsH+i!UUoA$PI9XX+P8F{ukK%fmhwYZj(LzoTn!6?D2)7CUB*qsbcC)&=yWhu) z4|vhyo%giTxGjjp@5CCgeu}oaUs^Ibm!^Kkr|M!it(`;q_V4lu)Y>iK0EpbD{XW8U#1r2 ztDf>dQ_I!U^AZ5yMgK4FfPt@1I>*i)Cl!4Yv&5R8Yn~s*#NkYzM|*BVGM>_CS8k%C zqN2o{ODuZmo2*79v_Vwlxx!jgtu@x`4D;_!`axUJb6{&JfbRW#eKa{4!cLNr zetEx&3G_DS#xA~>mseLt6ogujnV585oTfNQXI~jtfv*o+^$PMCCx^PaRInb1eIM_8 zop-Lg?f3}0dEcw)SXe$Z>&VOZg_CtJx4(>>K0jG=80yRau<>y_5uJZtG~kAQp6kP1 zABv?8v9zSqlCSH%MCbMu>= zV%V>Z$VQ4zqCyqJvD3>r(p*WD&CvCA9fuHq`wYmT;&rKR)a+O>K6a|clS_Q}>5en(M0jAQd7!p}`) zH$HAeH$KgCG62ojUR2O$8?pNw-^-R1LC7t`YPR_RFP#E=ZN$_nx9jxqj?_8v@%f2R z0x2FZgj-JjN6ZfNt?miuNLuHc5gqkJtAQbOSXEo0ASPWUO-MvGCA*ZvSF*ciKm-Fy zc6K@U2`>xjet}C)p2pk#{u0A|9)|xyyA@C}Im5l!{t&VGh;{B``xzdLghXnI2_Sf= zo+!k!AX&~GmWh{rj$Q-QEc0{eJ7pw|q|uVrHD5`t{i1tvu{6?9srt`9?yRyU5uxQ# z5_@Jle>7L6zrP(=S{j8l2N404Q8ySml)l()|EM;1eOq- zbvnHl3m&;+vH9o6!301$PD#EYAuuCSRJn#ijF?>1(`e7eX&m$oR;vA=9iOnpr>GES zJKhZ%%k1npJAKAISv6hoI<>mXd?Ng@Wm$00+$`W>YkRlDz9K5-cVD{PB~e6?XW?>q zJ39E=Jm9R&$f&KZ=J9Dk^@5c}>-_V#(d(mDtsVwg95XFTc2`&5`+9!Ao9D^MF^bNy zFJJzpidVU|m_MmoV*6%tV5)p0)zKl)=H;DUd3lbR6|gkBzd4w3nqCp}UAj9v*jbA{ z=`4fq6;N-tAkw>f5hESGw}A80(`(XyiJcaC86D{vINuqY5%bIRMMgvG)(-#yYzz_% z2LlrQ{|oHA7FYn|e_$u#xyj)bc9#DOJEO^lN}ASWu$T7nzobct$|Z~TXg|IknAUp3 z5E_&E$6r&&sdTtMnZkRQ|5&x&7ig5wnZjhF{W=Hoxhwm6I1pF}X#ADvL1EHx)SNt# zl*rKj4N%X9KV5BaRh|9RoPEQ5F5KttOrZS(d$qDi{wBRkKA8#`4Ve`W5n*}dhm_2x z=>@$~HO3eRkUIZWEtRFG=f1uIo?Qnd5sdql9k&tuz~p4B`8^E+ln#&Q)bFqnjheA?fqcdzz+ zcp+3g2A+<= zokp49*s{FV;|q7lc$Bi6LV+X`;%wn_?TH?}suFs((j|tC_}t<~5vMREHoQL}J3ZpL zi_Cg9pw6%5+J$U)PRh3_lqy6EQ(K*uPXCH08oRTK75aYnNC-Wfx*qg}-+mix{z zIC+v3I;;%+8*6W)a}E9Agp7KAU(W?OZN3FL@;LZ*5S_cyRn@EqogBYIK+rdE!BMdc zKLs02l3P)eAe2@lf%rJ7Yz7{MQ8stN&Op)^#-rt)CSs`AIJ&SS>YFEfY#k~c(x?x+ z`^1YB;@XB{#q;;xnesg;lw$1NqBmGJt3`k&R)s_j@hJus7MqVj6CGL0_xH`Jo_|SA z-9Ikw%r(B#r4&pJz%q3kqvUz(^`z9AuzkE-asJS5JApp+?&X2w9;xGTUD(&d`4^>G zt%a$X*_;!5VV4w{3C15{A0GER4Jzd5i&N!)^t{R@z$% z#}&vT;Y?QAn#xatjrMsT%qejw#sbFJvbxTIr4Ek1-8qi2b}C%SzB7rr$Ino~*cZ)R7(dVR z?c3@g;Q05x+Q1>3G^?C#K&aZ&*3SMg;rwVzZ_(k*TaKQkm@A%#z!=-vx0U6^72TF>#pZg*4F$BgvUUo4_DcN0$T z`HmEh*vPTrOz}^|P3VwDP`Q%L^0Uw5Nx6S!gz)A56psvL_OItE*X3kR<-T;>eGMH}epYA4)Z4{OR2A z)JkmZ2J_1Fo?;~B63TZp63^hXx-nUW+T?`)1r}e|nE;Oez@jePT?ham?Ee=Qr&0~o zaINv6W0E*7G_lCY^3C)TZNNJ40awU@PD>CW2=U*(O~!o5J?*^ctZ0}@(R^dT8PhuUB{b>>@MYtkAW5M6jTCK=5wd(kZ8fD0HA-vPnJ`^z+S0|-&UuzAFLK*< zfV*do(zkH1>6j!WR9c=9O$Hk==Lcmmb~RNVasXzOCiYh1jkGEer`amW}Zlp?qTQR6GmMD_M<_fBT%!!yQ9m#4s0EGYuoM3>B{eQAeKC zoceM_3&{!mzl&x5`XG+2ZsL)aO_?w>Fq0m|qHg2f)jb~?TJMu1h@&yAE~p`&T`>x+ zC&IO{S$h&U3RQG)AlVtk`A%VDj@E3EzVGcfQa3Iuu0Vw;4_-coPs3<{UPG7x#8+n^ zIuc{Ety077(>+p1xFYJzawf>RphBNbZq?z^t>H{iC}m`Pd4hFJzq8E-61cG(pKGn_j z7Hg+YonI4|PRt`FS0T8oivqp85*mvo2tUar<2(F1apN&qPfyx0lXh&iQ1kOosf2`Z z`4819PsI57(f6-2%VlIfsR)7Cj61%H$3Sl0rHNoAtsm`+7ME6lGaUbItjN)(Q0ixG zO}S?7UOKB@**JZE-gjXTze4EUQ6xO;G!LoX&Tr~956&vxU#z&bk4MVW^?3J;#4U^@R zKnAvXxA5OJ>H4;W_S(k0JR7_KT4bHbNBbWnHQB6!yDcdm$i?So;k32C{uv{~MC0O) z&G|5~IdbD}>1WI7oY(Dx1a1D4Q=_Tr`_kLxpwjuZPf7;E5m_~Wm{9y^3bDAoH2uEA zwI5NhhIUO~Z6he=qqRS+(4-q+DeIeFtv+FcH(T3kI&|nx^@`R4VAG*D4~6%=tFEB6 zeL+?D@FHb8rw7FB4-@Ew>`Trbjv0&?=jWN_i(TxhBZH`-UvqYzR*ad{w4WFlNy!mE zpnLnB^EKTVwa3-)V1KjsOM+~1&OOtmO***uw zPG&c7)f;{%@1Oq|7_#3$6{z1P;Iy@Rxxr>qJG6B*p@q27phko{oIhz&y^x$;UjNJb z-}Zp}2M_nT@Z%E_(m^MiHw-E0g`vZdp=USYVsR7b?cnIv{)oPV_dYw4x(rkD^j=h z-YgN{EJ7tSt6W}Lyp;DpFsamoq~Rij%1Tl4$|)qsz*Z#sVGbIZkC^^5M$h0@;9)y053vAEgPX4@3&PpV_k9t z<3#`*{%Q2++IlQTZ7+99i(g?Ai$o$vml*F?S+1_nc+we;Y`CQZoP6|nU+`jvEufcd zBD?20bai#X4V@GPQw)0)K7QlZcWq-2!Yenwp}LD4m7rO(sVQZi407l8c4S+-NHztm z%V8Av#UO`_VAeET(FS7R zOXlH;u6SOsXR}M<$pNWO7~S7mLq3LMV-A4m8uan6neLH?>rYAoMHLtBX}@>u(EV5I zTqw_|X#~^vRjxkFvU>6hPz`RJ@w4iH{+&AAaHRa4sE9o!u)`@KLk^}k!aZGs z21p_?V4K`~+ZZ#LFZ003oIny05#|43;#4UuI*Bm*J3PqA2`(EEiRw9HjGj)MmZWTO zwt=nR9r@p~FNBbkcq_!^Iz*+h{t7zX;23x@9U)2O&u<04n%@Uj0Jvy0={2Dh70VC$ zi<|G>1EqFOVYt(MG2^LI%EEC02Qv%kQ41D(dts*F9SnZ_Z|($hzPgNSmIb$@pyJNO zzgbm@VME@}^OfbDSwRpC1!E zq?UA$h1?1S;wj)8pW&K?F%h+ayy!l0ef{$8c1M)o{!bo?gyNsPu!=YMa@HS}RAzhN z_R?^Y$bs35-Xdngf{0Av>i!CEC<(yRO+LsdjsTRMSZ>va(zo5A?+01PF6)y3w^wlE zv$NH^f3IgJ+#CqxL&3ZFkpbu2_1q^p5v=Y`Z;>%j;2s72m)5PR60@=p^-w;gqh&v$ zQ(XRdAE9M<|9*Pp;m6R5dSFa)72@HDsw+7#xg64`@Dcjh))+BA9w1(%DX|u!{|D@R z-1;lKw$@2S=rBP#Zd@81pb3JF_-)Se5oEC=!WY3oe^lnKf1m2)MC#(PZeoE3$BXJ= ziwd<2%T;&Ht<`^WsKClTRAPmMy)PHUDypotc4SUK#7okGy)mAtgqsHRT%NypU=Ycu zvCh+kV1w0+G;F7d4Kw)4UetH~oFph*wN-s1v`4H~n*aL)ijnRflQQ@r=Atlna4{EE zDRuihI=t=|@92wdxI`Y9J#Z21Bma3VAo1S_ow8S-SS~=4CWvmZk}iSl?D1e_5i-T` zT`E{5Z=SaP4T_&M2zDy0ei_&u#=@3}SWM;b8k{JJas2fv4a3iCh=N&TR)bo40h4^7 z*2@g2iHZ_!V&0_|mrPCAIW^GFxXh9M*@-tZ4onVdC82 zA0;evY~6+I+cGsk+t5cDl*P6vF3jY@Jx7^<8$G*|vP2?f=Zc3JVp7paT_cX_B_fg$ zJo|^ky;|UYXUXxiiVhcXcC!fPJ2*TA2ZtqIjBqGG9hx6Pd3>uF*D*^z#=Ar=djW+f zBWZ7^`5s%MRk|%4ogWea47v7CIL|j(&}OUh7-dvv^m3EKX$SZW2L^UzPfCjTladNk zUu+KcpVxOL_;d6g@cgu5K~RY&_O`={DKO&CSoX zk5}h>m^Uskw$bGg{McdHH$HfboL&1%mnCt)?Px%ygc}VI7Gr+|lZ@|}&T-5>CQdq7GfTY>VYQ9oR_pp*(BPtAn@OAWo>Eqv`U%DX( z&4CvpH%NVS8hbm7M+b_^S^8}ppSSG1n11d)fHFG%77SCfcyXm1jwCkX-lTXrUJZ?G z{cL&SifmoUZVpR8WE~WM=z)4_Au04GFg~Z1`j2*o_#kar$HP`<~-hLUMnRwnbf+@!;=X7_TV)?|AfB*U&`&K#Y_5Jzf zFU(cHy=-isFnzw<|0VUBzUoc?lX};a-S`0j#ryxH-i%MqxHI_Bu_a@X2$`T>npAAy{i^QORg6J<*B@6f9&%?(*(!-R5x>WALRhA#Q z;ywE0Q2BvpOZ%+g`*x9cEiJZPe)5i_E4WV70Wt$0KbmuQ*E2l>Gy>BwT{xs|E zP{zsa7>RD~P}i8#-`g~%df^etvj z9w}<7fCJC-Ibtu zyHQgJYHCU3KVcd8>@IsBFd&+1n>swAr($Vc#IOZ7zu<*-pW()zXvqZ3YFcu6lYIp+ ztPI}(Cbj0lm@ckd{$|Pf*XF|HSYK`~yN?#w%KqFlYkqdn0`yo26WwR4b;xf64jFVL zYohK=YjbY40ZMVq?<%aEBz#M_7*N$ayvXUD?T-_K<_9$Tq%B_edQn^KXu*`o;(2q| zU>#qHE)s*d7P;v!5aKtaNFf`kgpvg%w}S}a)49iYziK7O?|xhVu0LoGm=4N*#Y1~9 z_so}Y`wPMu4y_F} z>q8bt8dE;<@;}>`=kqq$p&76}?OC94SE|)Uu~Xj4u}R`Dy7R{Q8tZpY&_mAH*Oq=t zqfyoE3NRMP#-=WF((c_D0bUuE&R!DR6*R9Fslx3l zaQLfO*@=}EOCa@=;A~75xTUU_v(qhPgJgN$zND1L{tRt3ek<#!q=ZQ|4$;)b1=XPJ zjcMbHkbMCX4tj2#7KJ8+2sS(0bA#V!jS4OUP5-=17=G8>tS`;fQNKm4G?J+S(bV{- z;9h-5Xv!f>9JdnaD&=;9iIL3wWAZ7DvNnN%?rGekgfxmmj1<53Aq`{8Fg&m0{q0{I zZF(>@0rZm%yh=L%LH$`ZUYqFQvSkD_{5r_!#6{A0DT$N#>I^E*1N^!|s!DI@Te>uJ z)oOpb+k|so-xOPIdqU;>FcUSOMCm8x=mJnG-*o_vCus{Zd@?;CO7%NYrnjsCH5xwa zaFqIY9YkwOK9%8uXrUnIj;GR@DO%&wUnbDp-}PY#0K+&YMMKZ%6uPX=j^*!)jIA&* zP(*-9F34mc%0O`5^}G^h^?e;P`woq?uHgebQ&_SUV+G(G@3mRndt%Lx6$qxB8Ta9L z?Zb@v<7f`V#|tBDbuuN2LTO~vp0?eFhA!2v52(#W^4r?bXWJ(k9RU7XkFw)Womp#( ziXR*iCmi>40$U3(Oka@vu$oSv8?G&9tEfVD^(6iR0dvn;zn|^Ye&P2>&el&N|GMDH zM_IiMt2K4%B)LqDh_F0;Fn-m=umFcQo$V)10Tpz_0Utr|hTaA65;Z_ja+Wh?&)e8U z7AO{0Mv(8$QdK9Ry)LtwFctWe#_3pu?Ap!BN_^F4&Xmoe?&FE!0u_x4F8~>^DMf&qE zktkEa2tXS}FzY=j_R&A3GCUmO&1yi&R5xdZaUwyZiH^liJq8X4QZXWXWO^XWVyDDP zO7JTL%8G{QCmk25Hro+$eE*uJI8MH}@bijkV9Ce{Qx)EYp+UQ4S*aY@8Wz|S|Bzu* zwD7@=jy6<%>wz21Ugb6=Szf-GiJ3X_+Igq`A_j2<JjoOeravSPfsED7rojsFq6C z=#}^LtGB_viieOiFn3}RB@S(pYyesb7>JDgYT}liXaewth?YNa9saA*3mEh2(0KZlJQI$L zl3hK>8{6TcKcawNXTa{MiJwywYkq%gX(^+A^(A0XsU%Nr%&~J{St=a6&WtqrsP#jqN<0mnISARHqR+5OzQ9}CglHu| znLljD`O*Sqv+JC-hdt0sXOjm^;GWLYk&#N2hYCV`UBF)|gIeNoV`$&goS zJ&i)5Al&D;SfPBD%*lxn8$PM3bNutvMrOKW+JP@30%cMqq1Y2d=g#wWft`4slFwOh zcK3$Y`Ls&MFOOoAfPP}^NqX@&{3gBVdUB>*+UM8U?QhV-cJ30KLbwXims_6`> z-RAW8N7r#Y0|q`9G}eyyjx7BqCG$E)kbTdta`d8R&bQww8@bR$&p*GFD)UB2u!EE* ztQjH0F7D1#A+y_H$qdN^teg!p_>P4y68Dc8ovf`#z`N z=piU;mc6eH?Hki(rYqJ~uw`5nL2$eoIbPV^(guCiL;pkCx<$=~UpHJL{9n@MrKUMY4C{w*i7gsVxIW(75i7&H(y`Q{kgTRTWZl7xbdYKX2rgRI5;G% zHsQNTOME7TPeQH0qE^?F!EtlHSizmNl6C`{n>8VA;$a}Y90EX-@xABUym@LqUdIi1VIDRr=zHm;b>`zDCq@J z;md*hCcV9#i3xQGE^fIE#tqgZGG?okr(^AvVzeq^mZVc91{r4mu+tHkvR+VPRvYADdGTOJE5YAiz%D(^j1fq`R@^&OD3fbH&Ssaa) zbl=^-2iap~cz~&@@@xt|TT6qS`|h8ASkJws_lWtKiLO~l>+BGjyVyL5=5S9I z35k*%<#_`8)VUSQ9>=e-5hWy_MWTuv4v)Z3W<1+e2671UgXns95}X>kb|nl0z)l8GeTYs$Mu6pP%3R zgI1N6j9bh=>Ca^HjdFC_Zfp(rLQ*J;Arn#s64e2t1>IgG#mXv_4yp#sCE zHoII@YrH>fOd7t)W^lIC(cD+99bf<|npVQ(DJm~g_Hi%w;C+5ndczoisdtHQf?(ec zPc5yiPT!8GY3!;pQg)R4txp2v1SPN5k$Fum?ztW>XS415Nb5 zZyJ0Gse?+33ma%9>*8<#72mP?e3M_|8r`SIl2@76dz~ue$G!&eTIM5SuT57P-BlcU z*=EAh2q{18KO-gIxgO{S5$?Wa<54*!1!m00=*TvPGQ^Th9f=KrD`@wMbM7trjE4W}oeUThbLA3x3wfmYokx6mPax%u!_XX{^W?AzFw ze0ktSq?cn^^vp-TCnqy{;!0ruAs{49Dun~SoLRFfkI&wg%QjZfL*T$LZMd36kaT!u zc|B}{L6zqGfm2tn-HtfFXyPZsYMq+qWFxtvgenkv+vKi}>H5Q|a{{bARn%>D*75Ob z-?9oC`9y}$lL7U5BIOW99B7N55wxY{ zJ-zto@NhtY3F|fxG_*zWOYlvs!3S0We{(>5i$c&ovBj+nB{3|GxBUApiiH5+0x#r& zk8i?J;@y~EMAy|)k3a?$FW9;E;r}u~Rz072r3T{xZ0;VewV5RER_*W{7S0VZp?@tz zl|Mk`f%wu#Id>2}sJQf*(8$IfpWT{!S`^`T z(qX68VrpVKcD&*We29GTy3y5KTCnCs=KzZ@=|-yEOd+U+Z?c;Jcf+m%z;laldN(&o zt*Pf<>82?k3OiN(?2t8PDppqP>B@JX0X0mDk}JW+kRf)~yMp!7;3uycSHPg+8*B&P z#M5W2p`0S3Ddsm9{BlQ<9WeVeXyQZf+rbj7jr{v3vIYh$ID96jbiZ!w#;4{13xb=c zNd}MbmOv_#Q?VU5TrfG(ZFLLdMAIod718b{fnO_zFGipoFU0!x=W#Bq8wY-E7g+-h zi2Alh=0};!gZx_^RRaw+?T|HOu%n9uUy1JbGq zvdsrGEjplSztM7`i0YwjC7}b!b*IqXXG}H?>x>yn05~hotkG=8cgB|7+R`v3+2@>ETw*bvfC}v1uaDb1Tqb2eb*7&9n9E8?_uJkXQ0JJIlCt0mdhj^?KBMPJB1@ z2Yv_y%larEbslIOjb()uXjc@lr3LCRFCg36+1ZhN9wUT*@=?jKL%t=K+5LKbsIk`p z2_ureBllP+p z!}uIoH3QE=kNQ%lvK1eWg2tFTP9L6d=_7)p8+{=Sc7a1v;)`(f)|yCG2NvdeYyJI! z5>BX}bqG4I1?=~S?{AJPQEe>aRw#hz=4UVPzZu!*?WiVQ3M3WWxR3GvOE(3Z?2xUH zh5Hf&an@ESjl+}ph0N)ziJa*Nie0Nr;u zgLo{KB^t(AbRs$*!Mz)a1YJ84^Ar&Mq0oCdm{` zwQdyz(y$W}lGL_?#rGzv|EgDACFLytm6ll`Vu(AxAXFnf5DClugBeAOyt{u9F&C32 zl!nqf3|w^JyhC)<4>H;srPt6iAUU5|7$}5=5OGnez)6)Lqf-vvJ1H&iE9` zsb30oR6!vF&Mm3psGOd8p?dahY849o1V5+mbY2SO>MF@fO#D`|a}s@kMFcRwW7Grd zKbo5K261ib4SctlAgxPm`p+%i#$*du8=t#)^qK@b@ za>*9HhK5O-*Xnna$ciGY*Ab`yyRiS8+7T8d^qmovwJ8arTAw;^rQ6M>rx@Urnt`ae zrw3wkAu7Rt;2nb_TF%j+RB;}NxH^S{on_GvK~iYuLvEkiFfDpJhgSTL^eeFHQ9Ga> zNLh z`YB6cq}vm=qOsqoJ3j0)v2=`4bLdcCf)K{uq+QVbMBSHCU{x~O8Q=$uUN5b;FJM!3 z?mM5>%i90{>Iw9!1_A%6o{U_ItzJ)Ito`5W$-!AmJGDP+VaZ<_&i}OV4MQv+wmj`W zNIKIb_KY#Hbt(PeyzP3(8{9&xUp?hGy-(|kVRZL{gk>aEc7@YDb6AKtjdA7kU%Od8 z$@QmVCsQX=+AMQe@WBYJBjf*QPJge?s)LFND$Td@-;a5vxJDbES+{jAJjK!f0RP4~rW zyTv^)1HpYdR>>efMIt5qSo{mr(5U%7kP?v|;oAC9WA(Mq^-KgaTsMmNz+%Xq^$wbM z>ypZFk~Iow3{FhMhst+YVlg7xgispN;vLLpgxp65SL1p@?BD?-etXKWlEI#nl#wAb zJSR3>JFw=w%16vt6G23bG9kwy-DPUi@zNG}*sv2p4Y%~_r%3f>-{$NuvazGKrOV|# zBl%ytmNdKm`&|5fRC4~X-UT*X_o$UtSi^1o{(fX0L_DCb?u(8x&WyOUq6LEQarANi z)UTusK3tnWcNa7~k25EZ3<2t-XIGAHGH>B2Km8-*dcdeIe6TmpO7mic2VrZQxh(FVxRToehp+R|nqY2IDo*B5YqnXr`#+oZ|1bJ$}g(>kUYn*s~!PEk{;ydKAwl%oy%C#@nE2k`w>GUSlGVB zL@PerLD-aWme<^5#ktHvG$T|HRYOVh^SDFWq0} zYWN_fnQK21%tg!sO| z08|_O`U)$&Tl*|OI~Oj-;HGx|*(?0Givbm?)N%Y^bvy?Oz5x9|F>Q&Q3ETZD#`O%) zszhY?Pw=!_(}Oaw$~$VAKegQ30~hD#V-v{-ut;iij`=v=W~&N$kDRD;G`1LYnl@y?`v-i7~yK<;BI;(=bprM|bzm_BYao1zvhh%J_6ZI!tW3 z(a&Pf;B`eq4b<=!rVuQ1gGrkJQPKl6^28R-y9?d`#r*>V#p6=O=MK?viE zf9D2pZ?rbw;rIbIf&5eh*e_<0t+^`PASJ>lbhAq&*laB{2%eJsX>PvLe$vyM3$W>_ z-YBx#g2Tx`7$AWD+l|hp0P18)`5AxeL+wGKt&gUcNa75&Q1}+4!T)h>C2Y6$*~3F1 ze#0nO6%0h4A+e2*$B5mlH})UR7pK1dKF_}ArYP4>AURO%ls^|*;3kT=)^*w|F5FZO z_cMURf83s5QX&NA?8?>*1zMtJyM~4u;(A1GQUE2Ej$AQf2cLB}nb?4{ONuwXAUTmT z;oGsS8`?JXP(uJI)u&Y;>O*AP`(Nz9rGL{`qOr+O3wB1W2^k=s2f@(15Y8tg@tt8H zGs@l0$S@vXJUlwuHkI$Jc;VI`|*jbIkzpVhf$kU>2tq;G78P2A_ zJxtI!6xewP@A?})VsIb`6_H)@^sgoAt?cf6{lRasXV}ff^U~oVU3|NJDWP&_Kzhx48aco;aM84N-m-=!!yVq2BRuaHUF*G!- zF<1DtF%g@R6A`Ji0M;DNtqALXTMJ4 zwLm2x%nQ6NLd@U!%5L6kd(BhHcI^t%gz>S#T zpo55r5=RaWA0?Bw)38HdQ!>sa7X0pbPM##<3Tjo5nZr@u0JbZpk{b~M{Ve-*eVzBE#|_8n zeLy_kj;7Dj1_XdNWtp8`M#9K4Esx%MmEJ{>i%BUfhn&3^hl4<;3LoVzOOS-rw62C- zkO8=H>(i5klc_6gWA3W<=g*4MDB^&i$;rvWj!dEnSg(4#!ni(BNDKg(wqq5VEd;h^ zt{{TM0c77zFeQLYiBMPn)yz+a#`gi#<9A!uf}9-uS8ZM`cOC7ZPzhAV~tKf5&f8t}Zd@uim{* z!60ePG9_*yrGH;mIgSyf|DPtVJQ~Wjjo&k7Y%>@`w!&b@o=T`>ri{o=Nt$HIi=?Ei zVdf!)wAivW%#f{Y^AbWBpJI> z<9bfaoVF0Z^7`y)m${M6^MbhU1Z?Li5dc$d$=2r_d19Agwd4{)A%Dy-NFe%z{w($4 zdcjptd6`Xh=3GD9WMtF-ITB$XRqlEx>K^VdAZoZKo5^|_`9cz z3ZlXb6$z4fhLNY5z-@pfP4}J1D4P+TgAARmZXvDtSw8Sp#=O`stBN1;Scn@`^SrcX&xC#z zc}kTx16*4N#HN-u;$YjTmOdN25s6rgmtKc+@l4w?hQDT^zq2q9+J0yj_3!R@D~`hU zTITBFe~*)81WcBn$3utF+sGC+@;Ovee4qQ+GBzKWK55vLQar0NG9_O)zO;scb|4tX zXPVCAb#$Z{5gQG$-Wd#GmLj~TW8K9{z>xFOh@{KKqgl?6FLV#LnU?dRlTY$&@*5W0 ze@_k{ICnvqdxfqKv>s}hjYkqL;gfuWhv(-XEsl3I8Iz2;HWc3&U3teMvQnBK>>`fi zhufs#I#M_4XB^wccHb(~tyG-g_Yj3ms2wc`n4=#$K;rbzeUA0aFv!w~eni88WE9cn zFfivMXu#c1Qt_N`H_KQOq7XKXqPY5&Pn$OqVAv9`(G36x8JI0e;G$gU>24?tyr-5s zvHlNQt62faqCj)GOHxDU4z6V}Y~F(B2K4HIBinz@PtkBsMQ{mhKMc2euNhTet+p)9 zz51pd$&k5Tde6R9Cp$~)F7tLXm!e-QwR)5+_Ex;wk+i>khum*`&v(f71WPrl1h?+o5!z;?w*;~NjybBZY<^c)awgTUKP+;|b=%h_ZYMchL@7kkwH$s; zQ4$v-t}YMh>TabKSkQ)tO)pj+D;?yzZA`+PrrGE7t-LHCv119+Hj=0RMlDpY>26Rw z4d7^4EA2|->cVe_O}XuL>Z>T!wQWe&t>0a4`fR?*L;2A3z{wgM1N(Y&olo!E546N6 z=0Qq&!13%0z!sKRM#YM|j`U$B9nuR0WXX?Z2C-IQ8q)^q;9J)cs*A za%bvh4Deu>?8S7HG%<@)Q?;B+M#gFu&@6NsL<9o4AFP1|^Kv-{nO|5qxo@>JlShX< zFM_~{X$8R*J-qgaMroe5g8E0!+b2~FYeP>%3r{M(f64Uo$fE?=+2rT{}`JF?Esqwq6N+iIUKIx>cgHJ=>cr2PV8S zE-d%T!xQ4N{RDWWHWh$S$mTslPrk*4SZ=$GPmP$3B&>ogL66v#XdEC&gOp1$+k?P_2gy+s|eyWEy1 zk6>U;AJB6O3SPx(BM1mr6Bs54iD6@)DkLjpDvPIXf=k+L3{lWkHrj>y)%Y7v0HS#Q z^HO-}6YZsF5MZgkvCMOqjRAO2u(DQMe2$;O53o#`(-$X^Fe04zY_z;2z^U$qBpgC$ z3LO5hGBrb}nQoTjC%$2Riu^pg;vR3Stgf!t(=9i0cgYCB@Uzv-V;6BeW2qgzF*g-Y2n0}}k8 z66V}oy{vvNe{diMbPCl+z1ZyeiHTI5lP?>D0*w%^vV+Qbqy5?)qerDOei3YG4_Nv% zZfp}4H!;*Y^-=!QiQ3xWFQ2SIHTmlF;Khzk{=uqqJIaL2W-qqP%^j54nhNfHFuJWR zlCi8v&(BxxKXz0q&k8QhW3%h$uk6lH^7bRBccME}H9a0joM<~W_VsyLHCmWr5qVwG zO9dDhsBBk#M@@A^>0 z%h(4D8(FUO5w8pXOXYd!jl6=_9}+)eSsPAjw|4tENNS#Y4z=r*zNs%wLi4U~*3qSz z9&)Jlr15Y>!+nDwwzzYX+)~(un#+r)2=B8}0RKNkwa6J98ok;kznZ!apa(YaO6#2yU8XHtvPCma_#nhrv2rHjmDsj*%BOOjV^`STJEElI%)k8CP`p~$5LRI~aNwJ6)_+}!5X}-po%z!2%8%jYz1eaYbSPXQegU;%)M|h|o}P_l?rBt5IJ9R^%r#o^+(p3H zd%c)5k@sds{o$Vj9w^4z*Rm|b?xvlg_i}N-{uOPUZcxlg!a@TZg$MLsi5d5HC!i5T zGCql#;u-wS!I86A@)VBoglT2nGxWD{lK_zQaq2js#NY9K+vXtw0iurt9QeKNT<^b@+>84vt$`czwY8k4|{03d4!g zd3PwjEsph{c9gbJE?Ym5x2It|HsN>TQN2dHAiV2LV_4Y6MR!HRKRRtKbai;Tx^Uf) zY=iijXR5}7GA(5QEVwyQgSg>+@j!0&LXK|O*^9X&l&1p3bY%E;?kQQQx4Y*M8fwAR ztY_XiLvHvP7bEj{{L3|J_s{&X78B)C@odQ7*Ug=s&p33b@kbk_t#F62EKa`Y9r` zRRDC9tql!P{O82{FUtjoK9nHb^n@o2%i*VmK|JTE@JZqr`&4l46 zo0B4L8gJEBZK|QSQUoFQ_X0fu*O*1Bk`{2+Of{;@XC|q_W;24~jDoH>pl`1nPZPRu z0(2UEmuwF^xM5wT&PFSN%!}H?s-p^w$z}IxU4?4F$V69i__Qv)=$>PAvPaE_Cw3!# zX0V<`;bu~ZZ~3Y7P(i)W;Vg~BouJNTiVrUNGwAodv%e2d#|QglOzKC2A7pILVgt{L z#P{(9QHI4}zN}`iez9LuR{^_D5||9fTUqMf(cpWWhV(rSL_q_BGWPMGGkp;`F)o>} z>M4 z3DTMDER&&*%4mWD4+Ek3w>}nacNLFkhmu1M9TOh0KH{-zr3_85S)|n;vuEm&y-aCX zhopyATZ^7z!jX3|+AgB~OZFeauJgqGwbB)$7k}3$$)Ep5xZW8<(tUYdQwdFo zkW3q^z|r|*ZvFiFRexM~S=IX5u7L^W5c!_WYJn(=W_UCtDGoYtl64tRJkh9-d%9vo z=3Yf(4GpyH8{4f?Y>=zc9r*M|G!KCw#ysaH3aUrT}EI^a74(LZ_FeA?x^67{I8GNe*iQ= B@$vuw literal 80168 zcmagEV~{1V)-`RZClgUwC!ozw$16Dw#{kVwr%sBdvDeE>#KV5BgslCsZ^!b z-q~vh0003%0g#aa{|XSm2nGQB`q#t8{vQ^F002two(HDL}x$C;(t$4**0cDM%u~;==wLM39ye zQ~B5a=LE>mkpF6;`)?xv0Q5;(Ojyl5TRutNLRTGq*zdTrCS$a?E@Q1Uv$U8&Y>i4& z3|u_HLP!=vOwGcwFNWkNEyI8&w%C95_-B!mEapVGsJ`!BrwQJ&Znvk)8k|`77H-f7Qo0_4f# zHgoZkXfjJw8&7%TNaA9Spe=R$X{0i}w3hZcXMe1inQ=B9KeG1rYp(n4{R-iZQy-&e z4U=|`6-}2xGr=Cn#pq513^23o=o3$5fnvU;h_rXxNkP-B2rtaBO;CP_ zqkYKp4JL@|8fhlA-&^KY;-W2b*ijWJCl)IKwEqS=1pSDgxK)gIKK{aL-CyVUdXKW; z3-*i)FGMX6*ZMf}M~04qZVU&E)FU8n`ecG7F*-g4{cXgW7BvMwwN3W7+5o~^8J_Qa zOCq=R&e@~{webH{<&9%~K24%)V!XNGx1Yt%DW>ISfYE%}rOUxfQ$~pF*O9I@-_aJW zs$XdvxZp&p%l;B)Z%YxZ(K9T$hs*GwQ+mT22glu~19L73G@89lad>xS#=8) zCrg3G47{qnomw?^G#mW>MIfMRSR$>eC_6sAA?Rmwdv;9Op5=Fq$(ka95q~FW!J%5~ z*=cQQbzJa#gq8b#kla-jv|cqUw}&#-wPjgZBbrY9cQD_UpDGNI>jh;zM@*jd?|CcU1Zu~C*00iv+=u)C?9j>m9{PR*|KLvjNe8Y(fIWdmt(xb6_OowKK7Ka%?%d0_IEaH5;PliCS_BV)+|B-kftY&<|%QhP>q=2&?AL-Y!4(~;SdnvU}8D_>03dTex=cc zd8o@08)sh$GT-fVUu8`ek}GZYu#=>jN{v!s7G!1RV@^O}IUAn%j{xf+(_n$)(*EIU zcg7^F10oWu_YK*2T$}|L_~EFm%qX%TIg>(+tyosm3MZ6H0S%%ckPw-0>VJ;<#Z2qC zAK-&NJGIKF&eO9V$ntTNa4jqVzb=9;xN1c}8*JOHAa4~MMK&;6pp-D%vv(RKbYwcH zQkTLw#DDN6joc{;kOA@6&Lx@>$cMDGh7o@8h59I~L3}sDQG2Z}uT#YD0wXd26w~wX zLkDO;!!BY?$aM!s4-tEZ6gJ@~t_A`FU>Ahgtje^897#XUM` zRhND}mNJ*_Wy^b&yB3Qk={ukimRcBp9l*k#Fd8~29*|8YW~B4}r?9n_?{?)Y%L?|$ELIFjruDQW|%O|b>}i+~E`C9tnrNMJb*PLHV1d>R@K z@zQwY4|s0#l@Uh?BeUj7AC~#nV1-^*{O$WrB##(Xq*lx`NCGrpxpE?WEZ51T%YJ`( z5O5Ir02F`4MH5kXTB0`-qmU(_h#%_av7`mXbuT1xmV}UZ}!p1l9FwWNpi8JJwG=Fk&5>RybITgQf{V;JOMZ!{3*`WcwSg`84o+gMa%O- zhrO$v8H1_x#c7+XO=n?Ayv@%KKCGoD2ut`B*b*vB?Y{=HQPSD=N(Ie0tF~@j%p3S8 z14$cLIN%JU1ZrdOo{7FJ_3gjQN5^Qf(~@RD6T|U?szOfB1_Ul_*nUx$R!hlox7fTb z618+orHyO)!L8JOMPYt>6f@!PD<0%nJ>mAS-v1va6!>o*_+Jz1?CJRc06^9J&xBTK z_(ZEqVtnUhcwMiz5Y^Je;Iys+z@oVZ9*j(n^P$HVdAY8&eRWvceb6%&atpo*sB$<`J zkB%k>(sblzJl)gLJo~^Ke~NL38EeKxKTx(n*827I&%~ep+=|EJ?qU_VKaybOXa<)q z6iZx*NJ?nC4$X)ElY=TmX=93hPPRVB-F>o;r?BO?9E#=pDDTPJ#GDqjYeavB)#+-g zcvJ^Q(cTqSRLT%rXo?>q%N=ROZ~R{0?wFC3^f;jso0;llF>H$8;xn6iVbYEoW1t$RIV&x1YFKWJiEqg^m$KIA$S zKA?|b+fEioZ=+J0-ZOT-#IJYicQdMZxhaeFbZuVZa91oeIB-2>%Srj2Tws%G6zwzy zt#p<;RB=>^VV@cd7kIIO^K6l>j%@O>2UWw~b^qiCd2m~2+_M0i#fgUj&h+hV&T4>b zYLW>p(HYT80djnAIBSBjyoz-w19mC9!Q*vocFTygMcn9@j>BV7v@t=Y7@N0QoIJGJ zu#J*wlqeJ8ey0sJMh;573zto{BL}VDf~!l@>j3(rK{%XwO7DK*HyZ>(;d+(oiio31 zp^D>A$HV~=lRqCS;=eA-F+@Z7CT0W%DE*M`F9;3IhOP<;gUJP|{V%G0;OeI+Rg_q? zDd1aO^lTz?X++c^4Eap;`N!>@VAW=s;`kBo_mBNI8Xi(d2s5A=Ag=BB>q!7!B0-e(W zQU{p)b_5A73?|}&I1*+v5z;UlZm-W;n7Lm|z+Zt7@CKh$6*`hNkbId?G`!=+Be(oO zLswf+=J!i1W5O60TUjubu6RShWy&{(VlwT04dI)av4vCOX~oMRDR_+97BW3wR$@So zWE6k{7pQOoP3rMh1xT@_B-|v6VWER!lQn&WYQZlQH1IkNN(w>82e4@_QjYzL)iIfRQzEXsn8|(7zN9fTPHb#|Er!UL;}8S0p>DlYrIM!18wY8n$FLG zD3wkZY78o!%N0N6`5SSpv6rHHVRQsj;kb+e{Ngse=3tyC@c^yDxf)F+lf^tFl}n9S zFrcZ+tJZ0s!2t%_XMY--|E)|SA=CvhWlnxkNyibz&f=vX)BIj5sPE+r4R$TO`@Fk?lj$kEouGYFj)D}!K z`bTC&RLe>c7IjVp>+Np41&6!^=KiY2H9rqF?R5tCrnSvy1ipW*fLrml&1wwXJvyfE zMVpS58vT^+Z!rVPMWqXZqUbMrVx={_Yhy)vf{~dZd;}60iHbo9m@w84O=5%{cVxu22(LnTv7%iA` z(rI;(tLWW2RI|T^At!cT56oRYZmP~FqpqFw7oZ%;9KUR0l1Ga%6>FES%+4Ba{w;YL zM=*e!LN*Q8KwH+af3-|be0j}Zuuv+WWdpQt`~7}%H^q3c*IK;c_(r0tLA0?ER2HX+ zL?dsqsJo}-JkSs!uT`b*;&)ZR+Y!6+ytQJoFoT@Y-EG8P8^-Xs!OhkB>u~>%(u)+i zBXO#XkP=^uYL!m+S>7u*?Y7Y#8PT!K{#k=wkd^FnY~Lf}EO}chQrp6b_FG-;K5a?B zbft$ti~AOTHVhB98k~=T$*_>D!9c*o=ePFqkd;PtRP5A};{DPUmRasMpM6b>y1c$R zGWHVbi6Y|Vo?@==^~TQNZI6$g=h2)k1@*XmQ?-b;fvOCmNOeRc8!Crog5x3d3=i9- z^75fN0bbm8%b)k-thl+?1qo3=>nRuuTGQ^Qh8WVe7fz+<$q_8- z9m@{|@Z-ORfaP%AFY5=#wc1fES^MhZ8{?E^MX?w*JsAqiVIKMslOyrEhvrfc=lcR$F3WRN9)-KF~52>U* zd)o;mrxRG(bkfK#!-i2aq?Mo(gV3$g)>}GmueF){C}>r(6J{$Gf|ekSBM=i2Av8lx z^e|;{I}&K0Zh6d^|4rmZ82HYmnsc@8^Ok-6+|^SxsL?*CphEOfiLsdcRw_+DS3S+?8U1qn5ZiXEhcPwe+ZxhsL%p((sd3pfN%Hp9UK zzW90`%n4@tW371UqX7&gpI@{XYM)Kf)FwdM15VZL`$i~;hc;1DV9N`LW&c`(A)|a- z7T)<~53nz8N2{GNejH7{4bW~DDnp^jYOO$!NWj=45hl)Bf)v20z){fyMeCiR1c<9{3k zq%?j~_6B?+H?aCInY;)dKT5fVND-Qst`eb72Yn99Q9_poKddWV2;lJ__duC{%|bnQ z`wp(=efB->4Z{Vb!x;`ED#F8q`@EknMTC9db9^373t#y$3+ZS9^U~sFCS+TrHWjiA zGnWP->n=fy*-y>np<*m)e)Y`iG+o_u9e)UR9d=jkJ&gg&3#3Ypp^(cb@6s&yPvU&t zTe@%U!A8Z>*2rA2Q5xcdkVE}n!}HukMd?dQrJ#R)MlL178X0Rt3FrJUcvJl2w-{#k zvPH0WUX>`tH@=fk-0hdRBxmz|s^>ZW7(kbX5h)%4ti_LP+Db^suA=|&Wf;B|UnYSh zQ~e-T!WQx%&$u6Vnx3vkLgeqcuW0d2VEDO5n@6=<1<{%9 zuh80WP01Gp-vS{QUGNylUG2T}U_me>1jDtNU6Fwe|OO~lTH_O(@0DS_=ZA62NkhG}~cE%k( zCBQzmy@Kz^EdKFF}yc1abkrfEqs4j(V@>E6_@fIVPFI2ekPh1E=XQ*ie zvx16YU~ph509w@af*dzPEz~)=Qfw4JO7Rs#ThQaeJ2D->;0slD4PQ2?e zbhk69E|vRgQLGYPcM8YYG&+XMmU1GvjhPKhC|IGc;T&5%Jr`1fl9-2AxF{)K(HDO0 z9w^v=LzPmQ?6Q}G2@f0F5R+jsKeDkR!IhcilfzaIPd*n<4`an4zI1;vnLYX5ySojt ztPiCO2mE9?*j3&+t0=f`M`L~u-d;=0RXRtSc_xliUO z42YcHSP0fE7KWak1-W>^IVoBe`jMsN%X?7398OyU>T_t9&tWa(Z%eW@-NwlBgzTmH z<@`J%kXE7(eLBoMl**P|mQ_beTL1y#pnXo!`WZss4-`RIork-9Q8ybH1p`}mU_gzNQ-wOJlVlPVl`mN6p@$a5y7#@uFj7&EMeL4)6QZ!|V7XSr#;B!G!wi_miu^xfFWZ8d^y1^lH7 z@i;(QR_Z?Rp)zH~5yc)2ChnXPm=Jho3cN%rsKk(A6*>);cny>V-a(vaXt!oFOITrujiohA0>c8& zC}Kf0>EdCIL$u*iU5eczVn>r!jA_^u2#3!F-0@Nk?x@*f*>`D?=91DozwMNKAnR9* zgeU6g8TKJY#2BJTws=!6Isa12P4LmS@f#kGL?d^;+VnbFDokA$Htd*^oh>S7-(TU? z-F0ymaS--ufYu)vUGwWO)qSqtta(+A%SkRIBdY$Xu4PzS9GVg!sdnZelw~;&)dmz7 zS*S=4o}ufpzBV)zwcaRl><0#9Dexyog~BNJ~@2vbMCZ#3JR51A!$P` z&9*ZH;ho~s=702d7;Kp=|jTeGvVATmppq*P;svRZeW!`sC(~h(7%j~#c;7} ziH6i5cnYK38m$P?XUC`njC3@bm6m(!2?2*8rn03Fz}eu8&fU(b z>!1NeBB4iRvY0qF{AE8qbOfv2|57acVNVW*GNAU}UtZoVCw(qZtIaka#y){eO-|j@ z!?F#eX(UC)EkK=tExj?596nY;6%mg2K%&%N3;FEmhTA=41!0f|lJL6ZIudi9Wsp!` zrd3guOcbo)|2=qsp0Y9sJv9rr=kuY@i2}o>>I2k3%97^Jw#n|znYQU& z_hkHgkJi(FGoaA;`g@&+o#P1^qK1GG|DC$K;~o>;nP6t4z!8&N1t=a94qS8EtgA8T&7feMC;w4B=R>jZgjl>y=tH9A>;3;z2_J?h)ZyTRVIuv#4VGF1)Nv=v&qf!&6Ot37oq!$r0QjIFY_s|KN z-I9GsGp=bw6QNxYWY-ym6wA#y1+NUOEja*7RSnAfkLEi8ru(+ zA4t(`(?z?C#p{|^fIFDS6NuotM6!bB@^#$jymgwo_in^mZ~&TZ(K5ZRIQ@j-Ycx4- zb~1d@#!d=ucteQkPaqxW7* z{4ZNx1NU;Y^V!UMzVptMj#scYghbyHro+R&yJ5oWwCahKMa)^+cbzU>><0Wf=qMSO z>Y$J~JvlP<+=40=q3U|^Id^jyoJ(6SKRj6HSGM!im)u*YIXS;E^ga!|TDCiUmWQd~ z&0V*yRW8vR2@2=ApZ20X^F3Z1JChp6g~=R^szOkUPmT3AE$W;@VtutPQ~M%G z$?Vq(tEvjg^H+#&BGd9P{J7>lZaxwp-+n7s{L=KcKVKu*YR&;mD4+xQhlzR&yEY!} zB;4{E@V)eQAmaOA8&ifUTg&;sL_P8`kI`o+3>}ek5KK~ejL@h)wAVIX%4;s(o3}cg zhUIFm>TEq=1^rw$3|V;7c+6;m0C&EX^^?5D;b!(4JbBq_nOcaYuUW1r-8N!f9udtL z=_9I#NIqaFuaB~xOQeM%PeF7Y$)qB0xtfr4q*q@UB2^O5faUoxl}vNZejZ=W2`!+7 z8WgoThC}J00uX)>jA`*AO^+LG$KmQtU&NBe;#^+sC3o5V62-tvZ3$7GVL?hT2-Wog zm)CBO?DyVdYDBJXZM1w;A=7s(n4gRro+yVg5NB$QL9_qlw;}3d# zwR@SFU)x<*VI2j>haiXhW5B8!8Y__S?EylCuE?S<)6>ehA`u|v4py>FG*GE(VGzr~ z!J8d>O;72JgN&h20}oaT4usrch{2^!$P{=sjsI=-#66#LcIELfakW)GmR_8g@bpXn z^M&a1bNhRm?&jN@v`5*R*u-njneFpopMN{G2eQrx44hbO$7ek_-~ET?i$N32+$LpZ zw}7yp7vT&gmBK0I$>pO7DPHfR2;EA?aDSuFaWP(xp5@3rQhLwznI85s-Rji*@uZJO z4NvN^?3Zf~l7}KG7{zZU7b{-K9w!bQSiOA7+U`TVfuR_@SFhjx^nnFw?kZihNXZIb z!_s=}+&{Q_+}%&a?43G8l4*AAAo6{~0)@WNbj$3U6!`pKR1*|a$C%4;TFH}5NNHqOwY(BGS z{@sH}r*j@}t;;f0JBQr$_O_0PZy0&52kb$N9?+N+w4@$b7}^h$aA`_;*29xZx};sV z+a<{@DXc03N7M5omi{ahvYoUVtX7Tj?d#Gh)@Gc? z<)z!l{_>nb8lcUG)Gu;8!N{iV*SS584E^E3N156~(Y_h9$m*AqEzWW26% z9ZxiA1U&g6Zky?!6FrY)73QZajyX+=R(eUp6OdH*T{AtZpnsfXuzra`(aRH=On*Mm zxc#K^x|Tm5Crav&o4)4FbB_q6(Yz;E1%96=f<&l%%>8 zb0!z$Ol1D$${XX#g|^1^{AFj@*D~~^$hj`bIJ=`RKBhaEc+U9Uw|%^_zYb-@i@QOa z1B~`1x-DnJmN4~wPlGNZh+0pwWtGdtz=FSQ`qdsYV>Y;Orw6L@rRz&-@kE-&w*FG6 z6SAVcCK0!SQu3!=uD_Rg9qhMkx;lQDMx$edlASGpTNU%c^1MNF+GOeN-dxUOI3)oAB?qqtAo4hWbm@;KzXB40>9qosa-7vhR}OPV zoNCW~P3y9~q=()c8&>e6wN}CND@WVID#Q%XeMSvt9BdzO2SeLhUuTGw@#qu96Kpnu z8qZmewR2wQ2=N=OP!up#WtN{i$>?l6Nwt~;m8zH;G6zDklrtNIRX&5%sNj7`*7_>a zfA|0V2-wckh=nK9;;K{n6YYV9W?#q^n{sx*v7ppjD*@#;#*gq%u4Jzc#4ZQZPX=%# z6}=e0{d*ew>~=ODLr)2>9fR09i-J_FOVo8onnP?6w&|PrkmDj?@NnEhj*)vyjrY)B z^us>uujhXRrj6ad+#o}jj0G2%B4DWnvcM+O0@3D?qd_vF1&JrWQ-CT8qz*%6{+-kH zeNexw*>!%!#A88g#}KQ(2S^m7@?%xHqJ$&gP#^^&N6BG9?yAGW9q8RRBSql?TZJEw ze{hkP6bf4Gd1FD2uk^$5g~)3|rqv@G+%!MXPcU-Sc~(*tF32PZKV)_u2hFGpX& zvV*`cObZ#tsHX)gI_y%3N}&b<@otKubRda{py#Dr7qNJ9f#hX%HVHd$EHSz2AYw;% zAgN@X%(yVUGqv_&Fl?AiY>cY9f&0 zpN$wlpF>?Hm~?gP+K+^+!IiXKhw@>V$zPmgmoX z$zML7K`^i)1!(Em_dR2fQ^QkGI^b!jLPbE+j&dd5u-l+r_U@7U7e1}FPwXThlrpY^ zQ?kGhzz@Pyps6=vIEDCbooAj9XA+@6;ar&DbR8jHTDr+m)cw#4;3rj0q11`*dXh92 z@%xJ<@%*_i4e5u&HiRpHpl8@4G!Bcd)Y?TRQc099Z8Vmt0H=>8%FQ+Db2v_YxQ~~- z-nBJhIDB-DksYoPN`|1;Yu`%JqlG@F(0kN2qf{O4!Jn%J-qPQKB)Q7_oWTDAe#l5v0S>_StX z4LvYd3=x;Eeejp`kjlb=DZn&Qku&U=5Dj6gsndQte(8}$4tO+Q9EG-etyofyvdvwr zbL)eAVSOO?L?x_)>ubB+n5}0>0l3v~AyovbuR-lr9Y65j)L?HF-_jij$}h+s#5&poU~H z`zO`qxtgueT}F!k_;2jY%Y_#j`E=ngs6ewW7byW;O-IX9(d>ki=%~4*h2O{ zhlfQzd(AGB7(eibF@QytM3~_bHcAE4pAx-T4RL{aQw5)c2y_9FGUZ{<#|bKH9^2z# zgyoEQZRL$1haKg@;*m~RTPAsi&qv;Xl37*PP$`09h$>KP5Y@yl$!>F zE!yoJFLiB%itUqDDw%@d1cSdw$3(*D`^V@9Pavh?NVKxfzUNZ+cCH{$ zz8n+@XPBA4#lqcb!87^em&r=WrINK5ojMM4MBX?h%vgje{m$ec4?7?iuN~}E2wezE zE4VNQaUO3_B7s^LFk%NW+ROpA9&|LPU@^>f&aBk_p}?}H(w7d^@76w4Nio9?qGY=srPba|Jl4jl&2Wj& zrr*z;t|CJ{8x5Bd9+cc5&?B>mK2!)7m`*4znk}IATL9w=>0dTq^Bh&l&{CF3JSyJG zNIoHbVO?a3-S*MEHv5sH5@S@xJd4tmF<6 zNfu{twFLeoEN50RrxOwU2_`AK!Pn>e;IAMlb?xM=@1jyhR5%%1sDuSt!7NBYenddV z7xM+F9E=yWo-8o#2OhQ*+bPaehla<=h{I{HRnM==^#SK2u8pW>B|G12=2omTvJ7SLb@LM%ew^B5C9#XqS_^u9Yb}+ zsn(DiaB1kb_OB-70_U>gjx@>8%>TwULMGHj@z9C+BjN+0^oXjmq3#`6ehz_pv*DQS zF_EI#^*|waI4%VPiA1=Tjs9fjw*PgkVO-A#Kb)BA7Q;@LSC2KDc`9Ol%;FwdacWaUnDNBZ7-(fAZ9qzs}ToXiM2AHa2|HC+i?E3%c(ZsVy`2CLsU zcf^WryTmB((8z0ffwvuE-(3rtl!8Lv8J~)TiUOLJHk+LrWc|ZdSm#}2q;uTZe;mL- zM6T3Z;_po$pR2LGAjnAh6J)TQneRSlJYoDwBRBsT$3x)&9fVNKvVrYNSpvjpWlQsI z3>QDS5ru6h#aPYqTI9SgP~m z#{#f{WIX=xco}z(2b@l}yXmt?msfP_g%i93h21rndp%9E5L>YA!G`x9+gMyu8j6mZ zY7nJj)k)$DOMCL2n;EM~FbL6RCxF@_2rB%Ig*zZ=G;OZf%zzjJ(SPuF@10B$IptnZ zn!#^YN?jxmyvt@>H*)hd3tNjIsMGpcF<61iSQ`7JYjCw5E6VBlB7m?J7X4DXH#Ozd z%1<i8g60}yHV(#)eOm7RQ)+jdC$ z`oH^PHa9)f2n{@F3P2jGP~`#5LX_&EimV~yWvcNkaz^N-RQS=fGx(V|c5d zzY-S83qA}9g_|T96;B`TN+PX3$zn+PorUK|EoyI_>fLZJDs{^VDjQIyUD)zQ2+SGw z^#^A>7aWz4qABb(*^da!^a@ffP?kCGU6Z=RMc!=}Xp+oJ{-*6IDI3Eps>Rn~)k0*T zc}PO#F7``AML_c6agLF-#`!)nTx71-AB0rDVioFUJg{X_wD=?AMabLS!OY{H+OV@P z#!ZX5lJjqC*l6-{q*!`uU^zXaQn#aR)yKve`NzYL7W3$&ywew#Etyn-fr(9}&N zzfCY+PLxCIF89@*@1mz*Wk+hZH#i(~I}z~qy+%5A%YHalRd`{oP{O^VsuQ(C#-n4t zwa|N4CUv>A=(0AIcwjsGLkPOC16ptw^Cu+$De|em-^F8ux8>Z-+Gu#RC%2>YJ8bN2 zU1M+y_jeQI<>low5ASG`dG<%h&*cp`=>qq3)z@&EA8pc8W9Ehtbo8a(&Wss`AF{3^UYC{= zIQ=Wk#nx*N=~xsD1Qzb9_gI}K)v!#%q@{H>U~U-dwI5j9S?j!I3i`}>vc;E_&N2PP zWYhKAJdu=rZHC$wsP%b;>$vWlSwWoMC-1X}RIx(S3XJru$n>=-D|1nwo(nCsdJ>Or znl?>GV_3$(TDkP|Bb zFv{k{?5g;C|;JEbW>6{nj=;d;7hosR+<7QZ*N3oFs}K?fNe1Jz)()m{Ii8* z6fE6}lI?!iMmxWe=8)DpQAjt43mfxm*w7lxtE3G^v4ws}k+Chwf^ubrk_0}ar2xR*vgfERsXZ-tCh3ajhw=6? z`_tn_(7-ZbtBr>Td@a^l4 z-WS29Y)c?N%=wAV&aR7ioHjZgrbumG!0o37tom#v7oH;sq@%p6Y+oi30S3V;N((apE>9{9EcLe$Qh zwei-r65$>AH-BBktg9#;;m>HynCbll!sp1)(9n~khUwB;U!OwS3s8T(q9U+wKFHxu ztIF=OEU}7rl4kd17g%b5(wK9kA22BTo0QS)On*q$=&t<=T3ruiS&YJq=n%~AF}c=e%s!aKS9eU(NOGXZ^`8^V4Jh25(4SEs(NI6J=FXjCG*-1 zx8yT#bglIpKOsoXw}K`0!Z> zBY4{?h9%KIbOD`zt|Gj7vfh3Y{^fKfC=>W=d&b*+j9zI?22peZM?2aSF#|273!;5$ zl=5Xd({->=Y5s~+X})@@Y`epy5=#|-`UwT~=V4q?fKcG`Dh*MfzwMU=^vKbqQ}p-s zPXOW#M`AKFW}e6Tm)oU#JS3><$ejC@^`wKN9`WRX!b%M^-H8+liD$yjCY zq=cF>p_e){;a88)jeQ2M-Sst-3|O0jJ8IXM5_zdCSD+)tQH?jI&Qr3+@B26p34Aoz zCL5RYwf)s|jvlL7{te@m_6%sl&jFU+rsW}%&$ok9PC?JUO?ju&kGC9oK(vbA$i7c0I3- zX|YS$T15K`SgxholA2?+_kHNqYeM7C3DwA+{}nM8I?Y zEiHidvV5`f{x?=OQHu^FyU%>rE<~%As)@9-%f2ao0IV8OXuHi)DFrng{6)@8BA?@% zi|h1a^0fbOI-;-)v3y<)#n;PqWc~!*?ZEuKGzDJ{sedPp(ZQU#+UA>cYp(tyh@eh zdz6#!ZOW^{N7PkBd-nJ{V=fK}MTG`Lt;3%z;yf(NZf^_E!shDyTS*PzgCw1as|J1- zwQ{HwF`Z;5Z?w07iasc$()I_jp>dW^1f?Czkw`NY5L1CHj?sGjhLrGb|AAelNM7|> zbkF&@dBp$S8zIOx;2wQ?y9~e$5V$UtH1PFVxL+)%k{K}2K)L z##rCAHO|g?_kg(W9e8@f#n@f6-k1QrB{Q;(2MdK*l=4n!t5mXps$CMI$C!{P&=LgD zE`QMTK2G?_j4RXX^Vy$~=Y8%Z+7raOIauKOGmp`}yz^2jbdg8&sKxC?f32rcb?w~; zJM0<9r=huo{F>#D3RqW~6y7JkHxkau+NLD?1taQO$8lA>4O!eaK#t#|lxY8@tax`_ z!T|UK-jtI$Crpkngla0)Qn%3rAnd$Xar(YA$Y*_&`DNJpVV~FSeIcfhRX{9|Hsf)| z$7&4TY(pGfH-_{~)cIz(9f>4z0-8lVCow#$uqq64>!vc9zy%=TnwV9KBt`UDEiAwSz- zfW+YR3fWyICQ--iPU#3@%j0))8lCPnY<*mP+V2tegG{u<-*kxo&m780gSB zAkvPJkYMN_E9Cp5Q7*H1?INHQdKG|+jqv2{>*#0n`Xmp%gthzo{_}>lmZ^h_rFcCm zCZ_9X`7}A4G$9klNmK(#dY zeKyaOyxn1Ivqio$uY%1I_Q)S(02Bg|p?SK}6Pbq53<}BQ!8%Q{dwtKP9|i1u{fM05 z;zJW>Ui{!9^bOh=j-Z4d`Y?+!J;8FK5qpQ(Fa=l0wp+DL(fhX^tV#3Vq*ojT!H@|( zU;}96hWtj|etns8-pEkczJ}jz)T41L^@$kVM5a-L)pg6$rJuY{Um;DGku_wCJt<#j zmg=W3FC1HCcg8*pGX|b-FXPf90n_-3kiQ{g2!FKtVgg4Nm>e3LY4Q?kB2gO1UgRE4 z(6IAmLwm3W<_sO;VN|V(#fH<~PI0uU63t_OH&_p`m)kdA)TrU()cWtQ(Cv<^_SI8a zbMdd>VjSbHckPSAst9U-BHyIQbYCh6+a6qazPUgW4$m+mOk7;%01WVLe2YQ6rN{I# z;h9kK97>Q0?-^;3QVGD&^GN(J5a&M|0OiUwR${Z}lB1RWmhjLPWJ>INv}&FGa60S$ zsm<*YOVW^tpwV$0TdV|+BRqb`=4I#%ayj1&J3U~cwB192~IrV@e!V^JjZfwlvxYUx@9@JLDb2&bo z%}Q+vea!mSL4~VFDAf6+pyh&9w9UIbWDyj|i*9$b>e@Vvr6iIxTuK!0DS+j}|9I2u zEQvLsh#PFck--DAKUEPOOzAaj{J@r-N`L(TG^=z1EQ=6KDobFdmZD1@#i(yqTRKi7 z-}ua2OonN51?n#;=kLQZywI?Dtef+SXIn0B1i<;Ug6dEq3UGP?mtMraCquLRPN~zr zI0s}Vfu%B6d1AW>X%Hymg-#KTUt86RqiKb28hwXs)rC;i?F^jMDL+Wv@%02pB{sBO z;^~vrv}k?HBOG1cJ!p9_jPLtOz>{W461L9dORE{!>RdEHT_*;+32TFGd)V47nMcl% z3ws9RtIa{}&KF%Lx3<`f=q@b2s8dB!0Y935zrs*qLvNO&tQc3)#9&o$Ak5HlU=#XQ zF(gy@!hj6ADpshasHC<*t1@+Vjh#=)o_WWJXaZ>!6$i}`78_^cam328j25Xt$$6-< zkC{S~wFjq1N&w;bAY&}<eFU|mBKt)XKcwZfhB$g@jYMZ%j6=;bxae8 z=G;p|MYAbIA|VdzWbresNYpBC_1cE@`{0r5CH!TpqZ^^!$UQGcck{gXiEx*tH7l^$ zG$>N7gGr<8LXFlFdQB9(f+XxRf$$A@}h&kFp2-8CQT@c1-!sFbYJ_2FB+w5&-lS}4qe?x#%+_U zxS})U9E{-)zxM(;I#fHX#F6xMh3Em(i-pv7JG;l&iVFMxILFwEkLPu{MXRZ8a@w^% zI2n1qH)y1==v#Qe5n=ex$Dg}D>^H)JgG_|JNHf*jgO!jf(1WnabIGdIe#$1lk&Z|v zim%^?{o$ILXH^}Ar+U78)rHV8Ll(L+A}IyH^Z+$Y$OR$GZnOp}z6q{q{9g5DGRg?#elLyE-BL-y<^?ZO&^ z4Wnl5qF!PE3z5(c)K6%OhO|jX%|uQAQqGP~5(PE;wrep_doMeH?XsdIR!ifE-!`O1 z`NRjU6HELX1Ne6p<8U((mk3z?dvsY!v!D4XTm&!RYT1{fM@o>%FWMLE z>XQ%f<6;#G3?V~cmK9a@7KO}eRC^JH5n(EsHMrfHnxHn^uKhlNg*p?P)XGB6M-_pz z&Dq@Ho4kymlmr??2#~>-yJ(U5)9Ch!v3^s`5`qVAe~uHx!KyPd(F#dmCeK=P2jIgPj}>^xBD4|* z{jUUn#gSEJGLIBAVX?Oz{2Hh)tWg^+$6vWTyI(F!Kx}Nlm#pVF9S=zQ8bC~$VUP+r zHxoFC*4f)AzGxdxp=QTIK!C$RE6#gf-Jre8vI6-$mWuhAS0!0G| z{QyEieI=Xt&#SD4Yudb5paeZ&bN#tqhgYuKZ04=WAE!1)1Au<--vE%ieAT8i$ZPu) zw7{Eu&{p4^V?913^t|`P%>G;ZmRS0h4$9p>Wb{yDf? zpU58rhk8bBVe^ivY5>2IWj`lS>hK3rSJ)1YC?CTy< zou_ur32YHSSy^D__HLwXCT34g&s^L<*3?Y}J)VvGJlZ(Sj;8#jQMJa?I&rYMnRy2f z%u%zaxI>kKFKOIRfxJI9ci_45eKcqcEZIsRtaVt159(V+@Yvv+l~kA9uNr{C#*%P^ z#F14s-lUvior994`fz<9_D*{N&Tau0B>ljtpK{r8g|S=u_{$c63juuHP>poEfzWmR z7Ncf@D-?&rH2dnv74};6`zy z{06WY_(i)P?1&z6>Tc|C4Gcpm3DKsOs)5$+@#>&-0I%YC0C>FlGs@Z3g|I6N zeu`N4y2JeKWbT%j@VgJB0Dz$YE*7}xBNU%88%v1XW=v>W&k0y!0du6qwG9PN(*{wH z4gu4G$+#EF;hN{wcI^25GtG-Jr8r-o&BN*VDJl)6kD{trtrxs=f!XAlQ;~!~iGY+| zWi{{B)bKF3Pq9QlJ^Z(Nf%lH-fM5Co`8_@XG_oL<0M)m!!x~+a)c2(8Zt?rhO#wPD zsQNyNIOxMRS6h4-pOg#DDWKU&@nb^oMSH7RmhDLjjUNW`ObsB(c*)>)nZ{kg%|+oW zZ>$EhF@?n7(H}+pn=b$`bo~NMz@B%v(gCi){xQp9%E6<=Qv@-=$AR>=kWT6kzOD+}%4wScA6%i=b&Fb-uatYcR>_iwi%y5Re<1fp} z+dPKW)1baiyRhWXXgYo}|6P1`T=#+3KLAY1aZdan_#N~25L{Y580zhj#1EK=f8`6X zFSQngl^sG}wQsITPD-T*VZ}UJs74C=LmmaC6K1$21%9XT6^A46^Sb-b%sIC0hZ=t~f@AahYTFG6gz9IvH~-5`$Wt<1@Y77^ zujf@b&vT0Vr%bj!0PlQof32xG z&gTt7MD7$#BS;n!JLtk9l+mxwn2s~AQEw+snO*?0nIJx5&}r_Mp9o=%g3r!QzkMEA z6_CUFavv%!!U=yCC#qeAo!x}!aayvX^C*Uaf7SAyW%3nBJ9UApM0h>C?1K?~k6Yre zzIQ?B^e#-f*lIZb(E;L)*0fdtzhPkZG$w8X+spjZqC`H7-U654Plq#p%ooKrKQ?RgZOtKY!U1A|WM zm?s_}K|MITlZvZVlhq`j)D!nY>l8740wVkYgL=J#7}Qv{d)T0npL3c27sQg*R&gJ+ zzB@UtE*F`YQ|Pe1B1YVe4nEP~oY(ey+|CbIo_F^IHh^$AEARu8BxQcF5N3z{Shec3 z9_g5nU@Iw<#VIt%#bAQaD0_zNG3LQ(B5%+;ob@Urc_xw|!@uW|2t5D(BRX!!g-ZUP zo4Jo-M@pZ;p9Z|sf<86|B1|P0@aCus+LB}$0Et4zA1!DQO+z0v6Qfjv0XzLi)EL2u zMtgJuN>=r*PqNY-^U?%a?1YL^y8Pz{@E!rOZtNGJjbc!0!20p)ENBu_i@fXb4!04l zPrJ^A7u-g&--Y_L;swI%-%emjN}%&A00tLCbnh<2d37t$^=16OB?0^`_XU+Ore=iH zuZA60GPfIZY<~0)Df}fg0!8#Rv$cMVE0U4pcT}2H!CdD-R+Z(Qgu#t&N8lLu!F1_| zJT#q0-mi07T&l2CT>T#-b>)sOf8eqQB1`b)wvy})031(1Ld3I0-Xp_z3mI@>D^*AQ zIq0wi`f7qCQaz-jDRO?j*A4`C|Y+>r#WqZRMG*F zU7YO#MRy_~#hbC#omeCB?1!&PGG3xoWiP2X`WRtF0sB*&Z-4^ST$OJ7XDN-M3$4?n6zocPM>Unaz+-TZ zNUH;Mc{dq12J;K96K<~yG&FC)V6N`3aM}I-oXV`v4>(FXMX{bI?}LW+tVV+YK^d`UDFx>l`zPm($sI(rUO13gvntigQ}qUD$I zldt)Bhdl5~0Ix1%w02ZTbW;p2y*yE%_JZntsy%^oO`zXZ>Own1FJh^5W((9A5u5X;T7Rm1?)&34xnp zy#}Sdoy7P)Ws;L^JQKAS$TOJ8Ldv3BbPW*B7dtWF0RUm~0}`%KNgboKID5i4m3Q2W z;mhPB7i98R0>cBaft^t92LWg8Ma10nfm!Zh6qX_7&szc65{VMI6VC`V^8!04mdbyb za1~t1616;0x4Da3EDOL#hzrX_3hGAh)S+ulV=j`#UOaG2za?k`g%GyoCjNtP4*Ca} zUt)OMjxa(mK|wF3Bv6K2t#ycb9Ea;jk3)Sc<~@5z9k?_&t)MO=u`kX+Z_TRj2kwhx z$@e}NUJPLflF1~m2t7Y(;uZmJJH{Bk+6Qw^WE7wYQ`QkG^V|uG?i_Zqi{}jps<8vr zC?IZ}9FxokRsPQ?HGAuOD47*zDD?H$#oP&=4!(F>0PlhGYv|H+ta+QtVCIfGwYF|iK4{{k3IIV7`eSyHFp$_d~hSByA=Uvf>^ z*Kn(0y%U%mO$GrxCVcjb^_H3(*nl4WEgom0_=s|fn;#Pb2}6|3HX2{U?*`^R5@88P z7{}v%+h`q2R4vb-BcLeL1whDwF)CrzWAQaTx?dzJYlbJp-!NmtFB4=T#tv}QDqj|A z&+FkhnTfKjnaI|9`;L1el)MRmm#Yw~T_OIV5y_CI`Wle%^7jZa=B9e0IkBzfxR%-H zosJ<{sw~svn!-ROw#EH%EQ)1M1gFd>_Q}wvaJE=?+dOR~auVfP)zfxrC$-*F-B7na z^}q3A`X2a03C#M5hT^0zg9DB~e^ML|*xda>01ro19i)%5XNJp!?-cNYko=fqo zOW_&7Fm{~=d5=#P_ zv))2`+nL9HH;&k|UmhZ)J(`+wZPTSvZkkI^~=Qt(7ybqqtopk-(2f z1V{s1ck#Dpe~Xbd5(n*;I#vfJpm<@|jVN^?%fsm)YGzLt8CH-F+)38nZ*v2Aba4X8 zGVwAH3Md;%|FW?{Og-^_am)dn zJeReHLs&5>fJ5z68-nV>L^nKl%%;Rv12rWOlSySDdFl^0>VlK~zK`Z9%Pm_56mO)` zi=casa{+-D$=^`*6YYL+U%h{jhFN%5=!3R|5oF%N&#F4_=rPsA7$4Dwp*T6jfXM@5 z?Fw`9Q4g-yJC#yD3*x*gyhqB>PKQJ8MH#7r=*!wXhLDarsOue;3BnM6`+Qan>n1Wf z1E}y`KG%J8#qu-8^QUj}OMB&Wyv^G-z}LJK?jUl%RnPVb{n`@@X+N}KJEF$z+LZ7) z8r|Zr*U#q=(*BDGl(_<_J$L@qP*PMwXImc9;E%6SKKTZp6!I{8Zo0I&LN@EgX#p^TUtC}8vF;NQ)DyBWUQ zW1|alsNS9DUvcS_@FeJ8B49fL2Jb199M%ZJp_w|G* zC26ye^K>7_GfbTZ1m0WCJ>{~rbbNv4@I0{^tS0DVRkkkZ+YoNsMvz{G>r0&VK+3Y8 zAvH5=mU~N(CV7u|`(Pr4mfR+JyLROTWxVHW*qc_tL4H&J)Cs^QN|gMwJm=yIF^ zr18+r$A#QQa|u){SUFDzwj05)4JhSvgfJmRB)4~#d#~WU-k|6kHH~aPQUW`!L2vQ? zC93=0kmGM>#V4!FDmRnR8XYFONbScJDa3xm*&x4+20HO^p6G42|M0L8UH>=(6z<(Yn1hc;{G(h z4gGL~l1v(XXumiON*T*#3V8cFbbArLugdpX1~Zl2^C@~SxiTu6(6w3aH54Bg5#k|A+20CES-h#VzbO+OVY zx{QbSbaG=rvu^<`^JSX-!>Z4-bnWf+^M@O{k$lja3$6DRa>7WutYFqqG~m zWK?tDM5_NFo;T(pd8qOjX$#~pCBxT~>20W{?PnD}Z|unV#Iz0Wc1>SoiKz@no*(DI zKI)^k>14SD6;2l%g(XmU#sMPVAA#qJy)Y>>W%NRei+;@;pviGOBrtff;d;R!Lx31m zE`QPFAr2R+q1hq$-*(F#K;Nd9ZTVa)0d?8kFh-8MMDg58Pl|V~jR^&4zOQ$_ftF1^ zIBjjHV}ghh|DU&g5sezifB_7M)v^rW0wB-(pyXL0;#zqbuO5ydy!c6>QC4~rfh9iA~;+Nr4t^7a9>;bmF2RwF*FLZ*csZ{SGWm{MbcACa~#&u(}DXEf$ zFVlX9z@^<&ytDbysDivz3r;hri>fR$YHA#iBO0-SRF$4I{cD2X8BovwUWVkmp`kN; zL#K}fOlB-VKw=RDfiTCtrI|!`1IxF;XgOc3X&EO8bdY9tQIuMR-n=%(h=aYlzcy|6 zJOAc)&QC*mB-vdG@;)07j&l(l<4V&)3RNG4@c)SpjKRen??phMOPuWvU83(da^Rl^ zLS?>&zw#YT6!0*O(>YJp-Kxg=CkIsN98aMIpP$fonJs_rZzb;jdG>&klx)_I%r7Q7 zEVUN}k)#f&we>{XvimKTucQr1sx5*M=SjgiY&$B#bP%?(<@(Ms+@1Phj)|FvuFBJU~Xh-WK_j-QO*LTIg1I~6qdKe33{f|0mf48#u&Mj30p=bXObZ4r`4$1|aq#-;z5n3w?t7Uym7$D8)1ZE?ulb8MIzEpDk?f}7R+ zX`Qp6?46{x2G9s1`S-4 z?EwG-R}~siy}sk@!zCgr<#fWYap-NC)CZxYJBNPYn*^$ZEd>dTOxK-O+9z)W@k6Dai*xT=64W zPNtVvABcrQt}0$`JEXXK-B$!M7TnkMc_n=tE>sn8RR`j|w9$(@pQOjd0aH)u%jNkt zcneh0`6IJK-2I$WK?M0;nxp>r^#~JK>gn(4^6Fn)Zj9nvs8EUq+^PFY9q8V+2ReKi z%pFZ}8l4BRwU|n}Yu3_O{3(K*&^@pzUaYdz;C!B^GL>YB^$Gbg4ZaeUrHWj5U4TB_uUKJk?u>=+n!SI53fm|s25aKE@|FzbVNwzQ=M-0mP_4ePJ7`Ir+c*(2NCI>)`-l9m`9Z1jC} zw0rcw6>>HAcX$p!nJEA@ItU`4r@SiP1?OwKCAup}whiU&8I_9!Zx%+=H#N$b-hfAu zXMmC~LLx{9to>Dj3V6orC39(6gs$nmcUY@`G?k}yo*XQW;xulo^B>`qEM*ro5$lHy z1wFr(pz=9LanL!6#8^&27QA7|=5g@q;z}s?U__A4*)3<-EcfeE^#$gJi`ug|z z_=Ww@LaClfw+_-NE9k}&G$8)1;O6uMOFoSFXa=3C>$@jQdG9Qz_{m`Z6Q6$Bhy0pF zb1Gsqr+(oFxYV$jB_p=X?vL0F)(0HDq7OgWT&d7{b`uk>xtGImvmp{7-(0pj@-kf& zqHO!oA-|6)oT1Fq)AN@`AG$-*yRNba2REl{`?0m3LzS#bPOFJFvw)bH7{UZIi_VBb zpnZrf`m8ZDeq}7WxaAWYj-;;50b95Ttkd+yNoP$z_a2(RzQfDPCQq2F=iFQIkCJld73*oCM!NR%P z4IKwntoe-{=6TuOmePYRjx9yyymSubo@Ni5*z}*)sJeZ~@COuhL(I<96u?<9tdp-> zMvzp!24=3}uk+@4c`|ml$7o19cJF|Uf21|5ru*#pUP61dBV+HLLGP9(&foiPZTLM8q?@euAFkxsO{o|ukBlmTb*x9nnhGE08nRb6MFJh>G ze4`PzZN6is-?o^uNrBsJh{v^UCbMP8&W(5y;`{|Q$0Ow!v^@H48#pBf1kP9$gQ(4m zG#L35f=xuem!nbd@ubPSsQbD%X!HjPC2O1r;s}opT(8fJZFtPtI1TLtFhtYJu}5UA zLA?SBzIB5FyB89^HNPe2W;O}HF=DIOSrCo3p*RVKVc5|o2on&_9bMhmikb7eRo770 z9`gA#EN9eVbK;pVLx-3e5&M~}qxJ+A2ecnTs|6lo;e%Hf#Yd%~CSY`UQxpUrKXkJj z{|uPuJfK?@ew~gfmz!HxXG_>!q4c!{elN*d*fH0IE?Z^DUBrQ)XN;3)e+JA{q#FGl znDo1LrS{(*GKmaeVmw1iLX#YsOniPlz1&7}lDaC?eYZq0N&WP*P3k(JB2z)!Oh{n0pw=0Uo%TIH6RF7yS;O2d*O`Qs1@MN zp|c(?$sg-o7YYhQ=@SzS?W$YyP29v_YfTa<9s4NB+Cj`Aos^ULrKTK4UY`m7!uf|J zbHlEIiF8jx{gnKf!wDa25{!X_&-oqV0U)y!rUJgqy}eTFM4I;yg|uJtnS2XEX%nFS zZlt4c!NL8$fk{pk*P%YC#+Y;$lkl5azCP}9v?cCtHnq1U2!i1Qh1Z=OC4!FmEi|S)CsoLom$*`E%3cT!gj4Ad@2o(f zYmg0oJbEPl9w0#nKFaHZ!{EQib3-XtUt>!e=s%-t$bH0lUni%%wp% z(topdWPwU~z|SlQ2a!T-@)zAzz|uryjZSIsq#(wV1+RZv=Smu}v)cXFi7~BxIqpMU zHP!LLY;y~iLj$vX0Qk{e#E?Ua<0ZPZ*p1zglv^KKJQK|HF3>=r{pzG}jQ5F5pRJ0( zCj*a{w6FPQPkbkE(x<~FBs_D-Fqp?nGccvsoc~iZIi8MO*qs;nS5Jinb}!Pp0Ygn* z8*Qrk)K89Xf{bQmC$Es}%wVJDWlvr{W$XJykFnp(Sw*P6sjDWDT?4)5&i z67^9HpWQF!V<``gz&vp}0>URh!9Jba;Gp9Haf*Ao0G~1^WY)aJ?y~)uc}iJWYZbH2 zP&H3ry0vcroA-Q4vUG3g-8buDS;fBD{t-0Oqt)@5&ioA>NI>Wk|A3AzjSr7DmOKtA zcD-++Sf?Fk5_B1ppXy;F&b02)UluIA-w)@z1W>&N_}B|(p709~n#z9J4`@E8urPpIu{_Z;r`yojX-- zw*Jxktd18;op&ttnXjbNQv%1C9&AAv;JGtkB$o%}+-0n)cheBJa5<^js7@9IYt{Ye0$qg+9J%pk>MZZ6HGju4j9 zQM{?nTyOBARbrm@=TiR!!N5v&pxL)517+He6?lxNW$p zTV5WG<_8w_Mi$zcn&oUbiQHcv_K&tg(JO|rDR9LnxJF2nUaj%PG{i!3uG>u-)H8bC z3#eCo;^3c7VjR0GL)2-x_sbhSkc4x-0deAxbSI!0jfW2_^Vf?qWuCUf^;K z?-uObv>d=0hFR*6bakhcRDxcbHp=Up{1yG@>idB9OGcm_YaE-LW>>Ad5=|mI!Cy|d zjrrP1@g>4Yl_6XA3nhu~C76df^)r0u4mad~(C>MN^Xdp*kI}iS=Z7d=?FjJwqFg6= zxpFFh!i?L$SSu8hnpVv<%!oJlAR3RgcDy)pFAm~o=v}env?}&MUg~u!@ZbpRGdAQ^ z`gIz&>Gyh{>>D}W{YB~0yxtDzAERNh#G5j*n(5rO183c=*Y5m((_yua!F*>I$@Vu` z025&Wo>BDzC*x^#--@niI9&{S`X=bOKJE&4A$@uGIMm?7HUDv{=E8)uyLs#EKY{V>KVR{?^{Vx*OSwmB5*(6IB+&P=lw{o)C)&9rFn?a?7atz`lf}a z2HrDF;mfJI{5v?>zk$ufs%}Ee7!`=VRi-~k*-M{tjy->ulxhJwZ4lV-mD@4psvAtVtg@vQ6S`&GV(&b1&x&eEOto z<0g<#(${`6vp~fXNYXxbE#`Qo$UljU!y1Gu)viu|21#JB8td>P4_^qmnTxE;r#G@i z0hD#+&w*nzA7pYH_|MZ?V~uy-p-Q^F%M| zv(#usAJ;;-3!@snhfTFK311j{8QzWlQTdw+UI3_y(f&%=Zc_EmyR9&l%S5P{@tNZx zJdIJ<@tadi?x`WFovvi!e>pcjWVdUuyW<&9u{CTiq^(xJ;!d+dt6{CWDF-(7lY~w; z%A@H0MR6X`9Aj??Mm~bXB-R_mxO#vz$aYYH8wKJlZ{B0C61iC;{kXZOeBR@eYafL> znXpN*5r;YFVktGobnSZuH<>Ijd-RKjE5%w!Ry!TM?FWhADce3&S}Dw^+_oL_LEtXl z|5*Q+S>eIL-eB?ye(dGHM>DuAY@XNG=y^yW=pM9qj*d#5qVK}6+mDOXC&0#u0yG@U zJCddO$TxluG!`AHLO`pxMn2G4KGK%o9ob_OvSy`zs#q_EgtXWxQ;0NI8}>B-@qyPs6q1a++?u z=X7v$q?s1acp`jnAGI#pV~1a%mXF`ere+pqW??ovT@rndQhb~}7dxGpnWdzaSmGPN zO3~hRo^Pq+gP%W%H#qB}q?cS08LUCxhx61s`uEzkYmb0`Vc&w6#Oj?|UeDC}>4S1C zN&z{=&u{__kf2|))TFc`%`r}tV?qi&a}u%{?QC4MZW|B%FTBLXftKB;@o2RWeh)!Y z|Joey>HkxXtQx(tOE^LoH-z?f`M!VRPp`J-Jr1R_d+14_Bqc`l3VM)Wqju6`q<6+g zE?(oq%R|KrStJvhV9hs@r1I-E&7|u+vO7`)g`lE{2La8}fe8Q_2LswQ_>;qRDbqc3 z75XOs;K}DtJZB68;^?-{vQ@7b^!V6l9BLYqdKUYB+MXWqFm=E0yq_OUOU|WCNXIiAn8#wjgH3?u z%wz>eO8l7oy!I1z!op-tpz#gQUB-q{hUqVi2`sjj0b>O380#CuZ5!qYfVgS; zmY^62d)y*yiKLvpD{y&c{a8EJ)IyEO>m4O)!~fboC)@(8fU}$hmvrSY4#@}CEO3_z zKX8Ni*opIGhd9VJbeFPy>?|l9R?co}wI_=`xm)FazB!US$8rI;t?31>7pqVry$yuE z)cQQ-KTX}oKaHDHX-N6e%4yQCS;R9=zO9#gRpz+~`4hG*cdgJ~D!o1%Afy7m0m@$( zanu7)#YluBPMpsdCEYoe7DU+!P7TdOM%^`b5Fyt8y$PUB&LeDDdlbS)6kkBrc#u{d zD0_py%|+d`eY=Ee2qK{_hAjU2QUyRn${?YpNtQ^rt(s6wFrqO7G)@|-nnF`bqMVaf zckzp}axISh>G9-kZ}yG*y7-E9*%g4`%TRI(`Dgo$X-R$iVgc(#+ig3q&R1)$a)l*3 zdS}!<*!seJyKZqVIG@sqnP{L$O4_3ZmQ(oI0Hh7b-9g!0KrXRCD$U`DIQ19cYSgak zA7{941HiO>s;;jc98NCx8=E^D*)E{-r4c87N>$hJN`j}BF7v$3pQ{AcmsZtI*E%&H z1R*-;saE>@(pWVl$tJ2S4Lc>OWXpO0P|mgDmtXm|Derb>ORJs?+m{cky*{*cJ=V;p zH_u+2(>*H})_b2%4Yp26ub_?;8?L5!7E)X0bhzvs9W?3@SHzWgD0Sh~{(ISZQM?`! z+{-}#uMq=6Yq{baYofH+@MvZWeDHBFGywY5=zs^+?jB0D!Lek0{=x$vAt`a;vRMNy z*nEHFqfJe26@9cy{B&ZhkjPw7)-EQ^|oFbheCMm{`$v?^VKh$>t{yPc?@V^qSmP6UUlgA_fPr~hn z>>^QBrtNWA<~WVnZVjCx0AvIp6--MOK-vTRz7sOt0_Bg-o7jOVIk;jf7 z2$YeR=U0#ik8GU8;141wC^#ZJ>&kulcloz9wE3k~Wx2gyEVQRzbFTe#seI!Qv#0rG z!xG6_Cv)|r+5NvbIIDJS-P`nuwr7;w8`g8+?tZBrW?9C#60z1Ej-7R8mfZcCz1{@; zEAV^m?QP{C_zkSlmXYY(-R->lma$zemccmFcU2-Lx#PR|D7mf6UE&e8sVxk_q#SFH z@TCeblMIw+oAwKv!7pe(+&q}RlghYz1Y(|EaN5_m?l-t1qI~gTE!__<^D6=@AN!MNp@WsEVw)~APq%WGgN+vrX%(U@ zI8~S$)fNsjQn0#Fk%h;iaC^%-*uM!L;DR=o$huudOk@?J_z= z2J5tts^*3rSQU@21L%6|UVJ@AzahToJ-4b^BHul#Yxy`++vn??%ZHot>}WUB&lx84 zXYI|+_tllk?#c?PY|}7X(QtgGKWADDA7iiHmsdU3i%kZ>gTbAdJ0hNCJC8CJoNcz8 zGPmhIw!~<$mtr!F-$GJOuT-e1xeqmihwVg#7y96$&m3&AmFvqtc~xLa%rCRmu;C$t zQ^*5n;V?r&dwh>|46fVv|4>F`0=-;e)_&70gCIN53m3&SV{Vg;vAqv%f?Z;@qMRY& ze4INglOZ+Tc%ReH{dUYW<|c|%0%;gS{98vHYVx%H%QudcP-l_;IM85U+SSox5x~q* zmOoY0O&{I3&mO=9B@hGot5Ie(>c!RF9`5=rUHKLfui5R5Z)-v~EYJJ5owG}-*^W|S zu(Sn{7v14KJs7mF(|vDfNGPc!W_&WjR5Fean{d&1_dA1?v7cl9!=boN$KJNi-8*e; zdHWZb^Jd~THRCMG*IAWF_ux3UK{HvWNO~@4O_f^V4$U_&x9>pcI7grL)f$&z1J3uZ z^IvKu^hAtSNNL25>Vn`EQBz4UOl%pgV{Q`k5_I0$Q&Y|+KEJ8y3vklpo4>Hfp&Lru zK!$C6U+Xyk34^nzZG9fM?3&~N^xfC53G2$LvO5>kd; zFJFhBfTmFgpf`!e2KdRq%>78f%Y&AYL6jQN7X6Y8jFpj0#TE`EA88?cEG&Tz|8h`f zWMtfjnK2V$#$v55^@!p6vWpo@93#VkF)@owIz0q@4z#Qv(BXRT>|OIc<(Z;!hBg_l3z-HjMAz6de zJt8248HEJ|TYnP+4AqDU1XQp?2D(volRX=JTNTs+9J~UDNIcWC_PHYYf)OLfOHmKL zQ72sEUA`KFDkQOZF^?GXskM!>AAdZx(F5t@K@<}C1cO?7$bF%gSD%AYXpaPcCI_YA zr8LNiaER2f?3Dvcu+p0$>uwp2w%w1$oKMv4Onm&nQ&WOR6KZ$ zKm{!%FWPu`gqWC_yPd=wbera~et*vT9z6Bd>Eizh=)@&#lY#`mJ}NOc$$dXAlzTt2 zc<#c=*ztyg9u-)8Ew72_aBx;YeV)Qr=#`s(Fwk@QI6xcIb_bnDt0n9>ul#$pE7#N@ zJ;T%?uP0Drhl>)zS%?ImrmeYg1|^O58!}RRRu~RUYm0%5p9^U2M$4bF5Kvxd_3Nqc zl;nqIvg5`?Woz0AIV%vVpaT&n}xyyJl zkE`HSXbT*#yz6<6vYN59`|+3Ch_S?z)&QbB%#fBo0Gb*4;=(n3lknN2o^{s^SZPrt z?N;WHu&ACNlD!hcvN*AITf|UQ={yyr$G3*|;!)C1A&q8{v$+e~LzylMVP~Eg6W#UF zpDWf3j{6;E-`LQ_mPTAzN3!imEe@IKxx#^EJyK>C6cO9V%*^w}ne=8k-o2dT-|uL= zjR%w3P?u|qiAh?L-k_%a=>Qly09vo#PtR1of32qKgdY3Oq5ra|_m|Op%hTNZ;`h@g z_AQiEh*a5X^4e*Z+)8@(H5`85ZI6Dg9UOAs=^Vsh!QW0xO3UsC1i}~vk03^?yJKj( zL1=jp>x6AFKyOT$;V@CrCA1|kTYbchEzEG^B^r7c6^$#YYv`!g&tF1+idhDxa>+U0 zFcy(r#jc$%cTf{a`?F`*dPpmx0Q4{S{2k>R2&_ACPS z1=HBU5=qIqGt!lkhozG*3;^-FsMj)j&H7XGJsbp0IPwu7UyBwQ1&_%(l$2OB@=Gum^nQ>v*iRLl7QTJLP-VUO z?Gjcj#lFy&SWu?*JZiMka8+IT>VG4od=AEJPGxm>&II*@{s7=*w6C^%goOFrOl8OPd_=phtcWmiiA_H-&!6&29Bc0IKlQ&V)prRqV_pB$iK3`@@z(;@ ziyk~+4$`^0%M?^uOslNSh>e{~fe8{#E`d3=ZtI`B&zn$t?Ga47?}3j+&jaDNhNw~r zΦ$oT#hkPoR}HROT5=3-wcpor`IBKZW~klD9C%rPH~&|LK4m|%v0mIMk4R+JiI z6+IgS5XZ2$XMcn4f-mbjxt(2zal{oV<=s1Pvik@GHZT*F^06aNiJOvh7u8;!ogTC)SvQ(a8*k?A$J$@=!6mFYeZDtzE*t&T2JjUK7Ne9dT(drqMd zUe%Eq&eW9~_S_Y+F-|wLp%uR-^ZDVIz@SxL@y2 zmcYeBAU#nLkOf+5=PJn$p|0!~!LSQvD0xUQq_acguj9qwBq zgYVp{LIn<-Kw;HJ3%Rk|+7UF{(c-=!_4nv2vMbS!sK6hF1OWHxKK7OGeh)l$$MMo1 zO2><;d;jq8;Kx!7PW8LsX{Wa!eOI!{D``u&*MFk-N2)+bY|J_xD(4gajv$d;Ku%it z68~EPDenZ}K(H!3pLG#$;|-H*(l&fuB-_t%eQS|qvX*6Zl z>#p0R=6*gX_0~HHj&~b=bp!z*RY&8a`TO^~U9mu?W`Rq>#uG+Qasz9_ol$1}$lobuy2RAKz$+D$@ zDa}>PlEju{W8yFDUoc!xjp_-|%|YE7?dA8h+E^-pgk_vI(mW`9MlOHl3S6xW9PrBf z+9l;B1{2c~gaT)wh&)k8g^u4Xk*VTFaaey$8-EbVm^X8OL6EWSPS-Iy=o>A|`B}dN z9h+b|qVpE@skG@qHWO>>IoB?=``8sJ_id&|(q?6d57oVvUu?WcSL<12k!Y)hc3D%{8vey=IoPRmwFdpOQGOG>jzI5qTz z0n=w}{mkm#%-n5F$cgBDuPsd6WF|UV(QFcM(I)AIVCN>~DyRkY(=0CfBME>TbUxurN}srO<1kWh!djnaEs9RVkzI zO~f{2qMXL*knA^$_uF+gZ#Yo5q@XuC!(RI(L%-X0cVh1_7q9C%p_jYvGcdLDKA6tR zW0VWL|Gsj|^^1`*B2wG=-qdFOd&k6P_Gd73R7pnr1BY{YCUH#rhnG_4>rxo}H8C?6 z&L7j7JvG8I-vf#_(Hq^T!Oyt?W%Jsa)rE4YREk*;G}_BLvPt70Dqw--!VJiow#itl zEuPR#6L`hKsV010NQ>7Ru7g#3N-gplrqJpeet{3>(q6bu7@K4p+uG(hfc*#X$=`j* zzODJqhQE%aF`5mBz;B5h;AHdGk^GYx7q@t4Nu}eJNLRWv=bMLFWBYc}azsWU*I^;% z!RJ+N{V~AQdegJZ8|bF3`rg-XqJ6aOzDDlWbDnE!>zBjfZO)AzH`TBg+*sc~qSuEA zrS_{=m{-Gg4r;$6-R)^f6Kn#{k0xqVgU=ejBE$R#rl~yjTZd?2jo(rjDp+!iHwPpbcT_9i^OM`H+S4Grw2(wQaAoT!Tw6 zSa+|WVWY)Z>~?Dr?X~O3o$H17lmqYfcRZJSt+etjmjJ+vvc%Uj3G4UD^!57v(!l2R zIutgh6m#{_pe`ew;Pqm+_&}zXsI?BBjI#Q46e?v>enRLS6Nm^{=*(IQT{vd)1mLSC zz%8!K&Kw>gbVd0x-5t9am2Jalzd#syjxhsKV z=Of&&5X-OXXACaJ?9!pb-xuv>XDpP|uAnX5FN*n>>m$moh|2!~L_oX0!MKIJsy7Cq z>m>mb048wLEfNwb7 zZ~sMic6R`MYrO3}E#5T8pmy&Fa?A{?Y!&DO;A`6x`7939jtoeqA9-v#yBaPIY!bn4 z1r-M|v4c>a=s;*ddp>qxxN?9_zt4}*`44{y)g!;TXxtF>s~y{CdJnz#@Zw+iiJg~S zdTCQ${0)v1aK|IaXYbXA9{c(g_nxn;tTxl5QQrTv~zdS`Hy%hx$!FX3ctay-<-6)@4oGV@gslo z$vgk!8!ZIBVXFX5(}b)&*B@Q`*0&uU{q=QAt8S*MT1ZQD-t`@ciQTmGDA+t$eZ zlJTX3_0r}I&G2a^M6nsF7bdh3V(o}Ad8hMp8)mBy24yqTGP^TWM&RB8yV4KN7Gx?! z?ac&co{9ZJk5xw(4D*Q?;eGv`OJr$IY^5w{)Q`D$TrD#_h`ILo!_tdZ0r{B@w_g%q zM%6<((!6ynAt(Rghj?yyei+%oyr%X}M4}-v(U_)YcF;{%zm=~3$ct!dYQYO>=XWCB zcymSn`SJ2?ANk1QDW3U#hGO~#UBx%d=@(E{GkEgVhu(C>xY#~BT&=6oxTv=6B=?5b zQu*fBliR){X?`Yx@Ii>hfM}?|;=7cwM>4}?VqikZS-Y}rm{CKv96NfW{%J3p;B2Y3HFOrL=xT zhUZZ2tijXs9eERZUPPS0svzzs@Sj=k%EkIqC{s?0(HfO&hZu90=SVkNi-BoX=UT+%}O;a|b z!{eK7-SWuay5!*R&}FXLy(c6!zUvEs-S2OD^r%OTU;KrGKk`wUdG_$|!EsSjZ@hII zHJ|zz6~D8a+|0nRKqjv|q!8)V!dgj6n*bVq!kP*~U#SAt`pl%PZ0IFLCk}>99102RKDZ$$MoaRq%u{qZk zbnS1sv`+ZXLYpTAG%^fQNK9UzabKd40hpKdaEQzTGWb1sHAYrtRH`>M{7QImnRzU6 z6BVE_zYy4)rq6$DHyz&hNg8+|Kuz!P!TsYeetGs+{^GL5$L-zg#@-)5cVz((w)H;B zjrT7%XIy${^s1W=9;&Ct7fXrCa@KK?%Sb`_HN_I{=80 zHFxChs^LvA88oUDxbhz466f8O{0z?#q6D7#mN*m?5u-AhCb9{{@V%nunvYU5UX7c@ zL}cl692%Zw(fVdz!KAT*xx&`wq0Y%sW7E9dg%S zLvD69L4O2J26NfvHF*Y-J4n9ic%SJ3A$gt995QSWiI2J~oH`vUViX*eg1&UF1vG;^qp4to)1vi`+Ujui{Jf;{+1_= zmye7KqOxAtPW8L~hTOaVmfXU&5JTOSio;&fGj7seoyX~~ z6C(;?nt7JCOtKL>x3%VWu|xnAsaG^?!Mq)~H7*kj9aVN(b-$x=@3|po(VJ ziaUR1sKtbRdAlmhT$wm?YySxwfMSJU?`1Jnger0;wdHvyP)6`8-O-++~{cFhG zdJ8p!K@b2sT&DwHrddEumNhBZXCMSH+&4sR`8aLkAk>b&Ob`}kgI?6_YOT>{>cp}{ zNhpQMLIlu(C=n0@QaHnN``OgZAAu@aP;h;@Em=r$9kOPEPC&)GWl)OZc3Zv+_dSi=h%>iuo%7d7Cgf=@=D)PPsUYsC|C=6KP=Q)%=DZIBO&O{a> zew>VC6X*=+q$p=-ap^j`>Z;dLuiQd@g={oDTpc>R<=cPn_YXbEn*zIcf1tce2mtT+ zyUe+IWwp8QN0-O{`{>e<+Mm1Qm;Bs^KS;$tzJqA%0y)2*KZ$&t2f2jU8OEAtfY8Ei zoappHu_B%39JE2eR&vHEWc{Sx@nCRVdAo6X9{;(o96?foxm$LePfd@>ljZbgVZgCQ`vK zCW;O(&Yds#LN%si150kGa&@U8hW6$C4T(dfU0nzxOSOOsf@ak8iT!G$acGMKDCI|F z6vQ{v%G+sFvk|^(t#<-oX|hm|WknGT`CT=m*0H2ot-p7KYzS@X6QSeAp{iP@t3Us$ zz?6k63sJKP~wg-f8B=OSg&J$wlBR;I8F;3m-k3_1!3CC#vR zI(Z<7()iuB2dimn8uYi)ZTtR>ZoTaTbo!}}qv7ySarofT=IYN^&u^OM4bIVG;;~7s zJ-!IQAJ*v`_^US@dDbmc^QZU6!@725L5oMpz3ojz{S=h39*_~j$q#igX_yNmjI;Yr z)Sf1O+~CnF&_)=l#Xw+*%;5aHH6=R7Mp~Gjv~ds61Fah-7M7RHc;5u?K5T8&$zisU z2k-#Te806C!5o9gr7cRRnA=9~gnRmf_>wbI_gOdEzOBQRv*x!W!1Dl!O7u^ywkalu z4hSN2rb%s(G;s>vdR8X~AFD-66;}2iE^!=VF zk2MN7W(XiirnobD@GrNoJY}iwHPim|XUaOzl!VPo?l4b_h4trLm^hDXp_W053Qa zv`q^>ITfLBJ-aTgkjd4W6_+%AP(-pQASsG$Z_9D>;Xxn6vGpW!r<_am?bnm@hqPb@ z9o4w5q$zqVJDFBcp3d&0aRWq(g8Jusxc<+d9istEP|QhGq2CIDN|l^Dz?wanr#+w2 zEF;F|uh0kdxT&evpQC;IKTNmY^6zxYN#8+Lu}I5{Yt5IhT76#AG=J$_J{x*NApn1J zpYPoz*BpJq4b(rk=RN;Luc$|3qQAe)=Sd3ozv5?(qEmKPzVe_8Zr8|Mw!^|ZV!vz6 zt@A$uv0FHz5J+|^U}~#0r#eAJo?(HQh#FPR0%`J7f@UoO!qtmJJS@ccXzpBpdjS(` z(W7fyhNeh51y~K98oKmkP0nX*zV#ZaKlP7+`Qy^A>H$2D6QzgUay|Rqf^WgDU#c8_ zDcT_kh!DsF2q`0mc3lnBrGOy{XI^4VJ_YPYr;RTXJpD_C4x@ zH6;`&%WAyT96db!pm)4u<=eeZJ(eND$4mh1z3eg{^*6Zu;M%he6+PE3{<}(m3;+yOX;|DtSKj1oedWnstTy6on zh{r4GP#9hV27&`Cn*|+99^AT4jfs^RCT?wC&i&`M;~oID913GE{p78^U7)YQ`)PDr z^MW=!BDBKP%l1UaRI*^PzJTd}-x!MGSMFVcbAtMB!}b(df6 z6AK=jCBb7N0It03ve1|pe)+)TZg5jGeX8qvFy)&4w~_nUhlr+UV$hE)Aa?=Kbm92o z-ujM*#jX#cTZvR%p56U{tjG~w2|mWV3TEUvU7yZiBC)<$mW?sMf-;>UB|sm*DUdzi z0Cih%rkzNn=`awO!SSDjT=NJ`U|Pg27;UK&p6JQa>-`-U?@sj7=}pGF-=F+ZaB9-h zvZa#qc?~nW08~94#M9D11apUpRNC2QvRHCN<58*dgTQ&~>Y zjkms!hN}n2QCZa0N?i?S&-|l5I{a<^kv)5kO<>S55dfFbEGymuUZ?PgB2~p|Y&&(VA;+x_0<|{;>~y z;3C=(0w6$v?xzn|%~E~lt*rOwBIN?xL@ItEoa2Az?C1y#)&-Ln5f)GG$w&r}SDt8RJG zJ+Wz;>B}#tYC{NsJ$s@L>^0XdecR2{+t#bcbzKfz;}7$=@>4|q2yfYq1*|3zOQR)D z%8{K#7#0f>9Tsw1wzicy>4PXJ6D1ZWGZQHX$=Eq_-Mjd8*9-}^v=6*xfr*Ye&;riV z?_?+qXob;L2L#gJv>DK5aI5}WY?d8tawl(X&hMIW6NZaSf+z4qSfA51tFq9Gml&*t zHuCg}#E|7lOqSZEpu^R@Et-D|b5-`p-FPK6M{W)oi1@Y6OQaU!IE%J;W0JpJQ&IHk zwu2v`(P)WWQx;XV(kw6b&iw1wEuIq=W4-U~>%6}10`{JNSzwosA00k+sXtZp{Myj; zG!(_RXPM^;+33$7#9&`6bdvRJYKLdQG0oGq)|HT*R@9zI-fswb)=!D*QX*de=M}? z(M*3G>;`*5lpbpu>J?LTWa%1OT)LKeZrW!Js>W+G<&C!vAMcU(o;_bb|9)KsuqU!^ znz<{Nt8W?CH5GO3sNW}d#V3hYS4A@FVFo7T)J!QRK8CjD*#7jmT(Zdmj|0vP(x0tZ zN7&fX$3o7Mpd&v3`w`ST@LOwexWOhBRR<|eP%>tuRCqcN8Zx(y__uM4`F>7l64%)q zFS?69QPGQQ??nMiak&qur4gy%Smu$$fiQz3!Yym_P7%pwLt^g88@X5f8>R$n&y^P5 zHa6Iq=g^+W(WYIq6f%2cp<|Qv3=5Wc~VFeH+A4}=wO;876Ss2W>6b#_KQrdb$KNn#O1tXz~@pYO&7Hli>$ zm)F@-+q|dU!&yIHy?)H2=LJHrZA~Ar0*$~R2VQQ|sCcyu&Ml7b~1ZydTxv;S(j2$8Xi>R_)kc-A) zn9$zVpsQVoV0od8g%scn02O4Gy-z7Bkmp#TmRxtEpm&oa4_R^YNyBdroo)ztSy{m@WWqk@mE_< zr*mKO^3gqi_W9$gm~F)au>F@Blg2cTF>{T{AOt-L*^i^-o=P z@z=-0U!MuEXAk)>jcXTI&Rg~23O)j$*Qe%|8;Mqy$a(u;v?UW#2jQf@aW1={9}t7` z;F@)I#!hLUyF!jho&=-G`H*BNSP6pEPIN*jOvp^BDFK)A%m!F}nJA*{sZae$p}L*v z(U_PNBIb1yzQ=vZUiHZ;0)%N*(c0p&8?Fvr*tcQjl=`kxv}BT3`4}eh8hpxa&HfF$ zWX4{8vzo>ZUd!9IPH+mV?ZYTg?PUf`_0;$exDP5UUBjI$2$Ul@zN(#}@a9Zu~0w1RzL7 zLO6E()P`EK#D?f7(@Hk%kSeS;XPC&8)n#Y3Y`FLj6^AHjTRaN|&L#mn55q5VeEek!b*{K*S?E2y+!I;5v3jwq$v_VHqB(Z`63UWkPN< ztgY~5rl2v^-j-b9Muk)#VV7_G4EZOO;$ZTMJ1eVtjTTpKq_XIfbG2Jt9?__(?$GL0wE)#eku;P0XzBnM%~}m?{hchd{+Eb0igI5rq{>-10mu zNS*SZ{IvssBOtY9^H<#ND|h{FzVVpcct|uF5e?VKjn>E?TFL$Oe}gDs0iO^K^E{)L zC>T(+uA`h3EJEgkef$8-5HEDXb|D{NNE~RK^DY8w^os5J-g`hmP>+m%l1y2A736x; zRMkDIYyWxB9TOmUI77OiRGp*c)!V49s<0YR7DR{kRmIelTZ&-m|2;#{u}BC~41L@$ z2W|K*zl%pg5=vgq%32IFA)pk1`4NelQhD)l%m@(pS*7Q*_HbZJ=MZTUm|)K)9J_QYtWGn}=tGfx`pVj8s=9#q_MQJJGwq zzh@!}C%PtvUJpY>Nk1W`TTB6rzC%a=jMnN%?sq0#P75|6JB}f`JHXhw6AH-Yf1z)l znmptWrdtwlHE&hM?be7>z`G5<~&F4+*oFzRqtQ1^9tHT4-G$Sg# zDM1xgmD@HNHuKQ98vv+GzdUPl&at-1sR z+=6_$Z2h!SR+-Ud4ih|%Ihga-*uV&Wr7$@$({mCeA}8nMO7H-T{1@5>$XtXRnlxq? zn?j-qMTL7_OQTn?13Gipx{29j92WnJ(e5w@Ij>+&yJg2*O$pT50xMFdX%(RN7U~f} zndU21Brw?mGlP!uR;awP0s~8VED>=`z}7OYZ8T&$2?!<$(D5n_s_Vjd)*e}f^!V-K z?Uz_XL3KTh?`P0<0x3x%i5+Qx51Hz;@OiYts(iJn{%#bt*)bMy3 z)pg0FLxXWMOp2^b>)5YcA9pDtVLOMW4)vWeFH3o*C2$~fU*I(zuA5P$Rx?SlNPF@e zDG}&WA8`<@`GT#MIEeN}qIT#5JD&ajE0w{B=RG4UjY_f)7Gsq2h;5v-;J#fR(fD}t z;5g!e&by@W*l7`R;*84~T=cjzgu-AUSavgXD@Ar8H2>LWt@TS@RYUbtpG@`BpG@Ni z+@G5HIiiuzYv&KQa6~?-$+g!~@s4*Az5X(CM;D1^21I_T49=R5b*S*Pc#A1jF(fvw z4TG`o%m*D(({#*~7K9IvRMX*=Zr2%q#;iJAV6I%CGYS>6+^LX)C`aypM-Km#`34m| z?@SDvkm@QhOcr7`URN_|*52*0!uSFO30;SJ2SK8-NqVy*1UU;rt8}u6#_w@%J;MN@ z0^rE>(uI36YCaa%*X;zH6{#P`)2{~LiLhOq>eh|KvRc+?JJ6iS1}qh`W-?BxvZQ8h zmHb|X>UFQC@t?h#>ia){XnB>~Z3oC5JWO=pAh~_FlUrURcji5*+WVVS|MT03&c6>e zM~((0FsXgoXoLCz%3}{FAxoQp(~hDLVY*{YpWw{o3F+7$(L&06mVzQ?wwE=Ek+`cC z0p_y^gvdckYZRo1EN4)P0W&^OCJbFUl}s}1J-nyJ#*SuoOh!a$N4$gi8xT&i!qG8H z2==g3t%qWvTx=T;8poov7c)6%QO}42-05HUbxc+PLZgz$(+dC{^K3SYh;{_YflQO0 zj#QA)guIuNSY&4uXrm70AMW{0ls+_hYKH2+{4*MV?{^d3x}V(IFh*zjCCjoP=kpl! zr@~*qBv#yVfSR+;rs_?9M9sbKLCwKKF~Lcz^9X`Fx&pk&nyNj4@ukS%O&5?AjgW)c zI+iabf1vfcbd3y*qarRI@GR8Sjyj9LR6wRotl}0l1<_nk1L<$hoJ8fl9^cUHDIsEx zowL#xnfDGBFo+=DLc5;-VjD^IN3&k5h4B|$6>XWCSRf-@o-Li)3=36Z9GFH5ae5XqEOOng0RKMu|Q1$5V zAb0craG(WiE*2s&Vvz6oeexx_eFvyn*hbA?y@}|74M%xlqe~navdi z*#1DAfB3PksNQi7o%XEXSgE$&J*+4x5^c3B_f7FQHi1zQ3{qF>k#IrcDjk}wFAyE_ zp7{XGpWcBjD>(J)>KOj`okcRVdlG~^E(jp2WhK3sz@))SFxuI&_wqXuN^D@XR=8%$ zxX7}g=ExChF1(QHr(7Jgx|^EHdw8VYyvKmyZzy-rCt6*j`s7oo{@cGKdf2y7bI6+m zy_PiZ(gi3W1XODDtZqnbfB_Wpy8%g-8hE2msEg-8O6HNmJqB4j;h@U)X{jq6g~bPd zCTFLy1ahV!$n21{p9%BE5MLih(i@Pw?F)46?>%p}*neeQjq%ArJ7_|DX3#_RjD22> z1U}}>Ty)$5*wS9Y?$ZAD1z0NP;4}FN86#dkuvWFxm~m3=GHo0IkhgPM(FWE!YP=Q> zHV{n|6~HH@WA(9|`F_WZrl9&qeXnf2lv}Tfk)s-=IiQTP;>et&SC%@`*|f?6F6S40=qWS2JcBr$-{G`i-14MpI7TAL zb+sBGs29gCmnkx&)olF?3R?bL`<0fpebnFg38JzY`2C<0a69pr8yRE)DVQ81|R9bx!`Q%~ZHfF?=gq294yrQF` z+f?997F^MEVS>+WDJj3CwH*)?Asp1jeRkYMsDCGX~7w`=DOmIBJOb8xrJzcj-3At3cIBU!v1Uc&7e8z^@!GnL_2m; z^|m)t^O(mF9rWga-`%C<7m{QPT52iSZi~G{+GVCT;;A*5c+yN(Uo3F46p&y*1ZBAH zo*F0d7OgC794?&R!8F78446oWMoV+3Bs3}(0>)cSJ%3<&QK&c9-P*v_Y%l~M4Pxf*d?-hx<^L+7E<}qmB%kn- z{-%fH8nQVs!oa^UkUlio=e+YyKd=1@%|sUYMaIuuk#_Xw11r5xzzcz?X{z4%M^r!Z z;ndu|KMDtV8aMN-vn>|SOElyZ$AH_Jqu1=tM;H|x>CA4H;NjtHBH){P6B4etbX~Fe zW~U_n1{LIIP{Q($$WYno$PAw4E5w6<%CLe7mrCQGjzzu3d=~q>jl%dd8lxgpL;;yR z7EFZ#G=SN$Z6>fqFJ0zd7h#WW;;^p)iF`v(2bNQI!X^jPJ|)n2o3QS52(L-AhUhqy zWwQcxozJD}$Qf@!daJc@%zW#!7BQvN(B>8tNcDm@SkOx-vmjbr37K80H@%jcM|~&J z{5fwjKSTGrt8mLpDW3QANR$C^F#uuZr% z&TtF1@S;X;FCTWy8L{}U&x-6?Gp04pix zL^#YFUXm1@nxf|BTghF2LnP{PitT5OAc+@r(oYFVW~TRtn|i_wf};rzz_9OOW{&E& zzJcn;KZa=kfe>f}9nXyxWUR7tXy(QE1pWBT9_)79#DO z+k|!C(9Wb5Wer}pbAm827ixW+Tg7kU%;+eHSEZCmV(80t)tdSXntW1IwP4auNZd<` zk7?0^`j~^^c|{F<2wAQ{V;~@Z$fJ0CX5?6wd3}n*XJbcEM;wNNa>^c)Oy~vMT znosp9w8c&d9|3sVO+@c}ceK@gsB$>{CfWPJ=457%IoMpCaqSZ0$(tLxb+2Be#z zWL1%yo~Guq%ZQei!%^jpXJoI@v297y3oW;nHW`8yQAh-H$N#5@5rtGz^@i6{^SH-Q zbK5?mo==GmYiI~zTEv)tRQc}JR$~n*)oYmo^~vN`bHM%eS;(lr)(qt5y5vJN8``qX zKB)IL+C72Y%W~|u(77e-s!iHw0OQj|Gn7U`=BbOPgo(4D%eWK%N?Q@Ps3%%~b7}2R zY}HD;@?yquWB34=VQZajHr!7V*#?WrVHM%K9xydXiV-!-Fv9{5h*kDM5i5gsYh6 z*z5*AM$3S}pweuV(mcTFUK9whw8N9zF&1(en^j)3k~^6k>hY>3x3EBT$t6U;a|zM* z9pKnXTAg9NH;;oLNLs$m|H5zlJN!$B4Uk*$iBL+a-}E|azVCZuu#k@*Nd4o}XcP5FK=)feo-3NN7O_!$2Sf0nMfe&{|C6y5p$o^ANJRz!jjhWN;U6c*tLoo0+2K z#e1pw!#|{E=k~N>kzs#?_5fc|^CQ3FcM%L}#bI2N&(%`D`W4YJ=$8lw0|S?>_dfRv znK}oL0E425#kmo}D>&Y~3<&~8LCg*tU=mYyNMvrQOcHW~Di;ho_NpUl=(_A}vy(A* zU&j{O@j;P==POL;Jn=K4@F!eW_jitCQu#UbK-bSviRH{rawVfg!w}MKeLwrY*2h@i z@XbJyzPa*29201PpXCpV=74}R?P1y~(xf7`=()pP%?V{Xt=uAMR z{xin|Y`EQkoEkj#-^T6(Eh58PBp^36)Q^AB_Dg>h;cIA^ZN)@%$&2r{EfBrr#YDgN zDry$Cgk0+xGIR71ECVv3gyldWxMVVYa=xc!6{J1<13Vzaf8~6(O#F!Lk|MC6%dmm?BdjEjH)#=zM|K;QIaT`mZrPI zf1J1Snk>=W4%}d+F=yII>JQ-vD^r|o1RaKC<~e^g^PS{x;F}tyHjmuXe{DIB?ZKQ^*2fK)IYfIf`4=*yv=g$%!)ATNr_b}yqavKdGuBp+Y zxFi#Y0^#zBD+zCKUc%vsXymB*gIAFo%o4r*?})ZTEjJ`nno8M&4K{mAl!eo<_iLdAH zsX;z6GYWjqu>KgXDXND3a9pKxBWlJg9-={zj?044!0m2Dk{C1fUFcCY6h0#wFnB1I z$+|v=glCGtTxmFm*-YDzp}=8myT%0X2|aX(+m4Q5C?=pPWD_VlduZ*-a%SvAZGMSx z+YX{v{2tMZUqa2+`Q+5g@%)`8LAvK435sNSmeqmeBk)&MQBfB}uYMIZ7w-y4AnZ+0 zZHoWlgrXwyx+3X}Mvf31S3VFj%V3!C1x*DdA@d4gfL)FfA*tGK_ZYw@v-Fh}MJxvw z1@do(2-K!95Xh<RqRA}AHAbXZ05?X<`=2wvSD8`_b} zf}kBGsWfa?JN)pw0ckqc0sL&o2K^sP0gxec%q-*Y0W^eIW-VsTrtU7OmY^OoFdGN$ zjk1uvwxvyScAQ*POa$t-?;!g9ms7K6cMt-B!N+XhMy*mbg+Kf}{0k;QFbh(@)Use~ zKwLUZJ_8T+{3^uKD$(!$F3}JE0MY*aIbzVj@~jbY`zWw`$fT@>DE?E4I8aKqVu@7> zQcb*eX5;gc0ap%A<#|DeEr=W-V*FnmQxFj*^N|ujz-Y`qyzCpn7)BxDPP|ARBXtdyf9}N$fSO1Y1!j#0x;@_ zcIg4m`?ezV$J1T_a`OvvD^6I3NFaVKv2u?1G)gLd1Ii6$1?qvUtbT=H`wnXU@RATS z(CmIOxuZ+bsTU%fjnT(~j5WQO2FBcA?dfe-|B81Ejw@x2ku zXoNQ}hBdjDUqZEK{J;83q7zPx=P9tQDMmb|w3Z5Uf0F~{_l`RdQ^Ks`V zWvs$lhPlw^plwv)eAZsjfUL>TQ;4b3Y!BN4j1Pa2U3EBC(l(xakG#gzj1JKB=p#WG z)M2$Hp$fw|KRuntd$~`H-Z3|<*mH& zt6!sxTN!H&e7Yp3NcT1!J`+yabZry*xZ(J2Nw9#?1^hA>KVp2N7tELUr9HLe`XU648n zdR(QD_JMdb{mYmtC;#OBVv^i|CZ#=D%0mnDIJKK~%&ub>2^hA}`Up9;vv`1X5152O z@npu%$d~W5s>(!_J*g5XVKzuajS1WU=#!UoB>Yz<&XMzH2y8!*n!orHs$TGmRL#!B zq4IqT&OPt>Pmx*vbD%G?3Q~JVp$qW^ff;Hr2RPHFKYL(tjofd&jG7;PI?;jsX?1{6 zQyMKXhFPcqqyfk;s08g>MMDT_c>*w0H#3pi%mSD|CM}E^Fm!Hp-k@V=qqW15*WdHo zGMDKcUfsYFGlE&iBOaJ9GQGGh#nW%L{xoey%PTSFIXj-M2Eb|pATJA|knqKSqe!)l zy3iNp|M+t`H^$LmVS6ov#v19xOz*GU$K=d0N=&XWxv)Lg*b2EE*a!lXXxC?D%|M(H zS)1iKfyO5x;NJ2&YQ{CyzjX-}s|^)?8?lcrlTxBrz^@8qRKxZmz;z+mtO3Jses$pJ zN-#IV#-TU7j%eoz2%DvJCIqpk+6n+G8EOV$L3oTTfMa+zU|1US2N|*na)K41H!Li;}8*oeg z#t<(A_P&hj%8~nvKci;HPI5j?Hlqsew_)%WV(yZpc4-iqsw>La6xhLfvE=MfP?cvv z%YHwcg&d>M(&~zxctQcT$xH2dwVluY1JfIvXW}~WJJ$sw6THg0=Df6rk$$TE{H$4@ zj!}=ekt%Zt$DeTUm*yoVUUy6+z%da3Y8^b~1At%Cf77xSXx(*C5fG+P?Q=O0_@C5f$DGnA~5RIZ@(r!;#UngsY!_X%Li>pb|Iw^I&2+>4# z9yNddTB030sPShT_+>1W**3Q~I}6*#iJ_oDw8=12;4Z9Ma9g`sC^cKbY8JUUC2H0I z84=>Xhul19;{-U<7DW~{V4!VoPcFF|F*v@*h))Sb&G0xhajf9P>u{4#jc#F0gMqny z`LP7<)@^S)_rtNdju`@ok#T0!&p&``E%RaJ12mXnsrsCid(XZ{L zv5ymY!yl2`xijooj7cNg2{&-8QR7+S(yP)bwxIf8qfXq~nS>n+x5S9ZpfOM-Y7%#3nR>te zVrt5Q>NmWWXxsL1#({LH)7giN*jhL>g96OXLOG3X2E}><3kD5mL5{PO>teLbmEBu!5vb~Z4%lg3Ol!dASfk$7icUh?vZOJ@Wa+BJ|4T$r zjj8Z)0`K~38a@A6fosEY<33_A2!WJ2m>Jvq3lrl9Q_271c!E$b3l1+Ve6(Q>6lo4F zQSl4ELG;X@rux8b)VLCm5yV26rVHV5z{v!Kkzpw7WB3gqwg?7fR9@N))ufC_IHGCH zoEKa(_n^6ZKf4*6{yjwHLJ$HGk~1UZ7cPQZu0oxp3V*3ybLCZN3-SR?m22n9XYS!#zvN4K9q%BR)Kp1!j1~BpDTCARCv6+~H*^esMPyKmPMXhYrRQj*NFa z2lLpjptfv7=M0%Up3NhXG(IO+q0ZE6u0zOr!B7Qqw4`CI@GVF80!bOS+vcBG8^jGZHVP1kMTpkQQJvQ=LT%G0DHb7W-jI?}Lqr0_ zwiBrOm$%dEFF%dkcu20-3lW1QXny_|&4L&r92q_48W1y}#(#NrCDeath5A4DD^xz` z=c(~w!+g-GB6u9b3HpU|C3@)GwKQkOA*ET$6QGn|_QrFnb!DdeBfhIOqLnYw+Esr- z<6HiTs5u($V?b_aOL6%ZnF5GG8w+WEBtmzBR8cI~D95d&M)S;1J#ytd6Q6S_a$-_N`LJ}%S=)p%>76(?S_cOmn?k9hl+~LF2c(X%A(6EG@MEps# z;g(jbA(5cP8LFquvMvJuY=%Y6P&XrLswHyGA)@98Q9bnQr*S!u(VJ=ltOJXF&`1SO z{s|s@_L+1@^K3p$*c8=RHjEBHSGcC?eG5JEY0tm;VNZVUp}INKn=VZZ1-E7`+U*_Y z01vZWy)bl$zsR1E4nyWx83D&e0HiQrNsrZOQ*xN3=1L?V9H5NQSW_DWC7WJQ6KENd z%QidoS_feumgM^LDuM`u+++DwoM9nY=p-6_=%4B6^PfR99#YZkQ!%co3>u%cem;Lt zV81t^s>JB%FswKOQCdL=;h~OtM^4Y!$~XgvREuO z-Yoq$a7sy6`^(-yb#I26;V`a&GfVk2LR*Cq&YqVxdAuVq;na> z+YVVdw7r;MaMJ=axy?DQB6kaVfgQ5oD1GNhO=s$r0=$Xh&A9P3^u_nSY@V7ma&#-z zA>uPGFM`q0qKlBH0dqSl%oN7}S36=~c{Qi4@5KURlXat=4lMiRIsf{EZKY8*utw%3 z5ZEdP0!3Z|$sN~LQL-?VU?o}TKwb#^_|K91sTWY= z9fKYTV7u@PJ;iOQ+P&YuR~$mYT}14(z1iCoZQ$zxGseYfI$%Q{+b8Cy)Yz62pfT1HS{1 zR}REU2&(~jCZPlkr8V}0t2F!UU!v*f?WKA#ngc-yi%%vaCCzi#Dc#TYj0e0}5oMta z$wTdv5)}i&Cvz~cOq;aB%E`K-!Z1xHn9fI||G2|$#Ez$k#im}Px>}>U9)`~iMH7a% zdrw+LiR9YB&QYQ<2HXF$J1xUiFkpZs?^^lMKcT0BIqH=rU20X%Fp zM+$nzj*Jfi0)#;D2~_Z#f3GO1cX*AapZ-%c`|~d%x3VHaRrZ}5z{FofWF^K!O=({RBsha{*sl2MIviBW=1?XW2jGmvqBQ zg%<)r5cqB2qmaKrP*oCqoPb426dww<7L=DtKMv|CnFVsFX$SxC{f&hH0NTe8NHBE#19Xkp?(9EIGNSXvkcqJ)JE?jiHqGf4n)cu!GkA z>vB5$YZp^*6lgzUa=AQm4tF6L!U1EpSM@rfv3L1F`8 z(*#N{I?|3Iz{^rT37YYcN{<9S_i@_)zn?+0?2$k}GA1}jE&Qn&M0Qev zR#q!F%m^vi)_VGLjIw54tW4$zgM@OqowNYI=CosKS~~*M8kxXcIvn;GZe^c9b{y+t zzjC~{3)_rZq!h43I97ZKQmTL!o1Gecb{em?4q?lYIc~iR;eDVmgg9VaHOs z52B%U3UK-$PBbiEmt+M~j;qH=K|4(vQ)Kf1LxvMHmoj`V!HSB7?X>!-f1`cB@f0f8 zj!@4d0q+>}%%9+V+#>+m9{&dD&-)7W@?l8E>Q~iVLFJJ(ntA%qQ~zi75-l&q^ktw~ zVtW(ALLg1I?Vh{cUSH1)!pph244XO~XreXQ`L{`du?FzBmWt6UuCPHurMm06{szaQ zt6$#PN5+h4(Y3bA840s*NODf{h9a|Rt*s)Vy@_rG&BzjaiXzHzNKu9pyVT=?7 z7;&ur7-x)pP6H0mN*fcnmTfY|%763Zwzh{@f-z0xnv8pfZ470X;yQDb&N+x9q!SNF z!21NY)7oc08iYW7)SnjYkwDleD;tC&tWSSaOn~?TAcnxBg;p$qzaJcfYqagho=aPw z_cChM{PL9<+_thYx;5RwS7aJN1Ewu^b zj{rS)Ey0As2^wtdg;rE6BS_`gysBbEhS}ZK!D! zSCdnK=@Fooq_V}vqV(7)c2w=*P-Lcqr1gEcw-@!U7lgFlS`58TG@JK2W#dEy2*|tCtv1lSeC3&)B35X@Kv@1*S9DITm zYp0?_r-iy;PXe?lj*U^Fqal@Bchd0l|3$a$`7x?j4uyz8H>x7^_e`9k0)sCgtPVu8 zz-J(e=7Curgn!|g(3g@%2gbDZN1jW)bI+rCWl;&KW<7Y%auMf1glO)lXhM~}!zV|{ z@EfgP83slNhMBDnR*5)}=#hsF9kfV9`T_xFCIr37y%V}r8o#YM)LT-!_=_^kj}}rv zg)6CW11jA#75<-(!3#p6s}DeFu$rJ!r1QbE8v_Bb^DTPE31wQCINQyx0qvMi#q`Oc zv<_0^3Oh>^Ppq|f?Fxh0DW*Q=?^$snJhLiQPozw4T~WF1L|XmyztJuK=gCyA9;E(M zL;``qeQDk|fM$Wu7bs^9YVA51DyL44s}f3e8z3(MN_Zk~o>Zk1IRsV`kQjqNZRWx{^z_HF zQYtaMZb+I^?40vpU|-U>v}q!VzS@eSo?fE`Gat3oqYPl4ijE93S_I}nK&1-h>A$V&GI1Ffs%)l_$~!P75Sb3j$dAhg z9xGH(0Lm`HxH>Sp;-hr)?x&JlK0v)B1cLVGxB%Em$U#K@enbQ8H1t9s3yX-v8Y*28 zHsa9(E($RTF{PLx(T?Hjir&oY*_vq-cnH8_3J@L81r*q>(h7m~E(jPyNT1f^4al6Z z@{|i`x*FJ3r~K8=+Od)mVnz-pX7{L9%+rZmo!z8D#hTcoM4B6 zW{WpANUP(V+a){;-DE0oO<9nL6(t(V+xg~b{-X4%{vG8zt}S-0c0gty0gFlT`Q#@} zHBD!q_K4-@Kl=%npSI)2!jJBB#{Ie@**|*SzdYvl#ZORK6fWdSP}0mQ zELL{FOG#>W86d~)bYKqtQG?V0QS2nD$X)p&B$p+Hgj9k4Pq&mkFXNX2gJeZOW`_m8 zmJbOvWZMw662A{WC@hAgWFars-$pEM3{oyM-h&w zYL3#rrT5Y;N8U$8F{HX#iOEucW)yv-qrq~-Y7*M%Dhqc^A_2dC0pM{6pN{J|uO26j+;$%0-@Tnm!Kc&F0NT^xxOpv@9>E}N{IY9Ce z`j!J4AKg#W=RA%VB3i_UahDIjG7^&2rQQVX_-Q4q$DgtlCbD!3}fA&DyX{g zCaP|}j*4EdBcR6$wJz7YIrBzTcMiYB$3s71k2sJ3l#$zkBiucpdQioqFP*n-)HD`e zYi%)yCE`vfV)q;vT>v`^xyZ<%qM1ao)#UdQ9Hp98!ivDC^5;IXIcCtYtjh52)7&=n zcO0v$cP#i{G=GXwZ%v*N0ysfJLwq}3oUsOP%3w&8iIMX1pqklqW|tP+D*ZGcYlV~w ziZ6PPqlW2Jl1#+nOMq!rZ2_oQ_NZDsKwG}$;dHm>{{{7?c2K=IrlJ>*B9~od(qIz6Wf3~E>&whle!}~(&GC8MEQ8tT82svqW)Q)wYG<8Z&MsA66KtT@NBV#pL-vc`nQ(W&!T;I7|uYerFW~cOV}Gz6XyP!HLR^nY^Zos&ueR z+)k{A0{k0*XxQh1*^Z7?81OL>03L+|o7kV4o7I$_aMfTOWK9g5L?Qmv*4&-n1zv*M zweT|}cgYYjKA)K#6HlUs%!ng=i}y@FM227)(1;8q;82i$pGJpoqpjyXlFs_&H96TC_R~1V z_`k`q$>WnHAFeCYu?e0A%QKP?EVN zmM5=Rp%W3t6zPcH3x)dKh0G!;efAT?5qx@3?fjOCw`V#kIsIIa%_)_RZUwL^F)od<@tHJy(IwdtDgYmk>bJ*h%J zpoygx@VN=XWT9;wYbptyYbzH;pB*5`{*D8KU?8IiNWjWWlAPB0;1bj{jEvh^lDHBH zX4?opfXM$j#WlAZS-=ik8(wk>Hu%m0JAFn8uOvS(zxrXy^?zKJ3(bF-eiKvvxf?e`h#Z}vvA}5UX_2G%F-&A(;^#6#KvPJ+(F*uc?`0(vYs^MQm+-Uq zM71RJkX>GOPs&C)8K|{RoS|N)&rAztQQ|E{4_Yb@Zbvy2uFNwCBdBE;W(Ca<7M93b zVWVcEOdI&9aFuP>Pn_5MM{lPc_kRMN^HZ;^@Tvsjy%b z6_^k~3Eoljpl80`c34mX%uu-@v zJ)q)5BCAYIpG&Tojj6W`v1G-9r}O4ARh~%PRgCpo-fiHo-N2wV*+iOCsk%V0@v>_y z-yo9Y&uO~?O-0b`Zsc;j4+jl{9oC>A#1uQ8z5nZ9pm&lD7e6!f%P>KK1q=m9+FJ%y zX&)=aV?`N6DIq|A@!!WM0CSWx(W83}vIuKU_LMr#$fc>6~Z0hMKBRg}Su!pW71Ws;-wbJ;<_{GTh=P3^PEuE!GV5&&Uo3 zcsj}NcKS5CM8!h42`# zUGU7;(x~oHHLfDE34EDTc5z$+!c~a@7oiqA9fE)u6?k*O)zn|;)AjHCGy3XVcGF;f zd-V9nV1Vv9lNee(uG-N79&w;gy=az4T`+eFV$lPP%UoPg7CE`<297)vDcolK#G96F zGIimxZ`q5lTHP374>Sf?KF{nVWzN#XfE~-c@)HYWSS?^>m!}1#l?=SlpvtO#`~^Vz zUuz8)m;wk+Gc0f4neN3p zi1FWRi?`Bg_y0aR|EYgM<24WWYwCqlX1$}1MMcV}lL!d>!m53Y5aMpO^iI=IRaG>z zqfa;f!=KRgZ{AIV=`AU3HEfH9LDPC4#xDHaf^P9CF(K5Xi^pA!k%jav0n3g%tOj%= zN7CUK!2XM@kaOGHKs%Ms3@`p1ejZVei?J2T=?G0ZR`&R2}^Y8yin2oyf-RoPqBM( zOgK8wx5s}@2UPU8^y%8ado|tgH!q>V%+{p&Yc-yzJq3~~C<`_bEVnDX3CIoCzT-?- z3EfSgd2QY3YYY?<=1<>6P$vW?p|%MnFr!%}X2H?Xp7>L85+*4F28SjBMJp^sjowIg z#bs<>{x(~t<)!ote}~u?q=DS}ycC)<5VzJKj@221z92A}CLmp~1T)XmNZF^~Ib|D4&B%5Es zV1~SzI9sU6XxJcnKoqCcyFq1Io&)qUmGCsSdVnbniy~*LAHoU|=v9Q(9;bRu_&H2Kf3-fUp@T zrv-}n=fq{91kWc&n2MxJ4UWRA ziJJfDt#tN-ewZHe1FxjfTH3nnig=z}THa&ln>h9W>USkG_CkXjf0 z*UXNex@ZFRKEizTPrx!QN2;bvv0G@jHY<1o7C#bZ4+6?U8OE}ibRi4qR;O}8z3CSS zj!YIqEMvVT&~Nm1U@ZVRiVd_eUM)jQa}=*MN4VOoA4AZMGqVK7-vKEEAO})b<_)xr z@Ch?N3_es7o-BgRe>>gl!B3@!e%~L^+H&kg&j;azj36ei;fGXiV> zFpL2}M7%JV>(iBg_cFTfA1|S)*&S5-s5xBX1Nh$>tL_Ov1fFLBT@VTqu{qHm_S}K^ zMfN_Z`7)o?ptg}l`|4uaNzXAp&wu2l?~G^6mk$I ztamR0;!36j=tzZdkL7htOaPyBf^*o#m=uO9pg}C`giACw_zdFlQ7~y49%O89;5-j7 z(z;Pv`E{1!!Yqm5F!WMjBl-&Xzntc|q}8R{=-dmQOpo~9SJK*$sF#>M93Uw%2zd)D z!^;r1DsGQNe95=v{Q`nYs<5j(!2T=$;pKGQKm1=B%x$CEA63QeR981_*Moo)ppkf& zOqH@MhQ>lVCRd}iNyXYo#R(5;er~tT!gCP*nU2`M#0bFuHwxc^rv<12_Rg4pcwLp8F;Bx?x}=)s=GuRfJ$1crKv})1=+nybe!9;CevBUe_*c+q$*;-#0V(p% zva~vZwX7bpCB5L|Dez6>`v1)3Xp6$^UfBzD?`hBkqHh=Bc__K>VQ+XOtwEuCC zSWxO^ID7<%QB9fMkbz^Ur=po;UbGT<@B{5~b2;{&h0uV0kojRQqgmvaVH3@T`S591 ze41|mHw(+!3jjCvDZ6A(gE-|>AS2h3J@Y|P;I7op@yWX;)fgz!0zOD|m=zCgtVqD% z0!XQ-LO)n|>J@k+uaa;8k9S){m?cEqaV8V3C3A~t)bcHF7RvQTvYB9{;!>F6miVyG zy}!J4E8X}0&!h{Va0#uf)>I}BeB*=rgLj|(8x7TB=ciU;VvEoa@Svv%7v4QKGt;9_ zzWYUV)%#yb({nqhuE*`hn$LDk#6AjOUWG8{O)E)IdMPEb$M|OEhUg!OLe!cDdQLL@ zmemQFt~Rx_T)W&(F1v$ZtU%zqkv_Q(J|}+}Qgrv+EETTb)U{{)p{wdusyHnf+x^lv zPy))yms=bk1&^Teh8y8H5z^<##O|NhaljszRR9^6w0lWmwRV0#e<=YM9@BZ>6kVvb zPe2YvwBW{(2+8s=#R)|xP!(FUqe%KJ3`1W4x&{AunL&QKnpz7 zBzYeK6v>04JFVeA5;r>8gkm!@DoL4ds}ZBzn50rRaA$3{W%c_*Al0{V^2t{J0@g$GAF~h=n=5UM?iuNP2M=6 zz!Co7x$7LGua{HXVmr>`gh5goE9(-Jzhs30#F$$)7ONl2g|W<`%+Zk<-_CF^X#SOb z^uPx`hc3G4Wwf&Dop2rz#9en@VTN^m2<`};{3d(!d6_S3{Cqz=6Y_R6G&NPyN8a)4 z^o0-qDNWBt=FccRLtggkb0j9`3S@z#`VKcZRvlLWI>}0I=0URv%nFz`!&;$|SX<&K zy5_436XL~VfLo3cc_x}v??mQ~x%#J?ZOym4=RW;MZl0%d<+olpJnMRQZR46za1F-& z`kaL*(K6E(DmrYf0Hih=>KYH(uVBn0iF3%3M}*EGV{YxMb#{#k(iS@UZrobE-Qi%>FX zJ`Gv*9LOR93c^Y3ht`lh!~PTe5etRF!M?>ULso2{Q*z>k>-zA#^>64+IsDJhY2kcy z=IF@Umc?-~?U7eO19Ej*+r~aKN@F2|0TanI$iYaNX6H`!#G%bF#%rR6!uWva?$`7dfn zf3(x`>VA621AdYo{m7Tns?XmS^hs3j!S}%mL&%HiB?Z|8y_53RgD)ULh&(vc(*?c% zUB5=3|Ja|?%-l|@t5Nn^cXB1_JQE3*246F*ea}XzA?|5kSVl_#sfs%0N1R1@Y~m2) zCCs?Ol9xe^87;zJ%ordl)HiZhfe&x%dre-_xLTznZ>#71*^CFvk5E;uxv+z> zJw*At1v8=|5TkZ5epKH|C|dQDf0csePd*_jKCvOP0tX)eE8?L2Q@SIig$-;}kkTwG zfriY$n}6bT%REG^sX@ER(oZHP9L4N>VgaH9LQKnIJ6u6aE4S0P-~XrSe?8(Qw7lX| zcxgG!=@61xoF`M9e2g$aC@`e-l42a&GUyY%|6RXGSA6_UG~*q9iTQaXBzA$#21VfQ z5G;+r)>EsK@T#SFvpou;r{Z#{{2q$CqBQ2v&NMZE zHF+mqt~1+T{#Yk(^FQMLKTD5!*o$dpX&jU_YGct`R!#y8vtkWK{epdB8Z9NH+$qrjz1<;ulapFWv`;Y z|JUcz6<>TC&Cc$i%4>dZYikEH;w1|@^AmY_kGa~GfWRCu56Gyzwx1CdI#BJtD0?an z-^2YC7IA3S$V+T6s2LfcCRg9Bd6Dl$upl*O3CDt(^tiC|FHVt>Y(lqakL%I5?hV`E ztSqntR@mgNz|joFl@8uiW(j%6<^xFA(ga9mlZjUo2}5hnHiP++ z(tTM~(Dep5DN@U8{*sp0Zl}kb_Y3sh_kAHPu8gV2$9@Mm&+tEQ8D&KmCl?4M2>@?R zM(1FhNzeR!pAFcJ>2Lq_XX%P--cGaA5%x>$IXb_Lostn&80qa#9%Dd^1k@ycVwRKQ}GBwb><5I{E8=@Qwu!R=2hRIt%T&B%Yk;~Bu zQgjm=6LY|HAGU!fXK2g{Ve@!lWe8>PV+Z;P=F3JB0RBI$J%W9vBe+m&@D<~xQR}cY z%)%oJQJzH5)d}Ycv2)N)XH-=tF9wtXR3HO|pRncC1N4Loo==ZG{{?~h`{I~nrc28) z+&2@#%ML#bhziMTQd9iI1?DeCbm_nTG+lY!-_hLkPO3cY7f*3+d5ihewqo5o^+@`h zJnTGfIH0n@2}T;=wjDdXW%$4hwS`?4US}d_haWq&Fmg|~!gX_8yYh}_;U(@nGF+3* zorvnX%)t!Lb!}QgwU{(8e?dJrO{MG6uvyAJgtnY&bfc4Itm>v=_67uXnAcvJ@J0OMo&Ed z1+?N}zuztrPTTQ$_v3EIcszGHy@yZo@j3Y+ufInO;XR-HS5Eu!`TT2|as~C95ncL$ zpQJ0Vdk4)nWiuF1v6= zW_XqOLIP@F2W6|^cH0!X__5$OC_dMtz!n91ery*TDX^_v(*mmRqXhj zC2B;YN?OCZES8I(J1qWC$I9A4dcwE7fS&Lzze0=4pH@q{!e(!}gq$!Xc1!fLQ&IH=6olFM>z^E|t7WB+)DFRjH zWI)*fVXAh<@M}thqA%r1P=DgmYM!r9l+p@-ASQK~H-4ou&`Su>`O=Oi*DT|lZFFu4 zww~u2={c1x{+3vBNhKXv`zn2W?OowGZ*_1sf)k7ARCXbtb(BfRNDv&mIDl|AfVYz) zsKDgTEdvTBHftCIe=!k=3ga~8w1@*Vfutq_7zi}SNMU7oh`#4r|2KX2y?&9FmPgd{ z2X-Whd`{T}?B+lA3x%fB7FjMOWVFng4{O`MI-koOIYK zHd(WcEQPO!$q~8>0^^NRZ6Z>_Q7Ca8TC=-)WUS+NvHvsIegQGB;tW5-Br}xa_g_MOW0Iksjg~rn|t`})cM>z|m5qj-OfT1#FBT74F zKEPw?1Bkm|LWUduA$2m&Ut%Pt1SYP^7M<+%=65AO@sN+%+V>JnQY=TXm7 z<{^l;?`oa}ceb3=P8B}QQHQOi#YR`oa|vP>|7$OCV|OraM!VTLt~$Zv19PuLJ5nxnHaca*@?%9Nida%kS66eOjx-Oz$YPbrw{OJXqrkIk5}n+pLjNX;nshm z*})DP*YVgZw8bZBS%H0MVyLmq!9UVHc#d}mc1sb%NL}y}hYf^{|F0ZuBz-qYNSc;N zK%c9sgB^hiimQ>;uPMGrcP#($yGUoUI>Cyg(TpC46w}XeACU?s3?#IrB_}5eSrCNK zR~BDlDqF?ZxQ(+JNCbS^#>$rfyorh@XTSySf{F)GDGje6bMDtTBT;6at8dq*py z!}R^RW#k}6S)!n*(ZLIzHqD8{3p^F z9ex6BjmEd4)3^k*U7X|Mk#zDfc1RdWcA43$C4eB#!OBqk4&N>e%e9`wcB=u@42Uv9 z7y;O?%%C~GyPbE!PT%hWyIRq z=?#$&K)MvS$>47-84JX>9;zgiyx;&tW^cVB4o6Kc@u#HuM~CT$&)-XrKlf+o$jT@z z%gHWAbUt;@wTjaY-+5IbG0eDbsNeKxv~rmK>@z<@SKjtMnjM@-<7$|-a%#EcCShwOYy znQ7XAvRa}|?tu2!7c;Jp-oV>Ah^Py;*=51_4q^t3#>``-K@v#YVDjn|c{V;|b|W1O z!m%_VFJMdM>w!UyZWMvhEWG?lYcCM^}f`FM2J= zA2nh(jcek}_Aa-(8rL)^d(_m+^e3PE3Hs8Z4+opS_6|RjbqB|!i{`tudWNz<*R9Qy z8HTq62|Sn`ae4t-7p{m7zw>{Ue(*ler2|LSs9$=|Jk*Va)ZL7z=v$nF zk%D~Q|EZ>=;o4z(&1auOpTGTsG&k5D9DWlEGjpukg4zt3FZC!C}nM?x-ECO*ce2Ek-aFevcuMPNz;$Uk7RQG z>CI_)Npz=}0fuoCfoDNwbv~~^ASQz{q?V4wD?qUh_TC(e>(~eY26Z?vJThumTKv6B z+o3$?`}V1PS`ZJH5s&7bNQaJw<7GNy%YEsIcl$+JS{u`#7Z2%F??OI3CL}Qh6kV|G zm^D^_{bi5V#*6f-Pd<&VI`ko$>+cNf{45~yN)J%5SQ4Wq(8^CeI3|~VmuwF31ucqnvhD)4pDp;(md%#@xb`Ec%g-UQ1T*knC-&yA(vd$V|wfv&!OpNidOtuf7y%U zh|7KcM#yN?f%t?tbE>N3r=hB@XyE!()ko=7pZ;;W>fncIZm?4=^~L8A@U@#R{msxv z?&Db6@MnZqVbe)bhBE_v2BW!|6KUJlGhzsJQ&H256Szrf%-Dg*fiV$_L(Hia$v&NS z5)~3m(`Hn?pPn%*vUnU-N?w3?cunl6Oju3I;`?|VJD~E52LT@l1 zqOfEL`RFcA1j7zb6|@sC810M_x@3?%#$(2-P5Je_&w~4!Ef-7ZwqE;HjU}euTd!^3 z_*HI7AHD*dV<1i9$39aP*R#ekW(Wm9%LAvBq&!Vdi{U^;0wq?yP&rkYfC_&*#=m(FE z__T0qaeB%#zbk1tTB6zMZFJ!Se}V3P#uI4kwo^iGg3-w5!fU8E@H-y;xHRPTWhmKa zB?^g&Vji3@DdF(anB`cY4%a2en`hM+@5M$*p49ZN3V{i{opwdCdE@IcWhx5t?Mqpf z*f$?;m5$a{SLya^K1N^qz$J9}hR@PqcDp};wYA5ThY%sZzho%QSfX;GmY*rpDC`B( z&%*LGYkLK}S}PElA~;5YQ{c%)rPSaY&A4HlfQg$q4Q~EL5xGEj&f(Y5342~&{A`C_ z;Vt>0EgBq+n-QHfIFshflhU?KpQs~;dh)J?ye`h`!-?YL0LUcd(%{!kO|$-dfZ<`f z%;!nQWlF-FO{k5JCo+RPmutHY2h*vZ2leNJ%IXtVZUC zDaZp31Dv5*9(uOhG&@5H)=S#jN)!j)=YE^*Z=pR#!tWV ze>sP~XZ~q_An4jb`rS`GgRVOKahmIS<{zbPt`|8i^71l3ui3y$N-q!`P0YZY3~`eakgF*%#bCi zNytHxmP($4yw#WUBZxV``5cm6#rz=e!oRr1K5AIr6W^>>G&Pu}?|9Ph&^y0+1sy$b z4VC?AaM;0+DIwhCauq}A4Ob}8#WaB3e;mP>pN}RMPf_>Qr7p_OIS13Q!mqx=HlM(T zN(hpon#kbn z*b}A(G!cA^NeBqzr(B6?M)-NIUw|W+8p1d!+cKI1Oiy70S!hO|s?`zA%+Jwz4|zKM z$Gd)=%Ki)_hOyWX6%%Q5Lyc_v;xWwg1Y$zAtZU{L;m|=}vApc1oTN6DkVw02H_$48 zzcA!CGXONZHSp0UL-J>*)C6W1tmP)(SF1GsB%eOrzIp>4S=>ikXHQ9qAQSKp+j*HZ z*h!ZhOzeP3dkcIHHt$HAF8j2&x{qG^$seU_j(m#ddfRC1gM3;cGi^zgqy4(B_S-Bp zb^bGc9LCsr?OxG2Cq9CjRiB5z_dE7aF)y1~keMkmCfnc%t^>mcf1I+(({E{f@mMpo z^+jd@qi3OznO=lPcq``Y4?Y28OnyH(0U2?`;Fwr$IO21yq0`U!HtJ6;=p)qB_#%{c z6B)fdhpcIsdlOs3N*9_G0Lx4bIZlQFT#jw9%|*eLPNNp9YXb=Ykw+?fP_d?RNU@|9TpI`RFHUt{)wKu^l)dZ7~qFuHBT+PqeX%y3)4hj)1)+pggMS z(~j9wsqhE24vETiK&dMoHDgFdy{F1OndsJD5vQCw_Qfxara$ut8ZGnjVJeHp%WLM5{gLW7Ac3{RMDd& zYq!(yeCkK(%ZteT7P_VHX6@PA;?ePi{WHR60+I0eustttz~&~R5v{E(QL)xgS*Pqp z-rvEeh+_o`iLpx2&^!(~Xh=?;Dq34JCCjnE(3N&FDw`g*cuVGe(dhgP=1`2ssj2vR zS*Z+Xdgk%)PRqqLs{NjekTw^0G67sSvcJJu5IukfxLLv!0#5Dj;>8YvN8aYSi**7` z$hiX<;YH~Ui9)*+!Ew(Akcd96mFG$Hm)cIN-qWx*KCI(|HDl$x`odso*3`c5cq>hG zO;f!s^tl6<)BCP{Gd=F~r_!O(DovGxSPsKC+%w@wVwYUos9`tMcU%mZPRG#vT;N0|H|FAnV z0S{h%(-Rg2Q!tgi#DD?F%#T8VnKiZn9eq3t5ium~jiv)IDz`r@8{#wYPW*{YmE`ba z;Uqy3xo-da>|8_pmT#ePRnb(hADq50K$-zFNx=o#kBkb+ESgbDTb=9AY?$8#DN^j z8i0n!l`GUYa#NMd!Zy&IPcvRj(f*a2=^ws)32mP#saN-?8jXm?6}hS+=a)dM%BLub z-_yUav{a45ee%g%(la!ws2RiahQN1j>Gfenee@$(*GM()Em|D^RS|x|Htp9-~7FDt)g zm!}&n^u&AoA}#cHl3xvsS`zAu zI8qG}PYu^3;T&CU?vl40w*e7=G8H(@@Sa-U!?v}RtVSJ4*_J-_L_xH&JfaW&*PH1d zKJ`*6eY``WL9l3A(7T1hk>@~eOFJ?kYHMF?v_u=){6R>Q28qZV!UL#Pg7XL)AC3&7 z4P^rO3r__?*I$esPqVgO2%}^}W!A?43%w_sGrC}~kREVdPx!6&2YdRQ{6z2j>YvlU z-TYT{*8BtM+=YkIiLgJIg0HS;#YG|aM+)vn46VJD$>2g4=ol|=qOdCdnrm>S9yFp{yw$a#b z(Ac)o*tTuGv9n|C*tYlAAMwq}oX)`<%yrLt?gjoh6e?EQ{y=pBWAhIOu5}-uPKA}`+cE25XcULZ9D2COVymknEsIGLq zQK{DsLN}LNuJu12S-4rS^5kv=s(mwpn+@8s7;KiOrTMl_6{MLNLHLkzO%d24r%t0} z76GE)v#RZ*`0tyZ>%-8)D+P({;ScyDLS!#zk#q0qGorwCmjmg#r#Cw;R?G^T7!Ewb zBL%wE=g0oybflk}etz}ADDhA8-VPoN7n2Ykfi~(0=LlUFzw$=DXw>Na^STqigAj8^ zQ)mCHy(@2R#y?538dM=lJL~I#h9E#1$ptnLsf@L;8v|AUOgsGgaOlUJMLW#dMML(JuzR@lZ{R8UHCeLq5 z1v{^VQ(UsP)KtaP&0sN4=Fc_=$?w#^)L@U|`4d{IA=BPOYaG((;DLU@mPABS zHn}R8LkzE(x~BUhJ^g3czGpZa8+O9T3sGA23sbCyX*;>hfID_L=$ngkUc0_7DclUo zBUmHe@}g-}rkySZ?z&2+8&j#bvVd*YOe2<^#1Wd`Z)g8}g0gahxIQ6qXCIC9e<~rk zF_V5M?B&o_Xv&cg{3Mh9FZ@Fr6W!)%O4czR+A{$4$5*!iKanmETZdK9tcuMUUmMx$ zcW(10*Rw!W;)yA+gx}l=C3@GvA34l1+v7&@MPwN0`JaaHev4&4XB4duZ8(KfEoon)T4^kk*AWc(UuZ z5F^1(dRPR?^;?3v$}r;Q&n~N0e+*wUFkiVNwnon9lh6C(heCMSW^YR zz4mG-aa7;465ce-{4|VjNJ5vd5Y_=AygEcnnW{$&KPkS-K8Zm}xq5)ZGc5p$b);&3 z=$T!u^nK}D_A5#P`c){JjZxvW+PR&%r=pBsK>8Lp-g@d-N3tes@I)zLO6k_$%7u)z zz@|+2C1!aPIAh3pw-1Gh>Qp_s{qOumtIJUZPwhs@K&N8vi;aY$h+9lJeKKlqiTXOm1O z;&fNICs5IF7@mP|+#}Adt0e6B2Vrdqn%jfW^o#EeG1=((T`89AAI;2QyGV292nTGPHv>(hp5&k~1`#SwX>Uj?nLV-7)*yc{xxBD1ptqOfDfKK4c z2mYxI%)MC&)A^1Sjl?NM>#|||NXD)quvm|uND{9eb|;Sn(bEaQ|6xEpD$rLng=v8X z51aL4?KSy{Q!Zlx&Z55dkJxT7(aKaQc{Zi1wk^UAMYhgw7unQ!fJ|VB=Ap%&M6z_2 zniqnxS-#H+lgGon7I{o$klqo0yqp{YUVHzbTGTGhES7pQ8$a3w**Gg;31$XKb*tT= zWIYxd7RC_WnS}|n^)5#VZM7WA(LgtTf3<8cl}njanT8(b@Sue92l`_DQC&F=!&RfA z>>Mm9mRLb|4W&c1QAe~ipDlL`$ygU`6E|egP^(BPE|TEHJ#7o#ZzH$@*ENPb!dFX! zrdI@9M8w2Q~GCe;+tNjuCq;;~K)i59|LRZw#=XQEs> z>DFyhxk}kY*wXusPxT0!8GDlweSwBV_@VvGeT`TvBxbBm#xxyW(x5q5wzkwizdJ?q zGEMit@l!$NCvg1EGb>V(V3DS%2GyJuk4)X$5r5;(h=2}BqP%h=aDt^*wRZazWe%o$A_$Dpo}Ai~So&Gq{m3e1?f$lg)lmbW_Vm zGfY*hP^_3bBA^_Ft!W%Z0BA)s!OCtUOH;t$!_7`GQe`S5m6;VvmPJ~+1t%>Y@3I<@ z;1vt5`hyl3cG>9^Ck*&GCzL#->u>uW*-SVLc?ky%7wv~)lW3t4YDOMEpc ztIs9nQ@S-K+JokP?Y>)4HBA4K*kW%$G$s@zB}wPN3`|+E$T5aIL7fxW5C6CmXW%~# znifY;LlChY9FIR-ZGXrwzM2?BiCbTWUQq>f)WY-4U!<^-9aH@Z3_PJN3R%9_E)?aP zb}xA5ESQ{#c$N8vsT6@OjvzZ(iCIW$O~YLRw3k#>2q0T1FY5{?4I%r+Ia{L76VQlj zQStB8*f^&KYIWI989$v=^$Rt~Wy2K-NxtcZH;!<&ivZEhdeYmlZ#uEFsx;UR7W4xiE-+cX!5c%&*}Yx&HMSPCO#Yj?b}W{F6A&h53h) z<{tkqQtbiVr5=+U_{0?%6?^HkQWT#p#_5PJM><>Vk^)bLHCm5b%k&85@q~X30nMaV zvv};WkYwH5kzI{k<49%}%Mg7JDeYQ*9Z&%>xB0B0ikHmLqM>a8*A7 z>&i>lz7%Qogg!3r1s=-c6~gAgmXJHywv633p9065vG4XIpT~=Tl?J6-xL``l+>flP zZkYQ8DUs1=+ssQ70vf>qJO_<=D=e|qox9Ug`jfsV#TmbF{h<`#gq(@H+S5qj!j;_7 z$%@BuIgIS%1S7;++C$YzO-Fh6Arn`L#wg`V$J9OfTC43QxidPKNL~Ex7#vDjdBy)& zAfOBDKVXmuyX4R<4z{z^7K}I3G)kJlbUMlc3}j!jp!R7~FR|QmRg#JFV_YoD+>)Hh z?v2#|lqsKkrSm#KP*0XKIT07N(LUfv|FY@t%b-_i(OO6HuHGApDI%Ft`1HzGka7=! zW{Y)qbisbG&Mx^(@VGTL9Gc>Q+Gc0WqZ>LBt*}e$TIPeQ=08z4Lnd8FJr-*{oWxRz z|KzoGLb_}_lQ*SOHU((<;;p@F2Na{ zIZC$QRe@f}y089J4`eI3DuJOXl`3JToy~j2hV@QFG2g(|q?+Fld0>XqlG_>%8cqGh zv+&yx3WZ}|?i_4FG1sBFC75*G=A$ugkT>lqs#<%)4S!uV$dsoGfdMv6@Nk~;ELS3A zT=Yc+3y)T#4|rtfanCi+UN8#NScVTY{C7KlFd&ZeNW3-ve#WPobB|6hEk#(ADWTp% zWT)vX$kr#{O&v^ehOJ?Fg(fJ_v6zh-Eg$U0?J{qg74#dfWcn?rws zE+HO@jqOMyD48QT$@XY^#r*T&l8d3w+hA_2l(K$DwaNN+M z@ZWQR35hv3&9T+{#i{yx2z#vaOl(DMX8bE?w$Dkw!tWR%K)=i6@HkszbMDzQ6JIN) ziX$_NC%Ck=Zc~8PUM8uq(5cNi-0hc*NZa`z? z_h!4dg6JEKzW?T}j+BOJPkPr2U%t0Vlwc(A%o`uG3hjhPi7v=;#Hd-Pr^(iSUr3do#MaHeSlJh}$nD>;5k!>{{GZ~CqZ%?apDH(cf{-@o7cwmDs+yD4v>F)nFh>bbe?+WvMm;RdbXOn zEKbC3a^{o!k8rq6bN}%`&)CU|d$W%!-AsLgXutaO(dX*AyFEPwajh+E0k0r+rRC zy~0?chs_35bevnWS|tI9Ej%z_?*@D?V{|m3c3S-9>+?gXfb_73iUEsV#K?xMybaLS zg{V$m&sx^i#DcrP=8vT>P^xy+%R%_Dx3X6D#>UP8>`ID2xh~xjgmTx{cPp!d((^Fh zFTz+U^_uQ`E9p;<%{>XpL7h65EkOwdlV6tft&<*Hd(Iok=*dis;ZHKfxTM2~4r$Uf zmoT4kMk;DxZAlWJl%!qz%vK*5QDSI>yU@<$qwwJlZZ<^@#)Myh7zpu7NAIze*{Gz6 zIW>$i+3rV?wf@O04_8aL#gP&I^&n?Vn(J@X{(vIiqu%!!2cAd=bTWg28O0CG5zM@r zM7-wHQeZ?|8x#(Rdg-p=vX>73b%VG-8HYC@*F*=GJmH zMw3hyiAk~OeTd9PXC-BCS#0;a1H+M$gRhG?4)@G1vO||qTd-RzF!b&D`cNrqq9mKm z9T9R^-3=?xmJ&o7F1TFwe)gd9SejnOCqt|@Q4_uj$G_)pQHa}x$g22~$oN% drM9m*$q*o=JMkA(9ws*tu(yiJ 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 diff --git a/nebula_app/windows/nebula.rc b/nebula_app/windows/nebula.rc index 12e7229..a42f918 100644 --- a/nebula_app/windows/nebula.rc +++ b/nebula_app/windows/nebula.rc @@ -1,6 +1,8 @@ #define IDI_ICON 0x101 IDI_ICON ICON "nebula.ico" +258 ICON "nebula-dark.ico" +259 ICON "nebula-titanium.ico" #define RT_MANIFEST 24 #define APP_MANIFEST 1 diff --git a/nebula_hook/src/main.rs b/nebula_hook/src/main.rs index 8f274b3..d85da65 100644 --- a/nebula_hook/src/main.rs +++ b/nebula_hook/src/main.rs @@ -37,6 +37,8 @@ use std::io::{Read, Write}; +const MAX_PAYLOAD_BYTES: usize = 1 << 20; + /// 其他 agent 的 hook runner 独有的环境变量。 /// /// 这些 CLI 会主动读取并信任 `~/.claude/settings.json`,于是我们装在那里的 @@ -67,6 +69,7 @@ fn foreign_hook_runner() -> Option<&'static str> { /// 一次调用的去向。写进侧信道日志,用来回答「通知为什么没出现」。 enum Outcome { + PayloadTooLarge, /// 写进了本地命名管道。 Sent, /// 写进了远端 OSC 通道(SSH pane)。 @@ -83,6 +86,7 @@ enum Outcome { impl Outcome { fn as_str(&self) -> &'static str { match self { + Self::PayloadTooLarge => "payload-too-large", Self::Sent => "sent", Self::RemoteOsc => "remote-osc", Self::NotHosted => "not-hosted", @@ -149,6 +153,17 @@ fn main() { let _ = std::panic::catch_unwind(run); } +fn read_payload(mut reader: impl Read) -> std::io::Result>> { + let mut bytes = Vec::with_capacity(4096); + reader.by_ref().take((MAX_PAYLOAD_BYTES + 1) as u64).read_to_end(&mut bytes)?; + if bytes.len() > MAX_PAYLOAD_BYTES { + std::io::copy(&mut reader, &mut std::io::sink())?; + Ok(None) + } else { + Ok(Some(bytes)) + } +} + fn run() { let args: Vec = std::env::args().skip(1).collect(); let Some(source) = @@ -160,11 +175,13 @@ fn run() { // Payload: claude streams JSON on stdin; codex and opencode append it as // the last arg. let payload = match source.as_str() { - "claude" => { - let mut buf = Vec::with_capacity(4096); - // Cap far above any real payload; claude closes stdin right away. - let _ = std::io::stdin().lock().take(1 << 20).read_to_end(&mut buf); - buf + "claude" => match read_payload(std::io::stdin().lock()) { + Ok(Some(bytes)) => bytes, + Ok(None) => { + log_outcome(source, "", MAX_PAYLOAD_BYTES + 1, &Outcome::PayloadTooLarge); + return; + }, + Err(_) => return, }, _ => args.last().cloned().unwrap_or_default().into_bytes(), }; @@ -246,6 +263,18 @@ fn base64_encode(input: &[u8]) -> String { mod tests { use super::{FOREIGN_HOOK_RUNNERS, Outcome, base64_encode, foreign_hook_runner, outcome_line}; + #[test] + fn oversized_stdin_is_drained_but_never_forwarded_as_truncated_json() { + let bytes = vec![b'x'; super::MAX_PAYLOAD_BYTES * 2]; + let mut input = std::io::Cursor::new(&bytes); + assert!(super::read_payload(&mut input).unwrap().is_none()); + assert_eq!(input.position(), bytes.len() as u64); + assert_eq!( + super::read_payload(&b"{\"answer\":\"ok\"}"[..]).unwrap().unwrap(), + b"{\"answer\":\"ok\"}" + ); + } + #[test] fn encodes_remote_hook_payload() { assert_eq!(base64_encode(b"abc"), "YWJj"); diff --git a/nebula_settings/src/lib.rs b/nebula_settings/src/lib.rs index ca141cb..76ed78c 100644 --- a/nebula_settings/src/lib.rs +++ b/nebula_settings/src/lib.rs @@ -16,18 +16,44 @@ use std::collections::HashMap; use std::path::PathBuf; -/// 设置目录:`NEBULA_CONFIG_DIR` 覆盖(便携/测试隔离,指向目录本身)→ -/// `%APPDATA%\Nebula` → `$HOME/.config/Nebula` → 临时目录。 -/// 与引擎 tty 层、旧壳、注入的 shell 提示符脚本使用同一套解析。 +/// Settings/data directory shared by every shell and renderer. +/// +/// Resolution: `NEBULA_CONFIG_DIR` override, then `%APPDATA%\Nebula` on +/// Windows, `~/Library/Application Support/Nebula` on macOS, and +/// `$XDG_CONFIG_HOME/nebula` (or `~/.config/nebula`) on Linux. Relative XDG +/// paths are ignored as required by the specification. pub fn settings_dir() -> PathBuf { if let Some(path) = std::env::var_os("NEBULA_CONFIG_DIR").filter(|p| !p.is_empty()) { return PathBuf::from(path); } - std::env::var_os("APPDATA") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) - .unwrap_or_else(std::env::temp_dir) - .join("Nebula") + + #[cfg(windows)] + { + std::env::var_os("APPDATA") + .map(PathBuf::from) + .or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from)) + .unwrap_or_else(std::env::temp_dir) + .join("Nebula") + } + + #[cfg(target_os = "macos")] + { + std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join("Library").join("Application Support")) + .unwrap_or_else(std::env::temp_dir) + .join("Nebula") + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config"))) + .unwrap_or_else(std::env::temp_dir) + .join("nebula") + } } pub fn settings_path() -> PathBuf { @@ -938,12 +964,42 @@ impl ProxyModeName { } } +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Default)] +pub enum AppIconName { + #[default] + SilverViolet, + GraphiteViolet, + Titanium, +} + +impl AppIconName { + pub const ALL: [Self; 3] = [Self::SilverViolet, Self::GraphiteViolet, Self::Titanium]; + + pub fn from_settings(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "silver-violet" => Some(Self::SilverViolet), + "graphite-violet" => Some(Self::GraphiteViolet), + "titanium" => Some(Self::Titanium), + _ => None, + } + } + + pub const fn settings_value(self) -> &'static str { + match self { + Self::SilverViolet => "silver-violet", + Self::GraphiteViolet => "graphite-violet", + Self::Titanium => "titanium", + } + } +} + /// 新 UI 消费的运行时设置。字段与出厂默认逐项对照旧壳 /// `nebula_settings_load`;`Option` 字段的 `None` = 键未设置,调用方自选 /// 回退(如 font_family 回落 nebula.toml)。 pub struct RuntimeSettings { pub language: LanguagePref, pub theme: ThemeName, + pub app_icon: AppIconName, /// 系统外观变化时自动在主题家族的深浅成员间切换(默认关,尊重显式选择)。 pub follow_system_theme: bool, pub font_family: Option, @@ -980,6 +1036,9 @@ pub struct RuntimeSettings { pub bell: BellModeName, /// 新会话欢迎屏 fastfetch(默认关:启动速度优先于观感,旧壳裁定)。 pub fetch: bool, + /// Check GitHub Releases after startup. Manual checks remain available + /// from the Application settings page when this is disabled. + pub auto_check_updates: bool, pub keep_session: bool, pub restore_session: bool, pub resume_ai: bool, @@ -1085,6 +1144,10 @@ impl RuntimeSettings { .and_then(LanguagePref::from_settings) .unwrap_or_default(), theme: raw.value("theme").and_then(ThemeName::from_prompt_name).unwrap_or_default(), + app_icon: raw + .value("app_icon") + .and_then(AppIconName::from_settings) + .unwrap_or_default(), follow_system_theme: raw.bool_on("follow_system_theme").unwrap_or(false), font_family: raw.value("font_family").map(str::to_owned), font_size_px: raw.f32("font_size").map(|size| size.clamp(4.0, 96.0)), @@ -1130,6 +1193,7 @@ impl RuntimeSettings { .unwrap_or_default(), bell: raw.value("bell").and_then(BellModeName::from_settings).unwrap_or_default(), fetch: raw.bool_on("fetch").unwrap_or(false), + auto_check_updates: raw.bool_on("auto_check_updates").unwrap_or(true), keep_session: raw.bool_on("keep_session").unwrap_or(false), restore_session: raw.bool_on("restore_session").unwrap_or(true), resume_ai: raw.bool_on("resume_ai").unwrap_or(true), @@ -1206,6 +1270,31 @@ pub fn format_hex_rgb(rgb: Rgb8) -> String { mod tests { use super::*; + #[test] + fn app_icon_defaults_and_unknown_values_use_silver_violet() { + for text in ["", "app_icon=\n", "app_icon=night-mint\n", "app_icon=../custom.ico\n"] { + let settings = RuntimeSettings::from_raw(&RawSettings::from_text(text)); + assert_eq!(settings.app_icon, AppIconName::SilverViolet); + } + } + + #[test] + fn curated_app_icons_round_trip_without_changing_other_settings() { + for variant in AppIconName::ALL { + let text = apply_updates( + "# keep\ntheme=Nord\ncustom=preserved\napp_icon=silver-violet\n", + &[("app_icon", variant.settings_value().to_owned())], + ); + let settings = RuntimeSettings::from_raw(&RawSettings::from_text(&text)); + assert_eq!(settings.app_icon, variant); + assert_eq!(settings.theme, ThemeName::Nord); + assert!(text.contains("custom=preserved")); + assert!(text.contains("# keep")); + assert_eq!(text.matches("app_icon=").count(), 1); + } + assert_eq!(AppIconName::from_settings(" TITANIUM "), Some(AppIconName::Titanium)); + } + #[test] fn nord_carries_the_flush_card_geometry() { // Nord 是卡几何抽象的第一个实验者:半径与卡缝双双归零、靠一条竖线分界。 @@ -1352,6 +1441,7 @@ mod tests { assert_eq!(settings.windowing_behavior, WindowingBehaviorName::UseAnyExisting); assert_eq!(settings.cell_width_mode, CellWidthModeName::Compact); assert!(!settings.fetch); + assert!(settings.auto_check_updates); assert!(!settings.keep_session); assert!(settings.restore_session); assert!(settings.resume_ai); @@ -1366,6 +1456,19 @@ mod tests { assert_eq!(settings.bell, BellModeName::Both); } + #[test] + fn automatic_update_checks_can_be_disabled_without_changing_the_default() { + assert!(RuntimeSettings::from_raw(&RawSettings::default()).auto_check_updates); + assert!( + RuntimeSettings::from_raw(&RawSettings::from_text("auto_check_updates=1\n")) + .auto_check_updates + ); + assert!( + !RuntimeSettings::from_raw(&RawSettings::from_text("auto_check_updates=0\n")) + .auto_check_updates + ); + } + #[test] fn bell_mode_parses_known_values_and_rejects_junk() { assert_eq!(BellModeName::from_settings("none"), Some(BellModeName::None)); diff --git a/nebula_terminal/src/event.rs b/nebula_terminal/src/event.rs index af3ca4f..b58f67a 100644 --- a/nebula_terminal/src/event.rs +++ b/nebula_terminal/src/event.rs @@ -29,12 +29,12 @@ pub enum Event { /// bundled PowerShell prompt emits). CwdReport(String), - /// An iTerm2 OSC 1337 inline image (PNG), sniffed out of the PTY stream. + /// An iTerm2 OSC 1337 inline image, sniffed out of the PTY stream. /// /// `abs_line` anchors the image's top row in the grid's absolute line /// numbering (see `Grid::scrolled_out`); `width`/`height` are the display /// size in pixels, already scaled to fit the terminal width. - InlineImage { png: Arc>, abs_line: usize, width: f32, height: f32 }, + InlineImage { data: Arc>, abs_line: usize, width: f32, height: f32 }, /// OSC 133;C — a command started executing in this pane. CommandStart, @@ -109,8 +109,8 @@ impl Debug for Event { Event::PtyWrite(text) => write!(f, "PtyWrite({text})"), Event::Title(title) => write!(f, "Title({title})"), Event::CwdReport(cwd) => write!(f, "CwdReport({cwd})"), - Event::InlineImage { png, abs_line, width, height } => { - write!(f, "InlineImage({} bytes @{abs_line}, {width}x{height})", png.len()) + Event::InlineImage { data, abs_line, width, height } => { + write!(f, "InlineImage({} bytes @{abs_line}, {width}x{height})", data.len()) }, Event::CommandStart => write!(f, "CommandStart"), Event::CommandDone { exit_code } => write!(f, "CommandDone({exit_code:?})"), diff --git a/nebula_terminal/src/event_loop.rs b/nebula_terminal/src/event_loop.rs index 8ffb1c6..390a759 100644 --- a/nebula_terminal/src/event_loop.rs +++ b/nebula_terminal/src/event_loop.rs @@ -225,7 +225,7 @@ impl StreamProcessor { advanced = offset; terminal.nebula_add_prompt_mark(); }, - OscEvent::InlineImage { png, width, height } => { + OscEvent::InlineImage { data, width, height } => { self.parser.advance(terminal, &bytes[advanced..offset]); advanced = offset; let (cell_w, cell_h) = self.window_size.map_or((9.0, 20.0), |ws| { @@ -241,7 +241,7 @@ impl StreamProcessor { self.parser.advance(terminal, b"\r\n"); } event_proxy.send_event(Event::InlineImage { - png: std::sync::Arc::new(png), + data: std::sync::Arc::new(data), abs_line, width: disp_w, height: disp_h, diff --git a/nebula_terminal/src/osc_cwd.rs b/nebula_terminal/src/osc_cwd.rs index 2f7310c..b089f2c 100644 --- a/nebula_terminal/src/osc_cwd.rs +++ b/nebula_terminal/src/osc_cwd.rs @@ -26,9 +26,13 @@ /// shorter; anything longer is not a directory report and gets dropped. const MAX_PAYLOAD: usize = 4096; -/// Cap for OSC 1337 inline-image payloads (base64 PNG). Anything larger is -/// dropped rather than buffered forever. +/// Cap for OSC 1337 inline-image payloads (metadata plus base64). Anything +/// larger is dropped while it is still arriving rather than buffered forever. const MAX_IMAGE_PAYLOAD: usize = 12 * 1024 * 1024; +/// A compressed image can expand by orders of magnitude. Keep a 4K screenshot +/// comfortably inside the budget, but reject image bombs before a decoder sees +/// them. The frontend repeats this check as a defense-in-depth boundary. +const MAX_IMAGE_PIXELS: u64 = 16 * 1024 * 1024; /// 远端 Hook JSON 使用 Base64 传输;上限阻止伪造 OSC 长时间占用内存。 const MAX_HOOK_PAYLOAD: usize = 96 * 1024; @@ -61,9 +65,11 @@ pub enum OscEvent { Progress { state: u8, value: Option }, /// Nebula 远端 Hook 私有 OSC:随机通道令牌 + 原始 Hook 信封。 RemoteHook { token: String, envelope: Vec }, - /// OSC 1337 `File=...inline=1:` — an iTerm2 inline image (PNG). - /// `width`/`height` come from the PNG header, in pixels. - InlineImage { png: Vec, width: u32, height: u32 }, + /// OSC 1337 `File=...inline=1:` — an iTerm2 inline image. + /// Only static PNG/JPEG/GIF input is accepted; animated GIFs are rendered + /// as their first frame by the frontend. + /// `width`/`height` come from the encoded image header, in pixels. + InlineImage { data: Vec, width: u32, height: u32 }, } #[derive(Default, Clone, Copy, PartialEq, Eq)] @@ -292,8 +298,9 @@ fn parse_remote_hook(rest: &[u8]) -> Option { } /// Parse an OSC 1337 body (`File=key=value;...:`) into an inline -/// image event. Only `inline=1` PNG payloads are accepted; `width`/`height` -/// come straight from the PNG IHDR, so broken/foreign params can't lie. +/// image event. Only `inline=1` PNG/JPEG/GIF payloads are accepted; +/// `width`/`height` come from the encoded file rather than terminal params, so +/// broken or hostile metadata cannot lie about the allocation size. fn parse_osc1337_image(rest: &[u8]) -> Option { use base64::Engine as _; @@ -307,7 +314,7 @@ fn parse_osc1337_image(rest: &[u8]) -> Option { return None; } - let png = base64::engine::general_purpose::STANDARD + let data = base64::engine::general_purpose::STANDARD .decode(data) .or_else(|_| { // Some emitters wrap base64 in whitespace/newlines; strip and retry. @@ -317,11 +324,21 @@ fn parse_osc1337_image(rest: &[u8]) -> Option { }) .ok()?; - let (width, height) = png_dimensions(&png)?; - Some(OscEvent::InlineImage { png, width, height }) + let (width, height) = image_dimensions(&data)?; + let pixels = u64::from(width).checked_mul(u64::from(height))?; + if pixels > MAX_IMAGE_PIXELS { + return None; + } + Some(OscEvent::InlineImage { data, width, height }) +} + +/// Read dimensions from the supported formats without allocating their pixel +/// buffers. This is deliberately a small sniffer, not a decoder: the frontend +/// still validates and decodes the bytes in a bounded background job. +fn image_dimensions(data: &[u8]) -> Option<(u32, u32)> { + png_dimensions(data).or_else(|| gif_dimensions(data)).or_else(|| jpeg_dimensions(data)) } -/// Read a PNG's pixel dimensions from its IHDR without decoding the image. fn png_dimensions(png: &[u8]) -> Option<(u32, u32)> { const MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n"; if png.len() < 24 || !png.starts_with(MAGIC) || &png[12..16] != b"IHDR" { @@ -329,7 +346,70 @@ fn png_dimensions(png: &[u8]) -> Option<(u32, u32)> { } let width = u32::from_be_bytes(png[16..20].try_into().ok()?); let height = u32::from_be_bytes(png[20..24].try_into().ok()?); - (width > 0 && height > 0 && width <= 16384 && height <= 16384).then_some((width, height)) + valid_dimensions(width, height) +} + +fn gif_dimensions(gif: &[u8]) -> Option<(u32, u32)> { + if gif.len() < 10 || !(gif.starts_with(b"GIF87a") || gif.starts_with(b"GIF89a")) { + return None; + } + let width = u16::from_le_bytes(gif[6..8].try_into().ok()?) as u32; + let height = u16::from_le_bytes(gif[8..10].try_into().ok()?) as u32; + valid_dimensions(width, height) +} + +fn jpeg_dimensions(jpeg: &[u8]) -> Option<(u32, u32)> { + if jpeg.len() < 4 || !jpeg.starts_with(&[0xff, 0xd8]) { + return None; + } + + let mut offset = 2; + while offset < jpeg.len() { + while jpeg.get(offset) == Some(&0xff) { + offset += 1; + } + let marker = *jpeg.get(offset)?; + offset += 1; + + // Standalone markers have no length field. + if marker == 0xd8 || marker == 0xd9 || marker == 0x01 || (0xd0..=0xd7).contains(&marker) { + continue; + } + + let segment_len = + u16::from_be_bytes(jpeg.get(offset..offset + 2)?.try_into().ok()?) as usize; + if segment_len < 2 || offset.checked_add(segment_len)? > jpeg.len() { + return None; + } + + let is_start_of_frame = matches!( + marker, + 0xc0..=0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf + ); + if is_start_of_frame { + if segment_len < 7 { + return None; + } + let height = + u16::from_be_bytes(jpeg.get(offset + 3..offset + 5)?.try_into().ok()?) as u32; + let width = + u16::from_be_bytes(jpeg.get(offset + 5..offset + 7)?.try_into().ok()?) as u32; + return valid_dimensions(width, height); + } + + // Start-of-scan is followed by entropy-coded data; a valid dimensions + // marker must have appeared before it. + if marker == 0xda { + return None; + } + offset += segment_len; + } + None +} + +fn valid_dimensions(width: u32, height: u32) -> Option<(u32, u32)> { + (width > 0 && height > 0 && u64::from(width) * u64::from(height) <= MAX_IMAGE_PIXELS) + .then_some((width, height)) } /// Decode an OSC 7 body (`file://HOST/PATH`) into a native path. @@ -603,8 +683,12 @@ mod tests { ]; fn tiny_png_osc() -> Vec { + inline_image_osc(TINY_PNG) + } + + fn inline_image_osc(data: &[u8]) -> Vec { use base64::Engine as _; - let b64 = base64::engine::general_purpose::STANDARD.encode(TINY_PNG); + let b64 = base64::engine::general_purpose::STANDARD.encode(data); let mut seq = b"\x1b]1337;File=name=eC5wbmc=;size=70;inline=1:".to_vec(); seq.extend_from_slice(b64.as_bytes()); seq.push(0x07); @@ -618,8 +702,8 @@ mod tests { assert_eq!(ev.len(), 1); assert_eq!(ev[0].0, seq.len()); match &ev[0].1 { - OscEvent::InlineImage { png, width, height } => { - assert_eq!((png.as_slice(), *width, *height), (TINY_PNG, 1, 1)); + OscEvent::InlineImage { data, width, height } => { + assert_eq!((data.as_slice(), *width, *height), (TINY_PNG, 1, 1)); }, other => panic!("expected InlineImage, got {other:?}"), } @@ -643,4 +727,36 @@ mod tests { assert_eq!(ev.len(), 1); assert!(matches!(ev[0].1, OscEvent::InlineImage { width: 1, height: 1, .. })); } + + #[test] + fn osc1337_accepts_jpeg_and_gif_headers() { + // Minimal SOF0 segment declaring a 3x2 image. The protocol layer only + // sniffs dimensions; the frontend decoder still rejects truncated data. + let jpeg = [ + 0xff, 0xd8, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x02, 0x00, 0x03, 0x03, 0x01, 0x11, + 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, + ]; + let jpeg_events = events(&inline_image_osc(&jpeg)); + assert!(matches!( + jpeg_events.as_slice(), + [(_, OscEvent::InlineImage { width: 3, height: 2, .. })] + )); + + let gif = b"GIF89a\x04\x00\x05\x00"; + let gif_events = events(&inline_image_osc(gif)); + assert!(matches!( + gif_events.as_slice(), + [(_, OscEvent::InlineImage { width: 4, height: 5, .. })] + )); + } + + #[test] + fn osc1337_rejects_video_unknown_formats_and_pixel_bombs() { + assert!(events(&inline_image_osc(b"\0\0\0\x18ftypmp42not-an-image")).is_empty()); + + let mut oversized = TINY_PNG.to_vec(); + oversized[16..20].copy_from_slice(&5000u32.to_be_bytes()); + oversized[20..24].copy_from_slice(&5000u32.to_be_bytes()); + assert!(events(&inline_image_osc(&oversized)).is_empty()); + } } diff --git a/nebula_terminal/src/tty/unix.rs b/nebula_terminal/src/tty/unix.rs index 361de21..65482c1 100644 --- a/nebula_terminal/src/tty/unix.rs +++ b/nebula_terminal/src/tty/unix.rs @@ -158,6 +158,10 @@ impl ShellUser { } } +pub fn default_shell_program() -> Result { + ShellUser::from_env().map(|user| user.shell) +} + #[cfg(not(target_os = "macos"))] fn default_shell_command(shell: &str, _user: &str, _home: &str) -> Command { Command::new(shell) @@ -401,6 +405,11 @@ impl EventedPty for Pty { Ok(exit_status) => Some(ChildEvent::Exited(exit_status)), } } + + #[inline] + fn child_pid(&self) -> Option { + Some(self.child.id()) + } } impl OnResize for Pty { diff --git a/packaging/linux/io.github.kuddev.nebula.preview.desktop b/packaging/linux/io.github.kuddev.nebula.preview.desktop new file mode 100644 index 0000000..800fcf4 --- /dev/null +++ b/packaging/linux/io.github.kuddev.nebula.preview.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Type=Application +Name=Nebula Terminal Preview +GenericName=Terminal Emulator +Comment=GPU-accelerated terminal for local and remote workflows +Exec=nebula +Icon=io.github.kuddev.nebula.preview +Terminal=false +Categories=System;TerminalEmulator; +Keywords=terminal;shell;prompt;ssh; +StartupNotify=true +StartupWMClass=Nebula diff --git a/packaging/linux/io.github.kuddev.nebula.preview.metainfo.xml b/packaging/linux/io.github.kuddev.nebula.preview.metainfo.xml new file mode 100644 index 0000000..4e82537 --- /dev/null +++ b/packaging/linux/io.github.kuddev.nebula.preview.metainfo.xml @@ -0,0 +1,28 @@ + + + io.github.kuddev.nebula.preview + Nebula Terminal Preview +

GPU-accelerated terminal for local and remote workflows + CC0-1.0 + GPL-3.0-or-later + Kuddev + +

+ Nebula Terminal combines GPU-accelerated terminal rendering, local and + SSH sessions, file workflows, and command-line agent integrations. + Preview packages are intended for cross-platform testing before a stable + release. +

+
+ io.github.kuddev.nebula.preview.desktop + + nebula + + + System + TerminalEmulator + + https://github.com/Kuddev/nebula + https://github.com/Kuddev/nebula/issues + + diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist new file mode 100644 index 0000000..8e3f322 --- /dev/null +++ b/packaging/macos/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Nebula Terminal Preview + CFBundleExecutable + nebula + CFBundleIconFile + nebula.icns + CFBundleIdentifier + io.github.kuddev.nebula.preview + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Nebula Terminal Preview + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.0.0 + CFBundleVersion + 1 + CFBundleGetInfoString + Nebula Terminal Preview + LSApplicationCategoryType + public.app-category.utilities + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + + diff --git a/scripts/check_platform_cfg.py b/scripts/check_platform_cfg.py new file mode 100644 index 0000000..070b51b --- /dev/null +++ b/scripts/check_platform_cfg.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""平台 cfg 预算:`nebula_app/src/platform/` 之外的平台 `cfg` 只能减不能增。 + +三端一致性的编译门。平台差异应收进 `platform/`(能力表 + 每平台实现), +业务代码写 `if CAPABILITIES.xxx` 而不是 `#[cfg(windows)]`。基线数字写在 +`scripts/platform_cfg_budget.txt`:超预算失败;低于预算时提示把数字收紧, +和 guardrails 的其他预算一样,报「少于预算」就照报告改数字。 + +用法:python3 scripts/check_platform_cfg.py [--update] +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SOURCE_ROOT = ROOT / "nebula_app" / "src" +BUDGET_FILE = ROOT / "scripts" / "platform_cfg_budget.txt" + +# 只数平台维度的 cfg:windows / unix / target_os。feature 维度(legacy-shell、 +# gpui-shell)不是本脚本的对象。`cfg!(...)`、`#[cfg(...)]`、`cfg_attr(...)`、 +# `all(...)`/`any(...)`/`not(...)` 里嵌套的都算一次。 +PLATFORM_CFG = re.compile(r'\b(?:windows\b|unix\b|target_os\s*=\s*"[a-z0-9_]+")') +CFG_CONTEXT = re.compile(r"cfg(?:_attr)?!?\s*\(([^)]*(?:\([^)]*\)[^)]*)*)\)") + +# 旧壳(legacy-shell)代码不在预算内:它只在显式 feature 下编译,也不是三端 +# 发布对象。目录清单与 main.rs 的 `#[cfg(feature = "legacy-shell")] mod` 对应。 +EXEMPT_DIRS = {"platform", "display", "renderer", "window_context", "input", "product_ui"} +EXEMPT_FILES = {"event.rs", "polling"} + + +def count_platform_cfgs(path: Path) -> int: + text = path.read_text(encoding="utf-8", errors="replace") + return sum(len(PLATFORM_CFG.findall(match.group(1))) for match in CFG_CONTEXT.finditer(text)) + + +def is_exempt(path: Path) -> bool: + relative = path.relative_to(SOURCE_ROOT).parts + return bool(set(relative[:-1]) & EXEMPT_DIRS) or relative[0] in EXEMPT_FILES + + +def main(argv: list[str]) -> int: + counts = { + path.relative_to(ROOT).as_posix(): count_platform_cfgs(path) + for path in sorted(SOURCE_ROOT.rglob("*.rs")) + if not is_exempt(path) + } + total = sum(counts.values()) + if "--update" in argv: + BUDGET_FILE.write_text(f"{total}\n", encoding="utf-8") + print(f"platform cfg budget set to {total}") + return 0 + budget = int(BUDGET_FILE.read_text(encoding="utf-8").strip()) + print(f"platform cfg outside platform/: {total} (budget {budget})") + if total > budget: + worst = sorted(counts.items(), key=lambda item: -item[1])[:10] + print("budget exceeded; heaviest files:", file=sys.stderr) + for name, count in worst: + print(f" {count:4d} {name}", file=sys.stderr) + print( + "move the platform branch into nebula_app/src/platform/ (or a CAPABILITIES check)", + file=sys.stderr, + ) + return 1 + if total < budget: + print(f"::notice::platform cfg count dropped to {total}; run with --update to tighten the budget") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/scripts/conformance/README.md b/scripts/conformance/README.md new file mode 100644 index 0000000..0ab2f68 --- /dev/null +++ b/scripts/conformance/README.md @@ -0,0 +1,94 @@ +# Nebula Runtime Conformance + +This standard-library-only suite checks user-visible behavior through Nebula's +public Runtime API. It launches one isolated application process with a private +`NEBULA_CONFIG_DIR`, talks to its loopback TCP JSONL endpoint, and only stops +the process it launched. + +The cases run in this order: + +1. `boot`: startup, Runtime API discovery, default shell, working directory, + and pane process-tree availability. +2. `echo`: cross-shell command input and terminal-tail reading. +3. `resize`: split-ratio mutation and propagation to both PTY column counts. +4. `split`: nested horizontal/vertical layouts, focus, and cleanup. +5. `scrollback`: a 600-line burst with at least 500 retained history rows. +6. `session`: live autosave, forced process termination, cold restore, a + custom tab label, and live PTYs in every restored tab. +7. `ssh_loop`: currently skipped because Runtime API v1 has no `ssh.open` + method. The skip is explicit in the report. +8. `paste`: multiline bracketed-paste transport when the target advertises it, + or an explicit safety refusal when it does not; neither path may submit. +9. `cjk_roundtrip`: UTF-8/CJK input through Runtime API, PTY, and terminal grid. +10. `close`: last-window shutdown within five seconds. It runs last because it + intentionally terminates the application. + +The suite does not claim coverage of native IME composition or the manual +clipboard confirmation dialog. Those paths require GUI automation and cannot +be reached through Runtime API v1. The CJK and paste cases cover only the +runtime-input paths stated above. + +## Run + +Run a freshly built binary or packaged artifact. ZIP archives, macOS `.app` +bundles, AppImages, unpacked package directories, and direct executable paths +are accepted. + +```powershell +python scripts/conformance/run.py ` + --app target/debug/nebula.exe ` + --platform windows-x86_64 ` + --output dist/conformance/windows-report.json +``` + +```sh +python3 scripts/conformance/run.py \ + --app target/release/nebula \ + --platform linux-x86_64 \ + --output dist/conformance/linux-report.json +``` + +`report.json` is the default output. Nebula logs are retained next to it in a +`-artifacts` directory. The isolated config and work directories +are temporary. A failed or timed-out run is cleaned up by killing only the +process owned by that run. + +The runner validates `golden/common.json` on every run. If a reviewed platform +golden exists, it is compared automatically. The first successful run on a new +platform reports that the golden is absent without treating that absence as a +failure. After reviewing the report, create or replace the platform baseline: + +```sh +python3 scripts/conformance/run.py \ + --app path/to/fresh/nebula \ + --platform linux-x86_64 \ + --output dist/conformance/linux-report.json \ + --update-golden +``` + +`--update-golden` refuses to write a baseline when a case or common invariant +fails. `--no-platform-golden` disables only the platform-specific comparison; +common invariants still apply. + +Compare two or more reports after each has passed its common checks: + +```sh +python3 scripts/conformance/run.py --compare \ + dist/conformance/windows-report.json \ + dist/conformance/linux-report.json \ + dist/conformance/macos-report.json +``` + +Only fields documented in `golden/whitelist.json` may vary. Timing and terminal +geometry are removed from platform goldens; default shell/path shape and the +optional SSH result are additionally ignored only for cross-platform report +comparison. + +## Protocol Boundary + +All operating systems publish the same discovery record in +`/runtime.port`. It contains a loopback TCP port and token. +Each connection sends one UTF-8 JSON request line using protocol +`nebula.runtime` version 1 and reads one response line. This suite deliberately +does not use Windows named pipes or Unix-domain sockets because Nebula's actual +Runtime API transport is loopback TCP on every platform. diff --git a/scripts/conformance/__init__.py b/scripts/conformance/__init__.py new file mode 100644 index 0000000..9c87a27 --- /dev/null +++ b/scripts/conformance/__init__.py @@ -0,0 +1 @@ +"""Nebula cross-platform runtime conformance suite.""" diff --git a/scripts/conformance/cases/__init__.py b/scripts/conformance/cases/__init__.py new file mode 100644 index 0000000..436c377 --- /dev/null +++ b/scripts/conformance/cases/__init__.py @@ -0,0 +1 @@ +"""Behavior cases executed by the Nebula conformance runner.""" diff --git a/scripts/conformance/cases/boot.py b/scripts/conformance/cases/boot.py new file mode 100644 index 0000000..cccee9f --- /dev/null +++ b/scripts/conformance/cases/boot.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from conformance.harness import ConformanceContext, PROTOCOL_NAME, PROTOCOL_VERSION, path_shape, require + + +def run(ctx: ConformanceContext) -> dict[str, object]: + snapshot = ctx.snapshot() + ctx.refresh_targets(snapshot) + windows = snapshot.get("windows") or [] + require(len(windows) == 1, f"isolated boot created {len(windows)} windows instead of one") + window = windows[0] + require(window.get("tabs"), "boot window has no tab") + require(snapshot.get("protocol_version") == PROTOCOL_VERSION, "snapshot protocol mismatch") + require(ctx.description.get("protocol") == PROTOCOL_NAME, "describe protocol mismatch") + require( + ctx.description.get("protocol_version") == PROTOCOL_VERSION, + "describe protocol version mismatch", + ) + required_methods = { + "runtime.snapshot", + "window.close", + "tab.new", + "tab.rename", + "pane.split", + "pane.resize", + "pane.prompt", + "pane.paste", + "pane.read", + "pane.procs", + "pane.send_key", + } + capabilities = set(ctx.description.get("capabilities") or []) + missing = sorted(required_methods - capabilities) + require(not missing, f"runtime.describe omits required methods: {missing}") + + shell = ctx.detect_shell() + require( + shell in {"powershell", "pwsh", "cmd", "bash", "zsh", "fish", "nu", "sh", "dash"}, + f"unrecognized default shell: {shell}", + ) + process_tree = ctx.api( + "pane.procs", {"window_id": ctx.window_id, "pane_id": ctx.pane_id} + ) + require(process_tree.get("processes"), "pane.procs returned an empty process tree") + tab = ctx.tab_for_pane(snapshot, ctx.pane_id) + pane = next(item for item in tab["panes"] if item["id"] == ctx.pane_id) + cwd = pane.get("cwd", "") + require(path_shape(cwd) != "relative", f"pane cwd is not absolute: {cwd!r}") + + return { + "window_count": 1, + "default_tab_present": True, + "runtime_methods_present": True, + "process_tree_available": True, + "shell_category": shell, + "cwd_shape": path_shape(cwd), + "startup_ms": ctx.startup_ms, + } diff --git a/scripts/conformance/cases/cjk_roundtrip.py b/scripts/conformance/cases/cjk_roundtrip.py new file mode 100644 index 0000000..e703b38 --- /dev/null +++ b/scripts/conformance/cases/cjk_roundtrip.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from conformance.harness import ConformanceContext, require + + +def run(ctx: ConformanceContext) -> dict[str, object]: + # This intentionally uses non-ASCII text: the case verifies the UTF-8 + # request -> PTY -> terminal-grid path. Native IME composition needs a GUI + # automation layer and is not claimed here. + text = "NEBULA_CJK_跨平台终端" + ctx.prompt(text, submit=False) + read = ctx.poll( + # PowerShell's restored multi-line prompt can leave the input above the + # bottom 20 rows of an otherwise empty grid. Scan the full viewport and + # available history instead of mistaking trailing blank rows for loss. + lambda: ctx.read(lines=160), + lambda value: text in value["text"], + "CJK text did not round-trip through the terminal grid", + ) + require(text in read["text"], "CJK input was altered") + ctx.send_key("c", control=True) + return {"utf8_roundtrip": True, "coverage": "runtime_input_not_native_ime"} diff --git a/scripts/conformance/cases/close.py b/scripts/conformance/cases/close.py new file mode 100644 index 0000000..999f699 --- /dev/null +++ b/scripts/conformance/cases/close.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from conformance.harness import ConformanceContext + + +def run(ctx: ConformanceContext) -> dict[str, object]: + ctx.ensure_single_tab() + exit_ms = ctx.close_and_wait(timeout=5.0) + return {"exited_within_5s": True, "exit_ms": exit_ms} diff --git a/scripts/conformance/cases/echo.py b/scripts/conformance/cases/echo.py new file mode 100644 index 0000000..ce376e9 --- /dev/null +++ b/scripts/conformance/cases/echo.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import re + +from conformance.harness import ConformanceContext + + +def run(ctx: ConformanceContext) -> dict[str, object]: + marker = "NEBULA_ECHO_OK" + ctx.prompt(ctx.marker_command("NEBULA_ECHO_", "OK")) + ctx.wait_for_line(re.compile(marker)) + return {"tail_contains_marker": True} diff --git a/scripts/conformance/cases/paste.py b/scripts/conformance/cases/paste.py new file mode 100644 index 0000000..00f0c63 --- /dev/null +++ b/scripts/conformance/cases/paste.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from conformance.harness import ApiFailure, ConformanceContext, require + + +def run(ctx: ConformanceContext) -> dict[str, object]: + first = "NEBULA_PASTE_ALPHA" + last = "NEBULA_PASTE_OMEGA" + try: + ctx.paste(f"{first}\n{last}", submit=False) + except ApiFailure as error: + require( + error.error.get("code") == "unsafe_input_mode", + f"pane.paste failed outside its documented safety guard: {error}", + ) + return { + "safe_delivery_or_refusal": True, + "outcome": "unsafe_input_mode", + "multiline_roundtrip": False, + "implicit_submission_prevented": True, + "coverage": "runtime_paste_safety", + } + + read = ctx.poll( + lambda: ctx.read(lines=40), + lambda value: first in value["text"] and last in value["text"], + "multi-line bracketed paste did not reach the terminal grid", + ) + require(first in read["text"] and last in read["text"], "paste text was not preserved") + ctx.send_key("c", control=True) + return { + "safe_delivery_or_refusal": True, + "outcome": "delivered", + "multiline_roundtrip": True, + "implicit_submission_prevented": True, + "coverage": "runtime_paste_safety", + } diff --git a/scripts/conformance/cases/resize.py b/scripts/conformance/cases/resize.py new file mode 100644 index 0000000..4fa0c1f --- /dev/null +++ b/scripts/conformance/cases/resize.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from conformance.harness import ConformanceContext, require + + +def run(ctx: ConformanceContext) -> dict[str, object]: + source = ctx.pane_id + split = ctx.api( + "pane.split", + { + "window_id": ctx.window_id, + "pane_id": source, + "direction": "left_right", + }, + ) + sibling = int(split["action"]["pane_id"]) + try: + source_before = ctx.measure_columns(source) + sibling_before = ctx.measure_columns(sibling) + 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) + sibling_after = ctx.measure_columns(sibling) + require(abs(ratio - 0.70) < 0.001, f"runtime reported wrong split ratio: {ratio}") + require( + source_after > source_before, + f"70% pane did not grow: {source_before} -> {source_after}", + ) + require( + sibling_after < sibling_before, + f"30% pane did not shrink: {sibling_before} -> {sibling_after}", + ) + require( + abs((source_before + sibling_before) - (source_after + sibling_after)) <= 4, + "PTY column total changed beyond divider rounding tolerance", + ) + return { + "layout_ratio_applied": True, + "pty_columns_changed": True, + "source_columns_before": source_before, + "source_columns_after": source_after, + "sibling_columns_before": sibling_before, + "sibling_columns_after": sibling_after, + } + 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/cases/scrollback.py b/scripts/conformance/cases/scrollback.py new file mode 100644 index 0000000..0370c1a --- /dev/null +++ b/scripts/conformance/cases/scrollback.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import re + +from conformance.harness import ConformanceContext, require + + +def run(ctx: ConformanceContext) -> dict[str, object]: + generated_lines = 600 + marker = "NEBULA_SCROLL_DONE" + ctx.prompt(ctx.scrollback_command(generated_lines, marker)) + _, read = ctx.wait_for_line(re.compile(marker), timeout=20.0) + read = ctx.poll( + lambda: ctx.read(lines=40), + lambda value: int(value["history_available"]) >= 500, + "terminal history never retained 500 rows", + timeout=5.0, + ) + history = int(read["history_available"]) + require(history >= 500, f"only {history} scrollback rows are available") + return { + "generated_lines": generated_lines, + "completion_marker_seen": True, + "history_at_least_500": True, + "history_available": history, + } diff --git a/scripts/conformance/cases/session.py b/scripts/conformance/cases/session.py new file mode 100644 index 0000000..7124e43 --- /dev/null +++ b/scripts/conformance/cases/session.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +import json + +from conformance.harness import ConformanceContext, ConformanceError, require + + +RESTORED_LABEL = "conformance-restored" + + +def run(ctx: ConformanceContext) -> dict[str, object]: + ctx.ensure_single_tab() + ctx.api( + "tab.rename", + {"window_id": ctx.window_id, "tab_index": 0, "name": RESTORED_LABEL}, + ) + ctx.api("tab.new", {"window_id": ctx.window_id}) + + saved = ctx.wait_for_session( + lambda value: len(value.get("tabs") or []) == 2 + and value["tabs"][0].get("custom_name") == RESTORED_LABEL + ) + require(not saved.get("clean_exit", False), "autosave incorrectly marked a live session clean") + + restart_ms = ctx.restart() + snapshot = ctx.poll( + ctx.snapshot, + lambda value: len((value.get("windows") or [{}])[0].get("tabs") or []) == 2, + "cold restart did not restore two tabs", + timeout=10.0, + ) + ctx.refresh_targets(snapshot) + labels = [tab.get("label") for tab in snapshot["windows"][0]["tabs"]] + require(RESTORED_LABEL in labels, f"restored tab label is missing: {labels}") + ctx.detect_shell() + + pane_ids = [ + int(pane["id"]) + for tab in snapshot["windows"][0]["tabs"] + if tab.get("kind") == "shell" + for pane in tab.get("panes") or [] + ] + markers = {pane_id: f"NEBULA_RESTORED_PANE_{pane_id}_OK" for pane_id in pane_ids} + observations: dict[int, dict[str, object]] = {} + for pane_id, marker in markers.items(): + processes = ctx.api( + "pane.procs", + {"window_id": ctx.window_id, "pane_id": pane_id}, + allow_error=True, + ) + observations[pane_id] = {"processes": processes} + try: + # Keep the complete marker out of PowerShell's command echo. Only + # executed output may satisfy the observation below. + ctx.prompt(ctx.marker_command(marker.removesuffix("OK"), "OK"), pane_id) + except Exception as error: + observations[pane_id]["prompt_error"] = str(error) + + def read_restored_panes() -> dict[int, dict[str, object]]: + for pane_id in pane_ids: + try: + observations[pane_id]["read"] = ctx.read(pane_id, lines=40) + except Exception as error: + observations[pane_id]["read_error"] = str(error) + return observations + + try: + ctx.poll( + read_restored_panes, + lambda value: all( + markers[pane_id] in str(value[pane_id].get("read", {}).get("text", "")) + for pane_id in pane_ids + ), + "restored panes did not answer runtime input", + timeout=10.0, + ) + except Exception as error: + raise ConformanceError( + f"{error}; restored pane diagnostics: " + f"{json.dumps(observations, ensure_ascii=False, sort_keys=True)}" + ) from error + + return { + "autosave_converged": True, + "unclean_exit_restored": True, + "restored_tab_count": 2, + "restored_custom_label": True, + "restored_panes_live": True, + "restart_ms": restart_ms, + } diff --git a/scripts/conformance/cases/split.py b/scripts/conformance/cases/split.py new file mode 100644 index 0000000..c552607 --- /dev/null +++ b/scripts/conformance/cases/split.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from conformance.harness import ConformanceContext, require + + +def _directions(layout: dict[str, object] | None) -> set[str]: + if not layout or layout.get("type") != "split": + return set() + directions = {str(layout["direction"])} + directions.update(_directions(layout.get("first"))) + directions.update(_directions(layout.get("second"))) + return directions + + +def run(ctx: ConformanceContext) -> dict[str, object]: + original = ctx.pane_id + right = ctx.api( + "pane.split", + { + "window_id": ctx.window_id, + "pane_id": original, + "direction": "left_right", + }, + ) + right_id = int(right["action"]["pane_id"]) + down_id = 0 + try: + down = ctx.api( + "pane.split", + { + "window_id": ctx.window_id, + "pane_id": right_id, + "direction": "top_bottom", + }, + ) + down_id = int(down["action"]["pane_id"]) + snapshot = ctx.snapshot() + tab = ctx.tab_for_pane(snapshot, down_id) + require(len(tab["panes"]) == 3, f"split tab has {len(tab['panes'])} panes") + require(tab.get("focused_pane_id") == down_id, "newest split did not receive focus") + require( + _directions(tab.get("layout")) == {"left_right", "top_bottom"}, + f"unexpected split tree: {tab.get('layout')!r}", + ) + ctx.api("window.focus", {"window_id": ctx.window_id, "pane_id": original}) + focused = ctx.tab_for_pane(ctx.snapshot(), original) + require(focused.get("focused_pane_id") == original, "explicit pane focus did not apply") + return { + "pane_count": 3, + "nested_directions_present": True, + "new_pane_focused": True, + "explicit_focus_applied": True, + } + finally: + if down_id: + ctx.best_effort_api( + "pane.close", {"window_id": ctx.window_id, "pane_id": down_id} + ) + ctx.best_effort_api( + "pane.close", {"window_id": ctx.window_id, "pane_id": right_id} + ) + ctx.best_effort_api( + "window.focus", {"window_id": ctx.window_id, "pane_id": original} + ) diff --git a/scripts/conformance/cases/ssh_loop.py b/scripts/conformance/cases/ssh_loop.py new file mode 100644 index 0000000..a76d29b --- /dev/null +++ b/scripts/conformance/cases/ssh_loop.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from conformance.harness import ConformanceContext, ConformanceError, SkipCase + + +def run(ctx: ConformanceContext) -> dict[str, object]: + capabilities = set(ctx.description.get("capabilities") or []) + if "ssh.open" not in capabilities: + raise SkipCase("runtime_api_has_no_ssh_open_method") + raise ConformanceError("ssh.open is now available; implement its conformance case before publishing") diff --git a/scripts/conformance/golden/common.json b/scripts/conformance/golden/common.json new file mode 100644 index 0000000..4ff9af8 --- /dev/null +++ b/scripts/conformance/golden/common.json @@ -0,0 +1,41 @@ +{ + "schema_version": 1, + "required": { + "summary.total": 10, + "summary.failed": 0, + "cases.boot.status": "passed", + "cases.boot.window_count": 1, + "cases.boot.default_tab_present": true, + "cases.boot.runtime_methods_present": true, + "cases.boot.process_tree_available": true, + "cases.echo.status": "passed", + "cases.echo.tail_contains_marker": true, + "cases.resize.status": "passed", + "cases.resize.layout_ratio_applied": true, + "cases.resize.pty_columns_changed": true, + "cases.split.status": "passed", + "cases.split.pane_count": 3, + "cases.split.nested_directions_present": true, + "cases.split.new_pane_focused": true, + "cases.split.explicit_focus_applied": true, + "cases.scrollback.status": "passed", + "cases.scrollback.generated_lines": 600, + "cases.scrollback.completion_marker_seen": true, + "cases.scrollback.history_at_least_500": true, + "cases.session.status": "passed", + "cases.session.autosave_converged": true, + "cases.session.unclean_exit_restored": true, + "cases.session.restored_tab_count": 2, + "cases.session.restored_custom_label": true, + "cases.session.restored_panes_live": true, + "cases.paste.status": "passed", + "cases.paste.safe_delivery_or_refusal": true, + "cases.paste.implicit_submission_prevented": true, + "cases.paste.coverage": "runtime_paste_safety", + "cases.cjk_roundtrip.status": "passed", + "cases.cjk_roundtrip.utf8_roundtrip": true, + "cases.cjk_roundtrip.coverage": "runtime_input_not_native_ime", + "cases.close.status": "passed", + "cases.close.exited_within_5s": true + } +} diff --git a/scripts/conformance/golden/whitelist.json b/scripts/conformance/golden/whitelist.json new file mode 100644 index 0000000..283bda3 --- /dev/null +++ b/scripts/conformance/golden/whitelist.json @@ -0,0 +1,21 @@ +{ + "volatile": { + "build.*": "validated separately against the release commit by the Preview gate", + "cases.*.duration_ms": "wall-clock timing", + "cases.boot.startup_ms": "startup timing", + "cases.resize.*columns*": "window and font geometry", + "cases.scrollback.history_available": "viewport geometry", + "cases.session.restart_ms": "startup timing", + "cases.paste.outcome": "the target application may not advertise bracketed-paste mode", + "cases.paste.multiline_roundtrip": "true only when the target advertises bracketed-paste mode", + "cases.close.exit_ms": "shutdown timing" + }, + "cross_platform": { + "platform": "runner and architecture identity", + "cases.boot.shell_category": "platform default shell", + "cases.boot.cwd_shape": "native path syntax", + "summary.passed": "optional service availability", + "summary.skipped": "optional service availability", + "cases.ssh_loop.*": "SSH Runtime API and service availability" + } +} diff --git a/scripts/conformance/golden/windows.json b/scripts/conformance/golden/windows.json new file mode 100644 index 0000000..84beaec --- /dev/null +++ b/scripts/conformance/golden/windows.json @@ -0,0 +1,45 @@ +{ + "cases.boot.cwd_shape": "drive_absolute", + "cases.boot.default_tab_present": true, + "cases.boot.process_tree_available": true, + "cases.boot.runtime_methods_present": true, + "cases.boot.shell_category": "powershell", + "cases.boot.status": "passed", + "cases.boot.window_count": 1, + "cases.cjk_roundtrip.coverage": "runtime_input_not_native_ime", + "cases.cjk_roundtrip.status": "passed", + "cases.cjk_roundtrip.utf8_roundtrip": true, + "cases.close.exited_within_5s": true, + "cases.close.status": "passed", + "cases.echo.status": "passed", + "cases.echo.tail_contains_marker": true, + "cases.paste.coverage": "runtime_paste_safety", + "cases.paste.implicit_submission_prevented": true, + "cases.paste.safe_delivery_or_refusal": true, + "cases.paste.status": "passed", + "cases.resize.layout_ratio_applied": true, + "cases.resize.status": "passed", + "cases.scrollback.completion_marker_seen": true, + "cases.scrollback.generated_lines": 600, + "cases.scrollback.history_at_least_500": true, + "cases.scrollback.status": "passed", + "cases.session.autosave_converged": true, + "cases.session.restored_custom_label": true, + "cases.session.restored_panes_live": true, + "cases.session.restored_tab_count": 2, + "cases.session.status": "passed", + "cases.session.unclean_exit_restored": true, + "cases.split.explicit_focus_applied": true, + "cases.split.nested_directions_present": true, + "cases.split.new_pane_focused": true, + "cases.split.pane_count": 3, + "cases.split.status": "passed", + "cases.ssh_loop.reason": "runtime_api_has_no_ssh_open_method", + "cases.ssh_loop.status": "skipped", + "platform": "windows-x86_64", + "schema_version": 1, + "summary.failed": 0, + "summary.passed": 9, + "summary.skipped": 1, + "summary.total": 10 +} diff --git a/scripts/conformance/harness.py b/scripts/conformance/harness.py new file mode 100644 index 0000000..66f48d5 --- /dev/null +++ b/scripts/conformance/harness.py @@ -0,0 +1,729 @@ +"""Standard-library-only harness for Nebula's public Runtime API.""" + +from __future__ import annotations + +import fnmatch +import glob +import json +import os +import re +import shutil +import socket +import subprocess +import tempfile +import time +import zipfile +from pathlib import Path +from typing import Any, Callable + +PROTOCOL_NAME = "nebula.runtime" +PROTOCOL_VERSION = 1 +MAX_RESPONSE_BYTES = 32 * 1024 * 1024 +DEFAULT_STARTUP_TIMEOUT = 20.0 + + +class ConformanceError(RuntimeError): + """A test precondition or behavioral assertion failed.""" + + +class ApiFailure(ConformanceError): + def __init__(self, method: str, error: dict[str, Any]) -> None: + self.method = method + self.error = error + code = error.get("code", "unknown_error") + message = error.get("message", "runtime request failed") + super().__init__(f"{method}: {code}: {message}") + + +class SkipCase(RuntimeError): + """The environment cannot exercise an optional conformance case.""" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ConformanceError(message) + + +def normalize_text(value: str) -> str: + lines = value.replace("\r\n", "\n").replace("\r", "\n").split("\n") + return "\n".join(line.rstrip() for line in lines).rstrip() + + +def find_last_line_match(pattern: re.Pattern[str], text: str) -> re.Match[str] | None: + for line in reversed(text.splitlines()): + if match := pattern.search(line.strip()): + return match + return None + + +def normalize_path(value: str) -> str: + value = value.replace("\\", "/") + match = re.match(r"^([A-Za-z]):(?:/|$)", value) + if match: + drive = match.group(1).lower() + value = f"/{drive}/{value[match.end():]}" + value = re.sub(r"/{2,}", "/", value) + return value.rstrip("/") or "/" + + +def path_shape(value: str) -> str: + normalized = normalize_path(value) + if re.match(r"^/[a-z](?:/|$)", normalized): + return "drive_absolute" + if normalized.startswith("/"): + return "posix_absolute" + return "relative" + + +def shell_category(value: str) -> str | None: + token = value.strip().strip("-").replace("\\", "/").rsplit("/", 1)[-1].lower() + if token.endswith(".exe"): + token = token[:-4] + if "powershell" in token: + return "powershell" + if token in {"pwsh", "cmd", "bash", "zsh", "fish", "nu", "sh", "dash"}: + return token + if "nushell" in token: + return "nu" + return None + + +def flatten(value: Any, prefix: str = "") -> dict[str, Any]: + result: dict[str, Any] = {} + if isinstance(value, dict): + for key in sorted(value): + path = f"{prefix}.{key}" if prefix else key + result.update(flatten(value[key], path)) + elif isinstance(value, list): + for index, item in enumerate(value): + path = f"{prefix}.{index}" if prefix else str(index) + result.update(flatten(item, path)) + else: + result[prefix] = value + return result + + +def filter_flat(values: dict[str, Any], patterns: list[str]) -> dict[str, Any]: + return { + path: value + for path, value in values.items() + if not any(fnmatch.fnmatchcase(path, pattern) for pattern in patterns) + } + + +def compare_flat( + expected: dict[str, Any], actual: dict[str, Any], label: str +) -> list[str]: + errors: list[str] = [] + for path in sorted(expected.keys() - actual.keys()): + errors.append(f"{label}: missing field {path}") + for path in sorted(actual.keys() - expected.keys()): + errors.append(f"{label}: unexpected field {path}={actual[path]!r}") + for path in sorted(expected.keys() & actual.keys()): + if expected[path] != actual[path]: + errors.append( + f"{label}: {path}: expected {expected[path]!r}, got {actual[path]!r}" + ) + return errors + + +class ResolvedApp: + """Resolve packaged and unpackaged application paths to one executable.""" + + def __init__(self, source: str | Path) -> None: + matches = sorted(glob.glob(os.fspath(source))) + if len(matches) != 1: + raise ConformanceError( + f"--app must resolve to exactly one path, got {len(matches)}: {source}" + ) + self.source = Path(matches[0]).resolve() + self._temporary: tempfile.TemporaryDirectory[str] | None = None + self.executable = self._resolve(self.source) + + def _resolve(self, source: Path) -> Path: + if source.is_dir() and source.suffix.lower() == ".app": + executable = source / "Contents" / "MacOS" / "nebula" + require(executable.is_file(), f"app bundle has no executable: {executable}") + return executable + if source.is_file() and source.suffix.lower() == ".zip": + self._temporary = tempfile.TemporaryDirectory(prefix="nebula-conformance-app-") + root = Path(self._temporary.name).resolve() + try: + with zipfile.ZipFile(source) as archive: + for member in archive.infolist(): + target = (root / member.filename).resolve() + require( + target == root or root in target.parents, + f"archive member escapes extraction root: {member.filename}", + ) + archive.extractall(root) + candidates = [ + path + for path in root.rglob("*") + if path.is_file() and path.name.lower() in {"nebula", "nebula.exe"} + ] + require( + len(candidates) == 1, + f"archive contains {len(candidates)} Nebula executables", + ) + return candidates[0] + except Exception: + self.close() + raise + if source.is_dir(): + candidates = [ + path + for path in source.rglob("*") + if path.is_file() and path.name.lower() in {"nebula", "nebula.exe"} + ] + require(len(candidates) == 1, f"directory contains {len(candidates)} Nebula executables") + return candidates[0] + require(source.is_file(), f"application path does not exist: {source}") + return source + + def close(self) -> None: + if self._temporary is not None: + self._temporary.cleanup() + self._temporary = None + + +class RuntimeClient: + def __init__(self, port: int, token: str, timeout: float = 5.0) -> None: + self.port = port + self.token = token + self.timeout = timeout + self.sequence = 0 + + @classmethod + def from_port_file(cls, path: Path, timeout: float = 5.0) -> RuntimeClient: + parts = path.read_text(encoding="utf-8").split() + require(len(parts) >= 2, f"invalid runtime discovery record: {path}") + try: + port = int(parts[0]) + except ValueError as error: + raise ConformanceError(f"invalid runtime port in {path}: {parts[0]!r}") from error + require(0 < port <= 65535, f"runtime port is out of range: {port}") + return cls(port, parts[1], timeout) + + def request( + self, + method: str, + params: dict[str, Any] | None = None, + *, + timeout: float | None = None, + allow_error: bool = False, + ) -> dict[str, Any]: + self.sequence += 1 + request = { + "protocol": PROTOCOL_NAME, + "version": PROTOCOL_VERSION, + "id": f"conformance-{os.getpid()}-{self.sequence}", + "token": self.token, + "method": method, + "params": params or {}, + } + payload = json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + request_timeout = timeout if timeout is not None else self.timeout + with socket.create_connection(("127.0.0.1", self.port), request_timeout) as stream: + stream.settimeout(request_timeout) + stream.sendall(payload + b"\n") + stream.shutdown(socket.SHUT_WR) + reader = stream.makefile("rb") + line = reader.readline(MAX_RESPONSE_BYTES + 1) + require(line, f"{method}: runtime closed the connection without a response") + require(len(line) <= MAX_RESPONSE_BYTES, f"{method}: response exceeds byte limit") + try: + response = json.loads(line.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ConformanceError(f"{method}: invalid UTF-8 JSON response") from error + require(response.get("protocol") == PROTOCOL_NAME, f"{method}: wrong response protocol") + require(response.get("version") == PROTOCOL_VERSION, f"{method}: wrong protocol version") + require(response.get("id") == request["id"], f"{method}: response id mismatch") + if not response.get("ok") and not allow_error: + raise ApiFailure(method, response.get("error") or {}) + return response + + +class ConformanceContext: + def __init__( + self, + app: ResolvedApp, + platform: str, + config_dir: Path, + work_dir: Path, + artifact_dir: Path, + startup_timeout: float = DEFAULT_STARTUP_TIMEOUT, + ) -> None: + self.app = app + self.platform = platform + self.platform_family = platform.split("-", 1)[0] + self.config_dir = config_dir.resolve() + self.work_dir = work_dir.resolve() + self.artifact_dir = artifact_dir.resolve() + self.startup_timeout = startup_timeout + self.process: subprocess.Popen[bytes] | None = None + self.client: RuntimeClient | None = None + self.description: dict[str, Any] = {} + self.startup_ms = 0 + self.window_id = 0 + self.pane_id = 0 + self.shell = "unknown" + self._launch_number = 0 + self._log_handle: Any = None + self._marker_sequence = 0 + + @property + def port_file(self) -> Path: + return self.config_dir / "runtime.port" + + @property + def session_file(self) -> Path: + return self.config_dir / "session.json" + + def prepare(self) -> None: + self.config_dir.mkdir(parents=True, exist_ok=True) + self.work_dir.mkdir(parents=True, exist_ok=True) + self.artifact_dir.mkdir(parents=True, exist_ok=True) + settings = "\n".join( + [ + "keep_session=0", + "tray=0", + "restore_session=1", + "resume_ai=0", + "fetch=0", + "auto_check_updates=0", + "ai_hooks=0", + "windowing_behavior=use_new", + "", + ] + ) + (self.config_dir / "nebula_settings.txt").write_text( + settings, encoding="utf-8", newline="\n" + ) + + def start(self, *, explicit_working_directory: bool = True) -> None: + require(self.process is None, "Nebula is already running in this context") + self._launch_number += 1 + log_path = self.artifact_dir / f"nebula-{self._launch_number}.log" + self._log_handle = log_path.open("wb") + env = os.environ.copy() + env["NEBULA_CONFIG_DIR"] = os.fspath(self.config_dir) + env["RUST_BACKTRACE"] = "1" + if self.app.executable.suffix.lower() == ".appimage": + env.setdefault("APPIMAGE_EXTRACT_AND_RUN", "1") + command = [os.fspath(self.app.executable)] + if explicit_working_directory: + 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, + ) + except OSError as error: + self._close_log() + raise ConformanceError(f"could not launch {self.app.executable}: {error}") from error + + deadline = started + self.startup_timeout + last_error = "runtime.port has not appeared" + while time.monotonic() < deadline: + if self.process.poll() is not None: + code = self.process.returncode + self.process = None + self._close_log() + raise ConformanceError(f"Nebula exited during startup with code {code}") + if self.port_file.is_file(): + try: + candidate = RuntimeClient.from_port_file(self.port_file, timeout=5.0) + response = candidate.request("runtime.describe", timeout=1.0) + remaining = max(0.1, deadline - time.monotonic()) + snapshot_response = candidate.request( + "runtime.snapshot", timeout=min(5.0, remaining) + ) + snapshot = snapshot_response.get("result") + require(isinstance(snapshot, dict), "runtime snapshot is not an object") + windows = snapshot.get("windows") or [] + ready = any( + any( + tab.get("kind") == "shell" and bool(tab.get("panes")) + for tab in window.get("tabs") or [] + ) + for window in windows + ) + require(ready, "runtime snapshot has no ready shell pane") + self.client = candidate + self.description = response["result"] + self.startup_ms = round((time.monotonic() - started) * 1000) + return + except (OSError, ConformanceError) as error: + last_error = str(error) + time.sleep(0.05) + 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() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + self._close_log() + + def restart(self) -> int: + self.stop(force=True) + # An explicit --working-directory intentionally suppresses Nebula's + # session restore. A cold-restore check must therefore be a plain + # launch; the process cwd remains the isolated work directory. + self.start(explicit_working_directory=False) + return self.startup_ms + + def _close_log(self) -> None: + if self._log_handle is not None: + self._log_handle.close() + self._log_handle = None + + def api( + self, + method: str, + params: dict[str, Any] | None = None, + *, + timeout: float | None = None, + allow_error: bool = False, + ) -> Any: + require(self.client is not None, "Nebula runtime is not connected") + response = self.client.request(method, params, timeout=timeout, allow_error=allow_error) + return response if allow_error else response.get("result") + + def best_effort_api(self, method: str, params: dict[str, Any]) -> None: + """Run cleanup without replacing the case's original failure.""" + try: + self.api(method, params) + except (OSError, ConformanceError): + pass + + def snapshot(self) -> dict[str, Any]: + snapshot = self.api("runtime.snapshot") + require(isinstance(snapshot, dict), "runtime.snapshot did not return an object") + return snapshot + + def refresh_targets(self, snapshot: dict[str, Any] | None = None) -> None: + snapshot = snapshot or self.snapshot() + windows = snapshot.get("windows") or [] + require(windows, "runtime snapshot has no window") + window = next((item for item in windows if item.get("focused")), windows[0]) + tabs = [item for item in window.get("tabs") or [] if item.get("kind") == "shell"] + require(tabs, "runtime window has no shell tab") + active_index = window.get("active_tab", 0) + tab = next((item for item in tabs if item.get("index") == active_index), tabs[0]) + panes = tab.get("panes") or [] + require(panes, "runtime shell tab has no pane") + focused = tab.get("focused_pane_id") or window.get("focused_pane_id") + pane = next((item for item in panes if item.get("id") == focused), panes[0]) + self.window_id = int(window["id"]) + self.pane_id = int(pane["id"]) + + def tab_for_pane(self, snapshot: dict[str, Any], pane_id: int) -> dict[str, Any]: + for window in snapshot.get("windows") or []: + for tab in window.get("tabs") or []: + if any(pane.get("id") == pane_id for pane in tab.get("panes") or []): + return tab + raise ConformanceError(f"pane {pane_id} is absent from the runtime snapshot") + + def read(self, pane_id: int | None = None, lines: int = 120) -> dict[str, Any]: + result = self.api( + "pane.read", + { + "window_id": self.window_id, + "pane_id": pane_id or self.pane_id, + "lines": lines, + }, + ) + require(isinstance(result, dict), "pane.read did not return an object") + result["text"] = normalize_text(result.get("text", "")) + return result + + def poll( + self, + probe: Callable[[], Any], + predicate: Callable[[Any], bool], + message: str, + timeout: float = 10.0, + ) -> Any: + deadline = time.monotonic() + timeout + last: Any = None + while time.monotonic() < deadline: + last = probe() + if predicate(last): + return last + time.sleep(0.1) + raise ConformanceError(f"{message}; last observation: {last!r}") + + def prompt(self, text: str, pane_id: int | None = None, submit: bool = True) -> Any: + params = { + "window_id": self.window_id, + "pane_id": pane_id or self.pane_id, + "text": text, + "submit": submit, + } + deadline = time.monotonic() + 3.0 + while True: + response = self.api("pane.prompt", params, allow_error=True) + if response.get("ok"): + return response.get("result") + error = response.get("error") or {} + if error.get("code") != "input_in_progress" or time.monotonic() >= deadline: + raise ApiFailure("pane.prompt", error) + time.sleep(0.05) + + def paste(self, text: str, pane_id: int | None = None, submit: bool = False) -> Any: + params = { + "window_id": self.window_id, + "pane_id": pane_id or self.pane_id, + "text": text, + "submit": submit, + } + deadline = time.monotonic() + 10.0 + while True: + response = self.api("pane.paste", params, allow_error=True) + if response.get("ok"): + return response.get("result") + error = response.get("error") or {} + retryable = error.get("code") in {"input_in_progress", "unsafe_input_mode"} + if not retryable or time.monotonic() >= deadline: + raise ApiFailure("pane.paste", error) + time.sleep(0.1) + + def send_key( + self, + key: str, + pane_id: int | None = None, + *, + control: bool = False, + alt: bool = False, + shift: bool = False, + ) -> Any: + return self.api( + "pane.send_key", + { + "window_id": self.window_id, + "pane_id": pane_id or self.pane_id, + "key": key, + "modifiers": {"control": control, "alt": alt, "shift": shift}, + "repeat": 1, + }, + ) + + def wait_for_line( + self, pattern: re.Pattern[str], pane_id: int | None = None, timeout: float = 10.0 + ) -> tuple[re.Match[str], dict[str, Any]]: + target = pane_id or self.pane_id + + def find(read: dict[str, Any]) -> re.Match[str] | None: + # Terminal output can begin on the same physical row as a wrapped + # prompt or command echo. Callers construct markers so the echoed + # source does not contain the complete expected token. + return find_last_line_match(pattern, read["text"]) + + read = self.poll( + lambda: self.read(target, 160), + lambda value: find(value) is not None, + f"pane {target} never produced {pattern.pattern!r}", + timeout, + ) + match = find(read) + assert match is not None + return match, read + + def detect_shell(self) -> str: + candidates: list[str] = [] + try: + processes = self.api( + "pane.procs", {"window_id": self.window_id, "pane_id": self.pane_id} + ) + candidates.extend( + process.get("executable", "") + for process in processes.get("processes") or [] + if process.get("depth") == 0 + ) + except ApiFailure: + pass + snapshot = self.snapshot() + tab = self.tab_for_pane(snapshot, self.pane_id) + pane = next(item for item in tab["panes"] if item["id"] == self.pane_id) + candidates.extend( + [pane.get("running_program") or "", pane.get("title") or "", tab.get("label") or ""] + ) + for candidate in candidates: + category = shell_category(candidate) + if category: + self.shell = category + return category + fallback = {"windows": "powershell", "macos": "zsh", "linux": "sh"} + self.shell = fallback.get(self.platform_family, "sh") + return self.shell + + def marker_command(self, prefix: str, suffix: str) -> str: + shell = self.shell + if shell in {"powershell", "pwsh"}: + return f"Write-Output ('{prefix}' + '{suffix}')" + if shell == "cmd": + return f"echo {prefix}^{suffix}" + if shell == "nu": + return f"print ('{prefix}' + '{suffix}')" + return f"printf '%s%s\\n' '{prefix}' '{suffix}'" + + def columns_command(self, marker: str) -> str: + shell = self.shell + if shell in {"powershell", "pwsh"}: + return f"Write-Output ('{marker}' + $Host.UI.RawUI.WindowSize.Width)" + if shell == "cmd": + return ( + "powershell -NoProfile -Command \"Write-Output " + f"('{marker}' + $Host.UI.RawUI.WindowSize.Width)\"" + ) + if shell == "fish": + return f"printf '{marker}%s\\n' (tput cols)" + if shell == "nu": + return f"print ('{marker}' + ((term size).columns | into string))" + return f"printf '{marker}%s\\n' \"$(tput cols)\"" + + def measure_columns(self, pane_id: int) -> int: + self._marker_sequence += 1 + marker = f"NEBULA_COLS_{self._marker_sequence}_" + self.prompt(self.columns_command(marker), pane_id) + match, _ = self.wait_for_line(re.compile(re.escape(marker) + r"(\d+)"), pane_id) + return int(match.group(1)) + + def scrollback_command(self, lines: int, marker: str) -> str: + shell = self.shell + if shell in {"powershell", "pwsh"}: + return f"1..{lines} | ForEach-Object {{ 'NEBULA_SCROLL_' + $_ }}; '{marker}'" + if shell == "cmd": + return f"for /L %i in (1,1,{lines}) do @echo NEBULA_SCROLL_%i & echo {marker}" + if shell == "fish": + return f"for i in (seq 1 {lines}); echo NEBULA_SCROLL_$i; end; echo {marker}" + if shell == "nu": + return ( + f"1..{lines} | each {{ |i| print $'NEBULA_SCROLL_($i)' }}; " + f"print '{marker}'" + ) + return ( + f"i=1; while [ \"$i\" -le {lines} ]; do printf 'NEBULA_SCROLL_%s\\n' \"$i\"; " + f"i=$((i + 1)); done; printf '%s\\n' '{marker}'" + ) + + def ensure_single_tab(self) -> None: + snapshot = self.snapshot() + window = next(item for item in snapshot["windows"] if item["id"] == self.window_id) + keep = next((tab for tab in window["tabs"] if tab["index"] == 0), None) + require(keep is not None, "runtime window has no tab at index zero") + panes = keep.get("panes") or [] + require(panes, "tab zero has no pane to retain") + keep_pane = keep.get("focused_pane_id") or panes[0]["id"] + for tab in sorted(window["tabs"], key=lambda item: item["index"], reverse=True): + if tab["index"] == 0: + continue + self.api( + "tab.close", {"window_id": self.window_id, "tab_index": tab["index"]} + ) + self.pane_id = int(keep_pane) + self.api("window.focus", {"window_id": self.window_id, "pane_id": self.pane_id}) + + def wait_for_session(self, predicate: Callable[[dict[str, Any]], bool]) -> dict[str, Any]: + def read_session() -> dict[str, Any] | None: + try: + return json.loads(self.session_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + return self.poll( + read_session, + lambda value: isinstance(value, dict) and predicate(value), + "session.json did not converge", + timeout=5.0, + ) + + def close_and_wait(self, timeout: float = 5.0) -> int: + require(self.process is not None, "Nebula process is not running") + process = self.process + started = time.monotonic() + try: + self.api("window.close", {"window_id": self.window_id}) + except ApiFailure: + raise + except (OSError, ConformanceError): + # A successful close may tear down the listener before its final + # response is observed. The owned process exit is authoritative. + pass + try: + process.wait(timeout=timeout) + 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() + if process.returncode != 0: + raise ConformanceError(f"Nebula exited with code {process.returncode}") + return round((time.monotonic() - started) * 1000) + + +def platform_family(platform: str) -> str: + return platform.split("-", 1)[0] + + +def load_rules(golden_dir: Path) -> dict[str, Any]: + path = golden_dir / "whitelist.json" + return json.loads(path.read_text(encoding="utf-8")) + + +def stable_flat(report: dict[str, Any], golden_dir: Path) -> dict[str, Any]: + rules = load_rules(golden_dir) + return filter_flat(flatten(report), list(rules.get("volatile", {}))) + + +def compare_platform_golden(report: dict[str, Any], golden_dir: Path) -> list[str]: + family = platform_family(str(report["platform"])) + path = golden_dir / f"{family}.json" + if not path.is_file(): + return [f"platform golden is missing: {path}"] + expected = json.loads(path.read_text(encoding="utf-8")) + return compare_flat(expected, stable_flat(report, golden_dir), family) + + +def compare_reports(reports: list[dict[str, Any]], golden_dir: Path) -> list[str]: + require(len(reports) >= 2, "--compare requires at least two reports") + rules = load_rules(golden_dir) + ignored = list(rules.get("volatile", {})) + list(rules.get("cross_platform", {})) + flattened = [filter_flat(flatten(report), ignored) for report in reports] + baseline = flattened[0] + baseline_name = str(reports[0].get("platform", "report-1")) + errors: list[str] = [] + for report, actual in zip(reports[1:], flattened[1:]): + name = str(report.get("platform", "report")) + errors.extend(compare_flat(baseline, actual, f"{baseline_name} vs {name}")) + return errors + + +def validate_common(report: dict[str, Any], golden_dir: Path) -> list[str]: + common = json.loads((golden_dir / "common.json").read_text(encoding="utf-8")) + errors: list[str] = [] + if report.get("schema_version") != common.get("schema_version"): + errors.append("report schema version does not match common.json") + flat = flatten(report) + for path, expected in common.get("required", {}).items(): + actual = flat.get(path) + if actual != expected: + errors.append(f"common: {path}: expected {expected!r}, got {actual!r}") + return errors diff --git a/scripts/conformance/macos_launch.py b/scripts/conformance/macos_launch.py new file mode 100644 index 0000000..42466d0 --- /dev/null +++ b/scripts/conformance/macos_launch.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Exercise a private installed .app through LaunchServices, not its executable.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import platform +import re +import signal +import subprocess +import sys +import tempfile +import time +from typing import BinaryIO + +SCRIPTS = Path(__file__).resolve().parents[1] +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +from conformance.harness import ConformanceContext, ResolvedApp, RuntimeClient, require + + +def capture_screenshot(destination: Path, log: BinaryIO) -> str: + try: + capture = subprocess.run(["/usr/sbin/screencapture", "-x", str(destination)], + stdout=log, stderr=subprocess.STDOUT, timeout=15, check=False) + if capture.returncode == 0 and destination.is_file() and destination.stat().st_size > 0: + return "captured" + except (OSError, subprocess.TimeoutExpired): + pass + return "unavailable" + + +def owned_processes(executable: Path) -> dict[int, str]: + listing = subprocess.check_output(["/bin/ps", "-axo", "pid=,comm="], text=True) + result = {} + for line in listing.splitlines(): + fields = line.strip().split(None, 1) + if len(fields) == 2 and fields[1] == str(executable): + process_id = int(fields[0]) + birth = subprocess.run(["/bin/ps", "-p", str(process_id), "-o", "lstart="], + capture_output=True, text=True, check=False).stdout.strip() + if birth: + result[process_id] = birth + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--app", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + report = {"status": "failed", "launch_method": "launchservices", + "commit": os.environ.get("GITHUB_SHA", "local"), + "architecture": "aarch64" if platform.machine() == "arm64" else platform.machine(), + "os_version": platform.mac_ver()[0], "quarantine_assessment": "not_tested"} + args.output.parent.mkdir(parents=True, exist_ok=True) + try: + require(sys.platform == "darwin", "LaunchServices requires native macOS") + with tempfile.TemporaryDirectory(prefix="nebula-installed-smoke-") as temporary: + root = Path(temporary).resolve() + installed = root / "Applications" / "Nebula Terminal Preview.app" + subprocess.run(["/usr/bin/ditto", str(args.app.resolve()), str(installed)], check=True) + subprocess.run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(installed)], check=True) + app = ResolvedApp(installed) + logs = args.output.parent / f"{args.output.stem}-artifacts" + ctx = ConformanceContext(app, "macos", root / "config", root / "work", logs) + ctx.prepare() + launcher = None + with (logs / "launchservices.log").open("wb") as log: + try: + launcher = subprocess.Popen( + ["/usr/bin/open", "-n", "-W", "-a", str(installed), + "--env", f"NEBULA_CONFIG_DIR={ctx.config_dir}", + "--env", "PATH=/usr/bin:/bin:/usr/sbin:/sbin", + "--env", "LANG=", "--env", "LC_ALL=", "--env", "LC_CTYPE="], + cwd="/", stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, + ) + deadline = time.monotonic() + 45 + while time.monotonic() < deadline: + if ctx.port_file.is_file(): + try: + client = RuntimeClient.from_port_file(ctx.port_file, timeout=2) + snapshot = client.request("runtime.snapshot")["result"] + require(snapshot["process_id"] in owned_processes(app.executable), + "runtime endpoint is not owned by this installed copy") + ctx.client = client + ctx.description = client.request("runtime.describe")["result"] + ctx.refresh_targets(snapshot) + break + except (OSError, ValueError, KeyError, RuntimeError): + pass + require(launcher.poll() is None, "LaunchServices exited before the app was ready") + time.sleep(0.1) + else: + raise RuntimeError("installed application did not become ready in 45 seconds") + ctx.detect_shell() + ctx.prompt('printf "NEBULA_GUI_LOCALE=%s_END\\n" "${LC_ALL:-${LC_CTYPE:-${LANG:-}}}"') + 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) + for window in ctx.snapshot().get("windows", []): + ctx.api("window.close", {"window_id": window["id"]}) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline and owned_processes(app.executable): + time.sleep(0.1) + require(not owned_processes(app.executable), "installed application did not quit after closing its windows") + launcher.wait(timeout=5) + report.update(status="passed", utf8_locale=True, home_cwd=True) + finally: + for process_id, birth in owned_processes(app.executable).items(): + if owned_processes(app.executable).get(process_id) == birth: + os.kill(process_id, signal.SIGKILL) + if launcher is not None: + try: + launcher.wait(timeout=5) + except subprocess.TimeoutExpired: + launcher.kill() + launcher.wait(timeout=5) + app.close() + except Exception as error: + report["error"] = f"{type(error).__name__}: {error}" + print(report["error"], file=sys.stderr) + args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + return 0 if report["status"] == "passed" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/conformance/run.py b/scripts/conformance/run.py new file mode 100644 index 0000000..53148f7 --- /dev/null +++ b/scripts/conformance/run.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Run or compare Nebula cross-platform Runtime API conformance reports.""" + +from __future__ import annotations + +import argparse +import importlib +import json +import os +import platform as host_platform +import sys +import tempfile +import time +from pathlib import Path +from types import ModuleType +from typing import Any + +# `python scripts/conformance/run.py` puts this file's directory on sys.path, +# while the package itself lives one level higher as `conformance`. +SCRIPT_DIR = Path(__file__).resolve().parent +SCRIPTS_DIR = SCRIPT_DIR.parent +if str(SCRIPTS_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPTS_DIR)) + +from conformance.harness import ( # noqa: E402 + ConformanceContext, + ConformanceError, + ResolvedApp, + SkipCase, + compare_platform_golden, + compare_reports, + platform_family, + stable_flat, + validate_common, +) + +SCHEMA_VERSION = 1 +CASE_NAMES = ( + "boot", + "echo", + "resize", + "split", + "scrollback", + "session", + "ssh_loop", + "paste", + "cjk_roundtrip", + "close", +) +RESERVED_CASE_FIELDS = {"status", "duration_ms", "error", "reason"} + + +def default_platform_name() -> str: + system = host_platform.system().lower() + family = {"windows": "windows", "darwin": "macos", "linux": "linux"}.get( + system, system + ) + machine = host_platform.machine().lower() + architecture = { + "amd64": "x86_64", + "x64": "x86_64", + "aarch64": "arm64", + }.get(machine, machine or "unknown") + return f"{family}-{architecture}" + + +def load_case(name: str) -> ModuleType: + return importlib.import_module(f"conformance.cases.{name}") + + +def case_error(error: Exception) -> str: + if isinstance(error, ConformanceError): + return str(error) + return f"{type(error).__name__}: {error}" + + +def execute_case(name: str, ctx: ConformanceContext) -> dict[str, Any]: + print(f"[{name}] running", flush=True) + started = time.monotonic() + try: + result = load_case(name).run(ctx) + if not isinstance(result, dict): + raise ConformanceError(f"case returned {type(result).__name__}, expected an object") + collision = RESERVED_CASE_FIELDS.intersection(result) + if collision: + raise ConformanceError(f"case returned reserved fields: {sorted(collision)}") + entry: dict[str, Any] = {"status": "passed"} + entry.update(result) + print(f"[{name}] passed", flush=True) + except SkipCase as error: + entry = {"status": "skipped", "reason": str(error)} + print(f"[{name}] skipped: {error}", flush=True) + except Exception as error: + entry = {"status": "failed", "error": case_error(error)} + print(f"[{name}] failed: {entry['error']}", flush=True) + entry["duration_ms"] = round((time.monotonic() - started) * 1000) + return entry + + +def summarize(cases: dict[str, dict[str, Any]]) -> dict[str, int]: + return { + "total": len(cases), + "passed": sum(case.get("status") == "passed" for case in cases.values()), + "failed": sum(case.get("status") == "failed" for case in cases.values()), + "skipped": sum(case.get("status") == "skipped" for case in cases.values()), + } + + +def write_json(path: Path, value: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + newline="\n", + ) + + +def record_boot_failure( + report: dict[str, Any], error: Exception, started: float +) -> None: + report["cases"]["boot"] = { + "status": "failed", + "error": case_error(error), + "duration_ms": round((time.monotonic() - started) * 1000), + } + print(f"[boot] failed: {case_error(error)}", flush=True) + for name in CASE_NAMES[1:]: + report["cases"][name] = { + "status": "skipped", + "reason": "dependency_boot_failed", + "duration_ms": 0, + } + + +def run_suite(args: argparse.Namespace) -> int: + output = args.output.resolve() + golden_dir = args.golden_dir.resolve() + artifact_dir = (args.artifacts or output.parent / f"{output.stem}-artifacts").resolve() + report: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "platform": args.platform, + "build": {"commit": os.environ.get("GITHUB_SHA", "local")}, + "cases": {}, + "summary": {"total": 0, "passed": 0, "failed": 0, "skipped": 0}, + } + app: ResolvedApp | None = None + ctx: ConformanceContext | None = None + isolated: tempfile.TemporaryDirectory[str] | None = None + setup_started = time.monotonic() + + try: + family = platform_family(args.platform) + if family not in {"windows", "linux", "macos"}: + raise ConformanceError( + "--platform must start with windows, linux, or macos" + ) + app = ResolvedApp(args.app) + isolated = tempfile.TemporaryDirectory(prefix="nebula-conformance-") + root = Path(isolated.name) + ctx = ConformanceContext( + app, + args.platform, + root / "config", + root / "work", + artifact_dir, + startup_timeout=args.startup_timeout, + ) + ctx.prepare() + ctx.start() + except Exception as error: + record_boot_failure(report, error, setup_started) + else: + for index, name in enumerate(CASE_NAMES): + report["cases"][name] = execute_case(name, ctx) + if name == "boot" and report["cases"][name]["status"] != "passed": + for remaining in CASE_NAMES[index + 1 :]: + report["cases"][remaining] = { + "status": "skipped", + "reason": "dependency_boot_failed", + "duration_ms": 0, + } + break + finally: + if ctx is not None: + ctx.stop(force=True) + if app is not None: + app.close() + if isolated is not None: + isolated.cleanup() + report["summary"] = summarize(report["cases"]) + write_json(output, report) + print(f"report: {output}", flush=True) + + errors = validate_common(report, golden_dir) + failed = report["summary"]["failed"] + if failed: + errors.append(f"{failed} conformance case(s) failed") + + golden_path = golden_dir / f"{platform_family(args.platform)}.json" + if args.update_golden: + if errors: + errors.append("platform golden was not updated because the run did not conform") + else: + write_json(golden_path, stable_flat(report, golden_dir)) + print(f"updated platform golden: {golden_path}", flush=True) + elif args.no_platform_golden: + print("platform golden comparison disabled", flush=True) + elif golden_path.is_file(): + errors.extend(compare_platform_golden(report, golden_dir)) + else: + print( + f"platform golden absent; bootstrap with --update-golden after review: {golden_path}", + flush=True, + ) + + if errors: + print("conformance errors:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + + summary = report["summary"] + print( + f"summary: {summary['passed']} passed, {summary['skipped']} skipped, " + f"{summary['failed']} failed", + flush=True, + ) + return 0 + + +def read_report(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ConformanceError(f"could not read report {path}: {error}") from error + if not isinstance(value, dict): + raise ConformanceError(f"report is not a JSON object: {path}") + return value + + +def compare_mode(args: argparse.Namespace) -> int: + if len(args.compare) < 2: + raise ConformanceError("--compare requires at least two reports") + reports = [read_report(path.resolve()) for path in args.compare] + errors: list[str] = [] + for path, report in zip(args.compare, reports): + errors.extend( + f"{path}: {error}" for error in validate_common(report, args.golden_dir.resolve()) + ) + errors.extend(compare_reports(reports, args.golden_dir.resolve())) + if errors: + print("comparison errors:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + print(f"reports conform across {len(reports)} platforms") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--app", help="Nebula executable, archive, app bundle, or package directory") + mode.add_argument( + "--compare", + nargs="+", + type=Path, + metavar="REPORT", + help="compare two or more existing reports", + ) + parser.add_argument("--platform", default=default_platform_name()) + parser.add_argument("--output", type=Path, default=Path("report.json")) + parser.add_argument("--artifacts", type=Path, help="directory for retained Nebula logs") + parser.add_argument( + "--golden-dir", type=Path, default=SCRIPT_DIR / "golden" + ) + parser.add_argument("--startup-timeout", type=float, default=20.0) + parser.add_argument("--update-golden", action="store_true") + parser.add_argument("--no-platform-golden", action="store_true") + return parser + + +def main() -> int: + # Windows Python commonly inherits a legacy console code page. Diagnostic + # terminal text is UTF-8 and may contain prompt glyphs or CJK; reporting a + # product failure must never crash the runner with UnicodeEncodeError. + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure is not None: + reconfigure(encoding="utf-8", errors="backslashreplace") + + parser = build_parser() + args = parser.parse_args() + if args.compare: + if args.update_golden or args.no_platform_golden: + parser.error("golden update options cannot be used with --compare") + try: + return compare_mode(args) + except ConformanceError as error: + parser.error(str(error)) + if args.update_golden and args.no_platform_golden: + parser.error("--update-golden conflicts with --no-platform-golden") + if args.startup_timeout <= 0: + parser.error("--startup-timeout must be positive") + return run_suite(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/conformance/tests/__init__.py b/scripts/conformance/tests/__init__.py new file mode 100644 index 0000000..a40d617 --- /dev/null +++ b/scripts/conformance/tests/__init__.py @@ -0,0 +1 @@ +"""Unit tests for the cross-platform conformance harness.""" diff --git a/scripts/conformance/tests/test_harness.py b/scripts/conformance/tests/test_harness.py new file mode 100644 index 0000000..db54ef0 --- /dev/null +++ b/scripts/conformance/tests/test_harness.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import json +import re +import subprocess +import sys +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import patch +import zipfile +from pathlib import Path + +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 ( # noqa: E402 + ConformanceError, + ResolvedApp, + compare_reports, + find_last_line_match, + filter_flat, + flatten, + normalize_path, + normalize_text, + shell_category, + SkipCase, +) +from conformance.macos_launch import capture_screenshot + + +class NormalizationTests(unittest.TestCase): + def test_normalize_path_handles_drive_and_posix_paths(self) -> None: + self.assertEqual(normalize_path(r"C:\Users\Test\nebula"), "/c/Users/Test/nebula") + self.assertEqual(normalize_path("/tmp//nebula/"), "/tmp/nebula") + self.assertEqual(normalize_path("/"), "/") + + def test_normalize_text_unifies_newlines_and_trailing_space(self) -> None: + self.assertEqual(normalize_text("alpha \r\nbeta\r\n\r\n"), "alpha\nbeta") + + def test_shell_category_accepts_paths_and_labels(self) -> None: + self.assertEqual(shell_category(r"C:\Windows\System32\WindowsPowerShell.exe"), "powershell") + self.assertEqual(shell_category("/bin/zsh"), "zsh") + self.assertEqual(shell_category("Nushell"), "nu") + self.assertIsNone(shell_category("unknown-shell")) + + def test_line_match_accepts_output_attached_to_a_wrapped_command(self) -> None: + text = ( + "❯ Write-Output ('NEBULA_COLS_2_' + " + "$Host.UI.RawUI.WindowSNEBULA_COLS_2_57\n❯" + ) + match = find_last_line_match(re.compile(r"NEBULA_COLS_2_(\d+)"), text) + self.assertIsNotNone(match) + assert match is not None + self.assertEqual(match.group(1), "57") + + +class ComparisonTests(unittest.TestCase): + def test_new_ssh_capability_cannot_remain_silently_skipped(self) -> None: + from conformance.cases.ssh_loop import run + + with self.assertRaises(SkipCase): + run(SimpleNamespace(description={"capabilities": []})) + with self.assertRaises(ConformanceError): + run(SimpleNamespace(description={"capabilities": ["ssh.open"]})) + + def test_flatten_and_filter_apply_glob_rules(self) -> None: + flat = flatten({"cases": {"boot": {"status": "passed", "duration_ms": 4}}}) + self.assertEqual( + filter_flat(flat, ["cases.*.duration_ms"]), + {"cases.boot.status": "passed"}, + ) + + def test_cross_platform_whitelist_ignores_only_documented_fields(self) -> None: + first = { + "schema_version": 1, + "platform": "windows-x86_64", + "cases": { + "boot": {"status": "passed", "shell_category": "powershell"}, + "echo": {"status": "passed", "tail_contains_marker": True}, + "ssh_loop": {"status": "skipped", "reason": "missing"}, + }, + "summary": {"total": 3, "passed": 2, "failed": 0, "skipped": 1}, + } + second = json.loads(json.dumps(first)) + second["platform"] = "linux-x86_64" + second["cases"]["boot"]["shell_category"] = "bash" + second["cases"]["ssh_loop"] = {"status": "passed"} + second["summary"]["passed"] = 3 + second["summary"]["skipped"] = 0 + + with tempfile.TemporaryDirectory() as directory: + golden = Path(directory) + (golden / "whitelist.json").write_text( + json.dumps( + { + "volatile": {}, + "cross_platform": { + "platform": "identity", + "cases.boot.shell_category": "default shell", + "cases.ssh_loop.*": "optional", + "summary.passed": "optional", + "summary.skipped": "optional", + }, + } + ), + encoding="utf-8", + ) + self.assertEqual(compare_reports([first, second], golden), []) + second["cases"]["echo"]["tail_contains_marker"] = False + errors = compare_reports([first, second], golden) + self.assertTrue(any("tail_contains_marker" in error for error in errors)) + + +class ArchiveSafetyTests(unittest.TestCase): + def test_zip_member_cannot_escape_extraction_root(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + archive = root / "bad.zip" + with zipfile.ZipFile(archive, "w") as output: + output.writestr("../outside/nebula", "not an executable") + with self.assertRaisesRegex(ConformanceError, "escapes extraction root"): + ResolvedApp(archive) + self.assertFalse((root.parent / "outside" / "nebula").exists()) + + +class LaunchCaptureTests(unittest.TestCase): + def test_screenshot_requires_success_and_a_nonempty_file(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + destination = root / "capture.png" + with (root / "capture.log").open("wb") as log, patch( + "conformance.macos_launch.subprocess.run", return_value=SimpleNamespace(returncode=0), + ): + self.assertEqual(capture_screenshot(destination, log), "unavailable") + destination.write_bytes(b"") + self.assertEqual(capture_screenshot(destination, log), "unavailable") + destination.write_bytes(b"screenshot") + self.assertEqual(capture_screenshot(destination, log), "captured") + + def test_screenshot_failure_does_not_fail_the_launch_test(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + destination = root / "capture.png" + destination.write_bytes(b"partial") + with (root / "capture.log").open("wb") as log, patch( + "conformance.macos_launch.subprocess.run", return_value=SimpleNamespace(returncode=1), + ): + self.assertEqual(capture_screenshot(destination, log), "unavailable") + + def test_screenshot_errors_do_not_fail_the_launch_test(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with (root / "capture.log").open("wb") as log: + for error in (OSError("capture unavailable"), subprocess.TimeoutExpired("screencapture", 15)): + with self.subTest(error=error), patch( + "conformance.macos_launch.subprocess.run", side_effect=error, + ): + self.assertEqual(capture_screenshot(root / "capture.png", log), "unavailable") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100644 index 0000000..d4b1ba2 --- /dev/null +++ b/scripts/package-linux.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=C.UTF-8 + +usage() { + cat <<'EOF' +Usage: scripts/package-linux.sh --binary PATH --version VERSION --preview-id ID + [--output-directory DIR] [--linuxdeploy PATH] [--force] + +Builds a Linux x86_64 Preview AppImage, portable tar.gz, and Debian package +from one fresh Nebula release binary. linuxdeploy must be supplied explicitly +or through LINUXDEPLOY; the CI workflow downloads a pinned, SHA-verified copy. +EOF +} + +binary="" +version="" +preview_id="" +output_directory="dist" +linuxdeploy="${LINUXDEPLOY:-}" +force=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --binary) + binary="${2:-}" + shift 2 + ;; + --version) + version="${2:-}" + shift 2 + ;; + --preview-id) + preview_id="${2:-}" + shift 2 + ;; + --output-directory) + output_directory="${2:-}" + shift 2 + ;; + --linuxdeploy) + linuxdeploy="${2:-}" + shift 2 + ;; + --force) + force=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$binary" || -z "$version" || -z "$preview_id" || -z "$linuxdeploy" ]]; then + usage >&2 + exit 2 +fi +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 + echo "invalid Preview id: $preview_id" >&2 + exit 2 +fi +if [[ "$(uname -s)" != "Linux" || "$(uname -m)" != "x86_64" ]]; then + echo "Linux Preview packages must be built natively on Linux x86_64" >&2 + exit 1 +fi + +for command in appstreamcli convert desktop-file-validate dpkg dpkg-deb \ + dpkg-shlibdeps gzip ldd readelf sha256sum tar; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "required packaging command is missing: $command" >&2 + exit 1 + fi +done +if [[ ! -f "$binary" || ! -x "$binary" ]]; then + echo "Nebula binary is missing or not executable: $binary" >&2 + exit 1 +fi +if [[ ! -f "$linuxdeploy" || ! -x "$linuxdeploy" ]]; then + echo "linuxdeploy is missing or not executable: $linuxdeploy" >&2 + exit 1 +fi + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo="$(cd "$script_directory/.." && pwd -P)" +binary="$(cd "$(dirname "$binary")" && pwd -P)/$(basename "$binary")" +python3 "$script_directory/preview_release.py" --check-binary "$binary" +linuxdeploy="$(cd "$(dirname "$linuxdeploy")" && pwd -P)/$(basename "$linuxdeploy")" +mkdir -p "$output_directory" +output_directory="$(cd "$output_directory" && pwd -P)" + +desktop_source="$repo/packaging/linux/io.github.kuddev.nebula.preview.desktop" +metainfo_source="$repo/packaging/linux/io.github.kuddev.nebula.preview.metainfo.xml" +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/LICENSE" "$repo/THIRD-PARTY-NOTICES" \ + "$repo/licenses/LICENSE-LUA" "$repo/licenses/LICENSE-MLUA" \ + "$repo/licenses/LICENSE-LATIN-MODERN-MATH" \ + "$repo/extra/completions/nebula.bash" \ + "$repo/extra/completions/nebula.fish" \ + "$repo/extra/completions/_nebula"; do + if [[ ! -f "$required" ]]; then + echo "required package input is missing: $required" >&2 + exit 1 + fi +done + +help_text="$("$binary" --help 2>&1)" +if [[ "$help_text" != *"--gpui"* ]]; then + echo "refusing to package a non-GPUI Nebula binary" >&2 + exit 1 +fi +version_text="$("$binary" --version 2>&1)" +if [[ "$version_text" != *"$version"* ]]; then + echo "binary version does not match package version $version: $version_text" >&2 + exit 1 +fi +if ! readelf -h "$binary" | grep -Fq "Advanced Micro Devices X86-64"; then + echo "Nebula binary is not an x86_64 ELF executable" >&2 + exit 1 +fi +ldd_output="$(ldd "$binary")" +if grep -Fq "not found" <<<"$ldd_output"; then + echo "Nebula has unresolved native dependencies:" >&2 + echo "$ldd_output" >&2 + exit 1 +fi + +mapfile -t glibc_versions < <( + readelf --version-info "$binary" | + sed -n 's/.*Name: GLIBC_\([0-9][0-9.]*\).*/\1/p' | + sort -Vu +) +if [[ ${#glibc_versions[@]} -eq 0 ]]; then + echo "could not determine Nebula's required GLIBC version" >&2 + exit 1 +fi +required_glibc="${glibc_versions[${#glibc_versions[@]} - 1]}" +if dpkg --compare-versions "$required_glibc" gt "2.35"; then + echo "Nebula requires GLIBC_$required_glibc; Preview baseline is at most GLIBC_2.35" >&2 + exit 1 +fi + +release="$version-preview.$preview_id" +appimage_path="$output_directory/NebulaTerminal-v$release-linux-x86_64.AppImage" +tar_path="$output_directory/NebulaTerminal-v$release-linux-x86_64.tar.gz" +deb_path="$output_directory/NebulaTerminal-v$release-linux-x86_64.deb" +outputs=("$appimage_path" "$tar_path" "$deb_path") +for output in "${outputs[@]}"; do + if [[ -e "$output" && $force -ne 1 ]]; then + echo "package already exists: $output (pass --force to replace it)" >&2 + exit 1 + fi +done +if [[ $force -eq 1 ]]; then + rm -f -- "${outputs[@]}" +fi + +work="$(mktemp -d "${TMPDIR:-/tmp}/nebula-linux-package.XXXXXX")" +cleanup() { + if [[ -n "${work:-}" && -d "$work" ]]; then + rm -rf -- "$work" + fi +} +trap cleanup EXIT + +common_root="$work/common" +appdir="$work/Nebula.AppDir" +deb_root="$work/debian/nebula-terminal-preview" +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/nebula-terminal-preview/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/nebula" +install -m 0644 "$desktop_source" \ + "$common_root/usr/share/applications/io.github.kuddev.nebula.preview.desktop" +install -m 0644 "$metainfo_source" \ + "$common_root/usr/share/metainfo/io.github.kuddev.nebula.preview.metainfo.xml" +convert "$icon_source" -resize 256x256 \ + "$common_root/usr/share/icons/hicolor/256x256/apps/io.github.kuddev.nebula.preview.png" +install -m 0644 "$repo/README.md" "$common_root/usr/share/doc/nebula-terminal-preview/README.md" +install -m 0644 "$repo/CHANGELOG.md" "$common_root/usr/share/doc/nebula-terminal-preview/CHANGELOG.md" +install -m 0644 "$repo/INSTALL.md" "$common_root/usr/share/doc/nebula-terminal-preview/INSTALL.md" +install -m 0644 "$repo/LICENSE" "$common_root/usr/share/doc/nebula-terminal-preview/licenses/LICENSE" +install -m 0644 "$repo/LICENSE" "$common_root/usr/share/doc/nebula-terminal-preview/copyright" +install -m 0644 "$repo/THIRD-PARTY-NOTICES" \ + "$common_root/usr/share/doc/nebula-terminal-preview/licenses/THIRD-PARTY-NOTICES" +install -m 0644 "$repo/licenses/LICENSE-LUA" \ + "$common_root/usr/share/doc/nebula-terminal-preview/licenses/LICENSE-LUA" +install -m 0644 "$repo/licenses/LICENSE-MLUA" \ + "$common_root/usr/share/doc/nebula-terminal-preview/licenses/LICENSE-MLUA" +install -m 0644 "$repo/licenses/LICENSE-LATIN-MODERN-MATH" \ + "$common_root/usr/share/doc/nebula-terminal-preview/licenses/LICENSE-LATIN-MODERN-MATH" +install -m 0644 "$repo/extra/completions/nebula.bash" \ + "$common_root/usr/share/bash-completion/completions/nebula" +install -m 0644 "$repo/extra/completions/nebula.fish" \ + "$common_root/usr/share/fish/vendor_completions.d/nebula.fish" +install -m 0644 "$repo/extra/completions/_nebula" \ + "$common_root/usr/share/zsh/vendor-completions/_nebula" + +desktop-file-validate \ + "$common_root/usr/share/applications/io.github.kuddev.nebula.preview.desktop" +appstreamcli validate --no-net \ + "$common_root/usr/share/metainfo/io.github.kuddev.nebula.preview.metainfo.xml" + +cp -a "$common_root/." "$appdir/" +tool_output="$work/linuxdeploy-output" +mkdir -p "$tool_output" +( + cd "$tool_output" + ARCH=x86_64 \ + APPIMAGE_EXTRACT_AND_RUN=1 \ + NO_STRIP=1 \ + "$linuxdeploy" \ + --appdir "$appdir" \ + --executable "$appdir/usr/bin/nebula" \ + --desktop-file "$appdir/usr/share/applications/io.github.kuddev.nebula.preview.desktop" \ + --icon-file "$appdir/usr/share/icons/hicolor/256x256/apps/io.github.kuddev.nebula.preview.png" \ + --output appimage +) +mapfile -t generated_appimages < <(find "$tool_output" -maxdepth 1 -type f -name '*.AppImage' -print) +if [[ ${#generated_appimages[@]} -ne 1 ]]; then + echo "linuxdeploy produced ${#generated_appimages[@]} AppImages; expected exactly one" >&2 + exit 1 +fi +install -m 0755 "${generated_appimages[0]}" "$appimage_path" +if [[ ! -x "$appdir/AppRun" ]]; then + echo "linuxdeploy did not create an executable AppRun launcher" >&2 + exit 1 +fi + +source_date_epoch="${SOURCE_DATE_EPOCH:-}" +if [[ -z "$source_date_epoch" ]]; then + source_date_epoch="$(git -C "$repo" show -s --format=%ct HEAD)" +fi +if [[ ! "$source_date_epoch" =~ ^[0-9]+$ ]]; then + echo "SOURCE_DATE_EPOCH must be an integer" >&2 + exit 1 +fi +archive_root="NebulaTerminal-v$release-linux-x86_64" +tar \ + --sort=name \ + --mtime="@$source_date_epoch" \ + --owner=0 \ + --group=0 \ + --numeric-owner \ + --transform="s|^Nebula.AppDir|$archive_root|" \ + -C "$work" \ + -cf - Nebula.AppDir | gzip -n >"$tar_path" + +mkdir -p "$deb_root" +cp -a "$common_root/." "$deb_root/" +cat >"$work/debian/control" < +Standards-Version: 4.6.2 + +Package: nebula-terminal-preview +Architecture: any +Description: Nebula Terminal cross-platform Preview + GPU-accelerated terminal for local and remote workflows. +EOF +dependency_line="$( + cd "$work" + dpkg-shlibdeps -O -e"$deb_root/usr/bin/nebula" +)" +if [[ "$dependency_line" != shlibs:Depends=* ]]; then + echo "dpkg-shlibdeps returned an unexpected value: $dependency_line" >&2 + exit 1 +fi +dependencies="${dependency_line#shlibs:Depends=}" +installed_size="$(du -sk "$deb_root/usr" | awk '{print $1}')" +mkdir -p "$deb_root/DEBIAN" +cat >"$deb_root/DEBIAN/control" < +Installed-Size: $installed_size +Depends: $dependencies +Recommends: libsecret-tools, gnome-keyring +Homepage: https://github.com/Kuddev/nebula +Description: Nebula Terminal cross-platform Preview + GPU-accelerated terminal for local and remote workflows. + This package is a Preview build and is not a stable release. +EOF +chmod 0755 "$deb_root/DEBIAN" +chmod 0644 "$deb_root/DEBIAN/control" +dpkg-deb --root-owner-group --build "$deb_root" "$deb_path" + +if [[ "$(dpkg-deb --field "$deb_path" Package)" != "nebula-terminal-preview" ]]; then + echo "Debian package identity verification failed" >&2 + exit 1 +fi +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 + echo "Debian package version verification failed" >&2 + exit 1 +fi + +for output in "${outputs[@]}"; do + if [[ ! -s "$output" ]]; then + echo "package output is missing or empty: $output" >&2 + exit 1 + fi + sha256sum "$output" +done +echo "required GLIBC: $required_glibc" diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh new file mode 100644 index 0000000..156a202 --- /dev/null +++ b/scripts/package-macos.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=en_US.UTF-8 + +usage() { + cat <<'EOF' +Usage: scripts/package-macos.sh --binary PATH --version VERSION --preview-id ID + --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 Nebula Terminal Preview .app and packages it in a DMG. +Developer ID mode requires both signing and notarization; failures never +silently downgrade to an ad-hoc signature. +EOF +} + +binary="" +version="" +preview_id="" +architecture="" +build_number="" +output_directory="dist" +force=0 +sign_identity="" +notary_profile="" +signing_keychain="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --binary) + binary="${2:-}" + shift 2 + ;; + --version) + version="${2:-}" + shift 2 + ;; + --preview-id) + preview_id="${2:-}" + shift 2 + ;; + --architecture) + architecture="${2:-}" + shift 2 + ;; + --build-number) + build_number="${2:-}" + shift 2 + ;; + --output-directory) + output_directory="${2:-}" + shift 2 + ;; + --force) + force=1 + shift + ;; + --sign-identity) sign_identity="${2:-}"; shift 2 ;; + --notary-profile) notary_profile="${2:-}"; shift 2 ;; + --signing-keychain) signing_keychain="${2:-}"; shift 2 ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -n "$sign_identity" || -n "$notary_profile" || -n "$signing_keychain" ]]; then + if [[ "$sign_identity" != "Developer ID Application: "* || -z "$notary_profile" ]]; then + echo "Developer ID mode requires a Developer ID Application identity and notary profile" >&2 + exit 2 + fi +fi + +if [[ -z "$binary" || -z "$version" || -z "$preview_id" || \ + -z "$architecture" || -z "$build_number" ]]; then + usage >&2 + exit 2 +fi +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 + echo "invalid Preview id: $preview_id" >&2 + exit 2 +fi +if [[ "$architecture" != "aarch64" && "$architecture" != "x86_64" ]]; then + echo "unsupported macOS package architecture: $architecture" >&2 + exit 2 +fi +if [[ ! "$build_number" =~ ^[1-9][0-9]*$ ]]; then + echo "CFBundleVersion build number must be a positive integer" >&2 + exit 2 +fi +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "macOS Preview packages must be built on a native macOS runner" >&2 + exit 1 +fi + +expected_uname="arm64" +if [[ "$architecture" == "x86_64" ]]; then + expected_uname="x86_64" +fi +if [[ "$(uname -m)" != "$expected_uname" ]]; then + echo "runner architecture $(uname -m) does not match requested $architecture" >&2 + exit 1 +fi +for command in codesign hdiutil iconutil lipo otool plutil shasum sips; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "required packaging command is missing: $command" >&2 + exit 1 + fi +done +if [[ ! -f "$binary" || ! -x "$binary" ]]; then + echo "Nebula binary is missing or not executable: $binary" >&2 + exit 1 +fi + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +repo="$(cd "$script_directory/.." && pwd -P)" +binary="$(cd "$(dirname "$binary")" && pwd -P)/$(basename "$binary")" +python3 "$script_directory/preview_release.py" --check-binary "$binary" +mkdir -p "$output_directory" +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/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 + if [[ ! -f "$required" ]]; then + echo "required package input is missing: $required" >&2 + exit 1 + fi +done + +help_text="$("$binary" --help 2>&1)" +if [[ "$help_text" != *"--gpui"* ]]; then + echo "refusing to package a non-GPUI Nebula binary" >&2 + exit 1 +fi +version_text="$("$binary" --version 2>&1)" +if [[ "$version_text" != *"$version"* ]]; then + echo "binary version does not match package version $version: $version_text" >&2 + exit 1 +fi +binary_architectures="$(lipo -archs "$binary")" +if [[ "$binary_architectures" != "$expected_uname" ]]; then + echo "binary architecture is $binary_architectures; expected only $expected_uname" >&2 + exit 1 +fi +while IFS= read -r dependency; do + case "$dependency" in + /usr/lib/*|/System/Library/*) ;; + *) echo "non-system Mach-O dependency is not bundled: $dependency" >&2; exit 1 ;; + esac +done < <(otool -L "$binary" | tail -n +2 | awk '{print $1}') + +release="$version-preview.$preview_id" +dmg_path="$output_directory/NebulaTerminal-v$release-macos-$architecture.dmg" +if [[ -e "$dmg_path" && $force -ne 1 ]]; then + echo "package already exists: $dmg_path (pass --force to replace it)" >&2 + exit 1 +fi +if [[ $force -eq 1 ]]; then + rm -f -- "$dmg_path" +fi + +work="$(mktemp -d "${TMPDIR:-/tmp}/nebula-macos-package.XXXXXX")" +cleanup() { + if [[ -n "${work:-}" && -d "$work" ]]; then + rm -rf -- "$work" + fi +} +trap cleanup EXIT + +stage="$work/dmg-root" +app="$stage/Nebula Terminal Preview.app" +contents="$app/Contents" +resources="$contents/Resources" +mkdir -p "$contents/MacOS" "$resources/docs" "$resources/licenses" +install -m 0755 "$binary" "$contents/MacOS/nebula" +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 \ + "Nebula Terminal $version Preview $preview_id" "$contents/Info.plist" +plutil -lint "$contents/Info.plist" + +iconset="$work/nebula.iconset" +mkdir -p "$iconset" +while read -r pixels filename; do + sips -z "$pixels" "$pixels" "$icon_source" --out "$iconset/$filename" >/dev/null +done <<'EOF' +16 icon_16x16.png +32 icon_16x16@2x.png +32 icon_32x32.png +64 icon_32x32@2x.png +128 icon_128x128.png +256 icon_128x128@2x.png +256 icon_256x256.png +512 icon_256x256@2x.png +512 icon_512x512.png +1024 icon_512x512@2x.png +EOF +iconutil -c icns "$iconset" -o "$resources/nebula.icns" + +install -m 0644 "$repo/README.md" "$resources/docs/README.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" +install -m 0644 "$repo/THIRD-PARTY-NOTICES" "$resources/licenses/THIRD-PARTY-NOTICES" +install -m 0644 "$repo/licenses/LICENSE-LUA" "$resources/licenses/LICENSE-LUA" +install -m 0644 "$repo/licenses/LICENSE-MLUA" "$resources/licenses/LICENSE-MLUA" +install -m 0644 "$repo/licenses/LICENSE-LATIN-MODERN-MATH" \ + "$resources/licenses/LICENSE-LATIN-MODERN-MATH" + +keychain_args=() +if [[ -n "$signing_keychain" ]]; then + keychain_args=(--keychain "$signing_keychain") +fi +if [[ -n "$sign_identity" ]]; then + codesign --force --sign "$sign_identity" --options runtime --timestamp \ + ${keychain_args[@]+"${keychain_args[@]}"} "$app" +else + codesign --force --sign - --timestamp=none "$app" +fi +codesign --verify --deep --strict --verbose=2 "$app" +if [[ "$(plutil -extract CFBundleIdentifier raw -o - "$contents/Info.plist")" != \ + "io.github.kuddev.nebula.preview" ]]; then + echo "application bundle identifier verification failed" >&2 + exit 1 +fi +if [[ "$(lipo -archs "$contents/MacOS/nebula")" != "$expected_uname" ]]; then + echo "packaged Mach-O architecture verification failed" >&2 + exit 1 +fi + +ln -s /Applications "$stage/Applications" +hdiutil create \ + -volname "Nebula Terminal Preview" \ + -srcfolder "$stage" \ + -ov \ + -format UDZO \ + "$dmg_path" +hdiutil verify "$dmg_path" +if [[ -n "$sign_identity" ]]; then + codesign --force --sign "$sign_identity" --timestamp ${keychain_args[@]+"${keychain_args[@]}"} "$dmg_path" + xcrun notarytool submit "$dmg_path" --keychain-profile "$notary_profile" \ + ${keychain_args[@]+"${keychain_args[@]}"} --wait --output-format json >"$work/notarization.json" + python3 - "$work/notarization.json" <<'PY' +import json +import sys +with open(sys.argv[1], encoding="utf-8") as stream: + result = json.load(stream) +if result.get("status") != "Accepted": + raise SystemExit(f"Apple notarization did not accept the DMG: {result.get('status')}") +PY + xcrun stapler staple "$dmg_path" + xcrun stapler validate "$dmg_path" + spctl --assess --type open --context context:primary-signature --verbose=2 "$dmg_path" +fi +if [[ ! -s "$dmg_path" ]]; then + echo "DMG output is missing or empty: $dmg_path" >&2 + exit 1 +fi +shasum -a 256 "$dmg_path" diff --git a/scripts/platform_cfg_budget.txt b/scripts/platform_cfg_budget.txt new file mode 100644 index 0000000..3091e8e --- /dev/null +++ b/scripts/platform_cfg_budget.txt @@ -0,0 +1 @@ +504 diff --git a/scripts/preview_release.py b/scripts/preview_release.py new file mode 100644 index 0000000..c1360c6 --- /dev/null +++ b/scripts/preview_release.py @@ -0,0 +1,422 @@ +#!/usr/bin/env python3 +"""Validate and describe the complete cross-platform Preview asset set.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import sys + + +MIN_ASSET_SIZE = 1024 * 1024 +VERSION_PATTERN = re.compile( + r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?" + r"(?:\+[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$" +) +PREVIEW_PATTERN = re.compile(r"^[0-9A-Za-z][0-9A-Za-z.-]{0,31}$") +CHECKSUM_PLACEHOLDER = "" +PACKAGE_VERSION_PATTERN = re.compile(r'^version\s*=\s*"([^"]+)"\s*(?:#.*)?$') +RUNTIME_REPORTS = { + "windows-report.json": "windows-x86_64", + "linux-appimage-report.json": "linux-x86_64-appimage", + "linux-appimage-wayland-report.json": "linux-x86_64-appimage-wayland", + "linux-deb-report.json": "linux-x86_64-deb", + "macos-aarch64-report.json": "macos-aarch64", + "macos-x86_64-report.json": "macos-x86_64", +} + + +class ManifestError(ValueError): + """The Preview asset set is incomplete or unsafe to publish.""" + + +def verify_binary_freshness(binary: Path, repo: Path) -> None: + sources = [repo / name for name in ("Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "CHANGELOG.md")] + for name in ("nebula_app", "nebula_terminal", "nebula_config", "nebula_config_derive", + "nebula_settings", "nebula_split", "nebula-completions", "assets", "extra", "third_party"): + root = repo / name + if root.is_dir(): + sources.extend(path for path in root.rglob("*") if path.is_file()) + timestamp = binary.stat().st_mtime_ns + stale = next((path for path in sources if path.is_file() and path.stat().st_mtime_ns > timestamp), None) + if stale: + raise ManifestError(f"binary is older than {stale.relative_to(repo)}; rebuild GPUI before packaging") + + +def validate_evidence(directory: Path, commit: str) -> None: + root = str(Path(__file__).resolve().parents[1]) + if root not in sys.path: + sys.path.insert(0, root) + from scripts.conformance.harness import compare_reports, validate_common + + golden = Path(__file__).resolve().parent / "conformance/golden" + reports = [] + for name, platform in RUNTIME_REPORTS.items(): + report = json.loads((directory / name).read_text(encoding="utf-8")) + if report.get("platform") != platform or report.get("build", {}).get("commit") != commit: + raise ManifestError(f"wrong platform or source commit in {name}") + cases = report.get("cases", {}) + if (report.get("schema_version") != 1 or len(cases) != report.get("summary", {}).get("total") + or any(case.get("status") not in {"passed", "skipped"} for case in cases.values())): + raise ManifestError(f"failed or incomplete conformance report: {name}") + errors = validate_common(report, golden) + if errors: + raise ManifestError(f"{name}: {'; '.join(errors)}") + reports.append(report) + errors = compare_reports(reports, golden) + if errors: + raise ManifestError("cross-platform mismatch: " + "; ".join(errors)) + for architecture in ("aarch64", "x86_64"): + name = f"macos-{architecture}-launch.json" + report = json.loads((directory / name).read_text(encoding="utf-8")) + 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): + raise ManifestError(f"installed macOS application did not pass LaunchServices smoke test: {name}") + + +def verify_release(metadata: dict, directory: Path, version: str, preview_id: str, + commit: str, tag_commit: str) -> None: + expected_title = f"Nebula Terminal {version} Preview {preview_id}" + expected_tag = f"preview-v{version}-{preview_id}" + if (metadata.get("name") != expected_title or metadata.get("tag_name") != expected_tag + or metadata.get("prerelease") is not True or metadata.get("draft") is not False + or tag_commit != commit): + raise ManifestError("remote Preview title, tag, commit, or prerelease state differs") + notes = (directory / "PREVIEW_NOTES.md").read_text(encoding="utf-8") + if (metadata.get("body") or "").strip() != notes.strip(): + raise ManifestError("remote Preview notes differ from the verified local notes") + names = set(expected_asset_names(version, preview_id)) | {"SHA256SUMS"} + assets = metadata.get("assets") or [] + if len(assets) != len(names) or {asset.get("name") for asset in assets} != names: + raise ManifestError("remote Preview 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 ManifestError(f"remote asset label, state, size, or SHA256 differs: {path.name}") + + +def validate_labels(version: str, preview_id: str) -> None: + if not VERSION_PATTERN.fullmatch(version): + raise ManifestError(f"invalid Cargo package version: {version!r}") + if not PREVIEW_PATTERN.fullmatch(preview_id): + raise ManifestError(f"invalid Preview id: {preview_id!r}") + + +def read_cargo_package_version(manifest: Path) -> str: + """Read only the top-level [package].version from one Cargo manifest.""" + in_package = False + for raw_line in manifest.read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + if line == "[package]": + in_package = True + continue + if in_package and line.startswith("["): + break + if in_package: + match = PACKAGE_VERSION_PATTERN.fullmatch(line) + if match: + version = match.group(1) + if not VERSION_PATTERN.fullmatch(version): + raise ManifestError( + f"invalid [package].version in {manifest}: {version!r}" + ) + return version + raise ManifestError(f"Cargo manifest has no [package].version: {manifest}") + + +def asset_version(version: str, preview_id: str) -> str: + validate_labels(version, preview_id) + return f"{version}-preview.{preview_id}" + + +def reviewed_notes(repo: Path, version: str, preview_id: str, macos_signing: str) -> str: + release = asset_version(version, preview_id) + source = repo / "docs" / "release-notes" / f"v{release}.md" + notes = source.read_text(encoding="utf-8").strip() + if not notes.startswith(f"# Nebula Terminal {version} Preview {preview_id}\n"): + raise ManifestError("reviewed Preview notes have the wrong release title") + for heading in ("## English", "## 中文", "## Contributors", "## SHA256"): + if notes.splitlines().count(heading) != 1: + raise ManifestError(f"reviewed Preview notes require one {heading} section") + body, _, checksum_section = notes.rpartition("\n## SHA256\n") + if checksum_section.strip() != CHECKSUM_PLACEHOLDER or notes.count(CHECKSUM_PLACEHOLDER) != 1: + raise ManifestError("reviewed notes require the SHA256 placeholder, not stale checksums") + signing_line = f"macOS signing / macOS 签名: `{macos_signing}`" + if signing_line not in body.splitlines(): + raise ManifestError("reviewed notes do not match the requested macOS signing mode") + english_start = body.index("\n## English\n") + 1 + chinese_start = body.index("\n## 中文\n") + 1 + contributors_start = body.index("\n## Contributors\n") + 1 + if not english_start < chinese_start < contributors_start: + raise ManifestError("reviewed notes must order English, 中文, then Contributors") + english = body[english_start:chinese_start] + chinese = body[chinese_start:contributors_start] + pairs = (("Added", "新增"), ("Fixed", "修复"), ("Improved", "改进")) + categories = 0 + for first, second in pairs: + present = f"### {first}" in english.splitlines() + if present != (f"### {second}" in chinese.splitlines()): + raise ManifestError("reviewed Preview change categories must be bilingual") + categories += present + if not categories: + raise ManifestError("reviewed Preview notes have no Added, Fixed, or Improved changes") + changelog = (repo / "CHANGELOG.md").read_text(encoding="utf-8") + heading = re.search(rf"(?m)^## {re.escape(release)}(?: - [^\n]*)?\n", changelog) + if heading is None: + raise ManifestError(f"CHANGELOG.md is missing the {release} entry") + entry = re.split(r"(?m)^## ", changelog[heading.end():], maxsplit=1)[0].strip() + entry = re.sub(r"(?m)^#(?=##)", "", entry) + if entry != body[english_start:contributors_start].strip(): + raise ManifestError("CHANGELOG.md and reviewed Preview notes describe different changes") + return notes + "\n" + + +def expected_asset_names(version: str, preview_id: str) -> tuple[str, ...]: + release = asset_version(version, preview_id) + return ( + f"NebulaTerminal-v{release}-linux-x86_64.AppImage", + f"NebulaTerminal-v{release}-linux-x86_64.deb", + f"NebulaTerminal-v{release}-linux-x86_64.tar.gz", + f"NebulaTerminal-v{release}-macos-aarch64.dmg", + f"NebulaTerminal-v{release}-macos-x86_64.dmg", + ) + + +def _check_magic(path: Path) -> None: + with path.open("rb") as stream: + header = stream.read(8) + if path.suffix == ".dmg": + stream.seek(-512, os.SEEK_END) + trailer = stream.read(4) + else: + trailer = b"" + + if path.name.endswith(".AppImage") and not header.startswith(b"\x7fELF"): + raise ManifestError(f"AppImage is not an ELF executable: {path.name}") + if path.suffix == ".deb" and header != b"!\n": + raise ManifestError(f"Debian package has an invalid ar header: {path.name}") + if path.name.endswith(".tar.gz") and not header.startswith(b"\x1f\x8b"): + raise ManifestError(f"tar.gz has an invalid gzip header: {path.name}") + if path.suffix == ".dmg" and trailer != b"koly": + raise ManifestError(f"DMG has no UDIF trailer: {path.name}") + + +def validate_assets(directory: Path, version: str, preview_id: str) -> list[Path]: + expected = set(expected_asset_names(version, preview_id)) + generated = {"SHA256SUMS", "PREVIEW_NOTES.md"} + if not directory.is_dir(): + raise ManifestError(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 ManifestError("Preview 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 ManifestError(f"asset is not a regular file: {path.name}") + size = path.stat().st_size + if size < MIN_ASSET_SIZE: + raise ManifestError( + f"asset is unexpectedly small ({size} bytes): {path.name}" + ) + _check_magic(path) + return assets + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def write_atomic(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + try: + temporary.write_text(content, encoding="utf-8", newline="\n") + temporary.replace(path) + finally: + temporary.unlink(missing_ok=True) + + +def checksum_text(assets: list[Path]) -> str: + return "".join(f"{sha256(path)} {path.name}\n" for path in assets) + + +def preview_notes( + version: str, + preview_id: str, + repository: str, + commit: str, + checksums: str, + macos_signing: str = "adhoc", +) -> str: + short_commit = commit[:12] + commit_url = f"https://github.com/{repository}/commit/{commit}" + title = f"Nebula Terminal {version} Preview {preview_id}" + signing_en = ( + "The macOS applications use ad-hoc signatures and are not Apple-notarized. Only open a verified, trusted download via System Settings > Privacy & Security > Open Anyway." + if macos_signing == "adhoc" else + "The macOS applications use Developer ID signatures; the final DMGs were accepted by Apple notarization and have stapled tickets." + ) + signing_zh = ( + "macOS 应用仅使用 ad-hoc 签名,尚未经过 Apple 公证;仅对已核验且可信的下载在系统设置 → 隐私与安全性中选择“仍要打开”。" + if macos_signing == "adhoc" else + "macOS 应用使用 Developer ID 签名;最终 DMG 已获 Apple 公证接受并附加公证票据。" + ) + return f"""# {title} + +## English + +### Added + +- Added automated Preview packages for Linux x86_64: AppImage, Debian package, and portable tar archive. +- Added native macOS Preview disk images for Apple Silicon and Intel. + +### Verification + +- Every package was built on a native GitHub-hosted runner from commit [`{short_commit}`]({commit_url}). +- Linux conformance ran against both the final AppImage and the installed Debian package. +- macOS conformance ran against the application from each final disk image, including a separate installed-copy LaunchServices startup check. +- Runtime reports were compared with a Windows build of the same commit. This does not certify native IME, display scaling, or interactive SSH authentication. + +### Preview limitations + +- These packages are for cross-platform testing and are not a stable release. +- {signing_en} +- Linux/macOS automatic update installation and platform-specific integrations remain outside this Preview. + +## 中文 + +### 新增 + +- 新增由 CI 自动构建的 Linux x86_64 Preview 包:AppImage、Debian 安装包和便携 tar 归档。 +- 新增 Apple Silicon 和 Intel 两种原生 macOS Preview 磁盘映像。 + +### 验证 + +- 所有安装包均由 GitHub 原生 runner 从提交 [`{short_commit}`]({commit_url}) 构建。 +- Linux conformance 分别针对最终 AppImage 和已安装的 Debian 包运行。 +- macOS conformance 针对每个最终 DMG 中的应用运行,并单独检查安装副本通过 LaunchServices 启动。 +- Runtime 报告与同一提交的 Windows 构建进行了比较;这不代表原生输入法、显示缩放或交互式 SSH 认证已通过验收。 + +### Preview 限制 + +- 这些产物用于跨平台测试,不是稳定版本。 +- {signing_zh} +- Linux/macOS 自动安装更新和平台专属集成功能不在本次 Preview 范围内。 + +## Contributors + +- See the commit history for this Preview build / 本 Preview 的贡献者以提交历史为准。 + +--- + +## SHA256 + +```text +{checksums.rstrip()} +``` +""" + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("directory", nargs="?", type=Path) + parser.add_argument("--version") + parser.add_argument("--preview-id") + parser.add_argument("--write-checksums", type=Path) + parser.add_argument("--write-notes", type=Path) + parser.add_argument("--repository") + parser.add_argument("--commit") + parser.add_argument("--print-package-version", type=Path) + parser.add_argument("--check-binary", type=Path) + parser.add_argument("--check-reviewed-notes", action="store_true") + parser.add_argument("--use-reviewed-notes", action="store_true") + parser.add_argument("--evidence-dir", type=Path) + parser.add_argument("--macos-signing", choices=("adhoc", "developer-id"), default="adhoc") + parser.add_argument("--verify-release", type=Path) + 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.print_package_version: + print(read_cargo_package_version(args.print_package_version.resolve())) + return 0 + if args.check_binary: + verify_binary_freshness(args.check_binary.resolve(), Path(__file__).resolve().parents[1]) + return 0 + source_notes = None + if args.check_reviewed_notes or args.use_reviewed_notes: + if not args.version or not args.preview_id: + raise ManifestError("reviewed notes require --version and --preview-id") + source_notes = reviewed_notes(Path(__file__).resolve().parents[1], args.version, + args.preview_id, args.macos_signing) + if args.check_reviewed_notes: + return 0 + if args.evidence_dir: + if not args.commit: + raise ManifestError("--evidence-dir requires --commit") + validate_evidence(args.evidence_dir.resolve(), args.commit) + if args.directory is None or not args.version or not args.preview_id: + raise ManifestError( + "asset validation requires directory, --version, and --preview-id" + ) + assets = validate_assets(args.directory.resolve(), args.version, args.preview_id) + if args.verify_release: + if not args.commit or not args.tag_commit: + raise ManifestError("--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.preview_id, args.commit, args.tag_commit) + checksums = checksum_text(assets) + if args.write_checksums: + write_atomic(args.write_checksums.resolve(), checksums) + if args.write_notes: + if not args.repository or not args.commit or not args.evidence_dir: + raise ManifestError( + "--write-notes requires --repository, --commit, and validated --evidence-dir" + ) + if source_notes is not None: + notes = source_notes.replace(CHECKSUM_PLACEHOLDER, f"```text\n{checksums.rstrip()}\n```") + else: + notes = preview_notes( + args.version, + args.preview_id, + args.repository, + args.commit, + checksums, + args.macos_signing, + ) + write_atomic(args.write_notes.resolve(), notes) + except (ManifestError, OSError, json.JSONDecodeError) as error: + print(f"preview manifest error: {error}", file=sys.stderr) + return 1 + + for path in assets: + print(f"{sha256(path)} {path.name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/render-app-icons.mjs b/scripts/render-app-icons.mjs new file mode 100644 index 0000000..0404fb1 --- /dev/null +++ b/scripts/render-app-icons.mjs @@ -0,0 +1,86 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { crc32, deflateSync, inflateSync } from 'node:zlib'; + +const require = createRequire(process.argv[2] ? resolve(process.argv[2]) : import.meta.url); +const { Resvg } = require('@resvg/resvg-js'); +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const sizes = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256]; + +function optimizePng(png) { + const chunks = []; + const data = []; + for (let offset = 8; offset < png.length;) { + const length = png.readUInt32BE(offset); + const chunk = png.subarray(offset, offset + length + 12); + chunks.push(chunk); + if (chunk.toString('ascii', 4, 8) === 'IDAT') data.push(chunk.subarray(8, -4)); + offset += chunk.length; + } + const compressed = deflateSync(inflateSync(Buffer.concat(data)), { level: 9 }); + const replacement = Buffer.alloc(compressed.length + 12); + replacement.writeUInt32BE(compressed.length); + replacement.write('IDAT', 4); + compressed.copy(replacement, 8); + replacement.writeUInt32BE(crc32(replacement.subarray(4, -4)), replacement.length - 4); + let inserted = false; + return Buffer.concat([png.subarray(0, 8), ...chunks.flatMap(chunk => { + if (chunk.toString('ascii', 4, 8) !== 'IDAT') return [chunk]; + if (inserted) return []; + inserted = true; + return [replacement]; + })]); +} + +function render(source, size) { + const opticalSource = size <= 24 + ? source.replace('x="19" y="19" width="90" height="90"', 'x="14" y="14" width="100" height="100"') + .replace('stroke-width="10"', 'stroke-width="11"') + : source; + return optimizePng(new Resvg(opticalSource, { + font: { loadSystemFonts: false }, + fitTo: { mode: 'width', value: size }, + }).render().asPng()); +} + +function encodeIco(source) { + const frames = sizes.map(size => ({ size, png: render(source, size) })); + const header = Buffer.alloc(6 + frames.length * 16); + header.writeUInt16LE(1, 2); + header.writeUInt16LE(frames.length, 4); + let offset = header.length; + + for (const [index, { size, png }] of frames.entries()) { + const entry = 6 + index * 16; + header.writeUInt8(size === 256 ? 0 : size, entry); + header.writeUInt8(size === 256 ? 0 : size, entry + 1); + header.writeUInt16LE(1, entry + 4); + header.writeUInt16LE(32, entry + 6); + header.writeUInt32LE(png.length, entry + 8); + header.writeUInt32LE(offset, entry + 12); + offset += png.length; + } + + return Buffer.concat([header, ...frames.map(frame => frame.png)]); +} + +let totalIcoBytes = 0; +for (const variant of ['light', 'dark', 'titanium']) { + const source = await readFile(join(root, 'extra/logo', `nebula-${variant}.svg`), 'utf8'); + const png = render(source, 1024); + const ico = encodeIco(source); + await writeFile(join(root, 'extra/logo', `nebula-${variant}.png`), png); + await writeFile(join(root, 'nebula_app/windows', `nebula-${variant}.ico`), ico); + + if (variant === 'light') { + await writeFile(join(root, 'extra/logo/nebula.png'), png); + await writeFile(join(root, 'nebula_app/windows/nebula.ico'), ico); + } + + totalIcoBytes += ico.length; + console.log(`${variant}: 1024px PNG; ICO frames ${sizes.join(', ')}px; ${ico.length} bytes`); +} +if (totalIcoBytes > 64 * 1024) throw new Error(`Icon resource budget exceeded: ${totalIcoBytes} bytes`); +console.log(`All three ICOs: ${totalIcoBytes} bytes (64 KiB budget); default: silver violet`); diff --git a/scripts/tests/test_app_icons.py b/scripts/tests/test_app_icons.py new file mode 100644 index 0000000..047bfef --- /dev/null +++ b/scripts/tests/test_app_icons.py @@ -0,0 +1,148 @@ +import binascii +import math +import struct +import unittest +import xml.etree.ElementTree as ET +import zlib +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SIZES = (16, 20, 24, 32, 40, 48, 64, 96, 128, 256) +VARIANTS = ("light", "dark", "titanium") +NAMESPACE = {"svg": "http://www.w3.org/2000/svg"} + + +def ico_frames(path): + data = path.read_bytes() + reserved, kind, count = struct.unpack_from("I", data, offset)[0] + kind = data[offset + 4:offset + 8] + payload = data[offset + 8:offset + 8 + length] + checksum = struct.unpack_from(">I", data, offset + 8 + length)[0] + assert binascii.crc32(kind + payload) == checksum + if kind == b"IHDR": + width, height, depth, color, compression, filtering, interlace = struct.unpack( + ">IIBBBBB", payload + ) + assert (depth, color, compression, filtering, interlace) == (8, 6, 0, 0, 0) + elif kind == b"IDAT": + image_data.append(payload) + offset += length + 12 + raw = zlib.decompress(b"".join(image_data)) + stride = width * 4 + assert len(raw) == height * (stride + 1) + previous = bytearray(stride) + rows = [] + for row_index in range(height): + start = row_index * (stride + 1) + filtering = raw[start] + assert filtering in range(5) + row = bytearray(raw[start + 1:start + 1 + stride]) + for column in range(stride): + left = row[column - 4] if column >= 4 else 0 + above = previous[column] + upper_left = previous[column - 4] if column >= 4 else 0 + predictor = left + above - upper_left + distances = [abs(predictor - value) for value in (left, above, upper_left)] + paeth = (left, above, upper_left)[distances.index(min(distances))] + row[column] = (row[column] + (0, left, above, (left + above) // 2, paeth)[filtering]) % 256 + rows.append([tuple(row[column:column + 4]) for column in range(0, stride, 4)]) + previous = row + return width, height, rows + + +def color(hex_value): + return tuple(int(hex_value[index:index + 2], 16) for index in (1, 3, 5)) + + +def contrast(first, second): + def luminance(channels): + linear = [value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4 + for value in (channel / 255 for channel in channels)] + return sum(channel * weight for channel, weight in zip(linear, (0.2126, 0.7152, 0.0722))) + brighter, darker = sorted((luminance(first), luminance(second)), reverse=True) + return (brighter + 0.05) / (darker + 0.05) + + +class AppIconTests(unittest.TestCase): + def test_curated_resource_budget_and_default(self): + paths = [ROOT / "nebula_app/windows" / f"nebula-{variant}.ico" for variant in VARIANTS] + self.assertLessEqual(sum(path.stat().st_size for path in paths), 64 * 1024) + self.assertEqual(paths[0].read_bytes(), (ROOT / "nebula_app/windows/nebula.ico").read_bytes()) + self.assertEqual((ROOT / "extra/logo/nebula.png").read_bytes(), + (ROOT / "extra/logo/nebula-light.png").read_bytes()) + resource = (ROOT / "nebula_app/windows/nebula.rc").read_text() + self.assertEqual(resource.count(' ICON "'), 3) + self.assertNotIn('"nebula-light.ico"', resource) + + def test_geometry_and_solid_color_contrast(self): + shapes = [] + for variant in VARIANTS: + source = ET.parse(ROOT / "extra/logo" / f"nebula-{variant}.svg").getroot() + paths = source.findall(".//svg:path", NAMESPACE) + shapes.append([path.get("d") for path in paths]) + tile = source.find("svg:rect", NAMESPACE) + mark = source.find("svg:use", NAMESPACE) + self.assertEqual((tile.get("x"), tile.get("width"), tile.get("rx")), ("5", "118", "32")) + self.assertEqual((mark.get("x"), mark.get("width")), ("19", "90")) + self.assertGreaterEqual(contrast(color(tile.get("fill")), color(mark.get("color"))), 4.5) + self.assertTrue(all(shape == shapes[0] for shape in shapes)) + + def test_native_frames_transparency_size_and_prompt_separation(self): + for variant in VARIANTS: + source = ET.parse(ROOT / "extra/logo" / f"nebula-{variant}.svg").getroot() + tile = color(source.find("svg:rect", NAMESPACE).get("fill")) + mark = color(source.find("svg:use", NAMESPACE).get("color")) + for size, frame in ico_frames(ROOT / "nebula_app/windows" / f"nebula-{variant}.ico").items(): + with self.subTest(variant=variant, size=size): + width, height, pixels = decode_png(frame) + self.assertEqual((width, height), (size, size)) + self.assertEqual(pixels[0][0][3], 0) + occupied = [(column, row) for row in range(size) for column in range(size) + if pixels[row][column][3] > 128] + self.assertGreaterEqual(max(column for column, _ in occupied) - min(column for column, _ in occupied), size * 0.85) + scale, origin = (100 / 128, 14) if size <= 24 else (90 / 128, 19) + def samples(local_x, local_y): + target_x = (origin + scale * local_x) * size / 128 - 0.5 + target_y = (origin + scale * local_y) * size / 128 - 0.5 + return [pixels[row][column][:3] + for row in {math.floor(target_y), math.ceil(target_y)} + for column in {math.floor(target_x), math.ceil(target_x)}] + def distance(first, second): + return sum((left - right) ** 2 for left, right in zip(first, second)) + for prompt_point in [(46, 59), (46, 71), (77, 80)]: + self.assertTrue(any(distance(sample, tile) < distance(sample, mark) + for sample in samples(*prompt_point))) + self.assertTrue(any(distance(sample, mark) < distance(sample, tile) + for sample in samples(62, 79))) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_platform_cfg.py b/scripts/tests/test_platform_cfg.py new file mode 100644 index 0000000..a3a1844 --- /dev/null +++ b/scripts/tests/test_platform_cfg.py @@ -0,0 +1,26 @@ +import tempfile +import unittest +from pathlib import Path + +from scripts.check_platform_cfg import count_platform_cfgs + + +class PlatformCfgTests(unittest.TestCase): + def count(self, source: str) -> int: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "source.rs" + path.write_text(source, encoding="utf-8") + return count_platform_cfgs(path) + + def test_plain_target_os_is_counted(self) -> None: + for target in ("macos", "linux", "freebsd"): + self.assertEqual(self.count(f'#[cfg(target_os = "{target}")] fn example() {{}}'), 1) + + def test_nested_conditions_and_macros_are_counted(self) -> None: + self.assertEqual(self.count('#[cfg(all(unix, not(target_os = "macos")))]'), 2) + self.assertEqual(self.count('cfg!(any(windows, target_os = "linux"))'), 2) + self.assertEqual(self.count('#[cfg_attr(target_os="macos", allow(dead_code))]'), 1) + + def test_features_are_not_platforms(self) -> None: + self.assertEqual(self.count('#[cfg(feature = "gpui-shell")]'), 0) + self.assertEqual(self.count('let windows = true;'), 0) diff --git a/scripts/tests/test_preview_release.py b/scripts/tests/test_preview_release.py new file mode 100644 index 0000000..d70cd6b --- /dev/null +++ b/scripts/tests/test_preview_release.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import tempfile +import unittest + +from scripts.preview_release import ( + MIN_ASSET_SIZE, + ManifestError, + asset_version, + expected_asset_names, + preview_notes, + read_cargo_package_version, + reviewed_notes, + validate_assets, + validate_evidence, + verify_binary_freshness, + verify_release, + sha256, + RUNTIME_REPORTS, +) + + +def write_fake_asset(path: Path) -> None: + if path.name.endswith(".AppImage"): + header = b"\x7fELF\x02\x01\x01\x00" + elif path.suffix == ".deb": + header = b"!\n" + elif path.name.endswith(".tar.gz"): + header = b"\x1f\x8b\x08\x00\x00\x00\x00\x00" + else: + header = b"\x00" * 8 + + with path.open("wb") as stream: + stream.write(header) + stream.truncate(MIN_ASSET_SIZE) + if path.suffix == ".dmg": + stream.seek(-512, 2) + stream.write(b"koly") + + +class PreviewReleaseTests(unittest.TestCase): + def write_reviewed_notes(self, root: Path) -> Path: + changes = "## English\n\n### Added\n\n- Preview packages.\n\n## 中文\n\n### 新增\n\n- 预览安装包。" + source = root / "docs/release-notes/v1.5.0-preview.42.md" + source.parent.mkdir(parents=True) + source.write_text( + "# Nebula Terminal 1.5.0 Preview 42\n\n" + changes + + "\n\n## Contributors\n\n- Contributors\n\n" + "macOS signing / macOS 签名: `adhoc`\n\n---\n\n## SHA256\n\n\n", + encoding="utf-8", + ) + changelog_changes = "\n".join("#" + line if line.startswith("#") else line for line in changes.splitlines()) + (root / "CHANGELOG.md").write_text( + "# Changelog\n\n## Unreleased\n\nNone.\n\n## 1.5.0-preview.42 - 2026-09-05\n\n" + + changelog_changes + "\n\n## 1.5.0 - 2026-09-01\n\nOld notes.\n", encoding="utf-8", + ) + return source + + def test_public_preview_requires_committed_synchronized_notes(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self.write_reviewed_notes(root) + self.assertEqual(reviewed_notes(root, "1.5.0", "42", "adhoc"), source.read_text(encoding="utf-8")) + changelog = root / "CHANGELOG.md" + changelog.write_text(changelog.read_text(encoding="utf-8").replace("Preview packages.", "Other changes."), encoding="utf-8") + with self.assertRaisesRegex(ManifestError, "different changes"): + reviewed_notes(root, "1.5.0", "42", "adhoc") + + def test_public_preview_rejects_missing_notes_or_wrong_signing_mode(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with self.assertRaises(OSError): + reviewed_notes(root, "1.5.0", "42", "adhoc") + self.write_reviewed_notes(root) + with self.assertRaisesRegex(ManifestError, "signing mode"): + reviewed_notes(root, "1.5.0", "42", "developer-id") + with self.assertRaises(OSError): + reviewed_notes(root, "1.5.0", "43", "adhoc") + + def test_public_preview_rejects_stale_checksums(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self.write_reviewed_notes(root) + source.write_text(source.read_text(encoding="utf-8").replace("", "old-checksum"), encoding="utf-8") + with self.assertRaisesRegex(ManifestError, "stale checksums"): + reviewed_notes(root, "1.5.0", "42", "adhoc") + + def test_public_preview_requires_matching_bilingual_categories(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = self.write_reviewed_notes(root) + source.write_text(source.read_text(encoding="utf-8").replace("### 新增", "### 修复"), encoding="utf-8") + with self.assertRaisesRegex(ManifestError, "bilingual"): + reviewed_notes(root, "1.5.0", "42", "adhoc") + + def test_packaging_rejects_stale_binary(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + source = root / "nebula_app/res/shell/zshrc" + source.parent.mkdir(parents=True) + source.write_text("updated script", encoding="utf-8") + binary = root / "nebula" + binary.write_bytes(b"binary") + os.utime(binary, (10, 10)) + os.utime(source, (20, 20)) + with self.assertRaisesRegex(ManifestError, "rebuild GPUI"): + verify_binary_freshness(binary, root) + os.utime(binary, (30, 30)) + verify_binary_freshness(binary, root) + + def test_reads_only_top_level_cargo_package_version(self) -> None: + with tempfile.TemporaryDirectory() as raw_directory: + manifest = Path(raw_directory) / "Cargo.toml" + manifest.write_text( + '[package]\nname = "nebula"\nversion = "1.5.0"\n\n' + '[dependencies]\nversion = "99.0.0"\n', + encoding="utf-8", + ) + self.assertEqual(read_cargo_package_version(manifest), "1.5.0") + + def test_asset_version_rejects_path_like_preview_id(self) -> None: + self.assertEqual(asset_version("1.5.0", "42.1"), "1.5.0-preview.42.1") + with self.assertRaises(ManifestError): + asset_version("1.5.0", "../../release") + + def test_complete_manifest_passes(self) -> None: + with tempfile.TemporaryDirectory() as raw_directory: + directory = Path(raw_directory) + for name in expected_asset_names("1.5.0", "42"): + write_fake_asset(directory / name) + assets = validate_assets(directory, "1.5.0", "42") + self.assertEqual(len(assets), 5) + + def test_unexpected_asset_fails_closed(self) -> None: + with tempfile.TemporaryDirectory() as raw_directory: + directory = Path(raw_directory) + for name in expected_asset_names("1.5.0", "42"): + write_fake_asset(directory / name) + (directory / "old-build.zip").write_bytes(b"old") + with self.assertRaisesRegex(ManifestError, "unexpected: old-build.zip"): + validate_assets(directory, "1.5.0", "42") + + def test_bad_magic_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as raw_directory: + directory = Path(raw_directory) + names = expected_asset_names("1.5.0", "42") + for name in names: + write_fake_asset(directory / name) + (directory / names[0]).write_bytes(b"not an appimage" + b"\0" * MIN_ASSET_SIZE) + with self.assertRaisesRegex(ManifestError, "not an ELF"): + validate_assets(directory, "1.5.0", "42") + + def test_notes_are_bilingual_and_disclose_preview_limits(self) -> None: + notes = preview_notes( + "1.5.0", + "42", + "Kuddev/nebula", + "a" * 40, + "deadbeef package\n", + ) + self.assertIn("## English", notes) + self.assertIn("## 中文", notes) + self.assertIn("ad-hoc", notes) + self.assertIn("不是稳定版本", notes) + self.assertIn("deadbeef package", notes) + + def test_signed_notes_never_claim_adhoc(self) -> None: + notes = preview_notes("1.5.0", "42", "Kuddev/nebula", "a" * 40, "hash file", "developer-id") + self.assertIn("Developer ID", notes) + self.assertIn("公证票据", notes) + self.assertNotIn("ad-hoc", notes) + + def test_missing_native_evidence_cannot_publish(self) -> None: + with tempfile.TemporaryDirectory() as directory: + with self.assertRaises(OSError): + validate_evidence(Path(directory), "a" * 40) + + def test_complete_evidence_requires_same_commit_and_installed_launch(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + common_path = Path(__file__).resolve().parents[1] / "conformance/golden/common.json" + required = json.loads(common_path.read_text(encoding="utf-8"))["required"] + report = {"schema_version": 1, "build": {"commit": "a" * 40}} + for path, value in required.items(): + cursor = report + parts = path.split(".") + for part in parts[:-1]: + cursor = cursor.setdefault(part, {}) + cursor[parts[-1]] = value + report["cases"]["ssh_loop"] = {"status": "skipped", "reason": "no_interactive_ssh_automation"} + report["summary"].update(passed=9, skipped=1) + for filename, platform in RUNTIME_REPORTS.items(): + report["platform"] = platform + (root / filename).write_text(json.dumps(report), encoding="utf-8") + for architecture in ("aarch64", "x86_64"): + (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, + }), encoding="utf-8") + validate_evidence(root, "a" * 40) + with self.assertRaisesRegex(ManifestError, "source commit"): + validate_evidence(root, "b" * 40) + (root / "macos-aarch64-launch.json").write_text('{}', encoding="utf-8") + with self.assertRaisesRegex(ManifestError, "LaunchServices"): + validate_evidence(root, "a" * 40) + + def test_remote_release_checks_assets_notes_labels_and_tag_commit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + names = [*expected_asset_names("1.5.0", "42"), "SHA256SUMS"] + (root / "PREVIEW_NOTES.md").write_text("verified notes", encoding="utf-8") + for name in names: + (root / name).write_bytes(b"fixture") + metadata = { + "name": "Nebula Terminal 1.5.0 Preview 42", "tag_name": "preview-v1.5.0-42", + "prerelease": True, "draft": False, "body": "verified notes", + "assets": [{"name": name, "label": "", "state": "uploaded", "size": 7, + "digest": f"sha256:{sha256(root / name)}"} for name in names], + } + verify_release(metadata, root, "1.5.0", "42", "a" * 40, "a" * 40) + with self.assertRaisesRegex(ManifestError, "commit"): + verify_release(metadata, root, "1.5.0", "42", "a" * 40, "b" * 40) + metadata["assets"][0]["label"] = "wrong label" + with self.assertRaisesRegex(ManifestError, "label"): + verify_release(metadata, root, "1.5.0", "42", "a" * 40, "a" * 40) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_shell_integration.py b/scripts/tests/test_shell_integration.py new file mode 100644 index 0000000..31f2aef --- /dev/null +++ b/scripts/tests/test_shell_integration.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import os +from pathlib import Path +import select +import shutil +import subprocess +import sys +import tempfile +import time +import unittest +from urllib.parse import quote + +SCRIPTS = Path(__file__).resolve().parents[2] / "nebula_app/res/shell" + + +class ShellSession: + def __init__(self, program: str, home: Path, args: list[str], env: dict[str, str]) -> None: + import pty + + self.master, slave = pty.openpty() + self.output = b"" + self.process = subprocess.Popen( + [program, *args], stdin=slave, stdout=slave, stderr=slave, cwd=home, + env={"PATH": os.environ["PATH"], "HOME": str(home), "TERM": "xterm-256color", + "LC_ALL": "en_US.UTF-8" if sys.platform == "darwin" else "C.UTF-8", "PS1": "nebula-test> ", + **{name: os.environ[name] for name in ("FPATH", "NEBULA_TEST_ZSH_MODULE_DIR") if name in os.environ}, + **env}, + start_new_session=True, + ) + os.close(slave) + + def wait(self, marker: bytes) -> bytes: + collected = b"" + deadline = time.monotonic() + 8 + while time.monotonic() < deadline: + if select.select([self.master], [], [], 0.1)[0]: + try: + block = os.read(self.master, 65536) + except OSError: + break + collected += block + self.output += block + if marker in collected: + return collected + raise AssertionError(f"shell never emitted {marker!r}: {self.output!r}") + + def command(self, command: str, marker: bytes) -> bytes: + os.write(self.master, command.encode("utf-8") + b"\n") + return self.wait(marker) + + def close(self) -> None: + if self.process.poll() is None: + self.process.kill() + self.process.wait(timeout=5) + os.close(self.master) + + +@unittest.skipUnless(os.name == "posix", "requires Unix PTYs") +class ShellIntegrationTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory(prefix="nebula-shell-test-") + self.addCleanup(self.temporary.cleanup) + self.home = Path(self.temporary.name) + + def start(self, shell: str, rc: str = "", original_zdotdir: Path | None = None) -> ShellSession: + program = shutil.which(shell) + if not program: + self.skipTest(f"{shell} is not installed; native CI must run this case") + if shell == "bash": + if sys.platform == "darwin": + self.skipTest("macOS Bash keeps native login startup; this rc wrapper is Linux-only") + (self.home / ".bashrc").write_text(rc, encoding="utf-8") + args = ["--noprofile", "--rcfile", str(SCRIPTS / "bashrc"), "-i"] + env = {} + else: + dotfiles = original_zdotdir or self.home + dotfiles.mkdir(exist_ok=True) + (dotfiles / ".zshenv").write_text( + '[[ -n $NEBULA_TEST_ZSH_MODULE_DIR ]] && module_path=("$NEBULA_TEST_ZSH_MODULE_DIR" $module_path)\n' + "export NEBULA_PROFILE_TEST=env\n", encoding="utf-8") + (dotfiles / ".zprofile").write_text("NEBULA_PROFILE_TEST+=:profile\n", encoding="utf-8") + (dotfiles / ".zshrc").write_text("NEBULA_PROFILE_TEST+=:rc\n" + rc, encoding="utf-8") + (dotfiles / ".zlogin").write_text("NEBULA_PROFILE_TEST+=:login\n", encoding="utf-8") + wrapper = self.home / "integration" + wrapper.mkdir() + for source, target in [("zshenv", ".zshenv"), ("zprofile", ".zprofile"), ("zshrc", ".zshrc")]: + shutil.copyfile(SCRIPTS / source, wrapper / target) + args = ["-l", "-i"] + env = {"ZDOTDIR": str(wrapper), "NEBULA_ZSH_INTEGRATION": str(wrapper), + "NEBULA_ZDOTDIR_WAS_SET": "1" if original_zdotdir else "0"} + if original_zdotdir: + env["NEBULA_ORIGINAL_ZDOTDIR"] = str(original_zdotdir) + session = ShellSession(program, self.home, args, env) + self.addCleanup(session.close) + session.wait(b"\x1b]133;A\x07") + return session + + def check_protocol(self, shell: str) -> None: + session = self.start(shell) + output = session.command("(exit 7)", b"\x1b]133;D;7\x07") + self.assertIn(b"\x1b]133;C\x07", output) + directory = self.home / "目录 % # space" + directory.mkdir() + encoded = quote(str(directory), safe="/._~-").encode("ascii") + session.command(f"cd '{directory}'", b"\x1b]7;file://localhost" + encoded + b"\x07") + + def test_bash_command_status_and_utf8_cwd(self) -> None: + self.check_protocol("bash") + + def test_zsh_command_status_and_utf8_cwd(self) -> None: + self.check_protocol("zsh") + + def test_bash_preserves_prompt_command(self) -> None: + session = self.start("bash", "PROMPT_COMMAND=\"printf 'USER_PROMPT\\\\n'\"\n") + session.command("(exit 7)", b"\x1b]133;D;7\x07") + self.assertIn(b"USER_PROMPT", session.output) + + def test_bash_does_not_replace_user_debug_trap(self) -> None: + session = self.start("bash", "trap 'printf USER_DEBUG' DEBUG\n") + session.command("trap -p DEBUG", b"trap -- 'printf USER_DEBUG' DEBUG") + self.assertIn(b"USER_DEBUG", session.output) + + def test_bash_preserves_array_prompt_command(self) -> None: + session = self.start("bash", "PROMPT_COMMAND=('printf FIRST_PROMPT' 'printf SECOND_PROMPT')\n") + session.command("(exit 7)", b"\x1b]133;D;7\x07") + self.assertIn(b"FIRST_PROMPT", session.output) + self.assertIn(b"SECOND_PROMPT", session.output) + + def test_zsh_sources_login_files_and_restores_zdotdir(self) -> None: + session = self.start("zsh") + session.command('printf "PROFILE=%s ZDOTDIR=%s\\n" "$NEBULA_PROFILE_TEST" "${ZDOTDIR-unset}"', + b"PROFILE=env:profile:rc:login ZDOTDIR=unset") + + def test_zsh_preserves_custom_zdotdir(self) -> None: + directory = self.home / "custom dotfiles" + session = self.start("zsh", original_zdotdir=directory) + session.command('printf "PROFILE=%s ZDOTDIR=%s_END\\n" "$NEBULA_PROFILE_TEST" "$ZDOTDIR"', + f"PROFILE=env:profile:rc:login ZDOTDIR={directory}_END".encode()) + + def test_zsh_preserves_precmd_hooks_after_failure(self) -> None: + session = self.start("zsh", """ +typeset -gi user_prompt_count=0 +_user_precmd() { + local status_code=$? + (( ++user_prompt_count )) + printf 'USER_PRECMD_%s_STATUS=%s_END\\n' "$user_prompt_count" "$status_code" +} +precmd_functions=(_user_precmd) +""") + output = session.command("(exit 7)", b"USER_PRECMD_2_STATUS=7_END") + self.assertIn(b"\x1b]133;D;7\x07", output) + session.command("true", b"USER_PRECMD_3_STATUS=0_END") + + +if __name__ == "__main__": + unittest.main()