diff --git a/.github/workflows/ci.generated.yml b/.github/workflows/ci.generated.yml index 3412e61c386299..f95ba3d997a975 100644 --- a/.github/workflows/ci.generated.yml +++ b/.github/workflows/ci.generated.yml @@ -763,6 +763,12 @@ jobs: df -h cargo build --release --locked -p deno -p denort -p test_server --bin deno --bin denort --bin test_server --features=deno/panic-trace df -h + - name: Build denort_desktop + if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno'' && (contains(github.event.pull_request.labels.*.name, ''ci-full'') || github.ref == ''refs/heads/main'' || startsWith(github.ref, ''refs/tags/''))' + run: |- + if [ "$(uname -s)" = "Linux" ]; then strip target/release/deno target/release/denort target/release/test_server 2>/dev/null || true; df -h; fi + cargo build --release --locked -p denort_desktop + df -h - name: Generate symcache if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno''' env: @@ -793,6 +799,9 @@ jobs: strip -x -S ./denort zip -r denort-x86_64-apple-darwin.zip denort shasum -a 256 denort-x86_64-apple-darwin.zip > denort-x86_64-apple-darwin.zip.sha256sum + strip -x -S ./libdenort.dylib + zip -r libdenort-x86_64-apple-darwin.zip libdenort.dylib + shasum -a 256 libdenort-x86_64-apple-darwin.zip > libdenort-x86_64-apple-darwin.zip.sha256sum - name: Build bsdiff helper if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno'' && startsWith(github.ref, ''refs/tags/'')' run: cargo build --release -p bsdiff_helper @@ -882,31 +891,43 @@ jobs: target/release/deno-x86_64-pc-windows-msvc.sha256sum target/release/denort-x86_64-pc-windows-msvc.zip target/release/denort-x86_64-pc-windows-msvc.zip.sha256sum + target/release/libdenort-x86_64-pc-windows-msvc.zip + target/release/libdenort-x86_64-pc-windows-msvc.zip.sha256sum target/release/deno-aarch64-pc-windows-msvc.zip target/release/deno-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno-aarch64-pc-windows-msvc.sha256sum target/release/denort-aarch64-pc-windows-msvc.zip target/release/denort-aarch64-pc-windows-msvc.zip.sha256sum + target/release/libdenort-aarch64-pc-windows-msvc.zip + target/release/libdenort-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno-x86_64-unknown-linux-gnu.zip target/release/deno-x86_64-unknown-linux-gnu.zip.sha256sum target/release/deno-x86_64-unknown-linux-gnu.sha256sum target/release/denort-x86_64-unknown-linux-gnu.zip target/release/denort-x86_64-unknown-linux-gnu.zip.sha256sum + target/release/libdenort-x86_64-unknown-linux-gnu.zip + target/release/libdenort-x86_64-unknown-linux-gnu.zip.sha256sum target/release/deno-x86_64-apple-darwin.zip target/release/deno-x86_64-apple-darwin.zip.sha256sum target/release/deno-x86_64-apple-darwin.sha256sum target/release/denort-x86_64-apple-darwin.zip target/release/denort-x86_64-apple-darwin.zip.sha256sum + target/release/libdenort-x86_64-apple-darwin.zip + target/release/libdenort-x86_64-apple-darwin.zip.sha256sum target/release/deno-aarch64-unknown-linux-gnu.zip target/release/deno-aarch64-unknown-linux-gnu.zip.sha256sum target/release/deno-aarch64-unknown-linux-gnu.sha256sum target/release/denort-aarch64-unknown-linux-gnu.zip target/release/denort-aarch64-unknown-linux-gnu.zip.sha256sum + target/release/libdenort-aarch64-unknown-linux-gnu.zip + target/release/libdenort-aarch64-unknown-linux-gnu.zip.sha256sum target/release/deno-aarch64-apple-darwin.zip target/release/deno-aarch64-apple-darwin.zip.sha256sum target/release/deno-aarch64-apple-darwin.sha256sum target/release/denort-aarch64-apple-darwin.zip target/release/denort-aarch64-apple-darwin.zip.sha256sum + target/release/libdenort-aarch64-apple-darwin.zip + target/release/libdenort-aarch64-apple-darwin.zip.sha256sum target/release/deno_src.tar.gz target/release/lib.deno.d.ts target/release/deno-*.bsdiff @@ -1614,7 +1635,7 @@ jobs: if: '!startsWith(github.ref, ''refs/tags/'')' env: CARGO_PROFILE_DEV_DEBUG: 0 - run: cargo test --locked --lib -p deno -p denort -p node_shim -p bsdiff_helper -p deno_lib -p deno_snapshots -p deno_bundle_runtime -p deno_cache -p deno_canvas -p deno_cron -p deno_crypto -p deno_fetch -p deno_ffi -p deno_fs -p deno_http -p deno_image -p deno_io -p deno_kv -p deno_napi -p napi_sym -p deno_net -p deno_node -p deno_node_crypto -p deno_node_sqlite -p denort_helper -p deno_signals -p deno_telemetry -p deno_url -p deno_web -p deno_webgpu -p deno_webidl -p deno_websocket -p deno_webstorage -p deno_cache_dir -p deno_config -p deno_crypto_provider -p deno_dotenv -p eszip -p deno_http_h1 -p deno_inspector_server -p deno_lockfile -p deno_maybe_sync -p napi_sys -p node_resolver -p deno_npm -p deno_npm_cache -p deno_npm_installer -p deno_npmrc -p deno_package_json -p deno_resolver -p deno_typescript_go_client_rust -p deno_runtime -p deno_features -p deno_permissions -p deno_subprocess_windows + run: cargo test --locked --lib -p deno -p denort -p node_shim -p bsdiff_helper -p deno_lib -p denort_desktop -p deno_snapshots -p deno_bundle_runtime -p deno_cache -p deno_canvas -p deno_cron -p deno_crypto -p deno_fetch -p deno_ffi -p deno_fs -p deno_http -p deno_image -p deno_io -p deno_kv -p deno_napi -p napi_sym -p deno_net -p deno_node -p deno_node_crypto -p deno_node_sqlite -p denort_helper -p deno_signals -p deno_telemetry -p deno_url -p deno_web -p deno_webgpu -p deno_webidl -p deno_websocket -p deno_webstorage -p deno_cache_dir -p deno_config -p deno_crypto_provider -p deno_dotenv -p eszip -p deno_http_h1 -p deno_inspector_server -p deno_lockfile -p deno_maybe_sync -p napi_sys -p node_resolver -p deno_npm -p deno_npm_cache -p deno_npm_installer -p deno_npmrc -p deno_package_json -p deno_resolver -p deno_typescript_go_client_rust -p deno_runtime -p deno_features -p deno_permissions -p deno_subprocess_windows - name: Cache cargo home uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 if: '!startsWith(github.ref, ''refs/tags/'') && github.ref == ''refs/heads/main''' @@ -1770,6 +1791,12 @@ jobs: df -h cargo build --release --locked -p deno -p denort -p test_server --bin deno --bin denort --bin test_server --features=deno/panic-trace df -h + - name: Build denort_desktop + if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno'' && (contains(github.event.pull_request.labels.*.name, ''ci-full'') || github.ref == ''refs/heads/main'' || startsWith(github.ref, ''refs/tags/''))' + run: |- + if [ "$(uname -s)" = "Linux" ]; then strip target/release/deno target/release/denort target/release/test_server 2>/dev/null || true; df -h; fi + cargo build --release --locked -p denort_desktop + df -h - name: Generate symcache if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno''' env: @@ -1800,6 +1827,9 @@ jobs: strip -x -S ./denort zip -r denort-aarch64-apple-darwin.zip denort shasum -a 256 denort-aarch64-apple-darwin.zip > denort-aarch64-apple-darwin.zip.sha256sum + strip -x -S ./libdenort.dylib + zip -r libdenort-aarch64-apple-darwin.zip libdenort.dylib + shasum -a 256 libdenort-aarch64-apple-darwin.zip > libdenort-aarch64-apple-darwin.zip.sha256sum - name: Build bsdiff helper if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno'' && startsWith(github.ref, ''refs/tags/'')' run: cargo build --release -p bsdiff_helper @@ -1889,31 +1919,43 @@ jobs: target/release/deno-x86_64-pc-windows-msvc.sha256sum target/release/denort-x86_64-pc-windows-msvc.zip target/release/denort-x86_64-pc-windows-msvc.zip.sha256sum + target/release/libdenort-x86_64-pc-windows-msvc.zip + target/release/libdenort-x86_64-pc-windows-msvc.zip.sha256sum target/release/deno-aarch64-pc-windows-msvc.zip target/release/deno-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno-aarch64-pc-windows-msvc.sha256sum target/release/denort-aarch64-pc-windows-msvc.zip target/release/denort-aarch64-pc-windows-msvc.zip.sha256sum + target/release/libdenort-aarch64-pc-windows-msvc.zip + target/release/libdenort-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno-x86_64-unknown-linux-gnu.zip target/release/deno-x86_64-unknown-linux-gnu.zip.sha256sum target/release/deno-x86_64-unknown-linux-gnu.sha256sum target/release/denort-x86_64-unknown-linux-gnu.zip target/release/denort-x86_64-unknown-linux-gnu.zip.sha256sum + target/release/libdenort-x86_64-unknown-linux-gnu.zip + target/release/libdenort-x86_64-unknown-linux-gnu.zip.sha256sum target/release/deno-x86_64-apple-darwin.zip target/release/deno-x86_64-apple-darwin.zip.sha256sum target/release/deno-x86_64-apple-darwin.sha256sum target/release/denort-x86_64-apple-darwin.zip target/release/denort-x86_64-apple-darwin.zip.sha256sum + target/release/libdenort-x86_64-apple-darwin.zip + target/release/libdenort-x86_64-apple-darwin.zip.sha256sum target/release/deno-aarch64-unknown-linux-gnu.zip target/release/deno-aarch64-unknown-linux-gnu.zip.sha256sum target/release/deno-aarch64-unknown-linux-gnu.sha256sum target/release/denort-aarch64-unknown-linux-gnu.zip target/release/denort-aarch64-unknown-linux-gnu.zip.sha256sum + target/release/libdenort-aarch64-unknown-linux-gnu.zip + target/release/libdenort-aarch64-unknown-linux-gnu.zip.sha256sum target/release/deno-aarch64-apple-darwin.zip target/release/deno-aarch64-apple-darwin.zip.sha256sum target/release/deno-aarch64-apple-darwin.sha256sum target/release/denort-aarch64-apple-darwin.zip target/release/denort-aarch64-apple-darwin.zip.sha256sum + target/release/libdenort-aarch64-apple-darwin.zip + target/release/libdenort-aarch64-apple-darwin.zip.sha256sum target/release/deno_src.tar.gz target/release/lib.deno.d.ts target/release/deno-*.bsdiff @@ -2572,7 +2614,7 @@ jobs: if: '!startsWith(github.ref, ''refs/tags/'')' env: CARGO_PROFILE_DEV_DEBUG: 0 - run: cargo test --locked --lib -p deno -p denort -p node_shim -p bsdiff_helper -p deno_lib -p deno_snapshots -p deno_bundle_runtime -p deno_cache -p deno_canvas -p deno_cron -p deno_crypto -p deno_fetch -p deno_ffi -p deno_fs -p deno_http -p deno_image -p deno_io -p deno_kv -p deno_napi -p napi_sym -p deno_net -p deno_node -p deno_node_crypto -p deno_node_sqlite -p denort_helper -p deno_signals -p deno_telemetry -p deno_url -p deno_web -p deno_webgpu -p deno_webidl -p deno_websocket -p deno_webstorage -p deno_cache_dir -p deno_config -p deno_crypto_provider -p deno_dotenv -p eszip -p deno_http_h1 -p deno_inspector_server -p deno_lockfile -p deno_maybe_sync -p napi_sys -p node_resolver -p deno_npm -p deno_npm_cache -p deno_npm_installer -p deno_npmrc -p deno_package_json -p deno_resolver -p deno_typescript_go_client_rust -p deno_runtime -p deno_features -p deno_permissions -p deno_subprocess_windows + run: cargo test --locked --lib -p deno -p denort -p node_shim -p bsdiff_helper -p deno_lib -p denort_desktop -p deno_snapshots -p deno_bundle_runtime -p deno_cache -p deno_canvas -p deno_cron -p deno_crypto -p deno_fetch -p deno_ffi -p deno_fs -p deno_http -p deno_image -p deno_io -p deno_kv -p deno_napi -p napi_sym -p deno_net -p deno_node -p deno_node_crypto -p deno_node_sqlite -p denort_helper -p deno_signals -p deno_telemetry -p deno_url -p deno_web -p deno_webgpu -p deno_webidl -p deno_websocket -p deno_webstorage -p deno_cache_dir -p deno_config -p deno_crypto_provider -p deno_dotenv -p eszip -p deno_http_h1 -p deno_inspector_server -p deno_lockfile -p deno_maybe_sync -p napi_sys -p node_resolver -p deno_npm -p deno_npm_cache -p deno_npm_installer -p deno_npmrc -p deno_package_json -p deno_resolver -p deno_typescript_go_client_rust -p deno_runtime -p deno_features -p deno_permissions -p deno_subprocess_windows - name: Cache cargo home uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 if: '!startsWith(github.ref, ''refs/tags/'') && github.ref == ''refs/heads/main''' @@ -2707,6 +2749,12 @@ jobs: df -h cargo build --release --locked -p deno -p denort -p test_server --bin deno --bin denort --bin test_server --features=deno/panic-trace df -h + - name: Build denort_desktop + if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno'' && (contains(github.event.pull_request.labels.*.name, ''ci-full'') || github.ref == ''refs/heads/main'' || startsWith(github.ref, ''refs/tags/''))' + run: |- + if [ "$(uname -s)" = "Linux" ]; then strip target/release/deno target/release/denort target/release/test_server 2>/dev/null || true; df -h; fi + cargo build --release --locked -p denort_desktop + df -h - name: Generate symcache if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno''' env: @@ -2758,6 +2806,8 @@ jobs: Get-FileHash target/release/deno-x86_64-pc-windows-msvc.zip -Algorithm SHA256 | Format-List > target/release/deno-x86_64-pc-windows-msvc.zip.sha256sum Compress-Archive -CompressionLevel Optimal -Force -Path target/release/denort.exe -DestinationPath target/release/denort-x86_64-pc-windows-msvc.zip Get-FileHash target/release/denort-x86_64-pc-windows-msvc.zip -Algorithm SHA256 | Format-List > target/release/denort-x86_64-pc-windows-msvc.zip.sha256sum + Compress-Archive -CompressionLevel Optimal -Force -Path target/release/denort.dll -DestinationPath target/release/libdenort-x86_64-pc-windows-msvc.zip + Get-FileHash target/release/libdenort-x86_64-pc-windows-msvc.zip -Algorithm SHA256 | Format-List > target/release/libdenort-x86_64-pc-windows-msvc.zip.sha256sum target/release/deno.exe -A tools/release/create_symcache.ts target/release/deno-x86_64-pc-windows-msvc.symcache - name: Build bsdiff helper if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno'' && startsWith(github.ref, ''refs/tags/'')' @@ -2851,31 +2901,43 @@ jobs: target/release/deno-x86_64-pc-windows-msvc.sha256sum target/release/denort-x86_64-pc-windows-msvc.zip target/release/denort-x86_64-pc-windows-msvc.zip.sha256sum + target/release/libdenort-x86_64-pc-windows-msvc.zip + target/release/libdenort-x86_64-pc-windows-msvc.zip.sha256sum target/release/deno-aarch64-pc-windows-msvc.zip target/release/deno-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno-aarch64-pc-windows-msvc.sha256sum target/release/denort-aarch64-pc-windows-msvc.zip target/release/denort-aarch64-pc-windows-msvc.zip.sha256sum + target/release/libdenort-aarch64-pc-windows-msvc.zip + target/release/libdenort-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno-x86_64-unknown-linux-gnu.zip target/release/deno-x86_64-unknown-linux-gnu.zip.sha256sum target/release/deno-x86_64-unknown-linux-gnu.sha256sum target/release/denort-x86_64-unknown-linux-gnu.zip target/release/denort-x86_64-unknown-linux-gnu.zip.sha256sum + target/release/libdenort-x86_64-unknown-linux-gnu.zip + target/release/libdenort-x86_64-unknown-linux-gnu.zip.sha256sum target/release/deno-x86_64-apple-darwin.zip target/release/deno-x86_64-apple-darwin.zip.sha256sum target/release/deno-x86_64-apple-darwin.sha256sum target/release/denort-x86_64-apple-darwin.zip target/release/denort-x86_64-apple-darwin.zip.sha256sum + target/release/libdenort-x86_64-apple-darwin.zip + target/release/libdenort-x86_64-apple-darwin.zip.sha256sum target/release/deno-aarch64-unknown-linux-gnu.zip target/release/deno-aarch64-unknown-linux-gnu.zip.sha256sum target/release/deno-aarch64-unknown-linux-gnu.sha256sum target/release/denort-aarch64-unknown-linux-gnu.zip target/release/denort-aarch64-unknown-linux-gnu.zip.sha256sum + target/release/libdenort-aarch64-unknown-linux-gnu.zip + target/release/libdenort-aarch64-unknown-linux-gnu.zip.sha256sum target/release/deno-aarch64-apple-darwin.zip target/release/deno-aarch64-apple-darwin.zip.sha256sum target/release/deno-aarch64-apple-darwin.sha256sum target/release/denort-aarch64-apple-darwin.zip target/release/denort-aarch64-apple-darwin.zip.sha256sum + target/release/libdenort-aarch64-apple-darwin.zip + target/release/libdenort-aarch64-apple-darwin.zip.sha256sum target/release/deno_src.tar.gz target/release/lib.deno.d.ts target/release/deno-*.bsdiff @@ -3527,6 +3589,12 @@ jobs: df -h cargo build --release --locked -p deno -p denort -p test_server --bin deno --bin denort --bin test_server --features=deno/panic-trace df -h + - name: Build denort_desktop + if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno'' && (contains(github.event.pull_request.labels.*.name, ''ci-full'') || github.ref == ''refs/heads/main'' || startsWith(github.ref, ''refs/tags/''))' + run: |- + if [ "$(uname -s)" = "Linux" ]; then strip target/release/deno target/release/denort target/release/test_server 2>/dev/null || true; df -h; fi + cargo build --release --locked -p denort_desktop + df -h - name: Generate symcache if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno''' env: @@ -3578,6 +3646,8 @@ jobs: Get-FileHash target/release/deno-aarch64-pc-windows-msvc.zip -Algorithm SHA256 | Format-List > target/release/deno-aarch64-pc-windows-msvc.zip.sha256sum Compress-Archive -CompressionLevel Optimal -Force -Path target/release/denort.exe -DestinationPath target/release/denort-aarch64-pc-windows-msvc.zip Get-FileHash target/release/denort-aarch64-pc-windows-msvc.zip -Algorithm SHA256 | Format-List > target/release/denort-aarch64-pc-windows-msvc.zip.sha256sum + Compress-Archive -CompressionLevel Optimal -Force -Path target/release/denort.dll -DestinationPath target/release/libdenort-aarch64-pc-windows-msvc.zip + Get-FileHash target/release/libdenort-aarch64-pc-windows-msvc.zip -Algorithm SHA256 | Format-List > target/release/libdenort-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno.exe -A tools/release/create_symcache.ts target/release/deno-aarch64-pc-windows-msvc.symcache - name: Build bsdiff helper if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno'' && startsWith(github.ref, ''refs/tags/'')' @@ -3671,31 +3741,43 @@ jobs: target/release/deno-x86_64-pc-windows-msvc.sha256sum target/release/denort-x86_64-pc-windows-msvc.zip target/release/denort-x86_64-pc-windows-msvc.zip.sha256sum + target/release/libdenort-x86_64-pc-windows-msvc.zip + target/release/libdenort-x86_64-pc-windows-msvc.zip.sha256sum target/release/deno-aarch64-pc-windows-msvc.zip target/release/deno-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno-aarch64-pc-windows-msvc.sha256sum target/release/denort-aarch64-pc-windows-msvc.zip target/release/denort-aarch64-pc-windows-msvc.zip.sha256sum + target/release/libdenort-aarch64-pc-windows-msvc.zip + target/release/libdenort-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno-x86_64-unknown-linux-gnu.zip target/release/deno-x86_64-unknown-linux-gnu.zip.sha256sum target/release/deno-x86_64-unknown-linux-gnu.sha256sum target/release/denort-x86_64-unknown-linux-gnu.zip target/release/denort-x86_64-unknown-linux-gnu.zip.sha256sum + target/release/libdenort-x86_64-unknown-linux-gnu.zip + target/release/libdenort-x86_64-unknown-linux-gnu.zip.sha256sum target/release/deno-x86_64-apple-darwin.zip target/release/deno-x86_64-apple-darwin.zip.sha256sum target/release/deno-x86_64-apple-darwin.sha256sum target/release/denort-x86_64-apple-darwin.zip target/release/denort-x86_64-apple-darwin.zip.sha256sum + target/release/libdenort-x86_64-apple-darwin.zip + target/release/libdenort-x86_64-apple-darwin.zip.sha256sum target/release/deno-aarch64-unknown-linux-gnu.zip target/release/deno-aarch64-unknown-linux-gnu.zip.sha256sum target/release/deno-aarch64-unknown-linux-gnu.sha256sum target/release/denort-aarch64-unknown-linux-gnu.zip target/release/denort-aarch64-unknown-linux-gnu.zip.sha256sum + target/release/libdenort-aarch64-unknown-linux-gnu.zip + target/release/libdenort-aarch64-unknown-linux-gnu.zip.sha256sum target/release/deno-aarch64-apple-darwin.zip target/release/deno-aarch64-apple-darwin.zip.sha256sum target/release/deno-aarch64-apple-darwin.sha256sum target/release/denort-aarch64-apple-darwin.zip target/release/denort-aarch64-apple-darwin.zip.sha256sum + target/release/libdenort-aarch64-apple-darwin.zip + target/release/libdenort-aarch64-apple-darwin.zip.sha256sum target/release/deno_src.tar.gz target/release/lib.deno.d.ts target/release/deno-*.bsdiff @@ -3919,6 +4001,11 @@ jobs: mkdir -p target/release tar --exclude=".git*" --exclude=target --exclude=third_party/prebuilt \ -czvf target/release/deno_src.tar.gz -C .. deno + - name: Free disk space (linux) + run: |- + sudo rm -rf /usr/local/lib/android /usr/local/share/powershell /usr/share/dotnet 2>/dev/null || true + sudo docker image prune -af 2>/dev/null || true + df -h - name: Restore cache cargo home uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 if: '!startsWith(github.ref, ''refs/tags/'')' @@ -4089,6 +4176,12 @@ jobs: df -h cargo build --release --locked -p deno -p denort -p test_server --bin deno --bin denort --bin test_server --features=deno/panic-trace df -h + - name: Build denort_desktop + if: '(contains(github.event.pull_request.labels.*.name, ''ci-full'') || github.ref == ''refs/heads/main'' || startsWith(github.ref, ''refs/tags/'')) && github.repository == ''denoland/deno''' + run: |- + if [ "$(uname -s)" = "Linux" ]; then strip target/release/deno target/release/denort target/release/test_server 2>/dev/null || true; df -h; fi + cargo build --release --locked -p denort_desktop + df -h - name: Check release snapshot flags run: |- if strings target/release/deno | grep -F -- '--no-lazy --no-lazy-eval --no-lazy-streaming'; then @@ -4114,6 +4207,7 @@ jobs: strip ./denort zip -r denort-x86_64-unknown-linux-gnu.zip denort shasum -a 256 denort-x86_64-unknown-linux-gnu.zip > denort-x86_64-unknown-linux-gnu.zip.sha256sum + if [ -f libdenort.so ]; then strip ./libdenort.so; zip -r libdenort-x86_64-unknown-linux-gnu.zip libdenort.so; shasum -a 256 libdenort-x86_64-unknown-linux-gnu.zip > libdenort-x86_64-unknown-linux-gnu.zip.sha256sum; fi ./deno types > lib.deno.d.ts - name: Build bsdiff helper if: 'github.repository == ''denoland/deno'' && startsWith(github.ref, ''refs/tags/'')' @@ -4202,31 +4296,43 @@ jobs: target/release/deno-x86_64-pc-windows-msvc.sha256sum target/release/denort-x86_64-pc-windows-msvc.zip target/release/denort-x86_64-pc-windows-msvc.zip.sha256sum + target/release/libdenort-x86_64-pc-windows-msvc.zip + target/release/libdenort-x86_64-pc-windows-msvc.zip.sha256sum target/release/deno-aarch64-pc-windows-msvc.zip target/release/deno-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno-aarch64-pc-windows-msvc.sha256sum target/release/denort-aarch64-pc-windows-msvc.zip target/release/denort-aarch64-pc-windows-msvc.zip.sha256sum + target/release/libdenort-aarch64-pc-windows-msvc.zip + target/release/libdenort-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno-x86_64-unknown-linux-gnu.zip target/release/deno-x86_64-unknown-linux-gnu.zip.sha256sum target/release/deno-x86_64-unknown-linux-gnu.sha256sum target/release/denort-x86_64-unknown-linux-gnu.zip target/release/denort-x86_64-unknown-linux-gnu.zip.sha256sum + target/release/libdenort-x86_64-unknown-linux-gnu.zip + target/release/libdenort-x86_64-unknown-linux-gnu.zip.sha256sum target/release/deno-x86_64-apple-darwin.zip target/release/deno-x86_64-apple-darwin.zip.sha256sum target/release/deno-x86_64-apple-darwin.sha256sum target/release/denort-x86_64-apple-darwin.zip target/release/denort-x86_64-apple-darwin.zip.sha256sum + target/release/libdenort-x86_64-apple-darwin.zip + target/release/libdenort-x86_64-apple-darwin.zip.sha256sum target/release/deno-aarch64-unknown-linux-gnu.zip target/release/deno-aarch64-unknown-linux-gnu.zip.sha256sum target/release/deno-aarch64-unknown-linux-gnu.sha256sum target/release/denort-aarch64-unknown-linux-gnu.zip target/release/denort-aarch64-unknown-linux-gnu.zip.sha256sum + target/release/libdenort-aarch64-unknown-linux-gnu.zip + target/release/libdenort-aarch64-unknown-linux-gnu.zip.sha256sum target/release/deno-aarch64-apple-darwin.zip target/release/deno-aarch64-apple-darwin.zip.sha256sum target/release/deno-aarch64-apple-darwin.sha256sum target/release/denort-aarch64-apple-darwin.zip target/release/denort-aarch64-apple-darwin.zip.sha256sum + target/release/libdenort-aarch64-apple-darwin.zip + target/release/libdenort-aarch64-apple-darwin.zip.sha256sum target/release/deno_src.tar.gz target/release/lib.deno.d.ts target/release/deno-*.bsdiff @@ -4702,6 +4808,11 @@ jobs: submodules: false - name: Clone submodule ./tests/util/std run: git submodule update --init --recursive --depth=1 -- ./tests/util/std + - name: Free disk space (linux) + run: |- + sudo rm -rf /usr/local/lib/android /usr/local/share/powershell /usr/share/dotnet 2>/dev/null || true + sudo docker image prune -af 2>/dev/null || true + df -h - name: Restore cache cargo home uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 if: '!startsWith(github.ref, ''refs/tags/'')' @@ -5371,6 +5482,11 @@ jobs: submodules: false - name: Clone submodule ./tests/util/std run: git submodule update --init --recursive --depth=1 -- ./tests/util/std + - name: Free disk space (linux) + run: |- + sudo rm -rf /usr/local/lib/android /usr/local/share/powershell /usr/share/dotnet 2>/dev/null || true + sudo docker image prune -af 2>/dev/null || true + df -h - name: Restore cache cargo home uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 if: '!startsWith(github.ref, ''refs/tags/'')' @@ -5807,7 +5923,7 @@ jobs: if: '!startsWith(github.ref, ''refs/tags/'')' env: CARGO_PROFILE_DEV_DEBUG: 0 - run: cargo test --locked --lib -p deno -p denort -p node_shim -p bsdiff_helper -p deno_lib -p deno_snapshots -p deno_bundle_runtime -p deno_cache -p deno_canvas -p deno_cron -p deno_crypto -p deno_fetch -p deno_ffi -p deno_fs -p deno_http -p deno_image -p deno_io -p deno_kv -p deno_napi -p napi_sym -p deno_net -p deno_node -p deno_node_crypto -p deno_node_sqlite -p denort_helper -p deno_signals -p deno_telemetry -p deno_url -p deno_web -p deno_webgpu -p deno_webidl -p deno_websocket -p deno_webstorage -p deno_cache_dir -p deno_config -p deno_crypto_provider -p deno_dotenv -p eszip -p deno_http_h1 -p deno_inspector_server -p deno_lockfile -p deno_maybe_sync -p napi_sys -p node_resolver -p deno_npm -p deno_npm_cache -p deno_npm_installer -p deno_npmrc -p deno_package_json -p deno_resolver -p deno_typescript_go_client_rust -p deno_runtime -p deno_features -p deno_permissions -p deno_subprocess_windows + run: cargo test --locked --lib -p deno -p denort -p node_shim -p bsdiff_helper -p deno_lib -p denort_desktop -p deno_snapshots -p deno_bundle_runtime -p deno_cache -p deno_canvas -p deno_cron -p deno_crypto -p deno_fetch -p deno_ffi -p deno_fs -p deno_http -p deno_image -p deno_io -p deno_kv -p deno_napi -p napi_sym -p deno_net -p deno_node -p deno_node_crypto -p deno_node_sqlite -p denort_helper -p deno_signals -p deno_telemetry -p deno_url -p deno_web -p deno_webgpu -p deno_webidl -p deno_websocket -p deno_webstorage -p deno_cache_dir -p deno_config -p deno_crypto_provider -p deno_dotenv -p eszip -p deno_http_h1 -p deno_inspector_server -p deno_lockfile -p deno_maybe_sync -p napi_sys -p node_resolver -p deno_npm -p deno_npm_cache -p deno_npm_installer -p deno_npmrc -p deno_package_json -p deno_resolver -p deno_typescript_go_client_rust -p deno_runtime -p deno_features -p deno_permissions -p deno_subprocess_windows - name: Cache cargo home uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 if: '!startsWith(github.ref, ''refs/tags/'') && github.ref == ''refs/heads/main''' @@ -5862,6 +5978,12 @@ jobs: - name: Clone submodule ./tests/util/std if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'')' run: git submodule update --init --recursive --depth=1 -- ./tests/util/std + - name: Free disk space (linux) + if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'')' + run: |- + sudo rm -rf /usr/local/lib/android /usr/local/share/powershell /usr/share/dotnet 2>/dev/null || true + sudo docker image prune -af 2>/dev/null || true + df -h - name: Restore cache cargo home uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && !startsWith(github.ref, ''refs/tags/'')' @@ -6038,6 +6160,12 @@ jobs: df -h cargo build --release --locked -p deno -p denort -p test_server --bin deno --bin denort --bin test_server --features=deno/panic-trace df -h + - name: Build denort_desktop + if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && (contains(github.event.pull_request.labels.*.name, ''ci-full'') || github.ref == ''refs/heads/main'' || startsWith(github.ref, ''refs/tags/'')) && github.repository == ''denoland/deno''' + run: |- + if [ "$(uname -s)" = "Linux" ]; then strip target/release/deno target/release/denort target/release/test_server 2>/dev/null || true; df -h; fi + cargo build --release --locked -p denort_desktop + df -h - name: Check release snapshot flags if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'')' run: |- @@ -6065,6 +6193,7 @@ jobs: strip ./denort zip -r denort-aarch64-unknown-linux-gnu.zip denort shasum -a 256 denort-aarch64-unknown-linux-gnu.zip > denort-aarch64-unknown-linux-gnu.zip.sha256sum + if [ -f libdenort.so ]; then strip ./libdenort.so; zip -r libdenort-aarch64-unknown-linux-gnu.zip libdenort.so; shasum -a 256 libdenort-aarch64-unknown-linux-gnu.zip > libdenort-aarch64-unknown-linux-gnu.zip.sha256sum; fi ./deno types > lib.deno.d.ts - name: Build bsdiff helper if: '!(!contains(github.event.pull_request.labels.*.name, ''ci-full'') && github.event_name == ''pull_request'') && github.repository == ''denoland/deno'' && startsWith(github.ref, ''refs/tags/'')' @@ -6158,31 +6287,43 @@ jobs: target/release/deno-x86_64-pc-windows-msvc.sha256sum target/release/denort-x86_64-pc-windows-msvc.zip target/release/denort-x86_64-pc-windows-msvc.zip.sha256sum + target/release/libdenort-x86_64-pc-windows-msvc.zip + target/release/libdenort-x86_64-pc-windows-msvc.zip.sha256sum target/release/deno-aarch64-pc-windows-msvc.zip target/release/deno-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno-aarch64-pc-windows-msvc.sha256sum target/release/denort-aarch64-pc-windows-msvc.zip target/release/denort-aarch64-pc-windows-msvc.zip.sha256sum + target/release/libdenort-aarch64-pc-windows-msvc.zip + target/release/libdenort-aarch64-pc-windows-msvc.zip.sha256sum target/release/deno-x86_64-unknown-linux-gnu.zip target/release/deno-x86_64-unknown-linux-gnu.zip.sha256sum target/release/deno-x86_64-unknown-linux-gnu.sha256sum target/release/denort-x86_64-unknown-linux-gnu.zip target/release/denort-x86_64-unknown-linux-gnu.zip.sha256sum + target/release/libdenort-x86_64-unknown-linux-gnu.zip + target/release/libdenort-x86_64-unknown-linux-gnu.zip.sha256sum target/release/deno-x86_64-apple-darwin.zip target/release/deno-x86_64-apple-darwin.zip.sha256sum target/release/deno-x86_64-apple-darwin.sha256sum target/release/denort-x86_64-apple-darwin.zip target/release/denort-x86_64-apple-darwin.zip.sha256sum + target/release/libdenort-x86_64-apple-darwin.zip + target/release/libdenort-x86_64-apple-darwin.zip.sha256sum target/release/deno-aarch64-unknown-linux-gnu.zip target/release/deno-aarch64-unknown-linux-gnu.zip.sha256sum target/release/deno-aarch64-unknown-linux-gnu.sha256sum target/release/denort-aarch64-unknown-linux-gnu.zip target/release/denort-aarch64-unknown-linux-gnu.zip.sha256sum + target/release/libdenort-aarch64-unknown-linux-gnu.zip + target/release/libdenort-aarch64-unknown-linux-gnu.zip.sha256sum target/release/deno-aarch64-apple-darwin.zip target/release/deno-aarch64-apple-darwin.zip.sha256sum target/release/deno-aarch64-apple-darwin.sha256sum target/release/denort-aarch64-apple-darwin.zip target/release/denort-aarch64-apple-darwin.zip.sha256sum + target/release/libdenort-aarch64-apple-darwin.zip + target/release/libdenort-aarch64-apple-darwin.zip.sha256sum target/release/deno_src.tar.gz target/release/lib.deno.d.ts target/release/deno-*.bsdiff diff --git a/.github/workflows/ci.ts b/.github/workflows/ci.ts index 9a5f17c2469fcf..7cea1b590c3807 100755 --- a/.github/workflows/ci.ts +++ b/.github/workflows/ci.ts @@ -704,6 +704,9 @@ const buildJobs = buildItems.map((rawBuildItem) => { "strip ./denort", `zip -r denort-${buildItem.arch}-unknown-linux-gnu.zip denort`, `shasum -a 256 denort-${buildItem.arch}-unknown-linux-gnu.zip > denort-${buildItem.arch}-unknown-linux-gnu.zip.sha256sum`, + // libdenort.so is only built on main/tags and ci-full PRs; skip + // gracefully when it's absent (e.g. regular PR builds). + `if [ -f libdenort.so ]; then strip ./libdenort.so; zip -r libdenort-${buildItem.arch}-unknown-linux-gnu.zip libdenort.so; shasum -a 256 libdenort-${buildItem.arch}-unknown-linux-gnu.zip > libdenort-${buildItem.arch}-unknown-linux-gnu.zip.sha256sum; fi`, "./deno types > lib.deno.d.ts", ], }, @@ -739,6 +742,9 @@ const buildJobs = buildItems.map((rawBuildItem) => { "strip -x -S ./denort", `zip -r denort-${buildItem.arch}-apple-darwin.zip denort`, `shasum -a 256 denort-${buildItem.arch}-apple-darwin.zip > denort-${buildItem.arch}-apple-darwin.zip.sha256sum`, + "strip -x -S ./libdenort.dylib", + `zip -r libdenort-${buildItem.arch}-apple-darwin.zip libdenort.dylib`, + `shasum -a 256 libdenort-${buildItem.arch}-apple-darwin.zip > libdenort-${buildItem.arch}-apple-darwin.zip.sha256sum`, ], }, { @@ -796,6 +802,8 @@ const buildJobs = buildItems.map((rawBuildItem) => { `Get-FileHash target/release/deno-${buildItem.arch}-pc-windows-msvc.zip -Algorithm SHA256 | Format-List > target/release/deno-${buildItem.arch}-pc-windows-msvc.zip.sha256sum`, `Compress-Archive -CompressionLevel Optimal -Force -Path target/release/denort.exe -DestinationPath target/release/denort-${buildItem.arch}-pc-windows-msvc.zip`, `Get-FileHash target/release/denort-${buildItem.arch}-pc-windows-msvc.zip -Algorithm SHA256 | Format-List > target/release/denort-${buildItem.arch}-pc-windows-msvc.zip.sha256sum`, + `Compress-Archive -CompressionLevel Optimal -Force -Path target/release/denort.dll -DestinationPath target/release/libdenort-${buildItem.arch}-pc-windows-msvc.zip`, + `Get-FileHash target/release/libdenort-${buildItem.arch}-pc-windows-msvc.zip -Algorithm SHA256 | Format-List > target/release/libdenort-${buildItem.arch}-pc-windows-msvc.zip.sha256sum`, `target/release/deno.exe -A tools/release/create_symcache.ts target/release/deno-${buildItem.arch}-pc-windows-msvc.symcache`, ], }, @@ -868,11 +876,24 @@ const buildJobs = buildItems.map((rawBuildItem) => { .map((name) => `-p ${name}`).join(" "); const binsToBuild = ["deno", "denort", "test_server"] .map((name) => `--bin ${name}`).join(" "); + const freeDiskStep = step({ + name: "Free disk space (linux)", + if: isLinux, + run: [ + // Removes large pre-installed tool suites not needed for Deno builds. + // Frees ~10-20 GB before cache restore, preventing OOM/disk exhaustion + // during ThinLTO V8 linking on ubuntu-24.04 runners. + "sudo rm -rf /usr/local/lib/android /usr/local/share/powershell /usr/share/dotnet 2>/dev/null || true", + "sudo docker image prune -af 2>/dev/null || true", + "df -h", + ], + }); const cargoBuildReleaseStep = step .if( isRelease.and(isDenoland.or(buildItem.use_sysroot)), ) .dependsOn( + freeDiskStep, installLldStep, restoreCacheStep, installRustStep, @@ -893,6 +914,24 @@ const buildJobs = buildItems.map((rawBuildItem) => { "df -h", ], }, + { + // Build the desktop runtime shared library (libdenort cdylib) for + // laufey-based desktop apps. Only on main/tags and ci-full PRs + // because the release build on Linux uses ThinLTO which consumes + // more RAM than standard GitHub-hosted runners can provide when + // combined with the earlier deno/denort/test_server builds. + name: "Build denort_desktop", + if: hasCiFullLabel.or(isMainOrTag).and(isDenoland), + run: [ + // Strip the earlier binaries to free several GB of disk before + // linking the cdylib (unstripped Linux release binaries are ~8 GB). + 'if [ "$(uname -s)" = "Linux" ]; then strip target/release/deno target/release/denort target/release/test_server 2>/dev/null || true; df -h; fi', + // Separate invocation because the panic-trace feature only + // applies to the deno/denort binaries. + "cargo build --release --locked -p denort_desktop", + "df -h", + ], + }, { name: "Check release snapshot flags", if: isLinux, @@ -925,6 +964,7 @@ const buildJobs = buildItems.map((rawBuildItem) => { ); const cargoBuildStep = step .dependsOn( + freeDiskStep, installLldStep, restoreCacheStep, installRustStep, @@ -1006,31 +1046,43 @@ const buildJobs = buildItems.map((rawBuildItem) => { "target/release/deno-x86_64-pc-windows-msvc.sha256sum", "target/release/denort-x86_64-pc-windows-msvc.zip", "target/release/denort-x86_64-pc-windows-msvc.zip.sha256sum", + "target/release/libdenort-x86_64-pc-windows-msvc.zip", + "target/release/libdenort-x86_64-pc-windows-msvc.zip.sha256sum", "target/release/deno-aarch64-pc-windows-msvc.zip", "target/release/deno-aarch64-pc-windows-msvc.zip.sha256sum", "target/release/deno-aarch64-pc-windows-msvc.sha256sum", "target/release/denort-aarch64-pc-windows-msvc.zip", "target/release/denort-aarch64-pc-windows-msvc.zip.sha256sum", + "target/release/libdenort-aarch64-pc-windows-msvc.zip", + "target/release/libdenort-aarch64-pc-windows-msvc.zip.sha256sum", "target/release/deno-x86_64-unknown-linux-gnu.zip", "target/release/deno-x86_64-unknown-linux-gnu.zip.sha256sum", "target/release/deno-x86_64-unknown-linux-gnu.sha256sum", "target/release/denort-x86_64-unknown-linux-gnu.zip", "target/release/denort-x86_64-unknown-linux-gnu.zip.sha256sum", + "target/release/libdenort-x86_64-unknown-linux-gnu.zip", + "target/release/libdenort-x86_64-unknown-linux-gnu.zip.sha256sum", "target/release/deno-x86_64-apple-darwin.zip", "target/release/deno-x86_64-apple-darwin.zip.sha256sum", "target/release/deno-x86_64-apple-darwin.sha256sum", "target/release/denort-x86_64-apple-darwin.zip", "target/release/denort-x86_64-apple-darwin.zip.sha256sum", + "target/release/libdenort-x86_64-apple-darwin.zip", + "target/release/libdenort-x86_64-apple-darwin.zip.sha256sum", "target/release/deno-aarch64-unknown-linux-gnu.zip", "target/release/deno-aarch64-unknown-linux-gnu.zip.sha256sum", "target/release/deno-aarch64-unknown-linux-gnu.sha256sum", "target/release/denort-aarch64-unknown-linux-gnu.zip", "target/release/denort-aarch64-unknown-linux-gnu.zip.sha256sum", + "target/release/libdenort-aarch64-unknown-linux-gnu.zip", + "target/release/libdenort-aarch64-unknown-linux-gnu.zip.sha256sum", "target/release/deno-aarch64-apple-darwin.zip", "target/release/deno-aarch64-apple-darwin.zip.sha256sum", "target/release/deno-aarch64-apple-darwin.sha256sum", "target/release/denort-aarch64-apple-darwin.zip", "target/release/denort-aarch64-apple-darwin.zip.sha256sum", + "target/release/libdenort-aarch64-apple-darwin.zip", + "target/release/libdenort-aarch64-apple-darwin.zip.sha256sum", "target/release/deno_src.tar.gz", "target/release/lib.deno.d.ts", "target/release/deno-*.bsdiff", diff --git a/Cargo.lock b/Cargo.lock index b24a0709ad8bd1..278e4399d456a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -500,6 +500,21 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b7e4c2464d97fe331d41de9d5db0def0a96f4d823b8b32a2efd503578988973" +[[package]] +name = "backhand" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72e7812fe72262dd26c2b4309c8e8f2c028db1ffbeb21054f009f847f09ca92c" +dependencies = [ + "deku", + "liblzma", + "no_std_io2", + "solana-nohash-hasher", + "thiserror 2.0.12", + "tracing", + "xxhash-rust", +] + [[package]] name = "backtrace" version = "0.3.74" @@ -884,6 +899,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "calendrical_calculations" version = "0.2.4" @@ -962,6 +986,16 @@ dependencies = [ "shlex", ] +[[package]] +name = "cdivsufsort" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edefce019197609da416762da75bb000bbd2224b2d89a7e722c2296cbff79b8c" +dependencies = [ + "cc", + "sacabase", +] + [[package]] name = "cexpr" version = "0.6.0" @@ -1692,8 +1726,18 @@ version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -1710,13 +1754,38 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn 2.0.117", ] @@ -1805,6 +1874,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "deku" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebf55291257a2a5c90cf50ae17b6bbaabc3fd13642cf3895a71c412513c19630" +dependencies = [ + "bitvec", + "deku_derive", + "no_std_io2", + "rustversion", +] + +[[package]] +name = "deku_derive" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec2a42b511fc5efd9183f4f71c17885d627b17e7fd9a61a92089406caa4397e" +dependencies = [ + "darling 0.21.3", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "deno" version = "2.8.3" @@ -1812,6 +1906,7 @@ dependencies = [ "anstream", "async-trait", "aws-lc-rs", + "backhand", "base64 0.22.1", "bincode", "boxed_error", @@ -1865,12 +1960,15 @@ dependencies = [ "esbuild_client", "eszip", "faster-hex", + "fastwebsockets 0.8.1", "flate2", "fluent-uri", "glob", "http", "http-body", "http-body-util", + "hyper", + "hyper-util", "imara-diff", "import_map", "indexmap 2.12.0", @@ -1901,6 +1999,7 @@ dependencies = [ "pathdiff", "percent-encoding", "phf 0.11.2", + "plist", "pretty_assertions", "pretty_yaml", "rand 0.8.5", @@ -3158,6 +3257,8 @@ dependencies = [ name = "deno_runtime" version = "0.259.0" dependencies = [ + "async-trait", + "base64 0.22.1", "boxed_error", "color-print", "deno_ast", @@ -3196,7 +3297,10 @@ dependencies = [ "deno_webidl", "deno_websocket", "deno_webstorage", + "ed25519-dalek", "encoding_rs", + "faster-hex", + "fastwebsockets 0.8.1", "http", "http-body-util", "hyper", @@ -3206,9 +3310,13 @@ dependencies = [ "node_resolver", "notify", "once_cell", + "qbsdiff", + "raw-window-handle", + "regex", "rustyline", "same-file", "serde", + "sha2", "sys_traits", "test_util", "thiserror 2.0.12", @@ -3585,6 +3693,7 @@ dependencies = [ "deno_error", "deno_lib", "deno_media_type", + "deno_node", "deno_npm", "deno_npmrc", "deno_package_json", @@ -3597,12 +3706,15 @@ dependencies = [ "deno_terminal", "import_map", "indexmap 2.12.0", + "jsonc-parser", "junction", "libsui", "log", "memmap2", "node_resolver", + "notify", "rustls", + "serde", "serde_json", "sys_traits", "test_util", @@ -3611,6 +3723,27 @@ dependencies = [ "url", ] +[[package]] +name = "denort_desktop" +version = "2.7.4" +dependencies = [ + "deno_core", + "deno_error", + "deno_lib", + "deno_runtime", + "deno_snapshots", + "deno_terminal", + "denort", + "laufey", + "libsui", + "log", + "raw-window-handle", + "rustls", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "denort_helper" version = "0.49.0" @@ -3724,7 +3857,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.117", @@ -6130,6 +6263,16 @@ dependencies = [ "libc", ] +[[package]] +name = "laufey" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b9829cff71e28a741d010b22da79ad04042370bd988a6feacf0893b0d196807" +dependencies = [ + "bindgen 0.72.1", + "tokio", +] + [[package]] name = "lax-core" version = "0.1.2" @@ -6212,6 +6355,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + [[package]] name = "libc" version = "0.2.186" @@ -6285,6 +6434,27 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "liblzma" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" +dependencies = [ + "liblzma-sys", + "num_cpus", +] + +[[package]] +name = "liblzma-sys" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.8" @@ -6316,15 +6486,16 @@ dependencies = [ [[package]] name = "libsui" -version = "0.12.6" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2b6d6bbf43ba95540d1681826c8d7acb9744708398463ccbcd3c3a5d04c2fdc" +checksum = "6af25036f57ea101cca401af0769d705cb5380181f9b1fe674c0e890a194eb03" dependencies = [ "editpe", "image", "libc", + "object", "sha2", - "windows-sys 0.48.0", + "windows 0.58.0", "zerocopy 0.7.32", ] @@ -6724,6 +6895,15 @@ dependencies = [ "memoffset", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "node_compat_tests" version = "0.0.0" @@ -6998,6 +7178,9 @@ version = "0.36.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9" dependencies = [ + "crc32fast", + "hashbrown 0.14.5", + "indexmap 2.12.0", "memchr", ] @@ -7594,6 +7777,19 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap 2.12.0", + "quick-xml", + "serde", + "time", +] + [[package]] name = "plotters" version = "0.3.7" @@ -7831,6 +8027,18 @@ dependencies = [ "unicase", ] +[[package]] +name = "qbsdiff" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc7f24528be166f08f2c7becaca5618865499b6ded2565d5afcd795cc0d7596" +dependencies = [ + "byteorder", + "bzip2", + "rayon", + "suffix_array", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -7843,6 +8051,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.8" @@ -8533,9 +8750,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.15" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rusty-fork" @@ -8600,6 +8817,15 @@ version = "5.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68f5acb362a0e75c2a963532fa7fabf13dff81626dc494df16488d30befcbea0" +[[package]] +name = "sacabase" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9883fc3d6ce3d78bb54d908602f8bc1f7b5f983afe601dabe083009d86267a84" +dependencies = [ + "num-traits", +] + [[package]] name = "salsa20" version = "0.10.2" @@ -9097,6 +9323,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "solana-nohash-hasher" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b8a731ed60e89177c8a7ab05fe0f1511cedd3e70e773f288f9de33a9cfdc21e" + [[package]] name = "sourcemap" version = "9.2.0" @@ -9252,6 +9484,15 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "suffix_array" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "907d9ca9637a22e3a7d7c7818f6105a7898857359e187ad3325d986684b9ec3f" +dependencies = [ + "cdivsufsort", +] + [[package]] name = "swc_allocator" version = "4.0.1" @@ -10725,9 +10966,9 @@ dependencies = [ [[package]] name = "v8" -version = "149.3.0" +version = "149.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65a9ff61a9b12e7b2559a7b1e5e1515e379a999c97a65b9356b936307b7f687a" +checksum = "ebfd8d6919bfb4b627ca778a67f85ca45e5fb442d352947ba093798bdf9b07e0" dependencies = [ "bindgen 0.72.1", "bitflags 2.9.3", diff --git a/Cargo.toml b/Cargo.toml index dcdf796eab479c..28f0c63c336bd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "cli", "cli/lib", "cli/rt", + "cli/rt_desktop", "cli/snapshot", "ext/bundle", "ext/cache", @@ -103,7 +104,7 @@ deno_task_shell = "=0.33.0" deno_terminal = "=0.2.3" deno_unsync = { version = "0.4.4", default-features = false } deno_whoami = "0.1.0" -v8 = { version = "149.3.0", default-features = false, features = ["simdutf"] } +v8 = { version = "149.4.0", default-features = false, features = ["simdutf"] } denokv_proto = "0.13.0" denokv_remote = "0.13.0" @@ -184,6 +185,8 @@ async-stream = "0.3" async-trait = "0.1.73" aws-lc-rs = { version = "1.16.3" } aws-lc-sys = { version = "0.40.0", features = ["prebuilt-nasm"] } +backhand = { version = "0.25.1", default-features = false, features = ["xz"] } +base32 = "=0.5.1" base64 = "0.22.1" bencher = "0.1" bincode = "1" @@ -267,6 +270,9 @@ prettyplease = "0.2.31" proc-macro2 = "1.0" proptest = "1" prost = "0.13" +prost-build = "0.13" +qbsdiff = "1.4" +quick-junit = "0.3.5" quinn = { version = "0.11.8", default-features = false } rand = "=0.8.5" rayon = "1.11.0" @@ -356,7 +362,7 @@ dunce = "1.0.5" env_logger = { version = "=0.11.6", default-features = false, features = ["regex"] } imara-diff = "=0.2.0" lax-sql = "=0.2.1" -libsui = "0.12.6" +libsui = "=0.15.0" malva = "=0.15.2" markup_fmt = "=0.27.3" object = { version = "0.36.3", default-features = false, features = ["read_core", "pe"] } diff --git a/cli/Cargo.toml b/cli/Cargo.toml index e35bc6da8dcb25..ed144c06dbc93a 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -52,6 +52,7 @@ serde_json.workspace = true zstd.workspace = true flate2 = { workspace = true, features = ["default"] } deno_error.workspace = true +sha2.workspace = true [target.'cfg(windows)'.build-dependencies] winres.workspace = true @@ -92,6 +93,7 @@ node_shim.workspace = true anstream.workspace = true async-trait.workspace = true aws-lc-rs.workspace = true +backhand.workspace = true base64.workspace = true bincode.workspace = true boxed_error.workspace = true @@ -115,6 +117,7 @@ dprint-plugin-markdown.workspace = true dprint-plugin-typescript.workspace = true esbuild_client = { version = "0.7.1", features = ["serde"] } faster-hex.workspace = true +fastwebsockets.workspace = true # If you disable the default __vendored_zlib_ng feature above, you _must_ be able to link against `-lz`. flate2.workspace = true fluent-uri.workspace = true @@ -122,6 +125,8 @@ glob.workspace = true http.workspace = true http-body.workspace = true http-body-util.workspace = true +hyper.workspace = true +hyper-util.workspace = true imara-diff.workspace = true import_map.workspace = true indexmap.workspace = true @@ -148,6 +153,7 @@ p256.workspace = true pathdiff.workspace = true percent-encoding.workspace = true phf.workspace = true +plist = "1" pretty_yaml.workspace = true rand = { workspace = true, features = ["small_rng"] } regex.workspace = true diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 89040e886c252f..e15ee08addb578 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -237,6 +237,47 @@ pub struct CompileFlags { pub exclude_unused_npm: bool, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IconSetEntry { + pub path: String, + pub size: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum IconConfig { + /// A single icon file (`.icns`, `.ico`, or `.png`). + Single(String), + /// Multiple PNGs at specific pixel sizes. + Set(Vec), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct DesktopFlags { + pub source_file: String, + pub output: Option, + pub args: Vec, + pub target: Option, + pub icon: Option, + pub include: Vec, + pub exclude: Vec, + pub hmr: bool, + pub backend: Option, + pub all_targets: bool, + /// Reverse-DNS bundle / application identifier (e.g. `com.acme.foo`). + /// Used for the macOS `CFBundleIdentifier`, the Linux `.desktop` file + /// identifier, and (eventually) the Windows AppUserModelID. When unset + /// a synthetic `com.deno.desktop.` is generated. + pub identifier: Option, + /// macOS codesigning identity (e.g. `Developer ID Application: Acme, + /// Inc. (TEAMID)`, or `-` for ad-hoc). When unset the bundle is left + /// unsigned; the system will quarantine it on download. + pub codesign_identity: Option, + /// Optional override for the CEF renderer debugger port. When unset, a free + /// port is allocated. The user-visible inspector port (from `--inspect`) is + /// separate and is carried on `Flags::inspect`. + pub inspect_renderer: Option, +} + impl CompileFlags { pub fn resolve_target(&self) -> String { self @@ -740,6 +781,7 @@ pub enum DenoSubcommand { Clean(CleanFlags), Compile(CompileFlags), Completions(CompletionsFlags), + Desktop(DesktopFlags), Coverage(CoverageFlags), Deploy(DeployFlags), Doc(DocFlags), @@ -851,6 +893,10 @@ impl DenoSubcommand { DenoSubcommand::Compile(CompileFlags { target: Some(target), .. + }) + | DenoSubcommand::Desktop(DesktopFlags { + target: Some(target), + .. }) => { // the values of NpmSystemInfo align with the possible values for the // `arch` and `platform` fields of Node.js' `process` global: @@ -1020,6 +1066,8 @@ pub struct InternalFlags { pub root_node_modules_dir_override: Option, /// Only reads to the lockfile instead of writing to it. pub lockfile_skip_write: bool, + /// Set when running the desktop subcommand to use desktop type libs. + pub is_desktop: bool, /// Set by `deno compile --bundle` when the bundled output contains /// references that need to resolve against npm packages at runtime /// (CJS dependencies, native addons). When true, the standalone @@ -1515,6 +1563,10 @@ impl Flags { | Compile(CompileFlags { source_file: script, .. + }) + | Desktop(DesktopFlags { + source_file: script, + .. }) => resolve_single_folder_path(script, current_dir, |mut p| { if p.pop() { Some(p) } else { None } }) @@ -2131,6 +2183,7 @@ pub fn flags_from_vec_with_initial_cwd( "clean" => clean_parse(&mut flags, &mut m), "compile" => compile_parse(&mut flags, &mut m)?, "create" => create_parse(&mut flags, &mut m)?, + "desktop" => desktop_parse(&mut flags, &mut m)?, "completions" => completions_parse(&mut flags, &mut m, app), "coverage" => coverage_parse(&mut flags, &mut m)?, "doc" => doc_parse(&mut flags, &mut m)?, @@ -2245,6 +2298,7 @@ heading! { DOC_HEADING = "Documentation options", FMT_HEADING = "Formatting options", COMPILE_HEADING = "Compile options", + DESKTOP_HEADING = "Desktop options", LINT_HEADING = "Linting options", TEST_HEADING = "Testing options", UPGRADE_HEADING = "Upgrade options", @@ -2257,7 +2311,7 @@ heading! { DEPENDENCY_MANAGEMENT_HEADING = "Dependency management options", UNSTABLE_HEADING = "Unstable options"; - 12 + 13 } fn help_parse(flags: &mut Flags, mut subcommand: Command) { @@ -2403,6 +2457,7 @@ pub fn clap_root() -> Command { .subcommand(clean_subcommand()) .subcommand(compile_subcommand()) .subcommand(create_subcommand()) + .subcommand(desktop_subcommand()) .subcommand(completions_subcommand()) .subcommand(coverage_subcommand()) .subcommand(doc_subcommand()) @@ -3036,6 +3091,14 @@ Unless --reload is specified, this command will not re-download already cached d ) } +const SUPPORTED_OS: [&str; 5] = [ + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + "x86_64-apple-darwin", + "aarch64-apple-darwin", +]; + fn compile_subcommand() -> Command { command( "compile", @@ -3098,13 +3161,7 @@ On the first invocation of `deno compile`, Deno will download the relevant binar Arg::new("target") .long("target") .help("Target OS architecture") - .value_parser([ - "x86_64-unknown-linux-gnu", - "aarch64-unknown-linux-gnu", - "x86_64-pc-windows-msvc", - "x86_64-apple-darwin", - "aarch64-apple-darwin", - ]) + .value_parser(SUPPORTED_OS) .help_heading(COMPILE_HEADING), ) .arg(no_code_cache_arg()) @@ -3170,6 +3227,121 @@ On the first invocation of `deno compile`, Deno will download the relevant binar }) } +fn desktop_subcommand() -> Command { + command( + "desktop", + cstr!("Build and run desktop applications. + + deno desktop main.tsx + deno desktop --hmr main.tsx + deno desktop --output MyApp.app main.tsx + deno desktop + +Compiles the given script into a desktop application using a backend for the UI +layer. The entrypoint can be a file, or omitted (or .) to auto-detect a +supported framework (Next.js, Astro, etc.) in the current directory. + +Read more: https://docs.deno.com/go/desktop +"), + UnstableArgsConfig::ResolutionAndRuntime, + ) + .defer(|cmd| { + runtime_args(cmd, true, true, true) + .arg(check_arg(true)) + .arg( + Arg::new("inspect-renderer") + .long("inspect-renderer") + .value_name("HOST_PORT") + .default_missing_value("127.0.0.1:0") + .help( + "Override the CEF renderer debugger listen address; defaults to an auto-allocated port", + ) + .num_args(0..=1) + .require_equals(true) + .value_parser(inspect_value_parser) + .help_heading(DEBUGGING_HEADING), + ) + .arg( + Arg::new("include") + .long("include") + .help( + cstr!("Includes an additional module or file/directory in the compiled executable. + Use this flag if a dynamically imported module or a web worker main module + fails to load in the executable or to embed a file or directory in the executable. + This flag can be passed multiple times, to include multiple additional modules.", + )) + .action(ArgAction::Append) + .value_hint(ValueHint::FilePath) + .help_heading(DESKTOP_HEADING), + ) + .arg( + Arg::new("exclude") + .long("exclude") + .help( + cstr!("Excludes a file/directory in the compiled executable. + Use this flag to exclude a specific file or directory within the included files.", + )) + .action(ArgAction::Append) + .value_hint(ValueHint::FilePath) + .help_heading(DESKTOP_HEADING), + ) + .arg( + Arg::new("output") + .long("output") + .short('o') + .value_parser(value_parser!(String)) + .help(cstr!("Output path (e.g. MyApp.app, MyApp.dmg, MyApp.msi)")) + .value_hint(ValueHint::FilePath) + .help_heading(DESKTOP_HEADING), + ) + .arg( + Arg::new("target") + .long("target") + .help("Target OS architecture") + .value_parser(SUPPORTED_OS) + .help_heading(DESKTOP_HEADING), + ) + .arg(no_code_cache_arg()) + .arg( + Arg::new("icon") + .long("icon") + .help("Set the application icon (.ico on Windows, .icns or .png on macOS)") + .value_parser(value_parser!(String)) + .help_heading(DESKTOP_HEADING), + ) + .arg( + Arg::new("hmr") + .long("hmr") + .help("Run the desktop app with Hot Module Replacement enabled") + .action(ArgAction::SetTrue) + .help_heading(DESKTOP_HEADING), + ) + .arg( + Arg::new("backend") + .long("backend") + .help("Backend to use for the desktop app") + .value_parser(["webview", "cef", "raw"]) + .default_value("cef") + .help_heading(DESKTOP_HEADING), + ) + .arg( + Arg::new("all-targets") + .long("all-targets") + .help("Build for all supported target platforms") + .action(ArgAction::SetTrue) + .help_heading(DESKTOP_HEADING), + ) + .arg(executable_ext_arg()) + .arg(env_file_arg()) + .arg( + // Optional: when omitted, defaults to "." so framework detection + // runs against the current directory (`deno desktop` == + // `deno desktop .`). + script_arg().trailing_var_arg(true), + ) + }) +} + fn completions_subcommand() -> Command { command( "completions", @@ -7195,6 +7367,68 @@ fn compile_parse( Ok(()) } +fn desktop_parse( + flags: &mut Flags, + matches: &mut ArgMatches, +) -> clap::error::Result<()> { + flags.type_check_mode = TypeCheckMode::Local; + runtime_args_parse(flags, matches, true, true, true)?; + + if let Some(initial_cwd) = flags.initial_cwd.take() { + flags.initial_cwd = Some( + crate::util::fs::canonicalize_path(&initial_cwd) + .ok() + .unwrap_or(initial_cwd), + ); + } + + // The entrypoint is optional: a bare `deno desktop` defaults to "." so + // framework detection runs against the current directory. + let mut script = matches + .remove_many::("script_arg") + .map(|s| s.collect::>()) + .unwrap_or_default() + .into_iter(); + let source_file = script.next().unwrap_or_else(|| ".".to_string()); + let args = script.collect(); + let output = matches.remove_one::("output"); + let target = matches.remove_one::("target"); + let icon = matches.remove_one::("icon"); + let hmr = matches.get_flag("hmr"); + let backend = matches.remove_one::("backend"); + let all_targets = matches.get_flag("all-targets"); + let inspect_renderer = matches.remove_one::("inspect-renderer"); + let include = matches + .remove_many::("include") + .map(|f| f.collect::>()) + .unwrap_or_default(); + let exclude = matches + .remove_many::("exclude") + .map(|f| f.collect::>()) + .unwrap_or_default(); + ext_arg_parse(flags, matches); + + flags.code_cache_enabled = !matches.get_flag("no-code-cache"); + + flags.subcommand = DenoSubcommand::Desktop(DesktopFlags { + source_file, + output, + args, + target, + icon: icon.map(IconConfig::Single), + include, + exclude, + hmr, + backend, + all_targets, + identifier: None, + codesign_identity: None, + inspect_renderer, + }); + + Ok(()) +} + fn completions_parse( flags: &mut Flags, matches: &mut ArgMatches, diff --git a/cli/args/mod.rs b/cli/args/mod.rs index d0f2802bbbce3b..e1b58afc091fcf 100644 --- a/cli/args/mod.rs +++ b/cli/args/mod.rs @@ -614,7 +614,11 @@ impl CliOptions { } pub fn ts_type_lib_window(&self) -> TsTypeLib { - TsTypeLib::DenoWindow + if self.flags.internal.is_desktop { + TsTypeLib::DenoDesktop + } else { + TsTypeLib::DenoWindow + } } pub fn ts_type_lib_worker(&self) -> TsTypeLib { @@ -806,6 +810,12 @@ impl CliOptions { self.initial_cwd(), )? } + DenoSubcommand::Desktop(desktop_flags) => { + resolve_url_or_path_normalized( + &desktop_flags.source_file, + self.initial_cwd(), + )? + } DenoSubcommand::Eval(_) => { let specifier = format!( "./$deno$eval.{}", @@ -1207,7 +1217,9 @@ impl CliOptions { if name.is_empty() { let maybe_subcommand_permissions = match &self.flags.subcommand { DenoSubcommand::Bench(_) => dir.to_bench_permissions_config()?, - DenoSubcommand::Compile(_) => dir.to_compile_permissions_config()?, + DenoSubcommand::Compile(_) | DenoSubcommand::Desktop(_) => { + dir.to_compile_permissions_config()? + } DenoSubcommand::Test(_) => dir.to_test_permissions_config()?, _ => None, }; @@ -1227,7 +1239,7 @@ impl CliOptions { .to_bench_permissions_config()? .filter(|permissions| !permissions.permissions.is_empty()) .map(|permissions| ("Bench", &permissions.base)), - DenoSubcommand::Compile(_) => dir + DenoSubcommand::Compile(_) | DenoSubcommand::Desktop(_) => dir .to_compile_permissions_config()? .filter(|permissions| !permissions.permissions.is_empty()) .map(|permissions| ("Compile", &permissions.base)), diff --git a/cli/build.rs b/cli/build.rs index cb562fa31846db..09315f58ccc1e9 100644 --- a/cli/build.rs +++ b/cli/build.rs @@ -13,6 +13,7 @@ fn compress_decls(out_dir: &Path) { "lib.deno.window.d.ts", "lib.deno.worker.d.ts", "lib.deno.shared_globals.d.ts", + "lib.deno.desktop.d.ts", "lib.deno.unstable.d.ts", "lib.deno_console.d.ts", "lib.deno_url.d.ts", @@ -260,6 +261,127 @@ fn compress_sources(out_dir: &Path) { } } +/// Read the pinned `laufey` capi crate version from the workspace Cargo.lock and +/// expose it as the `LAUFEY_VERSION` rustc env var. Desktop backend downloads are +/// resolved against `github.com/littledivy/laufey/releases/tag/v{LAUFEY_VERSION}`, so +/// tying this to Cargo.lock keeps a single source of truth. +/// +/// Also asserts that `cli/laufey_sums.lock` (the trust anchor for those downloads) +/// pins the same version, failing the build if someone bumps the crate without +/// refreshing the digests. This is a pure consistency check between two +/// checked-in files, so it runs here rather than at runtime — a stale lock file +/// becomes a compile error instead of a first-launch failure. +fn emit_laufey_version() { + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + let lock_path = Path::new(&manifest_dir).join("../Cargo.lock"); + println!("cargo:rerun-if-changed={}", lock_path.display()); + + let lock = match std::fs::read_to_string(&lock_path) { + Ok(s) => s, + Err(_) => return, + }; + + let mut laufey_version = None; + let mut in_laufey = false; + for line in lock.lines() { + if line == "name = \"laufey\"" { + in_laufey = true; + continue; + } + if in_laufey { + if let Some(rest) = line.strip_prefix("version = \"") + && let Some(version) = rest.strip_suffix('"') + { + laufey_version = Some(version.to_string()); + break; + } + if line.starts_with("[[package]]") { + break; + } + } + } + + let Some(laufey_version) = laufey_version else { + return; + }; + println!("cargo:rustc-env=LAUFEY_VERSION={laufey_version}"); + + check_laufey_pinned_sums_version(&manifest_dir, &laufey_version); +} + +/// Confirm `cli/laufey_sums.lock` targets `laufey_version`. The lock file carries a +/// `# version: vX.Y.Z` directive that must match the `laufey` crate version +/// the binary is built against; a mismatch means the pinned digests are stale. +fn check_laufey_pinned_sums_version(manifest_dir: &str, laufey_version: &str) { + let sums_path = Path::new(manifest_dir).join("laufey_sums.lock"); + println!("cargo:rerun-if-changed={}", sums_path.display()); + + let sums = match std::fs::read_to_string(&sums_path) { + Ok(s) => s, + Err(_) => return, + }; + + for line in sums.lines() { + let Some(rest) = line.trim_start().strip_prefix('#') else { + continue; + }; + let Some(version) = rest.trim().strip_prefix("version:") else { + continue; + }; + let pinned = version.trim().trim_start_matches('v'); + if pinned.is_empty() { + panic!( + "cli/laufey_sums.lock has no pinned laufey version — populate it for \ + v{laufey_version} before building" + ); + } + if pinned != laufey_version { + panic!( + "cli/laufey_sums.lock pins Laufey v{pinned} but this build expects \ + v{laufey_version} — refresh the lock file from the upstream SHA256SUMS" + ); + } + return; + } +} + +/// SHA-256 digests of the vendored AppImage Type-2 runtime stubs (from +/// `cli/tools/appimage_runtime/README.md`). Verified at build time so a +/// silent local modification (or a bad rebase) of those checked-in binaries +/// can't slip into a release build undetected. +const APPIMAGE_RUNTIME_HASHES: &[(&str, &str)] = &[ + ( + "tools/appimage_runtime/runtime-x86_64", + "2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d", + ), + ( + "tools/appimage_runtime/runtime-aarch64", + "00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444", + ), +]; + +fn check_appimage_runtime_hashes() { + use sha2::Digest; + let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap(); + for (rel, expected) in APPIMAGE_RUNTIME_HASHES { + let path = Path::new(&manifest_dir).join(rel); + println!("cargo:rerun-if-changed={}", path.display()); + let bytes = match std::fs::read(&path) { + Ok(b) => b, + Err(_) => continue, + }; + let actual = format!("{:x}", sha2::Sha256::digest(&bytes)); + if actual != *expected { + panic!( + "checked-in AppImage runtime stub {} does not match the SHA-256 \ + pinned in cli/tools/appimage_runtime/README.md (expected {expected}, got {actual}). \ + If this update is intentional, refresh both the binary and the README.", + path.display() + ); + } + } +} + fn emit_dts_rerun_if_changed() { let dts_dir = Path::new("tsc/dts"); for entry in std::fs::read_dir(dts_dir).unwrap() { @@ -277,6 +399,8 @@ fn main() { return; } + check_appimage_runtime_hashes(); + deno_napi::print_linker_flags("deno"); deno_webgpu::print_linker_flags("deno"); @@ -313,6 +437,8 @@ fn main() { println!("cargo:rustc-env=TARGET={}", env::var("TARGET").unwrap()); println!("cargo:rustc-env=PROFILE={}", env::var("PROFILE").unwrap()); + emit_laufey_version(); + #[cfg(target_os = "windows")] { let mut res = winres::WindowsResource::new(); diff --git a/cli/factory.rs b/cli/factory.rs index 35dcbb541262aa..63e2676e822587 100644 --- a/cli/factory.rs +++ b/cli/factory.rs @@ -629,6 +629,7 @@ impl CliFactory { | DenoSubcommand::Clean { .. } | DenoSubcommand::Compile { .. } | DenoSubcommand::Completions { .. } + | DenoSubcommand::Desktop { .. } | DenoSubcommand::Coverage { .. } | DenoSubcommand::Deploy { .. } | DenoSubcommand::Doc { .. } @@ -1043,6 +1044,7 @@ impl CliFactory { pub async fn create_compile_binary_writer( &self, + is_desktop: bool, ) -> Result, AnyError> { let cli_options = self.cli_options()?; Ok(DenoCompileBinaryWriter::new( @@ -1056,6 +1058,7 @@ impl CliFactory { self.npm_resolver().await?, self.workspace_resolver().await?.as_ref(), cli_options.npm_system_info(), + is_desktop, )) } @@ -1266,6 +1269,7 @@ impl CliFactory { residual_lazy_js_sources: deno_snapshots::RESIDUAL_LAZY_JS, residual_lazy_esm_sources: deno_snapshots::RESIDUAL_LAZY_ESM, enable_raw_imports: cli_options.unstable_raw_imports(), + close_on_idle: true, maybe_initial_cwd: Some(deno_path_util::url_from_directory_path( cli_options.initial_cwd(), )?), diff --git a/cli/laufey_sums.lock b/cli/laufey_sums.lock new file mode 100644 index 00000000000000..dc28d90e2bde1f --- /dev/null +++ b/cli/laufey_sums.lock @@ -0,0 +1,31 @@ +# Pinned SHA-256 digests for Laufey backend archives. +# +# This file is the trust anchor for `deno desktop` backend downloads. Each +# entry pins the digest of an archive published at: +# https://github.com/littledivy/laufey/releases/download/v/ +# `LAUFEY_VERSION` comes from the workspace `Cargo.lock` (the `laufey` crate). +# When that version bumps, regenerate this file from the upstream +# `SHA256SUMS` and verify the digests against the release out-of-band before +# committing — this is what removes the TOFU on the unsigned SHA256SUMS. +# +# Format: GNU `sha256sum` style, one ` ` per line. +# A `# version: vX.Y.Z` directive (optional) is matched against LAUFEY_VERSION +# at build time to guard against forgetting to refresh this file. +# +# version: v0.3.2 + +08ca9afbb017d5f3fcb7f610e0bb4f12c8721baaa65122740ccf05e6593f866b laufey-cef-aarch64-apple-darwin.tar.gz +30f4062e97060aca984b28039dcc5b7fd7291366a940d0d574fbfc48baf928c7 laufey-cef-aarch64-unknown-linux-gnu.tar.gz +e82769d843ef1c706344acde3e95a36f9c069c747d502a40b42e9b28f9610ea0 laufey-cef-x86_64-apple-darwin.tar.gz +d7939b35c401a4ec12ca285927a6dde6e071d4e43b5aa9fa0e53caed65b33a8a laufey-cef-x86_64-unknown-linux-gnu.tar.gz +2308760642c6d69ae8eb86f31648a09449d141cc9394a0d24cadc7401c743532 laufey-webview-aarch64-apple-darwin.tar.gz +73d1ed08feba2c1bfa36c0bc669d2480440e251f3c959afa32339a9e00c626d3 laufey-webview-aarch64-unknown-linux-gnu.tar.gz +770c42352d1164c9c9600e8599059bae237c4b61cbc3802681a3fa84aaf11568 laufey-webview-x86_64-apple-darwin.tar.gz +59dfda4b9bb36f2fbbfd0fd4aed91fac55c706f03fdbd946821372199cca9616 laufey-webview-x86_64-unknown-linux-gnu.tar.gz +3b02ee34e7864979db7c7f0d98fd98c3f3237b4f5d80bd8b2765a190341cbe5a laufey-winit-aarch64-apple-darwin.tar.gz +fa120e433cd5141b69c4ef314b3d8cbc915471bc71402c104f217507c9dcab4e laufey-winit-aarch64-unknown-linux-gnu.tar.gz +fd88cd7db2763c82a2c49b048f0b467995c5f598586344ab6ecc852b4f1ddb92 laufey-winit-x86_64-apple-darwin.tar.gz +4c0f091b89d8a1c98a560062cd342f43a7943b356d23e2d8f0030bcf0d79c35c laufey-winit-x86_64-unknown-linux-gnu.tar.gz +f89abe11afffe588cd9c6d367e217e211e1c8640260ccb2e7637f9eb4a0aa833 laufey-cef-x86_64-pc-windows-msvc.zip +c8fbfd17f98ad37bbc7567121a24beff2dbee374fdf5f5395b29fc7a3b5c7059 laufey-webview-x86_64-pc-windows-msvc.zip +899ab6e9dd6396e5fe7fb5f5860b540ed0092d0010e53ce7308a5bfff2bb4c1b laufey-winit-x86_64-pc-windows-msvc.zip diff --git a/cli/lib.rs b/cli/lib.rs index 9b5f5009d4747f..526d672110314d 100644 --- a/cli/lib.rs +++ b/cli/lib.rs @@ -185,6 +185,9 @@ async fn run_subcommand( DenoSubcommand::Compile(compile_flags) => spawn_subcommand(async { tools::compile::compile(flags, compile_flags).await }), + DenoSubcommand::Desktop(desktop_flags) => spawn_subcommand(async { + Box::pin(tools::desktop::desktop(flags, desktop_flags)).await + }), DenoSubcommand::Coverage(coverage_flags) => spawn_subcommand(async move { let reporter = crate::tools::coverage::reporter::create(coverage_flags.r#type.clone()); diff --git a/cli/lib/standalone/binary.rs b/cli/lib/standalone/binary.rs index c33f127cc5547a..462509542a2b6d 100644 --- a/cli/lib/standalone/binary.rs +++ b/cli/lib/standalone/binary.rs @@ -98,6 +98,16 @@ pub struct Metadata { /// hash of the VFS data used for versioning the extraction directory. #[serde(default, skip_serializing_if = "Option::is_none")] pub self_extracting: Option, + /// Application version from deno.json or package.json, used for + /// auto-update support in desktop apps. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_version: Option, + /// Error reporting URL from deno.json `desktop.errorReporting.url`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error_reporting_url: Option, + /// Auto-update release base URL from deno.json `desktop.release.baseUrl`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub release_base_url: Option, } #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] diff --git a/cli/lib/util/mod.rs b/cli/lib/util/mod.rs index 383c058a6a568d..f65b7f1159cbaa 100644 --- a/cli/lib/util/mod.rs +++ b/cli/lib/util/mod.rs @@ -3,6 +3,7 @@ pub mod checksum; pub mod hash; pub mod logger; +pub mod net; pub mod result; pub mod text_encoding; pub mod v8; diff --git a/cli/lib/util/net.rs b/cli/lib/util/net.rs new file mode 100644 index 00000000000000..a3c11fedcabf43 --- /dev/null +++ b/cli/lib/util/net.rs @@ -0,0 +1,11 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +/// Bind to an ephemeral TCP port on the loopback interface and return the +/// number assigned by the kernel. The listener is dropped before returning, +/// so the caller is racing against any other process for the port — on +/// loopback the window is small enough to be acceptable for ad-hoc service +/// ports (DevTools mux, desktop HTTP server, …). +pub fn allocate_random_port() -> std::io::Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + Ok(listener.local_addr()?.port()) +} diff --git a/cli/lib/version.rs b/cli/lib/version.rs index 0657b60367cffd..bd6175c6a75c7b 100644 --- a/cli/lib/version.rs +++ b/cli/lib/version.rs @@ -23,17 +23,33 @@ const IS_RC: bool = option_env!("DENO_RC").is_some(); pub static DENO_VERSION_INFO: std::sync::LazyLock = std::sync::LazyLock::new(|| { - #[cfg(not(all( - debug_assertions, - target_os = "macos", - target_arch = "x86_64" - )))] - let release_channel = libsui::find_section("denover") - .ok() - .flatten() - .and_then(|buf| std::str::from_utf8(buf).ok()) - .and_then(|str_| ReleaseChannel::deserialize(str_).ok()) - .unwrap_or({ + let release_channel = { + // On Linux, bypass libsui::find_section which uses dl_iterate_phdr and + // hangs in release builds. Read the ELF PT_NOTE segment directly. + #[cfg(all(unix, not(target_vendor = "apple")))] + { + read_denover_from_elf() + .as_deref() + .and_then(|buf| std::str::from_utf8(buf).ok()) + .and_then(|str_| ReleaseChannel::deserialize(str_).ok()) + .unwrap_or_else(|| { + if IS_CANARY { + ReleaseChannel::Canary + } else if IS_RC { + ReleaseChannel::Rc + } else { + release_channel_from_version_string(DENO_VERSION) + } + }) + } + + // On macOS x86_64 debug, libsui::find_section also hangs; use fallback. + #[cfg(all( + debug_assertions, + target_os = "macos", + target_arch = "x86_64" + ))] + { if IS_CANARY { ReleaseChannel::Canary } else if IS_RC { @@ -41,15 +57,29 @@ pub static DENO_VERSION_INFO: std::sync::LazyLock = } else { release_channel_from_version_string(DENO_VERSION) } - }); - - #[cfg(all(debug_assertions, target_os = "macos", target_arch = "x86_64"))] - let release_channel = if IS_CANARY { - ReleaseChannel::Canary - } else if IS_RC { - ReleaseChannel::Rc - } else { - release_channel_from_version_string(DENO_VERSION) + } + + // All other targets (macOS arm64/x86_64 release, Windows): libsui works. + #[cfg(not(any( + all(unix, not(target_vendor = "apple")), + all(debug_assertions, target_os = "macos", target_arch = "x86_64") + )))] + { + libsui::find_section("denover") + .ok() + .flatten() + .and_then(|buf| std::str::from_utf8(buf).ok()) + .and_then(|str_| ReleaseChannel::deserialize(str_).ok()) + .unwrap_or({ + if IS_CANARY { + ReleaseChannel::Canary + } else if IS_RC { + ReleaseChannel::Rc + } else { + release_channel_from_version_string(DENO_VERSION) + } + }) + } }; DenoVersionInfo { @@ -109,6 +139,105 @@ impl DenoVersionInfo { } } +/// On Linux, reads the `denover` section from the ELF binary's PT_NOTE segment +/// directly (via `/proc/self/exe`), avoiding `dl_iterate_phdr` which hangs in +/// release builds when libsui-embedded sections are present. +#[cfg(all(unix, not(target_vendor = "apple")))] +fn read_denover_from_elf() -> Option> { + use std::io::Read; + use std::io::Seek; + use std::io::SeekFrom; + + let mut file = std::fs::File::open("/proc/self/exe") + .or_else(|_| std::env::current_exe().and_then(std::fs::File::open)) + .ok()?; + + let mut ehdr = [0u8; 64]; + file.read_exact(&mut ehdr).ok()?; + if &ehdr[0..4] != b"\x7fELF" || ehdr[4] != 2 || ehdr[5] != 1 { + return None; + } + + let e_phoff = u64::from_le_bytes(ehdr[32..40].try_into().ok()?) as usize; + let e_phentsize = u16::from_le_bytes(ehdr[54..56].try_into().ok()?) as usize; + let e_phnum = u16::from_le_bytes(ehdr[56..58].try_into().ok()?) as usize; + + if e_phentsize < 56 || e_phnum == 0 { + return None; + } + + let phdrs_len = e_phentsize.checked_mul(e_phnum)?; + file.seek(SeekFrom::Start(e_phoff as u64)).ok()?; + let mut phdrs = vec![0u8; phdrs_len]; + file.read_exact(&mut phdrs).ok()?; + + const PT_NOTE: u32 = 4; + const SUI_NOTE_TYPE: u32 = 0x5355_4901; + + for i in 0..e_phnum { + let ph = &phdrs[i * e_phentsize..]; + let p_type = u32::from_le_bytes(ph[0..4].try_into().ok()?); + if p_type != PT_NOTE { + continue; + } + let p_offset = u64::from_le_bytes(ph[8..16].try_into().ok()?); + let p_filesz = u64::from_le_bytes(ph[32..40].try_into().ok()?) as usize; + if p_filesz == 0 { + continue; + } + + file.seek(SeekFrom::Start(p_offset)).ok()?; + let mut note_data = vec![0u8; p_filesz]; + file.read_exact(&mut note_data).ok()?; + + let mut pos = 0usize; + while pos + 12 <= note_data.len() { + let namesz = + u32::from_le_bytes(note_data[pos..pos + 4].try_into().ok()?) as usize; + let descsz = + u32::from_le_bytes(note_data[pos + 4..pos + 8].try_into().ok()?) + as usize; + let note_type = + u32::from_le_bytes(note_data[pos + 8..pos + 12].try_into().ok()?); + pos += 12; + + if pos + namesz > note_data.len() { + break; + } + let raw_name = ¬e_data[pos..pos + namesz]; + let name_end = raw_name + .iter() + .rposition(|&b| b != 0) + .map(|i| i + 1) + .unwrap_or(0); + let note_name = &raw_name[..name_end]; + pos = (pos + namesz + 3) & !3; + + if pos + descsz > note_data.len() { + break; + } + let desc = ¬e_data[pos..pos + descsz]; + pos = (pos + descsz + 3) & !3; + + if note_name != b"SUI" || note_type != SUI_NOTE_TYPE { + continue; + } + if desc.len() < 2 { + continue; + } + let inner_len = u16::from_le_bytes(desc[0..2].try_into().ok()?) as usize; + if desc.len() < 2 + inner_len { + continue; + } + if &desc[2..2 + inner_len] == b"denover" { + return Some(desc[2 + inner_len..].to_vec()); + } + } + } + + None +} + fn release_channel_from_version_string(version: &str) -> ReleaseChannel { let v = deno_semver::Version::parse_standard(version).ok(); match v.and_then(|v| v.pre.first().map(|s| s.as_str().to_string())) { diff --git a/cli/lib/worker.rs b/cli/lib/worker.rs index a52e28711dd95a..dd6a1d9acaeae4 100644 --- a/cli/lib/worker.rs +++ b/cli/lib/worker.rs @@ -275,6 +275,7 @@ pub struct LibMainWorkerOptions { pub residual_lazy_esm_sources: &'static [(&'static str, &'static str)], pub serve_port: Option, pub serve_host: Option, + pub close_on_idle: bool, pub maybe_initial_cwd: Option, } @@ -715,7 +716,7 @@ impl LibMainWorkerFactory { serve_port: shared.options.serve_port, serve_host: shared.options.serve_host.clone(), otel_config: shared.options.otel_config.clone(), - close_on_idle: true, + close_on_idle: shared.options.close_on_idle, }, extensions: custom_extensions, startup_snapshot: shared.options.startup_snapshot, diff --git a/cli/rt/Cargo.toml b/cli/rt/Cargo.toml index 1cc58b411eba52..cf44518f0db0ff 100644 --- a/cli/rt/Cargo.toml +++ b/cli/rt/Cargo.toml @@ -29,6 +29,8 @@ deno_runtime.workspace = true deno_core.workspace = true [dependencies] +async-trait.workspace = true +bincode.workspace = true deno_ast = { workspace = true, features = ["cjs"] } deno_cache_dir = { workspace = true, features = ["sync"] } deno_config = { workspace = true, features = ["sync", "workspace"] } @@ -36,6 +38,7 @@ deno_core.workspace = true deno_error.workspace = true deno_lib.workspace = true deno_media_type = { workspace = true, features = ["data_url", "decoding"] } +deno_node.workspace = true deno_npm.workspace = true deno_npmrc.workspace = true deno_package_json = { workspace = true, features = ["sync"] } @@ -45,15 +48,15 @@ deno_runtime.workspace = true deno_semver.workspace = true deno_snapshots.workspace = true deno_terminal.workspace = true -libsui.workspace = true -node_resolver.workspace = true - -async-trait.workspace = true -bincode.workspace = true import_map.workspace = true indexmap.workspace = true +jsonc-parser = { workspace = true } +libsui.workspace = true log = { workspace = true, features = ["serde"] } +node_resolver.workspace = true +notify.workspace = true rustls.workspace = true +serde = { workspace = true, features = ["derive"] } serde_json.workspace = true sys_traits = { workspace = true, features = ["getrandom", "filetime", "libc", "real", "strip_unc", "winapi"] } thiserror.workspace = true diff --git a/cli/rt/binary.rs b/cli/rt/binary.rs index 6d072519738cf9..b9d90c08a565e0 100644 --- a/cli/rt/binary.rs +++ b/cli/rt/binary.rs @@ -7,6 +7,7 @@ use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::sync::OnceLock; use deno_core::FastString; use deno_core::ModuleCodeBytes; @@ -59,7 +60,17 @@ pub struct StandaloneData { pub fn extract_standalone( cli_args: Cow<[OsString]>, ) -> Result { - let data = find_section()?; + extract_standalone_with_finder(cli_args, find_section) +} + +/// Like `extract_standalone`, but allows providing a custom section finder. +/// This is used by the desktop cdylib to search its own image rather than +/// the main executable. +pub fn extract_standalone_with_finder( + cli_args: Cow<[OsString]>, + section_finder: fn() -> Result<&'static [u8], AnyError>, +) -> Result { + let data = section_finder()?; // read metadata first to determine the root path let (mut metadata, remaining) = read_section_metadata(data)?; @@ -379,6 +390,14 @@ fn find_section() -> Result<&'static [u8], AnyError> { return read_from_file_fallback(); } + // On Linux, bypass dl_iterate_phdr (which can hang in some release-build + // environments) and read the PT_NOTE section directly from the ELF file. + // This also makes the read path independent of load-time virtual address + // layout, avoiding potential dlpi_addr arithmetic issues. + #[cfg(all(unix, not(target_vendor = "apple")))] + return read_section_from_elf_file(); + + #[cfg(not(all(unix, not(target_vendor = "apple"))))] match libsui::find_section("d3n0l4nd") .context("Failed reading standalone binary section.") { @@ -395,6 +414,176 @@ fn find_section() -> Result<&'static [u8], AnyError> { } } +/// On Linux, read the `d3n0l4nd` section directly from the ELF file +/// (`/proc/self/exe`) instead of via `dl_iterate_phdr`. This avoids a class +/// of release-build hangs where the in-memory PT_NOTE walk stalls. +#[cfg(all(unix, not(target_vendor = "apple")))] +fn read_section_from_elf_file() -> Result<&'static [u8], AnyError> { + use std::io::Read; + use std::io::Seek; + use std::io::SeekFrom; + + // /proc/self/exe is the canonical path to the running executable on Linux + // and works even when argv[0] has been modified or is a relative path. + let mut file = match std::fs::File::open("/proc/self/exe") { + Ok(f) => f, + Err(_) => { + let exe = std::env::current_exe()?; + std::fs::File::open(exe)? + } + }; + + // Read the 64-byte ELF64 file header. + let mut ehdr = [0u8; 64]; + file.read_exact(&mut ehdr).context("reading ELF header")?; + if &ehdr[0..4] != b"\x7fELF" { + bail!("standalone binary is not an ELF file"); + } + // EI_CLASS == ELFCLASS64 (2) + if ehdr[4] != 2 { + bail!("standalone binary is not ELF64"); + } + // EI_DATA == ELFDATA2LSB (1) – little-endian; we only handle LE here since + // all supported Linux targets (x86_64, aarch64, riscv64) are little-endian. + if ehdr[5] != 1 { + bail!("standalone binary is big-endian ELF; unsupported"); + } + + // e_phoff at offset 32 (8 bytes, LE), e_phentsize at 54 (2 bytes), e_phnum at 56 (2 bytes). + let e_phoff = u64::from_le_bytes(ehdr[32..40].try_into().unwrap()) as usize; + let e_phentsize = + u16::from_le_bytes(ehdr[54..56].try_into().unwrap()) as usize; + let e_phnum = u16::from_le_bytes(ehdr[56..58].try_into().unwrap()) as usize; + + if e_phentsize < 56 || e_phnum == 0 { + bail!("ELF has no usable program headers"); + } + + // Read all program headers. + let phdrs_len = e_phentsize + .checked_mul(e_phnum) + .context("PHDR table too large")?; + file + .seek(SeekFrom::Start(e_phoff as u64)) + .context("seeking to PHDR table")?; + let mut phdrs = vec![0u8; phdrs_len]; + file.read_exact(&mut phdrs).context("reading PHDR table")?; + + const PT_NOTE: u32 = 4; + + for i in 0..e_phnum { + let ph = &phdrs[i * e_phentsize..]; + let p_type = u32::from_le_bytes(ph[0..4].try_into().unwrap()); + if p_type != PT_NOTE { + continue; + } + // In Elf64_Phdr: p_offset at 8, p_filesz at 32. + let p_offset = u64::from_le_bytes(ph[8..16].try_into().unwrap()); + let p_filesz = u64::from_le_bytes(ph[32..40].try_into().unwrap()) as usize; + + if p_filesz == 0 { + continue; + } + + file + .seek(SeekFrom::Start(p_offset)) + .context("seeking to PT_NOTE data")?; + let mut note_data = vec![0u8; p_filesz]; + file + .read_exact(&mut note_data) + .context("reading PT_NOTE data")?; + + // Note entries within a PT_NOTE segment use 4-byte alignment on Linux + // (ELF SysV ABI / Linux convention) regardless of the segment's p_align + // field (which describes the segment's load alignment, not internal note + // alignment). + if let Some(section_bytes) = + find_in_elf_note_data(¬e_data, 4, b"d3n0l4nd") + { + // Leak the allocation so we can return &'static [u8]. This is safe + // because standalone binaries read the section data exactly once and + // use it for the entire process lifetime. + return Ok(Box::leak(section_bytes.to_vec().into_boxed_slice())); + } + } + + bail!("Could not find standalone binary section in ELF file.") +} + +/// Walk through ELF notes in `segment`, looking for a libsui-embedded +/// section named `section_name`. Returns a slice into `segment` that +/// contains the section payload, or `None` if not found. +/// +/// Note format (4-byte LE fields): +/// namesz | descsz | type | name[namesz] pad4 | desc[descsz] pad4 +/// +/// libsui note: name = "SUI\0", type = 0x5355_4901, +/// desc = u16-LE section_name_len | section_name | payload +#[cfg(all(unix, not(target_vendor = "apple")))] +fn find_in_elf_note_data<'a>( + segment: &'a [u8], + align: usize, + section_name: &[u8], +) -> Option<&'a [u8]> { + const SUI_NOTE_TYPE: u32 = 0x5355_4901; + + let align = align.max(4); + let mut pos = 0usize; + while pos + 12 <= segment.len() { + let namesz = + u32::from_le_bytes(segment[pos..pos + 4].try_into().ok()?) as usize; + let descsz = + u32::from_le_bytes(segment[pos + 4..pos + 8].try_into().ok()?) as usize; + let note_type = + u32::from_le_bytes(segment[pos + 8..pos + 12].try_into().ok()?); + pos += 12; + + if pos + namesz > segment.len() { + break; + } + let raw_name = &segment[pos..pos + namesz]; + // Strip trailing null bytes to get the bare name. + let name_end = raw_name + .iter() + .rposition(|&b| b != 0) + .map(|i| i + 1) + .unwrap_or(0); + let note_name = &raw_name[..name_end]; + pos = elf_align_up(pos + namesz, align); + + if pos + descsz > segment.len() { + break; + } + let desc = &segment[pos..pos + descsz]; + pos = elf_align_up(pos + descsz, align); + + if note_name != b"SUI" || note_type != SUI_NOTE_TYPE { + continue; + } + // desc layout: u16-LE name_len | name_bytes | payload + if desc.len() < 2 { + continue; + } + let inner_len = u16::from_le_bytes(desc[0..2].try_into().ok()?) as usize; + if desc.len() < 2 + inner_len { + continue; + } + if &desc[2..2 + inner_len] == section_name { + return Some(&desc[2 + inner_len..]); + } + } + None +} + +#[cfg(all(unix, not(target_vendor = "apple")))] +#[inline] +fn elf_align_up(pos: usize, align: usize) -> usize { + if align <= 1 { + return pos; + } + (pos + align - 1) & !(align - 1) +} + /// This is a temporary hacky fallback until we can find /// a fix for https://github.com/denoland/deno/issues/28982 #[cfg(windows)] @@ -690,6 +879,12 @@ impl StandaloneModules { /// TypeScript that it builds or discovers while running. `deno_ast` is already /// linked into the binary via `ext/node`, so transpiling here adds no extra /// binary size. +/// +/// JSX configuration (`jsx`, `jsxImportSource`, `jsxFactory`, etc.) is +/// discovered by walking up from the source file to find the user's +/// `deno.json` / `deno.jsonc` once and caching the result. Without this, +/// every `.tsx` from a Preact-based project would be emitted as +/// `React.createElement(...)` and fail at runtime with `React is not defined`. pub(crate) fn transpile_runtime_module( specifier: &Url, media_type: MediaType, @@ -714,12 +909,10 @@ pub(crate) fn transpile_runtime_module( maybe_syntax: None, }) .map_err(JsErrorBox::from_err)?; + let opts = runtime_transpile_options(specifier); let transpiled = parsed .transpile( - &deno_ast::TranspileOptions { - imports_not_used_as_values: deno_ast::ImportsNotUsedAsValues::Remove, - ..Default::default() - }, + opts, &deno_ast::TranspileModuleOptions { module_kind: None }, &deno_ast::EmitOptions { source_map: deno_ast::SourceMapOption::Inline, @@ -730,6 +923,132 @@ pub(crate) fn transpile_runtime_module( Ok(transpiled.into_source().text) } +/// Resolved JSX/decorator options for runtime transpiles, discovered lazily +/// from the user's project `deno.json[c]` and cached for the lifetime of the +/// process. +static RUNTIME_TRANSPILE_OPTIONS: OnceLock = + OnceLock::new(); + +/// Walk up from `specifier`'s parent directory looking for `deno.json` or +/// `deno.jsonc`, parse `compilerOptions.jsx` + related, and return a matching +/// `TranspileOptions`. Falls back to defaults (with `ImportsNotUsedAsValues::Remove`) +/// if no config is found or parsing fails — those are non-fatal here. +fn runtime_transpile_options( + specifier: &Url, +) -> &'static deno_ast::TranspileOptions { + RUNTIME_TRANSPILE_OPTIONS.get_or_init(|| { + let fallback = deno_ast::TranspileOptions { + imports_not_used_as_values: deno_ast::ImportsNotUsedAsValues::Remove, + ..Default::default() + }; + let Ok(path) = deno_path_util::url_to_file_path(specifier) else { + return fallback; + }; + let Some(mut dir) = path.parent().map(|p| p.to_path_buf()) else { + return fallback; + }; + let cfg_path = 'outer: loop { + for name in ["deno.json", "deno.jsonc"] { + let candidate = dir.join(name); + if candidate.exists() { + break 'outer Some(candidate); + } + } + if !dir.pop() { + break 'outer None; + } + }; + let Some(cfg_path) = cfg_path else { + return fallback; + }; + let Ok(text) = std::fs::read_to_string(&cfg_path) else { + return fallback; + }; + let Some(json) = + jsonc_parser::parse_to_serde_value(&text, &Default::default()) + .ok() + .flatten() + else { + return fallback; + }; + let json: &serde_json::Value = &json; + transpile_options_from_compiler_options( + json.get("compilerOptions"), + &fallback, + ) + }) +} + +/// Lower the relevant subset of `compilerOptions` (the same one +/// `cli/tools/pack/mod.rs::create_transpile_options` handles) into +/// `TranspileOptions`. Anything missing falls back to `fallback`. +fn transpile_options_from_compiler_options( + compiler_options: Option<&serde_json::Value>, + fallback: &deno_ast::TranspileOptions, +) -> deno_ast::TranspileOptions { + let get_str = |key: &str| -> Option { + compiler_options?.get(key)?.as_str().map(|s| s.to_string()) + }; + let get_bool = |key: &str| -> bool { + compiler_options + .and_then(|opts| opts.get(key)) + .and_then(|v| v.as_bool()) + .unwrap_or(false) + }; + let jsx = get_str("jsx"); + let jsx_import_source = get_str("jsxImportSource"); + let jsx_factory = get_str("jsxFactory"); + let jsx_fragment_factory = get_str("jsxFragmentFactory"); + let jsx_runtime = match jsx.as_deref() { + Some("react") => { + Some(deno_ast::JsxRuntime::Classic(deno_ast::JsxClassicOptions { + factory: jsx_factory + .unwrap_or_else(|| "React.createElement".to_string()), + fragment_factory: jsx_fragment_factory + .unwrap_or_else(|| "React.Fragment".to_string()), + })) + } + Some("react-jsx") => Some(deno_ast::JsxRuntime::Automatic( + deno_ast::JsxAutomaticOptions { + development: false, + import_source: jsx_import_source, + }, + )), + Some("react-jsxdev") => Some(deno_ast::JsxRuntime::Automatic( + deno_ast::JsxAutomaticOptions { + development: true, + import_source: jsx_import_source, + }, + )), + Some("precompile") => Some(deno_ast::JsxRuntime::Precompile( + deno_ast::JsxPrecompileOptions { + automatic: deno_ast::JsxAutomaticOptions { + development: false, + import_source: jsx_import_source, + }, + skip_elements: None, + dynamic_props: None, + }, + )), + _ => fallback.jsx.clone(), + }; + let experimental_decorators = get_bool("experimentalDecorators"); + let emit_decorator_metadata = get_bool("emitDecoratorMetadata"); + deno_ast::TranspileOptions { + jsx: jsx_runtime, + decorators: if experimental_decorators { + deno_ast::DecoratorsTranspileOption::LegacyTypeScript { + emit_metadata: emit_decorator_metadata, + } + } else { + deno_ast::DecoratorsTranspileOption::Ecma + }, + imports_not_used_as_values: deno_ast::ImportsNotUsedAsValues::Remove, + var_decl_imports: false, + verbatim_module_syntax: false, + } +} + pub struct DenoCompileModuleData<'a> { pub specifier: &'a Url, pub media_type: MediaType, diff --git a/cli/rt/desktop.rs b/cli/rt/desktop.rs new file mode 100644 index 00000000000000..e29bbe4d3d295f --- /dev/null +++ b/cli/rt/desktop.rs @@ -0,0 +1,1395 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +//! Desktop window management for `deno compile --desktop`. +//! +//! The ops are defined in `deno_runtime::ops::desktop` and included in the +//! V8 snapshot. This module re-exports the key types and provides the JS +//! initialization code. + +use std::sync::Arc; + +use deno_core::OpState; +// Re-export from runtime so denort_desktop can use them. +pub use deno_runtime::ops::desktop::AutoUpdateState; +pub use deno_runtime::ops::desktop::DesktopApi; +pub use deno_runtime::ops::desktop::MenuItem; + +/// JS code that exposes desktop APIs via `Deno.BrowserWindow` and `Deno.desktop`. +pub const DESKTOP_JS: &str = r#" +(() => { + const internals = Deno[Deno.internal]; + const { + BrowserWindow, + Dock, + Tray, + Notification: NotificationNative, + op_desktop_init, + op_desktop_recv_event, + op_desktop_resolve_bind_call, + op_desktop_reject_bind_call, + op_desktop_alert, + op_desktop_confirm, + op_desktop_prompt, + op_desktop_request_notification_permission, + op_desktop_query_notification_permission, + } = internals.core.ops; + const BrowserWindowPrototype = BrowserWindow.prototype; + Object.setPrototypeOf(BrowserWindowPrototype, EventTarget.prototype); + + class UIEvent extends Event { + #detail = 0; + #view = null; + + get detail() { return this.#detail; } + get view() { return this.#view; } + + constructor(type, init = {}) { + super(type, init); + this.#detail = init.detail ?? 0; + this.#view = init.view ?? null; + } + } + + class FocusEvent extends UIEvent { + #relatedTarget = null; + + get relatedTarget() { return this.#relatedTarget; } + + constructor(type, init = {}) { + super(type, init); + this.#relatedTarget = init.relatedTarget ?? null; + } + } + + class KeyboardEvent extends UIEvent { + #key = ""; + #code = ""; + #location = 0; + #ctrlKey = false; + #shiftKey = false; + #altKey = false; + #metaKey = false; + #repeat = false; + #isComposing = false; + + get key() { return this.#key; } + get code() { return this.#code; } + get location() { return this.#location; } + get ctrlKey() { return this.#ctrlKey; } + get shiftKey() { return this.#shiftKey; } + get altKey() { return this.#altKey; } + get metaKey() { return this.#metaKey; } + get repeat() { return this.#repeat; } + get isComposing() { return this.#isComposing; } + + constructor(type, init = {}) { + super(type, init); + this.#key = init.key ?? ""; + this.#code = init.code ?? ""; + this.#location = init.location ?? 0; + this.#ctrlKey = init.ctrlKey ?? false; + this.#shiftKey = init.shiftKey ?? false; + this.#altKey = init.altKey ?? false; + this.#metaKey = init.metaKey ?? false; + this.#repeat = init.repeat ?? false; + this.#isComposing = init.isComposing ?? false; + } + + getModifierState(key) { + switch (key) { + case "Alt": return this.#altKey; + case "Control": return this.#ctrlKey; + case "Meta": return this.#metaKey; + case "Shift": return this.#shiftKey; + default: return false; + } + } + } + + class MouseEvent extends UIEvent { + #button = 0; + #clientX = 0; + #clientY = 0; + #ctrlKey = false; + #shiftKey = false; + #altKey = false; + #metaKey = false; + + get button() { return this.#button; } + get clientX() { return this.#clientX; } + get clientY() { return this.#clientY; } + get screenX() { return this.#clientX; } + get screenY() { return this.#clientY; } + get ctrlKey() { return this.#ctrlKey; } + get shiftKey() { return this.#shiftKey; } + get altKey() { return this.#altKey; } + get metaKey() { return this.#metaKey; } + + constructor(type, init = {}) { + super(type, init); + this.#button = init.button ?? 0; + this.#clientX = init.clientX ?? 0; + this.#clientY = init.clientY ?? 0; + this.#ctrlKey = init.ctrlKey ?? false; + this.#shiftKey = init.shiftKey ?? false; + this.#altKey = init.altKey ?? false; + this.#metaKey = init.metaKey ?? false; + } + + getModifierState(key) { + switch (key) { + case "Alt": return this.#altKey; + case "Control": return this.#ctrlKey; + case "Meta": return this.#metaKey; + case "Shift": return this.#shiftKey; + default: return false; + } + } + } + + class WheelEvent extends MouseEvent { + #deltaX = 0; + #deltaY = 0; + #deltaZ = 0; + #deltaMode = 0; + + get deltaX() { return this.#deltaX; } + get deltaY() { return this.#deltaY; } + get deltaZ() { return this.#deltaZ; } + get deltaMode() { return this.#deltaMode; } + + constructor(type, init = {}) { + super(type, init); + this.#deltaX = init.deltaX ?? 0; + this.#deltaY = init.deltaY ?? 0; + this.#deltaZ = init.deltaZ ?? 0; + this.#deltaMode = init.deltaMode ?? 0; + } + } + + op_desktop_init( + internals.webidlBrand, + internals.setEventTargetData, + ); + + // Window registry: windowId -> BrowserWindow instance. + const windows = new Map(); + const nativeConstructor = BrowserWindow; + const OrigBW = function(...args) { + const instance = new nativeConstructor(...args); + const windowId = instance.windowId; + windows.set(windowId, instance); + return instance; + }; + Object.setPrototypeOf(OrigBW, nativeConstructor); + Object.setPrototypeOf(OrigBW.prototype, nativeConstructor.prototype); + Deno.BrowserWindow = OrigBW; + + internals.defineEventHandler(BrowserWindowPrototype, "keydown"); + internals.defineEventHandler(BrowserWindowPrototype, "keyup"); + internals.defineEventHandler(BrowserWindowPrototype, "mousedown"); + internals.defineEventHandler(BrowserWindowPrototype, "mouseup"); + internals.defineEventHandler(BrowserWindowPrototype, "click"); + internals.defineEventHandler(BrowserWindowPrototype, "dblclick"); + internals.defineEventHandler(BrowserWindowPrototype, "mousemove"); + internals.defineEventHandler(BrowserWindowPrototype, "wheel"); + internals.defineEventHandler(BrowserWindowPrototype, "mouseenter"); + internals.defineEventHandler(BrowserWindowPrototype, "mouseleave"); + internals.defineEventHandler(BrowserWindowPrototype, "focus"); + internals.defineEventHandler(BrowserWindowPrototype, "blur"); + internals.defineEventHandler(BrowserWindowPrototype, "resize"); + internals.defineEventHandler(BrowserWindowPrototype, "move"); + internals.defineEventHandler(BrowserWindowPrototype, "close"); + internals.defineEventHandler(BrowserWindowPrototype, "menuclick"); + internals.defineEventHandler(BrowserWindowPrototype, "contextmenuclick"); + + // Per-window bind callback registry: windowId -> Map + const windowBindCallbacks = new Map(); + + // Binding-call correlation: when --inspect is active, both the Deno + // and renderer consoles emit matching console.debug messages so the + // developer can trace a binding call across isolates. + // bindingTrace is only useful under --inspect (where DENO_DESKTOP_MUX_WS + // is set by the parent). `Deno.env.get` THROWS `NotCapable` if the + // runtime wasn't compiled with --allow-env, which aborts DESKTOP_JS + // execution before the event-loop IIFE below has a chance to register. + // That's the "nothing works — no mouse, no keyboard, no alerts" + // failure mode: events fire on the wef side and pile up in the mpsc + // channel, but the JS side never reads them because this throw kills + // the script. Catch the env-permission error and disable tracing. + let bindingTrace = false; + try { + bindingTrace = typeof Deno.env?.get === "function" + && Deno.env.get("DENO_DESKTOP_MUX_WS") != null; + } catch (_) { + // No env access — fine, we just don't trace binding calls. + } + + const nativeBind = BrowserWindowPrototype.bind; + BrowserWindowPrototype.bind = function(name, fn) { + const windowId = this.windowId; + if (!windowBindCallbacks.has(windowId)) { + windowBindCallbacks.set(windowId, new Map()); + } + windowBindCallbacks.get(windowId).set(name, fn.bind(this)); + nativeBind.call(this, name); + + // Inject a renderer-side wrapper that emits console.debug around + // every binding call. The wrapper waits for the native binding to + // appear (CEF registers it asynchronously via IPC) and then + // replaces it with a logging shim. + if (bindingTrace) { + const escapedName = JSON.stringify(name); + // Cap retries at ~2s (200 × 10ms). CEF registers bindings via async + // IPC; missing them after that long means navigation tore down the + // page or the binding will never appear, and an unbounded + // setTimeout loop would otherwise leak forever per such call. + this.executeJs(`(function() { + var n = ${escapedName}; + var seq = 0; + var attempts = 0; + function tryWrap() { + if (typeof window.bindings === "undefined" || typeof window.bindings[n] !== "function") { + if (++attempts >= 200) return; + setTimeout(tryWrap, 10); + return; + } + var orig = window.bindings[n]; + if (orig.__bindTrace) return; + window.bindings[n] = async function() { + var id = ++seq; + var args = Array.prototype.slice.call(arguments); + console.debug("[binding:call]", n, ":" + id, args); + try { + var result = await orig.apply(this, arguments); + console.debug("[binding:return]", n, ":" + id, result); + return result; + } catch (e) { + console.debug("[binding:error]", n, ":" + id, e); + throw e; + } + }; + window.bindings[n].__bindTrace = true; + } + tryWrap(); + })();`); + } + }; + + const nativeUnbind = BrowserWindowPrototype.unbind; + BrowserWindowPrototype.unbind = function(name) { + const windowId = this.windowId; + const callbacks = windowBindCallbacks.get(windowId); + if (callbacks) callbacks.delete(name); + nativeUnbind.call(this, name); + }; + + function alert(message = "Alert") { + op_desktop_alert("", String(message)); + } + + function confirm(message = "Confirm") { + return op_desktop_confirm(String(message)); + } + + function prompt(message = "Prompt", defaultValue) { + return op_desktop_prompt(String(message), defaultValue != null ? String(defaultValue) : null); + } + + + Object.defineProperties(globalThis, { + alert: internals.core.propWritable(alert), + confirm: internals.core.propWritable(confirm), + prompt: internals.core.propWritable(prompt), + UIEvent: internals.core.propNonEnumerable(UIEvent), + FocusEvent: internals.core.propNonEnumerable(FocusEvent), + KeyboardEvent: internals.core.propNonEnumerable(KeyboardEvent), + MouseEvent: internals.core.propNonEnumerable(MouseEvent), + WheelEvent: internals.core.propNonEnumerable(WheelEvent), + }); + + const DockPrototype = Dock.prototype; + Object.setPrototypeOf(DockPrototype, EventTarget.prototype); + + const docks = new Set(); + const nativeDockConstructor = Dock; + const OrigDock = function(...args) { + const instance = new nativeDockConstructor(...args); + docks.add(instance); + return instance; + }; + Object.setPrototypeOf(OrigDock, nativeDockConstructor); + Object.setPrototypeOf(OrigDock.prototype, nativeDockConstructor.prototype); + Deno.Dock = OrigDock; + + internals.defineEventHandler(DockPrototype, "menuclick"); + internals.defineEventHandler(DockPrototype, "reopen"); + + const dock = new OrigDock(); + Object.defineProperty(Deno, "dock", internals.core.propReadOnly(dock)); + + const TrayPrototype = Tray.prototype; + Object.setPrototypeOf(TrayPrototype, EventTarget.prototype); + + const trays = new Map(); + const nativeTrayConstructor = Tray; + const OrigTray = function(...args) { + const instance = new nativeTrayConstructor(...args); + trays.set(instance.trayId, instance); + return instance; + }; + Object.setPrototypeOf(OrigTray, nativeTrayConstructor); + Object.setPrototypeOf(OrigTray.prototype, nativeTrayConstructor.prototype); + Deno.Tray = OrigTray; + + const nativeTrayDestroy = TrayPrototype.destroy; + TrayPrototype.destroy = function() { + trays.delete(this.trayId); + nativeTrayDestroy.call(this); + }; + TrayPrototype[Symbol.dispose] = function() { + this.destroy(); + }; + + internals.defineEventHandler(TrayPrototype, "click"); + internals.defineEventHandler(TrayPrototype, "dblclick"); + internals.defineEventHandler(TrayPrototype, "menuclick"); + + // High-level convenience: wire a frameless, non-activating popover window + // to this tray icon (the classic menu-bar-app pattern). Built entirely on + // the primitives — `new BrowserWindow({ frameless, noActivate })`, + // `tray.getBounds()`, the tray "click" event and the window "blur" event. + TrayPrototype.attachPanel = function(options) { + if (typeof options === "string") options = { url: options }; + options = options ?? {}; + const width = options.width ?? 360; + const height = options.height ?? 480; + const hideOnBlur = options.hideOnBlur ?? true; + const positionFn = options.position; + const tray = this; + + const window = new Deno.BrowserWindow({ + width, + height, + frameless: true, + noActivate: true, + resizable: false, + }); + window.hide(); + if (options.url != null) window.navigate(options.url); + + let visible = false; + // Guards the click -> blur -> click toggle race: a tray click on a + // focused panel blurs it (hiding via the blur handler) *before* the + // tray "click" fires, which would otherwise immediately re-show it. + let suppressNextShow = false; + + const place = () => { + const bounds = tray.getBounds(); + // No bounds (e.g. Linux, where the tray protocol has no geometry): + // leave the window at its current position. + if (!bounds) return; + const pos = positionFn + ? positionFn(bounds, { width, height }) + : { + x: Math.round(bounds.x + bounds.width / 2 - width / 2), + y: Math.round(bounds.y + bounds.height), + }; + window.setPosition(pos.x, pos.y); + }; + + const show = () => { + place(); + window.show(); + // Take key focus so the panel is interactive and so losing focus + // (clicking elsewhere) dismisses it via the blur handler. + window.focus(); + visible = true; + }; + const hide = () => { + window.hide(); + visible = false; + }; + const toggle = () => { + if (visible) hide(); + else show(); + }; + + const onTrayClick = () => { + if (suppressNextShow) { + suppressNextShow = false; + return; + } + toggle(); + }; + tray.addEventListener("click", onTrayClick); + + let onBlur = null; + if (hideOnBlur) { + onBlur = () => { + if (!visible) return; + hide(); + // If this blur was caused by clicking the tray icon, the tray + // "click" is about to fire — tell it to stay hidden. + suppressNextShow = true; + setTimeout(() => { + suppressNextShow = false; + }, 250); + }; + window.addEventListener("blur", onBlur); + } + + return { + window, + get visible() { + return visible; + }, + show, + hide, + toggle, + destroy() { + tray.removeEventListener("click", onTrayClick); + if (onBlur) window.removeEventListener("blur", onBlur); + window.close(); + }, + }; + }; + + // --- Web Notifications API --- + // + // Backend wants raw PNG bytes for the icon while the Web Notifications + // API specifies icon as a URL string. We synchronously decode `data:` + // URLs (the only form a sync constructor can resolve without I/O) and + // ignore other schemes — the URL is still stored verbatim on the + // instance so `notification.icon` round-trips per spec. + function decodeDataUrlSync(url) { + if (typeof url !== "string" || !url.startsWith("data:")) return null; + const comma = url.indexOf(","); + if (comma === -1) return null; + const meta = url.slice(5, comma); + const isBase64 = meta.endsWith(";base64"); + const payload = url.slice(comma + 1); + try { + if (isBase64) { + const bin = atob(payload); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + return new TextEncoder().encode(decodeURIComponent(payload)); + } catch { + return null; + } + } + + const NotificationPrototype = NotificationNative.prototype; + Object.setPrototypeOf(NotificationPrototype, EventTarget.prototype); + + const notifications = new Map(); + // The Web Notifications API constructor is `new Notification(title, options?)` + // and shows the notification immediately. The native constructor takes + // a third arg for pre-decoded icon bytes so the icon URL → bytes step + // happens on the JS side (sync data: URL decoding only). + const Notification = function Notification(title, options) { + if (arguments.length < 1) { + throw new TypeError( + "Failed to construct 'Notification': 1 argument required, but only 0 present.", + ); + } + const t = String(title); + const opts = options ?? {}; + const iconBytes = decodeDataUrlSync(opts.icon); + const instance = new NotificationNative(t, opts, iconBytes ?? undefined); + if (instance.notificationId !== 0) { + notifications.set(instance.notificationId, instance); + } else { + // Backend didn't show it (no support / failure). The native side + // already emitted a NotificationError event; nothing to track here. + } + return instance; + }; + Object.setPrototypeOf(Notification, NotificationNative); + Object.setPrototypeOf(Notification.prototype, NotificationPrototype); + Notification.prototype.constructor = Notification; + + // Cache of the last status the OS reported. `Notification.permission` + // is a *synchronous* getter per spec, but the underlying laufey call is + // async (it's serviced on the UI thread). The cache starts at + // "default" and updates as `requestPermission()` / permissions.query() + // resolve. We deliberately do NOT do a startup query — that op + // dispatches into the laufey backend's UI thread, which may not be + // pumping when DESKTOP_JS first runs; a hung promise there shouldn't + // be possible to interfere with the event loop below, but the cost + // of being defensive is also zero (apps generally read `.permission` + // only after a user-driven request anyway). + // + // Web spec maps laufey's "prompt" status (no decision yet) to "default" + // for `Notification`, and "prompt" for `navigator.permissions`. The + // laufey "unsupported" status — emitted when the backend or platform + // has no permission model — surfaces to JS as a thrown error from + // requestPermission (most honest) and as "denied" from + // permissions.query (spec doesn't have an "unsupported" state). + let cachedNotificationPermission = "default"; + + function laufeyToNotificationPermission(s) { + // "prompt" → "default" per the Notifications spec; "unsupported" + // is handled by the caller (throws on requestPermission). + switch (s) { + case "granted": return "granted"; + case "denied": return "denied"; + case "prompt": return "default"; + default: return "default"; + } + } + + // Wrap every new descriptor mutation in a try/catch. Anything that + // throws here would otherwise abort DESKTOP_JS execution and prevent + // the event-loop IIFE at the bottom of this script from registering, + // which manifests as "nothing works" (no mouse, no keyboard, no + // alerts). The catch is logged via console.error so a regression is + // visible but doesn't take the whole desktop runtime down with it. + try { + Object.defineProperties(Notification, { + permission: { + get() { return cachedNotificationPermission; }, + enumerable: true, + configurable: true, + }, + maxActions: internals.core.propReadOnly(0), + requestPermission: internals.core.propWritable(function requestPermission( + cb, + ) { + // The Web Notifications spec gates `requestPermission` on a + // transient user activation. The desktop runtime can't observe + // renderer activations cleanly (the OS-level UN dialog lives + // outside Chromium's activation tracking), so we don't enforce. + const promise = (async () => { + const s = await op_desktop_request_notification_permission(); + if (s === "unsupported") { + // Honest signaling: this OS / backend has no notification + // permission model. Throw rather than silently returning a + // misleading "denied" or "granted". + throw new TypeError( + "Notification.requestPermission: not supported by this platform/backend", + ); + } + const perm = laufeyToNotificationPermission(s); + cachedNotificationPermission = perm; + return perm; + })(); + if (typeof cb === "function") { + // Deprecated callback form. Per spec, the callback is invoked + // with the resolved permission *and* the promise still resolves. + promise.then( + (perm) => { try { cb(perm); } catch (_) {} }, + () => { try { cb("denied"); } catch (_) {} }, + ); + } + return promise; + }), + }); + } catch (e) { + console.error("[deno desktop] failed to install Notification permission API:", e); + } + + internals.defineEventHandler(NotificationPrototype, "show"); + internals.defineEventHandler(NotificationPrototype, "click"); + internals.defineEventHandler(NotificationPrototype, "close"); + internals.defineEventHandler(NotificationPrototype, "error"); + + Object.defineProperty(globalThis, "Notification", { + value: Notification, + writable: true, + enumerable: false, + configurable: true, + }); + + // --- navigator.permissions.query (minimal) --- + // + // Spec surface: `navigator.permissions.query({name})` returns a + // Promise where `PermissionStatus` extends EventTarget + // and exposes a readonly `state` plus an `onchange` slot. The desktop + // runtime today only routes `notifications` through laufey; other names + // resolve to "denied" (Chrome's behavior for unknown / unsupported + // names — closer to honest than "prompt" for things we can't fulfill). + // + // Note: we don't fire `change` events. laufey has no change-notification + // channel for permissions, and the cached decision only flips when the + // user goes through System Settings (rare, manual, not worth polling). + class PermissionStatus extends EventTarget { + #name; + #state; + #onchange = null; + constructor(name, state) { + super(); + this.#name = name; + this.#state = state; + } + get name() { return this.#name; } + get state() { return this.#state; } + get status() { return this.#state; } // legacy alias kept by some libs + get onchange() { return this.#onchange; } + set onchange(v) { this.#onchange = typeof v === "function" ? v : null; } + } + + function laufeyToPermissionsApiState(s) { + // Spec maps "prompt" through verbatim; "unsupported" has no spec + // analog so we return "denied" — query() shouldn't throw, but we + // shouldn't lie and say "granted" either. + switch (s) { + case "granted": return "granted"; + case "denied": return "denied"; + case "prompt": return "prompt"; + default: return "denied"; + } + } + + const permissionsImpl = { + async query(descriptor) { + if (descriptor == null || typeof descriptor !== "object") { + throw new TypeError( + "Failed to execute 'query' on 'Permissions': descriptor required", + ); + } + const name = String(descriptor.name); + if (name === "notifications") { + // No side effects per spec — never call request_*. + const s = await op_desktop_query_notification_permission(); + // Keep Notification.permission's cache in sync: a permissions.query + // result is authoritative and lets the synchronous getter report + // a current value without us needing a second roundtrip. + if (s !== "unsupported") { + cachedNotificationPermission = laufeyToNotificationPermission(s); + } + return new PermissionStatus(name, laufeyToPermissionsApiState(s)); + } + // Unknown / unrouted name. Chrome returns "denied" for descriptors + // it doesn't recognize; mimic that rather than throwing. + return new PermissionStatus(name, "denied"); + }, + }; + + // Plug into globalThis.navigator. The base Deno runtime defines + // `navigator` without a `permissions` slot — add ours, but don't + // clobber the object if it's missing entirely (defensive against + // future-Deno changes). Wrapped: a failure here must not abort the + // event-loop IIFE below, because that's what drives all input. + try { + if (typeof navigator === "object" && navigator != null) { + Object.defineProperty(navigator, "permissions", { + value: permissionsImpl, + writable: true, + enumerable: true, + configurable: true, + }); + } + Object.defineProperty(globalThis, "PermissionStatus", { + value: PermissionStatus, + writable: true, + enumerable: false, + configurable: true, + }); + } catch (e) { + console.error("[deno desktop] failed to install navigator.permissions:", e); + } + + // Start polling loops immediately. Use core.unrefOpPromise so these + // pending ops don't block event loop completion (e.g. the pre-module + // tick used by HMR, or module evaluation with top-level await). + const { unrefOpPromise } = internals.core; + + // Single polling loop for all native desktop events. + (async () => { + while (true) { + try { + const p = op_desktop_recv_event(); + unrefOpPromise(p); + const ev = await p; + if (ev == null) break; + switch (ev.kind) { + case "appMenuClick": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new CustomEvent("menuclick", { detail: { id: ev.id } })); + break; + } + case "contextMenuClick": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new CustomEvent("contextmenuclick", { detail: { id: ev.id } })); + break; + } + case "keyboardEvent": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new KeyboardEvent(ev.type, { + key: ev.key, + code: ev.code, + shiftKey: ev.shift, + ctrlKey: ev.control, + altKey: ev.alt, + metaKey: ev.meta, + repeat: ev.repeat, + })); + break; + } + case "bindCall": { + const callbacks = windowBindCallbacks.get(ev.windowId); + const fn_ = callbacks?.get(ev.name); + if (!fn_) { + op_desktop_reject_bind_call(ev.callId, "No callback bound for: " + ev.name); + break; + } + // Run async so it doesn't block the event loop + (async () => { + try { + const args = Array.isArray(ev.args) ? ev.args : []; + if (bindingTrace) { + console.debug("[binding:call]", ev.name, ":" + ev.callId, args); + } + const result = await fn_(...args); + if (bindingTrace) { + console.debug("[binding:return]", ev.name, ":" + ev.callId, result); + } + op_desktop_resolve_bind_call(ev.callId, result ?? null); + } catch (e) { + if (bindingTrace) { + console.debug("[binding:error]", ev.name, ":" + ev.callId, String(e)); + } + op_desktop_reject_bind_call(ev.callId, String(e)); + } + })(); + break; + } + case "mouseClick": { + const target = windows.get(ev.windowId); + if (!target) break; + const init = { + button: ev.button, + clientX: ev.clientX, + clientY: ev.clientY, + ctrlKey: ev.control, + shiftKey: ev.shift, + altKey: ev.alt, + metaKey: ev.meta, + detail: ev.clickCount, + }; + if (ev.state === "pressed") { + target.dispatchEvent(new MouseEvent("mousedown", init)); + } else { + target.dispatchEvent(new MouseEvent("mouseup", init)); + if (ev.button === 0) { + target.dispatchEvent(new MouseEvent("click", init)); + if (ev.clickCount >= 2) { + target.dispatchEvent(new MouseEvent("dblclick", init)); + } + } + } + break; + } + case "mouseMove": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new MouseEvent("mousemove", { + clientX: ev.clientX, + clientY: ev.clientY, + ctrlKey: ev.control, + shiftKey: ev.shift, + altKey: ev.alt, + metaKey: ev.meta, + })); + break; + } + case "wheel": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new WheelEvent("wheel", { + deltaX: ev.deltaX, + deltaY: ev.deltaY, + deltaMode: ev.deltaMode, + clientX: ev.clientX, + clientY: ev.clientY, + ctrlKey: ev.control, + shiftKey: ev.shift, + altKey: ev.alt, + metaKey: ev.meta, + })); + break; + } + case "cursorEnterLeave": { + const target = windows.get(ev.windowId); + if (!target) break; + const init = { + clientX: ev.clientX, + clientY: ev.clientY, + ctrlKey: ev.control, + shiftKey: ev.shift, + altKey: ev.alt, + metaKey: ev.meta, + }; + target.dispatchEvent(new MouseEvent( + ev.entered ? "mouseenter" : "mouseleave", init)); + break; + } + case "focusChanged": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new FocusEvent(ev.focused ? "focus" : "blur")); + break; + } + case "windowResize": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new CustomEvent("resize", { + detail: { width: ev.width, height: ev.height }, + })); + break; + } + case "windowMove": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new CustomEvent("move", { + detail: { x: ev.x, y: ev.y }, + })); + break; + } + case "closeRequested": { + const target = windows.get(ev.windowId); + if (!target) break; + target.dispatchEvent(new Event("close")); + break; + } + case "runtimeError": { + dispatchEvent(new ErrorEvent("error", { + message: ev.message, + error: new Error(ev.message), + })); + break; + } + case "dockMenuClick": { + for (const d of docks) { + d.dispatchEvent(new CustomEvent("menuclick", { + detail: { id: ev.id }, + })); + } + break; + } + case "dockReopen": { + for (const d of docks) { + d.dispatchEvent(new CustomEvent("reopen", { + detail: { hasVisibleWindows: ev.hasVisibleWindows }, + })); + } + break; + } + case "trayClick": { + const target = trays.get(ev.trayId); + if (!target) break; + target.dispatchEvent(new MouseEvent("click")); + break; + } + case "trayDoubleClick": { + const target = trays.get(ev.trayId); + if (!target) break; + target.dispatchEvent(new MouseEvent("dblclick")); + break; + } + case "trayMenuClick": { + const target = trays.get(ev.trayId); + if (!target) break; + target.dispatchEvent(new CustomEvent("menuclick", { + detail: { id: ev.id }, + })); + break; + } + case "notificationShow": { + const target = notifications.get(ev.notificationId); + if (!target) break; + target.dispatchEvent(new Event("show")); + break; + } + case "notificationClick": { + const target = notifications.get(ev.notificationId); + if (!target) break; + target.dispatchEvent(new Event("click")); + break; + } + case "notificationClose": { + const target = notifications.get(ev.notificationId); + notifications.delete(ev.notificationId); + if (!target) break; + target.dispatchEvent(new Event("close")); + break; + } + case "notificationError": { + // notificationId === 0 means the backend rejected the show + // before any instance was registered. Per spec, errors only + // fire on the instance — best-effort dispatch to whichever + // instance is registered under that id (or none for id 0). + const target = notifications.get(ev.notificationId); + if (!target) break; + target.dispatchEvent(new Event("error")); + break; + } + } + } catch (e) { + console.error("Desktop event loop error:", e?.stack ?? e); + } + } + })(); +})(); +"#; + +/// JS code that initializes auto-update APIs. Executed separately so +/// version and rollback state can be baked in as literals. +pub fn desktop_auto_update_js( + version: Option<&str>, + rolled_back: bool, + release_base_url: Option<&str>, +) -> String { + format!( + r#"(() => {{ + const {{ + op_desktop_apply_patch, + op_desktop_verify_ed25519, + op_desktop_confirm_update, + }} = Deno[Deno.internal].core.ops; + const {{ propReadOnly, propWritable }} = Deno[Deno.internal].core; + + const _version = {version}; + const _rolledBack = {rolled_back}; + const _releaseBaseUrl = {release_base_url}; + + const ROLLBACK_REASON = "Update failed to start, rolled back."; + + if (!_rolledBack) {{ + op_desktop_confirm_update(); + }} + + let autoUpdateTimer = null; + + function isHttpsUrl(u) {{ + try {{ + const parsed = new URL(u); + return parsed.protocol === "https:"; + }} catch {{ + return false; + }} + }} + + function autoUpdate(urlOrOpts) {{ + const opts = typeof urlOrOpts === "string" + ? {{ url: urlOrOpts }} + : (urlOrOpts ?? {{}}); + const {{ + url = _releaseBaseUrl, + interval, + onUpdateReady, + onRollback, + publicKey, + }} = opts; + + if (_rolledBack && typeof onRollback === "function") {{ + queueMicrotask(() => {{ + try {{ onRollback(ROLLBACK_REASON); }} catch (e) {{ + console.error("Deno.autoUpdate onRollback threw:", e); + }} + }}); + }} + + if (!_version) {{ + console.warn("Deno.autoUpdate: no version in deno.json, skipping"); + return; + }} + if (typeof url !== "string" || url.length === 0) {{ + console.warn("Deno.autoUpdate: missing 'url' option, skipping"); + return; + }} + if (!isHttpsUrl(url)) {{ + console.error( + "Deno.autoUpdate: refusing non-https url (got %s); ignoring.", url, + ); + return; + }} + + const base = url.replace(/\/$/, ""); + const te = new TextEncoder(); + + const check = async () => {{ + try {{ + const resp = await fetch(base + "/latest.json", {{ + cache: "no-store", + redirect: "error", + }}); + if (!resp.ok) return; + const manifestText = await resp.text(); + let manifest; + try {{ + manifest = JSON.parse(manifestText); + }} catch {{ + console.warn("Deno.autoUpdate: latest.json is not valid JSON"); + return; + }} + if (manifest.version === _version) return; + + if (publicKey) {{ + const sig = manifest.signature; + if (typeof sig !== "string" || !sig) {{ + console.error( + "Deno.autoUpdate: publicKey configured but manifest has no signature", + ); + return; + }} + // Signature is computed over the manifest with the `signature` field + // removed, serialized canonically. To avoid depending on a JCS + // implementation, signers must put the signature on a top-level + // `signature` field and include the rest of the manifest verbatim + // under a `signed` field (string). We then verify over that string. + const signed = manifest.signed; + if (typeof signed !== "string") {{ + console.error( + "Deno.autoUpdate: signed manifest must include a `signed` string field", + ); + return; + }} + if (!op_desktop_verify_ed25519(publicKey, sig, te.encode(signed))) {{ + console.error("Deno.autoUpdate: manifest signature verification failed"); + return; + }} + // Re-parse the signed payload — only its contents are trusted. + try {{ + manifest = JSON.parse(signed); + }} catch {{ + console.error("Deno.autoUpdate: signed payload is not valid JSON"); + return; + }} + if (manifest.version === _version) return; + }} + + const patchEntry = manifest.patches?.[_version]; + if (!patchEntry) {{ + console.warn("Deno.autoUpdate: no patch available for", + _version, "->", manifest.version); + return; + }} + // Accept either a string (legacy/unsafe) or {{ name, sha256 }}. The + // SHA-256 is required — Rust will reject the patch otherwise. + const patchName = typeof patchEntry === "string" + ? patchEntry + : patchEntry?.name; + const patchSha256 = typeof patchEntry === "object" + ? patchEntry?.sha256 + : undefined; + if (!patchName) {{ + console.error("Deno.autoUpdate: malformed patch entry"); + return; + }} + if (typeof patchSha256 !== "string" || patchSha256.length !== 64) {{ + console.error( + "Deno.autoUpdate: manifest patch entry must include sha256", + ); + return; + }} + const patchResp = await fetch(base + "/" + patchName, {{ + cache: "no-store", + redirect: "error", + }}); + if (!patchResp.ok) return; + const patchBytes = new Uint8Array(await patchResp.arrayBuffer()); + op_desktop_apply_patch(patchBytes, patchSha256); + if (typeof onUpdateReady === "function") {{ + try {{ onUpdateReady(manifest.version); }} catch (e) {{ + console.error("Deno.autoUpdate onUpdateReady threw:", e); + }} + }} + if (autoUpdateTimer) {{ + clearInterval(autoUpdateTimer); + autoUpdateTimer = null; + }} + }} catch (e) {{ + console.warn("Deno.autoUpdate: check failed:", e.message); + }} + }}; + + setTimeout(check, 1000); + if (interval) {{ + autoUpdateTimer = setInterval(check, interval); + }} + }} + + Object.defineProperties(Deno, {{ + desktopVersion: propReadOnly(_version), + autoUpdate: propWritable(autoUpdate), + }}); +}})(); +"#, + version = serde_json::to_string(&version).unwrap(), + rolled_back = if rolled_back { "true" } else { "false" }, + release_base_url = serde_json::to_string(&release_base_url).unwrap(), + ) +} + +/// JS code that initializes error reporting. Installs `"error"` and +/// `"unhandledrejection"` listeners that show a native alert and +/// optionally POST error reports to a configured URL. +pub fn desktop_error_reporting_js( + url: Option<&str>, + version: Option<&str>, +) -> String { + format!( + r#"(() => {{ + const {{ op_desktop_alert, op_desktop_send_error_report }} = Deno[Deno.internal].core.ops; + const _errorReportingUrl = {url}; + const _appVersion = {version}; + + function handleError(message, stack) {{ + if (_errorReportingUrl) {{ + const body = JSON.stringify({{ + version: 1, + message: String(message), + stack: stack ?? null, + appVersion: _appVersion, + timestamp: new Date().toISOString(), + platform: Deno.build.os, + arch: Deno.build.arch, + }}); + op_desktop_send_error_report(_errorReportingUrl, body); + }} + + try {{ + op_desktop_alert("Application Error", String(message)); + }} catch (_) {{}} + }} + + addEventListener("error", (ev) => {{ + if (ev.defaultPrevented) return; + const err = ev.error; + handleError( + err?.message ?? ev.message ?? "Unknown error", + err?.stack ?? null, + ); + }}); + + addEventListener("unhandledrejection", (ev) => {{ + if (ev.defaultPrevented) return; + const err = ev.reason; + handleError( + err?.message ?? String(err ?? "Unhandled promise rejection"), + err?.stack ?? null, + ); + }}); +}})(); +"#, + url = serde_json::to_string(&url).unwrap(), + version = serde_json::to_string(&version).unwrap(), + ) +} + +pub use deno_runtime::ops::desktop::DesktopEvent; +pub use deno_runtime::ops::desktop::DesktopEventReceiver; +pub use deno_runtime::ops::desktop::DesktopEventSender; +pub use deno_runtime::ops::desktop::DesktopEventTx; +pub use deno_runtime::ops::desktop::InitialWindowId; +pub use deno_runtime::ops::desktop::PendingBindCall; +pub use deno_runtime::ops::desktop::PendingBindResponses; +pub use deno_runtime::ops::desktop::create_desktop_event_channel; +pub use deno_runtime::ops::desktop::register_bind_call; + +/// Place the DesktopApi and optional AutoUpdateState into OpState. +/// The ops are already registered in the snapshot; this just provides +/// the runtime implementation. +pub fn init_desktop_state( + state: &mut OpState, + api: Box, + auto_update: Option, +) { + let api: Arc = Arc::from(api); + state.put::>(api); + if let Some(au) = auto_update { + state.put::(au); + } +} + +pub use deno_lib::util::net::allocate_random_port; + +#[cfg(test)] +mod tests { + use super::DESKTOP_JS; + use super::desktop_auto_update_js; + use super::desktop_error_reporting_js; + + // --- DESKTOP_JS structural invariants --- + // + // DESKTOP_JS is an 800+ line string baked into the binary. We can't + // cheaply exec it in a v8 isolate from here, but the asserts below + // pin the regressions that motivated this whole fix: the "Deno.env + // throws NotCapable and aborts the IIFE" bug from May 2026. + + #[test] + fn desktop_js_wraps_binding_trace_env_read_in_try_catch() { + // The original bug: `Deno.env.get("DENO_DESKTOP_MUX_WS")` threw + // NotCapable when --allow-env wasn't granted, aborting the rest of + // DESKTOP_JS. The fix is to wrap that read in try/catch and let it + // soft-fail. A regression that removed the try/catch would + // reintroduce the "blank window where nothing works" failure mode. + let near = locate_around(DESKTOP_JS, "DENO_DESKTOP_MUX_WS"); + assert!( + near.contains("try {") || near.contains("try{"), + "DENO_DESKTOP_MUX_WS read must be wrapped in try/catch; got:\n{near}" + ); + } + + #[test] + fn desktop_js_installs_alert_confirm_prompt_overrides() { + // The renderer reaches `op_desktop_alert/confirm/prompt` through + // these globalThis overrides; the assignment lines must survive. + // Each must reference its op so a stub/no-op replacement would + // fail the check. + assert!(DESKTOP_JS.contains("op_desktop_alert")); + assert!(DESKTOP_JS.contains("op_desktop_confirm")); + assert!(DESKTOP_JS.contains("op_desktop_prompt")); + assert!(DESKTOP_JS.contains("globalThis")); + } + + #[test] + fn desktop_js_installs_recv_event_loop() { + // The event-loop IIFE at the bottom polls `op_desktop_recv_event` + // and dispatches to BrowserWindow listeners. Without this code path + // mouse / keyboard / focus / resize events would never reach JS, + // exactly the symptom from the original bug report. + assert!( + DESKTOP_JS.contains("op_desktop_recv_event"), + "DESKTOP_JS must call op_desktop_recv_event" + ); + } + + #[test] + fn desktop_js_installs_notification_permission_getter() { + // Notification.permission is a synchronous spec-mandated getter. + // A regression that turned the property definition into a plain + // value would break feature-detection in user code. + assert!(DESKTOP_JS.contains("Notification")); + assert!(DESKTOP_JS.contains("permission")); + assert!(DESKTOP_JS.contains("requestPermission")); + } + + #[test] + fn desktop_js_installs_navigator_permissions() { + assert!(DESKTOP_JS.contains("navigator")); + assert!(DESKTOP_JS.contains("permissions")); + assert!(DESKTOP_JS.contains("PermissionStatus")); + } + + #[test] + fn desktop_js_installs_browser_window_constructor() { + assert!(DESKTOP_JS.contains("Deno.BrowserWindow")); + // The original BrowserWindow is wrapped so per-window state is + // recorded. + assert!(DESKTOP_JS.contains("windows.set")); + } + + // --- desktop_auto_update_js --- + + #[test] + fn auto_update_js_inlines_version_as_json_literal() { + let js = desktop_auto_update_js(Some("1.2.3"), false, None); + // The version must be a JSON-quoted string, not a bare identifier: + // we feed it through serde_json::to_string. A regression that + // dropped the quoting would produce invalid JS for any non-trivial + // version (e.g. `1.2.3-alpha`). + assert!( + js.contains(r#""1.2.3""#), + "version must be quoted; got: {js}" + ); + assert!(js.contains("const _version =")); + assert!(js.contains("const _rolledBack = false")); + } + + #[test] + fn auto_update_js_serializes_none_as_null_literal() { + let js = desktop_auto_update_js(None, true, None); + assert!(js.contains("const _version = null")); + assert!(js.contains("const _rolledBack = true")); + assert!(js.contains("const _releaseBaseUrl = null")); + } + + #[test] + fn auto_update_js_inlines_release_base_url() { + // The configured `desktop.release.baseUrl` is baked in as the default + // `url` for `Deno.autoUpdate`, so a no-arg call uses it. + let js = desktop_auto_update_js( + Some("1.0.0"), + false, + Some("https://releases.example/app"), + ); + assert!( + js.contains(r#"const _releaseBaseUrl = "https://releases.example/app""#), + "release base url must be quoted; got: {js}" + ); + assert!(js.contains("url = _releaseBaseUrl")); + } + + #[test] + fn auto_update_js_blocks_non_https_manifest_url() { + // Anti-downgrade defence: the auto-update path must refuse to + // fetch its manifest over http://, gopher://, file://, etc. A + // change that loosened this check is a security regression. + let js = desktop_auto_update_js(Some("1.0.0"), false, None); + assert!(js.contains("isHttpsUrl")); + assert!(js.contains("https:")); + } + + // --- desktop_error_reporting_js --- + + #[test] + fn error_reporting_js_quotes_url_and_version() { + let js = + desktop_error_reporting_js(Some("https://err.example/r"), Some("0.1.0")); + assert!(js.contains(r#""https://err.example/r""#)); + assert!(js.contains(r#""0.1.0""#)); + // The URL must be referenced by the script body — otherwise the + // emitted code would silently never POST. + assert!(js.contains("_errorReportingUrl")); + } + + #[test] + fn error_reporting_js_handles_none_url() { + let js = desktop_error_reporting_js(None, None); + // null both — the handler short-circuits the POST but still shows + // the alert. + assert!(js.contains("const _errorReportingUrl = null")); + assert!(js.contains("const _appVersion = null")); + } + + #[test] + fn error_reporting_js_listens_for_unhandledrejection() { + // Both `error` and `unhandledrejection` events must be hooked + // — missing either would let half of all user-code failures fall + // out the bottom of the runtime without notification. + let js = desktop_error_reporting_js(None, None); + assert!(js.contains("\"error\"")); + assert!(js.contains("\"unhandledrejection\"")); + } + + // --- helpers --- + + /// Return a window of DESKTOP_JS around the first occurrence of `needle`, + /// covering ~10 lines on each side. Useful for asserting "the region + /// near this token contains a try/catch" without coupling the test + /// to a precise line range. + fn locate_around(hay: &str, needle: &str) -> String { + // The first occurrence of DENO_DESKTOP_MUX_WS in DESKTOP_JS is + // inside a comment block; the *code* occurrence is the second. We + // want the window around the code path, so search after the first. + let first = hay.find(needle).unwrap_or_else(|| { + panic!("needle {needle:?} not found in DESKTOP_JS"); + }); + let idx = hay[first + needle.len()..] + .find(needle) + .map(|i| first + needle.len() + i) + .unwrap_or(first); + let start = idx.saturating_sub(500); + let end = (idx + needle.len() + 500).min(hay.len()); + hay[start..end].to_string() + } +} diff --git a/cli/rt/file_system.rs b/cli/rt/file_system.rs index 07ddb311fb0169..50ad626971752d 100644 --- a/cli/rt/file_system.rs +++ b/cli/rt/file_system.rs @@ -1248,9 +1248,19 @@ impl VfsRoot { let relative_path = match path.strip_prefix(&self.root_path) { Ok(p) => p, Err(_) => { + // "outside root" is the common host-FS fallback path in + // desktop --hmr / runtime-dynamic imports. Don't print at all + // by default; the caller falls through to a real filesystem + // read. Set DENO_LOG=denort=debug to see it. + log::debug!( + target: "denort", + "[VFS] path outside root '{}': {}", + self.root_path.display(), + path.display(), + ); return Err(std::io::Error::new( std::io::ErrorKind::NotFound, - "path not found", + format!("path not found (outside root): {}", path.display()), )); } }; @@ -1276,7 +1286,7 @@ impl VfsRoot { _ => { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, - "path not found", + format!("path not found (symlink not dir): {}", path.display()), )); } } @@ -1284,7 +1294,7 @@ impl VfsRoot { _ => { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, - "path not found", + format!("path not found (not dir): {}", path.display()), )); } }; @@ -1293,7 +1303,10 @@ impl VfsRoot { .entries .get_by_name(&component, case_sensitivity) .ok_or_else(|| { - std::io::Error::new(std::io::ErrorKind::NotFound, "path not found") + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("path not found (entry missing): {}", path.display()), + ) })? .as_ref(); } diff --git a/cli/rt/hmr.rs b/cli/rt/hmr.rs new file mode 100644 index 00000000000000..d60b6637086086 --- /dev/null +++ b/cli/rt/hmr.rs @@ -0,0 +1,687 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +//! Simplified HMR (Hot Module Replacement) for the standalone/desktop runtime. +//! +//! Watches source files on disk, transpiles changed TypeScript/TSX/JSX files +//! using `deno_ast`, and hot-replaces them via V8's `Debugger.setScriptSource`. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicI32; +use std::time::Duration; + +use deno_core::LocalInspectorSession; +use deno_core::parking_lot::Mutex; +use deno_core::serde_json::Value; +use deno_core::serde_json::json; +use deno_core::serde_json::{self}; +use deno_core::url::Url; +use deno_error::JsErrorBox; +use notify::RecursiveMode; +use notify::Watcher; +use notify::event::ModifyKind; +use tokio::sync::mpsc; +use tokio::sync::oneshot; + +/// Coalesce events that arrive within this window into one transpile + +/// `setScriptSource` round-trip. Editors that save-then-format (or do +/// atomic-rename saves) emit several events per keystroke; without this we +/// would re-transpile each one. +const HMR_DEBOUNCE: Duration = Duration::from_millis(50); + +static NEXT_MSG_ID: AtomicI32 = AtomicI32::new(0); +fn next_id() -> i32 { + NEXT_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +// Minimal CDP types needed for HMR. +mod cdp { + use serde::Deserialize; + use serde_json::Value; + + #[derive(Debug, Deserialize)] + pub struct Notification { + pub method: String, + pub params: Value, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct ScriptParsed { + pub script_id: String, + pub url: String, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct ExceptionThrown { + pub exception_details: ExceptionDetails, + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct ExceptionDetails { + pub text: String, + pub exception: Option, + } + + #[derive(Debug, Clone, Deserialize)] + pub struct RemoteObject { + pub description: Option, + } + + impl ExceptionDetails { + pub fn get_message_and_description(&self) -> (String, String) { + let description = self + .exception + .clone() + .and_then(|ex| ex.description) + .unwrap_or_else(|| "undefined".to_string()); + (self.text.to_string(), description) + } + } + + #[derive(Debug, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct SetScriptSourceResponse { + pub status: Status, + pub exception_details: Option, + } + + #[derive(Debug, Deserialize)] + pub enum Status { + Ok, + CompileError, + BlockedByActiveGenerator, + BlockedByActiveFunction, + BlockedByTopLevelEsModuleChange, + } +} + +fn explain(response: &cdp::SetScriptSourceResponse) -> String { + match response.status { + cdp::Status::Ok => "OK".to_string(), + cdp::Status::CompileError => { + if let Some(details) = &response.exception_details { + let (message, description) = details.get_message_and_description(); + format!( + "compile error: {}{}", + message, + if description == "undefined" { + "".to_string() + } else { + format!(" - {}", description) + } + ) + } else { + "compile error: No exception details available".to_string() + } + } + cdp::Status::BlockedByActiveGenerator => { + "blocked by active generator".to_string() + } + cdp::Status::BlockedByActiveFunction => { + "blocked by active function".to_string() + } + cdp::Status::BlockedByTopLevelEsModuleChange => { + "blocked by top-level ES module change".to_string() + } + } +} + +fn should_retry(status: &cdp::Status) -> bool { + matches!( + status, + cdp::Status::BlockedByActiveGenerator + | cdp::Status::BlockedByActiveFunction + ) +} + +/// Transpile a TypeScript/TSX/JSX source file to JavaScript for HMR. +fn transpile_for_hmr( + specifier: &Url, + source_code: String, +) -> Result { + use deno_ast::*; + let media_type = deno_media_type::MediaType::from_specifier(specifier); + match media_type { + deno_media_type::MediaType::TypeScript + | deno_media_type::MediaType::Mts + | deno_media_type::MediaType::Cts + | deno_media_type::MediaType::Jsx + | deno_media_type::MediaType::Tsx => { + let parsed = parse_module(ParseParams { + specifier: specifier.clone(), + text: source_code.into(), + media_type, + capture_tokens: false, + scope_analysis: false, + maybe_syntax: None, + }) + .map_err(JsErrorBox::from_err)?; + + let transpiled = parsed + .transpile( + &TranspileOptions::default(), + &TranspileModuleOptions::default(), + &EmitOptions { + source_map: SourceMapOption::None, + ..Default::default() + }, + ) + .map_err(JsErrorBox::from_err)? + .into_source(); + Ok(transpiled.text) + } + // JS files don't need transpilation + _ => Ok(source_code), + } +} + +#[derive(Debug)] +enum InspectorMessageState { + Ready(Value), + WaitingFor(oneshot::Sender), +} + +#[derive(Debug)] +struct HmrStateInner { + script_ids: HashMap, + messages: HashMap, + exception_tx: mpsc::UnboundedSender, +} + +#[derive(Clone, Debug)] +pub struct HmrState(Arc>); + +impl HmrState { + fn new(exception_tx: mpsc::UnboundedSender) -> Self { + Self(Arc::new(Mutex::new(HmrStateInner { + script_ids: HashMap::new(), + messages: HashMap::new(), + exception_tx, + }))) + } + + pub fn callback(&self, msg: deno_core::InspectorMsg) { + let deno_core::InspectorMsgKind::Message(msg_id) = msg.kind else { + // Notifications: drop on parse failure rather than panic — the + // inspector can ship payload shapes we don't model. + match serde_json::from_str::(&msg.content) { + Ok(notification) => self.handle_notification(notification), + Err(e) => log::debug!("HMR: failed to parse CDP notification: {}", e), + } + return; + }; + + let message: Value = match serde_json::from_str(&msg.content) { + Ok(v) => v, + Err(e) => { + log::debug!("HMR: failed to parse CDP response {}: {}", msg_id, e); + return; + } + }; + let mut state = self.0.lock(); + let Some(message_state) = state.messages.remove(&msg_id) else { + state + .messages + .insert(msg_id, InspectorMessageState::Ready(message)); + return; + }; + let InspectorMessageState::WaitingFor(sender) = message_state else { + return; + }; + let _ = sender.send(message); + } + + fn handle_notification(&self, notification: cdp::Notification) { + if notification.method == "Runtime.exceptionThrown" { + let exception_thrown = match serde_json::from_value::( + notification.params, + ) { + Ok(v) => v, + Err(e) => { + log::debug!("HMR: malformed Runtime.exceptionThrown: {}", e); + return; + } + }; + let (message, description) = exception_thrown + .exception_details + .get_message_and_description(); + let _ = self + .0 + .lock() + .exception_tx + .send(JsErrorBox::generic(format!("{} {}", message, description))); + } else if notification.method == "Debugger.scriptParsed" { + let params = match serde_json::from_value::( + notification.params, + ) { + Ok(v) => v, + Err(e) => { + log::debug!("HMR: malformed Debugger.scriptParsed: {}", e); + return; + } + }; + if params.url.starts_with("file://") { + // Store with the URL as-is (no canonicalization — VFS paths + // don't exist on disk so canonicalize would fail). + self + .0 + .lock() + .script_ids + .insert(params.url.clone(), params.script_id); + } + } + } +} + +/// Callback invoked after a module is successfully hot-replaced. +pub type HmrReloadCallback = Box; + +/// Result of attempting to apply a single change. +enum ChangeOutcome { + /// `setScriptSource` succeeded — the URL was hot-replaced. + Replaced(String), + /// V8 can't apply the change in place; ask the host to reload. + NeedsReload(String), + /// Nothing to do (untracked file, transient I/O error, etc.). + Skipped, +} + +/// What happened to a watched source file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FileChange { + /// File was modified or newly created — try a hot-replace. + Updated, + /// File was deleted or renamed — fall back to a page reload. + Removed, +} + +/// Desktop HMR runner. Watches source files and hot-replaces changed modules. +pub struct DesktopHmrRunner { + session: LocalInspectorSession, + state: HmrState, + changed_rx: mpsc::UnboundedReceiver<(PathBuf, FileChange)>, + exception_rx: mpsc::UnboundedReceiver, + /// The directory being watched on disk (original source location). + watch_dir: PathBuf, + /// The VFS root path inside the compiled binary. Script URLs use this base. + vfs_root: PathBuf, + _watcher: notify::RecommendedWatcher, + /// Optional callback to trigger a reload (e.g. refresh the webview). + on_reload: Option, + /// Optional sender to dispatch errors as DesktopEvents for the error reporter. + desktop_event_tx: Option, +} + +impl DesktopHmrRunner { + /// Create a new HMR runner. Watches the given root directory for changes. + /// `vfs_root` is the embedded root path that V8 scripts are registered under. + pub fn new( + session: LocalInspectorSession, + state: HmrState, + watch_dir: PathBuf, + vfs_root: PathBuf, + exception_rx: mpsc::UnboundedReceiver, + ) -> Result { + let (changed_tx, changed_rx) = mpsc::unbounded_channel(); + + let mut watcher = + notify::recommended_watcher(move |res: Result| { + let Ok(event) = res else { + return; + }; + // Filter event kinds: + // Create / Modify(non-Metadata) → Updated (hot-replace candidate) + // Remove → Removed (fall back to reload) + // Metadata-only modifications (chmod, touch) are pure noise. + let change = match event.kind { + notify::EventKind::Create(_) => FileChange::Updated, + notify::EventKind::Modify(ModifyKind::Metadata(_)) => return, + notify::EventKind::Modify(_) => FileChange::Updated, + notify::EventKind::Remove(_) => FileChange::Removed, + _ => return, + }; + for path in event.paths { + if let Some(ext) = path.extension().and_then(|e| e.to_str()) + && matches!(ext, "js" | "ts" | "jsx" | "tsx" | "mjs" | "cjs") + { + let _ = changed_tx.send((path, change)); + } + } + }) + .map_err(|e| JsErrorBox::generic(e.to_string()))?; + + watcher + .watch(&watch_dir, RecursiveMode::Recursive) + .map_err(|e| JsErrorBox::generic(e.to_string()))?; + + #[allow( + clippy::disallowed_methods, + reason = "denort has no canonicalize helper; falls back to the original path" + )] + let watch_dir_canonical = watch_dir + .canonicalize() + .unwrap_or_else(|_| watch_dir.clone()); + + Ok(Self { + session, + state, + changed_rx, + exception_rx, + watch_dir: watch_dir_canonical, + vfs_root, + _watcher: watcher, + on_reload: None, + desktop_event_tx: None, + }) + } + + /// Set a callback to be invoked after each successful hot-replace. + pub fn set_on_reload(&mut self, cb: HmrReloadCallback) { + self.on_reload = Some(cb); + } + + pub fn start(&mut self) { + self + .session + .post_message::<()>(next_id(), "Debugger.enable", None); + self + .session + .post_message::<()>(next_id(), "Runtime.enable", None); + } + + pub async fn run(&mut self) -> Result<(), deno_core::error::CoreError> { + loop { + tokio::select! { + biased; + + maybe_error = self.exception_rx.recv() => { + if let Some(err) = maybe_error { + log::error!("HMR exception: {}", err); + if let Some(tx) = &self.desktop_event_tx { + let _ = tx.try_send(crate::desktop::DesktopEvent::RuntimeError { + message: err.to_string(), + stack: None, + }); + } + } + } + + maybe_path = self.changed_rx.recv() => { + let Some(first) = maybe_path else { + break Ok(()); + }; + // Coalesce a burst of events (e.g. save-then-format) into a single + // pass, with the latest kind per path winning. Ends as soon as + // the channel quiets down for HMR_DEBOUNCE. + let mut pending: HashMap = HashMap::new(); + pending.insert(first.0, first.1); + loop { + match tokio::time::timeout( + HMR_DEBOUNCE, + self.changed_rx.recv(), + ) + .await + { + Ok(Some((path, change))) => { + pending.insert(path, change); + } + Ok(None) => break, + Err(_) => break, + } + } + + let mut needs_reload = false; + let mut handled: HashSet = HashSet::new(); + for (path, change) in pending { + match self.handle_change(&path, change).await { + ChangeOutcome::Replaced(url) => { + handled.insert(url); + } + ChangeOutcome::NeedsReload(reason) => { + log::info!("HMR: {} — falling back to page reload", reason); + needs_reload = true; + } + ChangeOutcome::Skipped => {} + } + } + + for url in &handled { + self.dispatch_hmr_event(url); + log::info!("HMR: replaced {}", url); + } + + if (needs_reload || !handled.is_empty()) + && let Some(on_reload) = &self.on_reload + { + on_reload(); + } + } + } + } + } + + /// Process a single coalesced change. Returns whether the change was + /// hot-replaced, requires a page reload, or was a no-op. + async fn handle_change( + &mut self, + path: &Path, + change: FileChange, + ) -> ChangeOutcome { + #[allow( + clippy::disallowed_methods, + reason = "denort has no canonicalize helper; non-canonicalizable paths are handled below" + )] + let canonical = match (change, path.canonicalize()) { + (FileChange::Updated, Ok(p)) => p, + (FileChange::Updated, Err(_)) => return ChangeOutcome::Skipped, + // Removed paths can't be canonicalized; reconstruct best-effort. + (FileChange::Removed, _) => path.to_path_buf(), + }; + + let Ok(relative) = canonical.strip_prefix(&self.watch_dir) else { + return ChangeOutcome::Skipped; + }; + let vfs_path = self.vfs_root.join(relative); + let Ok(module_url) = Url::from_file_path(&vfs_path) else { + return ChangeOutcome::Skipped; + }; + + log::debug!( + "HMR: {:?} {} -> VFS {}", + change, + canonical.display(), + module_url + ); + + let script_id = { + let state = self.state.0.lock(); + let id = state.script_ids.get(module_url.as_str()).cloned(); + if id.is_none() { + let known: Vec = state.script_ids.keys().cloned().collect(); + drop(state); + log::debug!( + "HMR: no script ID for {}, known scripts: {:?}", + module_url, + known, + ); + } + id + }; + + if change == FileChange::Removed { + // Either the file was deleted/renamed or this was a tracked module + // that V8 had loaded. Either way, setScriptSource isn't applicable — + // ask the host to reload so it picks up the new state. + return if script_id.is_some() { + ChangeOutcome::NeedsReload(format!("{} removed", module_url)) + } else { + ChangeOutcome::Skipped + }; + } + + let Some(script_id) = script_id else { + return ChangeOutcome::Skipped; + }; + + let source_code = match tokio::fs::read_to_string(&canonical).await { + Ok(s) => s, + Err(e) => { + log::warn!("HMR: failed to read {}: {}", canonical.display(), e); + return ChangeOutcome::Skipped; + } + }; + + let source_code = match transpile_for_hmr(&module_url, source_code) { + Ok(s) => s, + Err(e) => { + log::warn!("HMR: transpile error for {}: {}", module_url, e); + return ChangeOutcome::Skipped; + } + }; + + let mut tries = 1; + loop { + let msg_id = self.set_script_source(&script_id, &source_code); + let value = match self.wait_for_response(msg_id).await { + Some(v) => v, + None => { + log::warn!( + "HMR: inspector dropped response for {}; aborting reload", + module_url + ); + return ChangeOutcome::Skipped; + } + }; + let result: cdp::SetScriptSourceResponse = + match serde_json::from_value(value) { + Ok(r) => r, + Err(e) => { + log::warn!("HMR: bad CDP response: {}", e); + return ChangeOutcome::Skipped; + } + }; + + if matches!(result.status, cdp::Status::Ok) { + return ChangeOutcome::Replaced(module_url.into()); + } + + log::error!("HMR: failed to reload {}: {}", module_url, explain(&result)); + + // V8 can't replace modules whose top-level surface (imports, exported + // bindings, top-level let/const) changed. The classic run-mode HMR + // restarts the worker; here we tell the host to reload the page so the + // user's edit isn't silently dropped. + if matches!(result.status, cdp::Status::BlockedByTopLevelEsModuleChange) { + return ChangeOutcome::NeedsReload(format!( + "{} requires a top-level module reload", + module_url + )); + } + + if should_retry(&result.status) && tries <= 2 { + tries += 1; + tokio::time::sleep(Duration::from_millis(100)).await; + continue; + } + return ChangeOutcome::Skipped; + } + } + + async fn wait_for_response(&self, msg_id: i32) -> Option { + if let Some(message_state) = self.state.0.lock().messages.remove(&msg_id) { + let InspectorMessageState::Ready(mut value) = message_state else { + unreachable!(); + }; + return Some(value["result"].take()); + } + + let (tx, rx) = oneshot::channel(); + self + .state + .0 + .lock() + .messages + .insert(msg_id, InspectorMessageState::WaitingFor(tx)); + // The inspector session may be torn down while we're waiting. Treat + // that as a no-reload rather than a panic. + match rx.await { + Ok(mut value) => Some(value["result"].take()), + Err(_) => { + // Drop our pending entry so the state map doesn't grow. + self.state.0.lock().messages.remove(&msg_id); + None + } + } + } + + fn set_script_source(&mut self, script_id: &str, source: &str) -> i32 { + let msg_id = next_id(); + self.session.post_message( + msg_id, + "Debugger.setScriptSource", + Some(json!({ + "scriptId": script_id, + "scriptSource": source, + "allowTopFrameEditing": true, + })), + ); + msg_id + } + + fn dispatch_hmr_event(&mut self, module_url: &str) { + // Encode via serde so embedded quotes/backslashes in `module_url` don't + // break out of the string literal. + let detail = json!({ "path": module_url }).to_string(); + let expr = format!( + "dispatchEvent(new CustomEvent(\"hmr\", {{ detail: {} }}));", + detail + ); + // Intentionally no `contextId`: the inspector session is bound to a + // single isolate, and pinning to context 1 misroutes the event in any + // setup with multiple V8 contexts (workers, navigation, etc.). + self.session.post_message( + next_id(), + "Runtime.evaluate", + Some(json!({ "expression": expr })), + ); + } +} + +/// Set up HMR for the desktop runtime. Returns a runner that should be +/// polled concurrently with the event loop. +/// +/// `watch_dir` is the original source directory on disk. +/// `vfs_root` is the VFS root path that V8 scripts are registered under. +pub fn setup_desktop_hmr( + worker: &mut deno_lib::worker::LibMainWorker, + watch_dir: PathBuf, + vfs_root: PathBuf, +) -> Result { + let (exception_tx, exception_rx) = mpsc::unbounded_channel(); + let state = HmrState::new(exception_tx); + let state_clone = state.clone(); + let cb = Box::new(move |msg| state_clone.callback(msg)); + let session = worker.create_inspector_session(cb); + + let mut runner = + DesktopHmrRunner::new(session, state, watch_dir, vfs_root, exception_rx)?; + + // Extract the desktop event sender from OpState if available, so HMR + // errors can be dispatched as DesktopEvent::RuntimeError. + let desktop_event_tx = worker + .js_runtime() + .op_state() + .borrow() + .try_borrow::() + .map(|s| s.0.clone()); + runner.desktop_event_tx = desktop_event_tx; + + runner.start(); + Ok(runner) +} diff --git a/cli/rt/lib.rs b/cli/rt/lib.rs index dc7874b7b50f16..3810b481d15808 100644 --- a/cli/rt/lib.rs +++ b/cli/rt/lib.rs @@ -16,13 +16,15 @@ use indexmap::IndexMap; use self::binary::extract_standalone; use self::file_system::DenoRtSys; -mod binary; +pub mod binary; mod code_cache; -mod file_system; +pub mod desktop; +pub mod file_system; +pub mod hmr; mod node; -mod run; +pub mod run; -pub(crate) fn unstable_exit_cb(feature: &str, api_name: &str) { +pub fn unstable_exit_cb(feature: &str, api_name: &str) { log::error!( "Unstable API '{api_name}'. The `--unstable-{}` flag must be provided.", feature @@ -53,7 +55,7 @@ fn unwrap_or_exit(result: Result) -> T { } } -fn load_env_vars(env_vars: &IndexMap) { +pub fn load_env_vars(env_vars: &IndexMap) { env_vars.iter().for_each(|env_var| { if env::var(env_var.0).is_err() { // SAFETY: called during single-threaded startup before tokio runtime @@ -112,7 +114,7 @@ pub fn main() { )); } -fn init_logging( +pub fn init_logging( maybe_level: Option, otel_config: Option, ) { diff --git a/cli/rt/run.rs b/cli/rt/run.rs index 532bcd99bd722d..847a2cf3edcc98 100644 --- a/cli/rt/run.rs +++ b/cli/rt/run.rs @@ -577,6 +577,65 @@ impl EmbeddedModuleLoader { } } Ok(None) => { + // Fall back to reading from disk (used by dev entrypoint in HMR mode). + if original_specifier.scheme() == "file" + && let Ok(path) = original_specifier.to_file_path() + && let Ok(source) = std::fs::read_to_string(&path) + { + let media_type = MediaType::from_specifier(original_specifier); + let (module_type, should_transpile) = match media_type { + MediaType::JavaScript | MediaType::Mjs | MediaType::Cjs => { + (ModuleType::JavaScript, false) + } + MediaType::TypeScript + | MediaType::Mts + | MediaType::Cts + | MediaType::Jsx + | MediaType::Tsx => (ModuleType::JavaScript, true), + MediaType::Json => (ModuleType::Json, false), + _ => (ModuleType::JavaScript, false), + }; + let code = if should_transpile { + match deno_ast::parse_module(deno_ast::ParseParams { + specifier: original_specifier.clone(), + text: source.into(), + media_type, + capture_tokens: false, + scope_analysis: false, + maybe_syntax: None, + }) { + Ok(parsed) => match parsed.transpile( + &deno_ast::TranspileOptions::default(), + &deno_ast::TranspileModuleOptions::default(), + &deno_ast::EmitOptions::default(), + ) { + Ok(transpiled) => { + ModuleSourceCode::String(transpiled.into_source().text.into()) + } + Err(e) => { + return deno_core::ModuleLoadResponse::Sync(Err( + JsErrorBox::type_error(format!("Transpile error: {e}")), + )); + } + }, + Err(e) => { + return deno_core::ModuleLoadResponse::Sync(Err( + JsErrorBox::type_error(format!("Parse error: {e}")), + )); + } + } + } else { + ModuleSourceCode::String(source.into()) + }; + return deno_core::ModuleLoadResponse::Sync(Ok( + deno_core::ModuleSource::new( + module_type, + code, + original_specifier, + None, + ), + )); + } deno_core::ModuleLoadResponse::Sync(Err(JsErrorBox::type_error( format!("Module not found: {}", original_specifier), ))) @@ -1081,11 +1140,65 @@ impl RootCertStoreProvider for StandaloneRootCertStoreProvider { } } +/// Callback to initialize additional `OpState` during worker creation. +pub type OpStateInitFn = Box; + +/// Options to override default standalone runtime behavior. +/// Used by the desktop runtime to enable auto_serve and set the port. +#[derive(Default)] +pub struct RunOptions { + pub auto_serve: bool, + pub serve_port: Option, + pub serve_host: Option, + /// Enable HMR file watching from this directory. + pub hmr_watch_dir: Option, + /// Callback invoked after each successful HMR replacement. + pub hmr_on_reload: Option, + /// Callback to initialize additional OpState (e.g. desktop APIs). + /// Called during worker creation to inject state without adding extensions. + pub op_state_init: Option, + /// Override the main module to execute instead of the embedded entrypoint. + /// Used by desktop runtime for forked worker processes (child_process.fork) + /// where the child should run a specific script, not the embedded entrypoint. + pub override_main_module: Option, + /// Version and rollback state for desktop auto-update JS initialization. + pub auto_update_version: Option, + pub auto_update_rolled_back: bool, + /// Error reporting URL from deno.json `desktop.errorReporting.url`. + pub error_reporting_url: Option, + /// Auto-update release base URL from deno.json `desktop.release.baseUrl`. + /// Used as the default `url` for `Deno.autoUpdate` when none is passed. + pub release_base_url: Option, + /// Stop on the first line of user code. Mirrors `--inspect-brk`. + pub inspect_brk: bool, + /// Wait for a DevTools session to attach before running user code. + /// Mirrors `--inspect-wait`. + pub inspect_wait: bool, + /// Enables inspector-related setup in the worker (registers the runtime + /// with the global inspector server). Mirrors `--inspect`/`-brk`/`-wait`. + pub is_inspecting: bool, +} + +/// Read NODE_CHANNEL_FD from the environment to initialize IPC for +/// forked child processes (e.g. child_process.fork()). pub async fn run( fs: Arc, sys: DenoRtSys, data: StandaloneData, ) -> Result { + run_with_options(fs, sys, data, RunOptions::default()).await +} + +pub async fn run_with_options( + fs: Arc, + sys: DenoRtSys, + data: StandaloneData, + options: RunOptions, +) -> Result { + let hmr_watch_dir = options.hmr_watch_dir; + let hmr_on_reload = options.hmr_on_reload; + let op_state_init = options.op_state_init; + let override_main_module = options.override_main_module; let StandaloneData { metadata, modules, @@ -1103,6 +1216,10 @@ pub async fn run( // use a dummy npm registry url let npm_registry_url = Url::parse("https://localhost/").unwrap(); let root_dir_url = Arc::new(Url::from_directory_path(&root_path).unwrap()); + let workspace_root_path = + hmr_watch_dir.clone().unwrap_or_else(|| root_path.clone()); + let workspace_root_dir_url = + Arc::new(Url::from_directory_path(&workspace_root_path).unwrap()); let entrypoint = root_dir_url.join(&metadata.entrypoint_key).unwrap(); // When this process was spawned by node:child_process.fork() from a compiled // binary, the parent asks us to run a specific embedded module instead of the @@ -1117,20 +1234,24 @@ pub async fn run( // SAFETY: single-threaded during startup, before the runtime is created. unsafe { std::env::remove_var(INTERNAL_CHILD_ENTRYPOINT_ENV_VAR) }; } - let main_module = match child_entrypoint { - // Only honor the override for a genuine fork() child, which always wires up - // an IPC channel (NODE_CHANNEL_FD). This is defense in depth against an - // accidental collision with a stray DENO_INTERNAL_CHILD_ENTRYPOINT, not a - // security boundary: an attacker who can set one env var can set both, and - // resolve_child_entrypoint's cwd-relative fallback will then run an on-disk - // module with the binary's baked-in permissions. That on-disk fallback is - // intentional (it matches fork() semantics outside a compiled binary), so a - // hostile environment is out of scope here. NODE_CHANNEL_FD is still present - // because node_ipc_init() consumes it later. - Some(module_path) if std::env::var("NODE_CHANNEL_FD").is_ok() => { - resolve_child_entrypoint(&module_path, &entrypoint, &vfs, &sys) + let main_module = if let Some(url) = override_main_module { + url + } else { + match child_entrypoint { + // Only honor the override for a genuine fork() child, which always wires + // up an IPC channel (NODE_CHANNEL_FD). This is defense in depth against an + // accidental collision with a stray DENO_INTERNAL_CHILD_ENTRYPOINT, not a + // security boundary: an attacker who can set one env var can set both, and + // resolve_child_entrypoint's cwd-relative fallback will then run an + // on-disk module with the binary's baked-in permissions. That on-disk + // fallback is intentional (it matches fork() semantics outside a compiled + // binary), so a hostile environment is out of scope here. NODE_CHANNEL_FD + // is still present because node_ipc_init() consumes it later. + Some(module_path) if std::env::var("NODE_CHANNEL_FD").is_ok() => { + resolve_child_entrypoint(&module_path, &entrypoint, &vfs, &sys) + } + _ => entrypoint, } - _ => entrypoint, }; let npm_global_cache_dir = root_path.join(".deno_compile_node_modules"); let pkg_json_resolver = Arc::new(PackageJsonResolver::new( @@ -1174,7 +1295,7 @@ pub async fn run( )); let snapshot = npm_snapshot.unwrap(); let maybe_node_modules_path = node_modules_dir - .map(|node_modules_dir| root_path.join(node_modules_dir)); + .map(|node_modules_dir| workspace_root_path.join(node_modules_dir)); let in_npm_pkg_checker = DenoInNpmPackageChecker::new(CreateInNpmPkgCheckerOptions::Managed( ManagedInNpmPkgCheckerCreateOptions { @@ -1201,7 +1322,7 @@ pub async fn run( root_node_modules_dir, }) => { let root_node_modules_dir = - root_node_modules_dir.map(|p| vfs.root().join(p)); + root_node_modules_dir.map(|p| workspace_root_path.join(p)); let in_npm_pkg_checker = DenoInNpmPackageChecker::new(CreateInNpmPkgCheckerOptions::Byonm); let npm_resolver = NpmResolver::::new::( @@ -1209,7 +1330,7 @@ pub async fn run( sys: node_resolution_sys.clone(), pkg_json_resolver: pkg_json_resolver.clone(), root_node_modules_dir, - search_stop_dir: Some(root_path.clone()), + search_stop_dir: Some(workspace_root_path.clone()), }), ); (in_npm_pkg_checker, npm_resolver) @@ -1296,7 +1417,7 @@ pub async fn run( let import_map = match metadata.workspace_resolver.import_map { Some(import_map) => Some( import_map::parse_from_json_with_options( - root_dir_url.join(&import_map.specifier).unwrap(), + workspace_root_dir_url.join(&import_map.specifier).unwrap(), &import_map.json, import_map::ImportMapOptions { address_hook: None, @@ -1312,7 +1433,7 @@ pub async fn run( .package_jsons .into_iter() .map(|(relative_path, json)| { - let path = root_dir_url + let path = workspace_root_dir_url .join(&relative_path) .unwrap() .to_file_path() @@ -1323,7 +1444,7 @@ pub async fn run( }) .collect::, AnyError>>()?; WorkspaceResolver::new_raw( - root_dir_url.clone(), + workspace_root_dir_url.clone(), import_map, metadata .workspace_resolver @@ -1384,20 +1505,23 @@ pub async fn run( sys: sys.clone(), }; + // Desktop runtimes always need a baseline set of permissions to function: + // the renderer is served over loopback HTTP, so net access to 127.0.0.1 + // is mandatory; the desktop init JS reads `DENO_DESKTOP_*` env vars to + // discover the inspector mux, HMR watch dir, etc. Without these, the + // very first `Deno.serve(...)` or `Deno.env.get(...)` throws NotCapable + // and aborts script execution before the event-loop IIFE registers — + // which manifests as a blank window where nothing works (no input, + // no auto-tests, no bindings). Granting them up front avoids forcing + // every `deno desktop` user to remember `-A`. + let has_desktop = op_state_init.is_some(); let permissions = { let mut permissions = metadata.permissions; // grant read access to the vfs - match &mut permissions.allow_read { - Some(vec) if vec.is_empty() => { - // do nothing, already granted - } - Some(vec) => { - vec.push(root_path.to_string_lossy().into_owned()); - } - None => { - permissions.allow_read = - Some(vec![root_path.to_string_lossy().into_owned()]); - } + grant_vfs_read_access(&mut permissions, &root_path); + + if has_desktop { + apply_desktop_permission_defaults(&mut permissions); } let desc_parser = @@ -1421,12 +1545,12 @@ pub async fn run( log_level: WorkerLogLevel::Info, enable_testing_features: false, has_node_modules_dir, - inspect_brk: false, - inspect_wait: false, + inspect_brk: options.inspect_brk, + inspect_wait: options.inspect_wait, trace_ops: None, - is_inspecting: false, + is_inspecting: options.is_inspecting, is_standalone: true, - auto_serve: false, + auto_serve: options.auto_serve, skip_op_registration: true, location: metadata.location, argv0: NpmPackageReqReference::from_specifier(&main_module) @@ -1441,14 +1565,15 @@ pub async fn run( unsafely_ignore_certificate_errors: metadata .unsafely_ignore_certificate_errors, node_ipc_init: deno_lib::args::node_ipc_init(&sys)?, - serve_port: None, - serve_host: None, + serve_port: options.serve_port, + serve_host: options.serve_host, otel_config: metadata.otel_config, no_legacy_abort: false, startup_snapshot: deno_snapshots::CLI_SNAPSHOT, residual_lazy_js_sources: deno_snapshots::RESIDUAL_LAZY_JS, residual_lazy_esm_sources: deno_snapshots::RESIDUAL_LAZY_ESM, enable_raw_imports: metadata.unstable_config.raw_imports, + close_on_idle: !options.auto_serve, maybe_initial_cwd: None, }; let worker_factory = LibMainWorkerFactory::new( @@ -1501,16 +1626,97 @@ pub async fn run( .map(|key| root_dir_url.join(key).unwrap()) .collect::>(); - let mut worker = worker_factory.create_main_worker( + let mut worker = worker_factory.create_custom_worker( WorkerExecutionMode::Run, - permissions, main_module, preload_modules, require_modules, + permissions, + vec![], + Default::default(), + None, )?; - let exit_code = worker.run().await?; - Ok(exit_code) + // Inject desktop API state into OpState after worker creation. + if let Some(init_fn) = op_state_init { + let op_state = worker.js_runtime().op_state(); + init_fn(&mut op_state.borrow_mut()); + } + + // Initialize desktop APIs (Deno.desktop.*). + if has_desktop { + worker + .js_runtime() + .execute_script("ext:deno_desktop/init", crate::desktop::DESKTOP_JS)?; + + // Initialize auto-update JS (DesktopUpdater cppgc object is already + // registered via the desktop extension's objects). + let js = crate::desktop::desktop_auto_update_js( + options.auto_update_version.as_deref(), + options.auto_update_rolled_back, + options.release_base_url.as_deref(), + ); + worker + .js_runtime() + .execute_script("ext:deno_desktop/auto_update", js)?; + + let js = crate::desktop::desktop_error_reporting_js( + options.error_reporting_url.as_deref(), + options.auto_update_version.as_deref(), + ); + worker + .js_runtime() + .execute_script("ext:deno_desktop/error_reporting", js)?; + } + + if let Some(watch_dir) = hmr_watch_dir { + let mut hmr_runner = + crate::hmr::setup_desktop_hmr(&mut worker, watch_dir, root_path.clone())?; + if let Some(cb) = hmr_on_reload { + hmr_runner.set_on_reload(cb); + } + + // Run one event loop tick so the inspector processes the + // Debugger.enable / Runtime.enable messages before we load modules. + // This ensures scriptParsed notifications are emitted during module load. + worker.run_event_loop(false).await?; + + // Run preload modules first + worker.execute_preload_modules().await?; + worker.execute_main_module().await?; + worker.dispatch_load_event()?; + + loop { + let hmr_fut = hmr_runner.run(); + let event_loop_fut = worker.run_event_loop(false); + + tokio::select! { + hmr_result = hmr_fut => { + if let Err(e) = hmr_result { + log::error!("HMR error: {:?}", e); + } + } + event_loop_result = event_loop_fut => { + event_loop_result?; + let web_continue = worker.dispatch_beforeunload_event()?; + if !web_continue { + let node_continue = + worker.dispatch_process_beforeexit_event()?; + if !node_continue { + break; + } + } + } + } + } + + worker.dispatch_unload_event()?; + worker.dispatch_process_exit_event()?; + Ok(worker.exit_code()) + } else { + let exit_code = worker.run().await?; + Ok(exit_code) + } } // Internal env var carrying the module a fork()ed child of a compiled binary @@ -1594,3 +1800,215 @@ fn create_default_npmrc() -> Arc { min_release_age_days: None, }) } + +/// Grant read access to the embedded VFS root so the compiled binary +/// can read the files it shipped with. Preserves the `Some(vec![])` +/// allow-all sentinel and avoids duplicating an already-present entry. +pub(crate) fn grant_vfs_read_access( + permissions: &mut deno_runtime::deno_permissions::PermissionsOptions, + root_path: &std::path::Path, +) { + let entry = root_path.to_string_lossy().into_owned(); + match &mut permissions.allow_read { + Some(vec) if vec.is_empty() => { + // already wide-open (--allow-read with no args / -A); nothing to + // extend. + } + Some(vec) => { + if !vec.contains(&entry) { + vec.push(entry); + } + } + None => { + permissions.allow_read = Some(vec![entry]); + } + } +} + +/// Loopback hosts a desktop runtime must be able to reach. The compiled +/// app's renderer talks to its `Deno.serve()` over loopback HTTP, so +/// net access here is structural, not optional. +const DESKTOP_LOOPBACK_HOSTS: &[&str] = &["127.0.0.1", "localhost", "[::1]"]; + +/// Env vars the DESKTOP_JS init script reads. Granting only these +/// (instead of blanket `allow-env`) keeps the user's shell environment +/// off-limits to arbitrary `Deno.env.get` calls in their app code while +/// still letting the framework boot. +const DESKTOP_ENV_KEYS: &[&str] = &[ + "DENO_DESKTOP_MUX_WS", + "DENO_DESKTOP_HMR", + "DENO_DESKTOP_INSPECT_INTERNAL_PORT", + "DENO_DESKTOP_INSPECT_BRK", + "DENO_DESKTOP_INSPECT_WAIT", + "LAUFEY_RUNTIME_PATH", + "LAUFEY_REMOTE_DEBUGGING_PORT", +]; + +/// Grant the baseline net (loopback) and env (DESKTOP_JS read keys) +/// access a desktop runtime needs to boot. Idempotent: re-applying +/// preserves any wider grants the user already had. +/// +/// `Some(vec)` with `vec.is_empty()` represents allow-all (e.g. `-A`) +/// and is left untouched. `Some(vec)` with entries gets the missing +/// loopback/key entries appended. `None` is replaced with the baseline +/// vec. +pub(crate) fn apply_desktop_permission_defaults( + permissions: &mut deno_runtime::deno_permissions::PermissionsOptions, +) { + fn extend(slot: &mut Option>, defaults: &[&str]) { + match slot { + Some(vec) if vec.is_empty() => { + // already allow-all — wider than our baseline; don't narrow. + } + Some(vec) => { + for entry in defaults { + let s = (*entry).to_string(); + if !vec.contains(&s) { + vec.push(s); + } + } + } + None => { + *slot = Some(defaults.iter().map(|s| (*s).to_string()).collect()); + } + } + } + + extend(&mut permissions.allow_net, DESKTOP_LOOPBACK_HOSTS); + extend(&mut permissions.allow_env, DESKTOP_ENV_KEYS); +} + +#[cfg(test)] +mod tests { + use deno_runtime::deno_permissions::PermissionsOptions; + + use super::DESKTOP_ENV_KEYS; + use super::DESKTOP_LOOPBACK_HOSTS; + use super::apply_desktop_permission_defaults; + use super::grant_vfs_read_access; + + fn strs(v: &[&str]) -> Vec { + v.iter().map(|s| (*s).to_string()).collect() + } + + #[test] + fn defaults_populate_empty_options() { + let mut p = PermissionsOptions::default(); + apply_desktop_permission_defaults(&mut p); + assert_eq!(p.allow_net, Some(strs(DESKTOP_LOOPBACK_HOSTS))); + assert_eq!(p.allow_env, Some(strs(DESKTOP_ENV_KEYS))); + // Other permission categories must remain untouched. + assert_eq!(p.allow_read, None); + assert_eq!(p.allow_write, None); + } + + #[test] + fn defaults_extend_existing_allowlist_without_duplicating() { + let mut p = PermissionsOptions { + allow_net: Some(vec![ + "example.com".to_string(), + "127.0.0.1".to_string(), // already present — must not duplicate + ]), + allow_env: Some(vec!["MY_VAR".to_string()]), + ..Default::default() + }; + apply_desktop_permission_defaults(&mut p); + + let net = p.allow_net.unwrap(); + // Original entries preserved. + assert!(net.contains(&"example.com".to_string())); + // 127.0.0.1 still appears exactly once. + assert_eq!(net.iter().filter(|s| *s == "127.0.0.1").count(), 1); + // Missing loopback entries appended. + assert!(net.contains(&"localhost".to_string())); + assert!(net.contains(&"[::1]".to_string())); + + let env = p.allow_env.unwrap(); + assert!(env.contains(&"MY_VAR".to_string())); + for key in DESKTOP_ENV_KEYS { + assert!(env.contains(&key.to_string()), "missing {key}"); + } + } + + #[test] + fn defaults_preserve_allow_all_sentinel() { + // `Some(vec![])` is the allow-all sentinel (-A / --allow-net with + // no args). Narrowing it down to a specific allowlist would silently + // break user code that depends on broader access — leave it alone. + let mut p = PermissionsOptions { + allow_net: Some(vec![]), + allow_env: Some(vec![]), + ..Default::default() + }; + apply_desktop_permission_defaults(&mut p); + assert_eq!(p.allow_net, Some(vec![])); + assert_eq!(p.allow_env, Some(vec![])); + } + + #[test] + fn defaults_are_idempotent() { + let mut p = PermissionsOptions::default(); + apply_desktop_permission_defaults(&mut p); + let after_once = p.clone(); + apply_desktop_permission_defaults(&mut p); + assert_eq!(p, after_once); + } + + // --- grant_vfs_read_access --- + + #[test] + fn vfs_grant_populates_empty_options() { + let mut p = PermissionsOptions::default(); + grant_vfs_read_access(&mut p, std::path::Path::new("/data/vfs")); + assert_eq!(p.allow_read, Some(vec!["/data/vfs".to_string()])); + // Net / env / write must remain None. + assert_eq!(p.allow_net, None); + assert_eq!(p.allow_env, None); + assert_eq!(p.allow_write, None); + } + + #[test] + fn vfs_grant_preserves_allow_all_sentinel() { + // `Some(vec![])` means --allow-read with no args (allow-all). The + // compiled binary should keep its broader privilege; narrowing it + // to a single path here would silently break user code that reads + // outside the VFS. + let mut p = PermissionsOptions { + allow_read: Some(vec![]), + ..Default::default() + }; + grant_vfs_read_access(&mut p, std::path::Path::new("/data/vfs")); + assert_eq!(p.allow_read, Some(vec![])); + } + + #[test] + fn vfs_grant_extends_existing_allowlist() { + let mut p = PermissionsOptions { + allow_read: Some(vec!["/etc".to_string()]), + ..Default::default() + }; + grant_vfs_read_access(&mut p, std::path::Path::new("/data/vfs")); + let v = p.allow_read.unwrap(); + assert!( + v.contains(&"/etc".to_string()), + "original entries preserved" + ); + assert!(v.contains(&"/data/vfs".to_string())); + } + + #[test] + fn vfs_grant_is_idempotent() { + let mut p = PermissionsOptions::default(); + grant_vfs_read_access(&mut p, std::path::Path::new("/data/vfs")); + grant_vfs_read_access(&mut p, std::path::Path::new("/data/vfs")); + // Same path twice must not duplicate. + assert_eq!(p.allow_read, Some(vec!["/data/vfs".to_string()])); + } + + #[test] + fn vfs_grant_handles_path_with_unicode() { + let mut p = PermissionsOptions::default(); + grant_vfs_read_access(&mut p, std::path::Path::new("/Users/café/My App")); + assert_eq!(p.allow_read, Some(vec!["/Users/café/My App".to_string()])); + } +} diff --git a/cli/rt_desktop/Cargo.toml b/cli/rt_desktop/Cargo.toml new file mode 100644 index 00000000000000..be24b5eb14d5f5 --- /dev/null +++ b/cli/rt_desktop/Cargo.toml @@ -0,0 +1,40 @@ +# Copyright 2018-2026 the Deno authors. MIT license. + +[package] +name = "denort_desktop" +version = "2.7.4" +authors.workspace = true +edition.workspace = true +license.workspace = true +publish = false +repository.workspace = true +description = "Provides libdenort shared library for laufey desktop apps" + +[lib] +name = "denort" +path = "lib.rs" +crate-type = ["cdylib"] + +[build-dependencies] +deno_runtime.workspace = true +deno_core.workspace = true + +[dependencies] +deno_core.workspace = true +deno_error.workspace = true +deno_lib.workspace = true +deno_runtime.workspace = true +deno_snapshots.workspace = true +deno_terminal.workspace = true +denort = { path = "../rt" } +laufey = "0.3.2" +libsui.workspace = true + +log = { workspace = true, features = ["serde"] } +raw-window-handle.workspace = true +rustls.workspace = true +serde_json.workspace = true +tokio.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/cli/rt_desktop/build.rs b/cli/rt_desktop/build.rs new file mode 100644 index 00000000000000..bf68995876bb40 --- /dev/null +++ b/cli/rt_desktop/build.rs @@ -0,0 +1,52 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +#![allow(clippy::disallowed_methods, reason = "build code")] + +fn main() { + // Skip building from docs.rs. + if std::env::var_os("DOCS_RS").is_some() { + return; + } + + // For cdylib targets, we must explicitly export NAPI symbols so that + // native addons (e.g. next-swc) can resolve them via dlsym. + // The print_linker_flags() functions use cargo:rustc-link-arg-bin + // which only applies to binary targets, so we emit cdylib-specific + // linker args here instead. + print_cdylib_napi_linker_flags(); +} + +fn print_cdylib_napi_linker_flags() { + let symbols_file_name = match std::env::consts::OS { + "android" | "freebsd" | "openbsd" => { + "generated_symbol_exports_list_linux.def".to_string() + } + os => format!("generated_symbol_exports_list_{}.def", os), + }; + + // Path relative to this build script's Cargo.toml + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let symbols_path = std::path::Path::new(&manifest_dir) + .join("../../ext/napi") + .join(&symbols_file_name) + .canonicalize() + .expect("Missing NAPI symbols list"); + + println!("cargo:rerun-if-changed={}", symbols_path.display()); + + #[cfg(target_os = "macos")] + println!( + "cargo:rustc-cdylib-link-arg=-Wl,-exported_symbols_list,{}", + symbols_path.display(), + ); + + #[cfg(any( + target_os = "linux", + target_os = "freebsd", + target_os = "openbsd" + ))] + println!( + "cargo:rustc-cdylib-link-arg=-Wl,--export-dynamic-symbol-list={}", + symbols_path.display(), + ); +} diff --git a/cli/rt_desktop/lib.rs b/cli/rt_desktop/lib.rs new file mode 100644 index 00000000000000..613e4ca32015bc --- /dev/null +++ b/cli/rt_desktop/lib.rs @@ -0,0 +1,2278 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +//! Desktop runtime for Deno (libdenort). +//! +//! This is a cdylib that exports the Laufey C ABI (laufey_runtime_init, +//! laufey_runtime_start, laufey_runtime_shutdown) and boots the full Deno +//! standalone runtime. A Laufey backend (CEF, WebView) loads this +//! shared library and provides the browser/window layer. +//! +//! The user's code uses `Deno.serve()` or `export default { fetch }` +//! to serve an HTTP app. The desktop runtime starts it on a local port +//! and navigates the webview to it. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::collections::HashSet; +use std::env; +// Only used by the unix-only `apply_pending_update`; gated to avoid an unused +// import on other platforms. +#[cfg(unix)] +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicU32; +use std::sync::atomic::Ordering; + +use deno_core::anyhow::Context; +use deno_core::anyhow::bail; +use deno_core::error::AnyError; +use deno_core::serde_json; +use deno_core::v8; +use deno_lib::util::net::allocate_random_port; +use deno_lib::util::result::js_error_downcast_ref; +use deno_lib::version::otel_runtime_config; +use deno_runtime::fmt_errors::format_js_error; +use deno_terminal::colors; +use denort::desktop::DesktopApi; +use denort::run::RunOptions; + +/// Compile-time check: the laufey crate we're linking against must use the +/// same C ABI version as the prebuilt backend we ship. If laufey bumps +/// `LAUFEY_API_VERSION` without our side updating, `init_api` would reject the +/// backend at startup with `-2`. Catching the mismatch at `cargo build` time +/// makes the failure mode obvious instead of "the desktop app silently won't +/// launch". +const _: () = assert!( + laufey::LAUFEY_API_VERSION == 25, + "LAUFEY_API_VERSION mismatch: update this assert and the prebuilt backend release pin in cli/tools/desktop.rs when laufey bumps its API version", +); + +/// Laufey-backed implementation of [`denort::desktop::DesktopApi`]. +struct WefDesktopApi { + event_tx: deno_runtime::ops::desktop::DesktopEventTx, + pending_responses: deno_runtime::ops::desktop::PendingBindResponses, + closed_windows: Arc>>, + /// IDs of every window currently displayed. Shared with the HMR reload + /// callback so it can refresh all windows, not just the initial one. + open_windows: Arc>>, + trays: Arc>>, + notifications: Arc>>, + /// Singleton for the unified-mux DevTools window. Without this, every + /// `openDevtools()` call would spawn another DevTools window. + devtools_window: Mutex>, +} + +impl WefDesktopApi { + /// Set up all event handlers on a newly created window, wiring events + /// into the shared event channel. + fn setup_window_events(&self, window: laufey::Window) -> laufey::Window { + let kb_tx = self.event_tx.clone(); + let mouse_click_tx = self.event_tx.clone(); + let mouse_move_tx = self.event_tx.clone(); + let wheel_tx = self.event_tx.clone(); + let cursor_tx = self.event_tx.clone(); + let focus_tx = self.event_tx.clone(); + let resize_tx = self.event_tx.clone(); + let move_tx = self.event_tx.clone(); + let close_tx = self.event_tx.clone(); + let closed_windows = self.closed_windows.clone(); + let open_windows_on_close = self.open_windows.clone(); + + window + .on_keyboard_event(move |ev| { + let _ = kb_tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::KeyboardEvent { + window_id: ev.window_id, + r#type: match ev.state { + laufey::KeyState::Pressed => "keydown".to_string(), + laufey::KeyState::Released => "keyup".to_string(), + }, + key: ev.key, + code: ev.code, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + repeat: ev.repeat, + }, + ); + }) + .on_mouse_click(move |ev| { + let _ = mouse_click_tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::MouseClick { + window_id: ev.window_id, + state: match ev.state { + laufey::MouseButtonState::Pressed => "pressed".to_string(), + laufey::MouseButtonState::Released => "released".to_string(), + }, + button: match ev.button { + laufey::MouseButton::Left => 0, + laufey::MouseButton::Middle => 1, + laufey::MouseButton::Right => 2, + laufey::MouseButton::Back => 3, + laufey::MouseButton::Forward => 4, + laufey::MouseButton::Other(n) => n, + }, + client_x: ev.x, + client_y: ev.y, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + click_count: ev.click_count, + }, + ); + }) + .on_mouse_move(move |ev| { + let _ = mouse_move_tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::MouseMove { + window_id: ev.window_id, + client_x: ev.x, + client_y: ev.y, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + }, + ); + }) + .on_wheel(move |ev| { + let _ = + wheel_tx.try_send(deno_runtime::ops::desktop::DesktopEvent::Wheel { + window_id: ev.window_id, + delta_x: ev.delta_x, + delta_y: ev.delta_y, + delta_mode: match ev.delta_mode { + laufey::WheelDeltaMode::Pixel => 0, + laufey::WheelDeltaMode::Line => 1, + laufey::WheelDeltaMode::Page => 2, + }, + client_x: ev.x, + client_y: ev.y, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + }); + }) + .on_cursor_enter_leave(move |ev| { + let _ = cursor_tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::CursorEnterLeave { + window_id: ev.window_id, + entered: ev.entered, + client_x: ev.x, + client_y: ev.y, + shift: ev.modifiers.shift, + control: ev.modifiers.control, + alt: ev.modifiers.alt, + meta: ev.modifiers.meta, + }, + ); + }) + .on_focused(move |ev| { + let _ = focus_tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::FocusChanged { + window_id: ev.window_id, + focused: ev.focused, + }, + ); + }) + .on_resize(move |ev| { + let _ = resize_tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::WindowResize { + window_id: ev.window_id, + width: ev.width, + height: ev.height, + }, + ); + }) + .on_move(move |ev| { + let _ = move_tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::WindowMove { + window_id: ev.window_id, + x: ev.x, + y: ev.y, + }, + ); + }) + .on_close_requested(move |ev| { + closed_windows.lock().unwrap().insert(ev.window_id); + open_windows_on_close.lock().unwrap().remove(&ev.window_id); + let _ = close_tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::CloseRequested { + window_id: ev.window_id, + }, + ); + }) + } +} + +impl denort::desktop::DesktopApi for WefDesktopApi { + fn create_window( + &self, + width: i32, + height: i32, + frameless: bool, + no_activate: bool, + transparent_titlebar: bool, + ) -> u32 { + let window = laufey::Window::new_with_options( + width, + height, + laufey::WindowOptions { + frameless, + no_activate, + transparent_titlebar, + }, + ); + let window = self.setup_window_events(window); + let id = window.id(); + self.open_windows.lock().unwrap().insert(id); + id + } + + fn close_window(&self, window_id: u32) { + self.closed_windows.lock().unwrap().insert(window_id); + self.open_windows.lock().unwrap().remove(&window_id); + laufey::Window::from_id(window_id).close(); + } + + fn is_closed(&self, window_id: u32) -> bool { + self.closed_windows.lock().unwrap().contains(&window_id) + } + + fn set_title(&self, window_id: u32, title: &str) { + laufey::Window::from_id(window_id).set_title(title); + } + + fn get_window_size(&self, window_id: u32) -> (i32, i32) { + laufey::Window::from_id(window_id).get_size() + } + + fn set_window_size(&self, window_id: u32, width: i32, height: i32) { + laufey::Window::from_id(window_id).set_size(width, height); + } + + fn get_window_position(&self, window_id: u32) -> (i32, i32) { + laufey::Window::from_id(window_id).get_position() + } + + fn set_window_position(&self, window_id: u32, x: i32, y: i32) { + laufey::Window::from_id(window_id).set_position(x, y); + } + + fn is_resizable(&self, window_id: u32) -> bool { + laufey::Window::from_id(window_id).get_resizable() + } + + fn set_resizable(&self, window_id: u32, resizable: bool) { + laufey::Window::from_id(window_id).set_resizable(resizable); + } + + fn is_always_on_top(&self, window_id: u32) -> bool { + laufey::Window::from_id(window_id).get_always_on_top() + } + + fn set_always_on_top(&self, window_id: u32, always_on_top: bool) { + laufey::Window::from_id(window_id).set_always_on_top(always_on_top); + } + + fn is_visible(&self, window_id: u32) -> bool { + laufey::Window::from_id(window_id).get_visible() + } + + fn show(&self, window_id: u32) { + laufey::Window::from_id(window_id).show(); + } + + fn hide(&self, window_id: u32) { + laufey::Window::from_id(window_id).hide(); + } + + fn focus(&self, window_id: u32) { + laufey::Window::from_id(window_id).focus(); + } + + fn open_devtools(&self, window_id: u32, renderer: bool, deno: bool) { + if let Ok(mux) = env::var("DENO_DESKTOP_MUX_WS") { + // Reuse an existing DevTools window when one is already open, so + // repeated `openDevtools()` calls don't pile up windows. + if let Some(id) = *self.devtools_window.lock().unwrap() + && !self.closed_windows.lock().unwrap().contains(&id) + { + laufey::Window::from_id(id).focus(); + return; + } + + let (endpoint, frontend) = match (renderer, deno) { + (true, true) => ("/unified", "inspector.html"), + (true, false) => ("/cef", "inspector.html"), + (false, true) => ("/deno", "js_app.html"), + (false, false) => unreachable!(), + }; + let url = format!("http://{mux}/devtools/{frontend}?ws={mux}{endpoint}"); + log::info!( + "[desktop] openDevtools(renderer={renderer}, deno={deno}) → {url}" + ); + let window = laufey::Window::new(1200, 800); + window.set_title("Deno Desktop DevTools"); + window.navigate(&url); + let window = self.setup_window_events(window); + let id = window.id(); + // Track for HMR reload + the singleton check above. + self.open_windows.lock().unwrap().insert(id); + *self.devtools_window.lock().unwrap() = Some(id); + return; + } + laufey::Window::from_id(window_id).open_devtools(); + } + + fn execute_js( + &self, + window_id: u32, + script: &str, + callback: Box< + dyn FnOnce( + Result< + deno_runtime::ops::desktop::DesktopValue, + deno_runtime::ops::desktop::DesktopValue, + >, + ) + Send + + 'static, + >, + ) { + laufey::Window::from_id(window_id).execute_js( + script, + Some(move |result: Result| { + callback(match result { + Ok(val) => Ok(laufey_value_to_desktop_value(val)), + Err(err) => Err(laufey_value_to_desktop_value(err)), + }); + }), + ); + } + + fn bind(&self, window_id: u32, name: &str) { + let tx = self.event_tx.clone(); + let responses = self.pending_responses.clone(); + let name_owned = name.to_string(); + laufey::Window::from_id(window_id).add_binding_async( + name, + move |js_call| { + let tx = tx.clone(); + let responses = responses.clone(); + let name = name_owned.clone(); + async move { + let args: Vec = + js_call.args.iter().map(laufey_value_to_json).collect(); + let (resp_tx, resp_rx) = tokio::sync::oneshot::channel(); + let call_id = + deno_runtime::ops::desktop::register_bind_call(&responses, resp_tx); + let event = deno_runtime::ops::desktop::DesktopEvent::BindCall { + window_id: js_call.window_id, + name, + args: serde_json::Value::Array(args), + call_id, + }; + if let Err(err) = tx.try_send(event) { + let msg = match err { + tokio::sync::mpsc::error::TrySendError::Full(_) => { + "event channel saturated".to_string() + } + tokio::sync::mpsc::error::TrySendError::Closed(_) => { + "event channel closed".to_string() + } + }; + js_call.reject(laufey::Value::String(msg)); + return; + } + match resp_rx.await { + Ok(Ok(result)) => { + js_call.resolve(json_to_laufey_value(&result)); + } + Ok(Err(error)) => { + js_call.reject(laufey::Value::String(error)); + } + Err(_) => { + js_call.reject(laufey::Value::String( + "bind response channel dropped".to_string(), + )); + } + } + } + }, + ); + } + + fn unbind(&self, window_id: u32, name: &str) { + laufey::Window::from_id(window_id).unbind(name); + } + + fn navigate(&self, window_id: u32, url: &str) { + laufey::Window::from_id(window_id).navigate(url); + } + + fn quit(&self) { + laufey::quit(); + } + + fn set_application_menu( + &self, + window_id: u32, + menu: Vec, + ) { + let menu = menu + .into_iter() + .map(desktop_menu_item_to_laufey_menu_item) + .collect::>(); + let tx = self.event_tx.clone(); + laufey::Window::from_id(window_id).set_menu(&menu, move |id: &str| { + let _ = + tx.try_send(deno_runtime::ops::desktop::DesktopEvent::AppMenuClick { + window_id, + id: id.to_string(), + }); + }); + } + + fn show_context_menu( + &self, + window_id: u32, + x: i32, + y: i32, + menu: Vec, + ) { + let menu = menu + .into_iter() + .map(desktop_menu_item_to_laufey_menu_item) + .collect::>(); + let tx = self.event_tx.clone(); + laufey::Window::from_id(window_id).show_context_menu( + x, + y, + &menu, + move |id: &str| { + let _ = tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::ContextMenuClick { + window_id, + id: id.to_string(), + }, + ); + }, + ); + } + + fn get_raw_window_handle( + &self, + window_id: u32, + ) -> Result< + ( + raw_window_handle::RawWindowHandle, + raw_window_handle::RawDisplayHandle, + ), + deno_error::JsErrorBox, + > { + let window = laufey::Window::from_id(window_id); + let handle_type = window.get_window_handle_type(); + let raw_win = window.get_window_handle(); + let raw_display = window.get_display_handle(); + + let null_window = || { + deno_error::JsErrorBox::generic("Laufey returned a null window handle") + }; + let null_display = || { + deno_error::JsErrorBox::generic("Laufey returned a null display handle") + }; + + match handle_type { + laufey::LAUFEY_WINDOW_HANDLE_APPKIT => { + use raw_window_handle::*; + let win = RawWindowHandle::AppKit(AppKitWindowHandle::new( + std::ptr::NonNull::new(raw_win).ok_or_else(null_window)?, + )); + let display = RawDisplayHandle::AppKit(AppKitDisplayHandle::new()); + Ok((win, display)) + } + laufey::LAUFEY_WINDOW_HANDLE_WIN32 => { + use raw_window_handle::*; + let mut handle = Win32WindowHandle::new( + std::num::NonZeroIsize::new(raw_win as isize) + .ok_or_else(null_window)?, + ); + handle.hinstance = std::num::NonZeroIsize::new(raw_display as isize); + let win = RawWindowHandle::Win32(handle); + let display = RawDisplayHandle::Windows(WindowsDisplayHandle::new()); + Ok((win, display)) + } + laufey::LAUFEY_WINDOW_HANDLE_X11 => { + use raw_window_handle::*; + let win = RawWindowHandle::Xlib(XlibWindowHandle::new(raw_win as _)); + let display = RawDisplayHandle::Xlib(XlibDisplayHandle::new( + std::ptr::NonNull::new(raw_display), + 0, + )); + Ok((win, display)) + } + laufey::LAUFEY_WINDOW_HANDLE_WAYLAND => { + use raw_window_handle::*; + let win = RawWindowHandle::Wayland(WaylandWindowHandle::new( + std::ptr::NonNull::new(raw_win).ok_or_else(null_window)?, + )); + let display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new( + std::ptr::NonNull::new(raw_display).ok_or_else(null_display)?, + )); + Ok((win, display)) + } + other => Err(deno_error::JsErrorBox::generic(format!( + "unknown Laufey window handle type: {other}", + ))), + } + } + + fn alert(&self, title: &str, message: &str) { + laufey::alert(title, message); + } + + fn confirm(&self, title: &str, message: &str) -> bool { + laufey::confirm(title, message) + } + + fn prompt( + &self, + title: &str, + message: &str, + default_value: &str, + ) -> Option { + laufey::prompt(title, message, default_value) + } + + fn set_dock_badge(&self, text: &str) { + laufey::set_dock_badge(if text.is_empty() { None } else { Some(text) }); + } + + fn bounce_dock(&self, critical: bool) { + laufey::bounce_dock(if critical { + laufey::DockBounceType::Critical + } else { + laufey::DockBounceType::Informational + }); + } + + fn set_dock_menu(&self, menu: Option>) { + match menu { + Some(menu) => { + let menu = menu + .into_iter() + .map(desktop_menu_item_to_laufey_menu_item) + .collect::>(); + let tx = self.event_tx.clone(); + laufey::set_dock_menu(&menu, move |id: &str| { + let _ = tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::DockMenuClick { + id: id.to_string(), + }, + ); + }); + } + None => laufey::clear_dock_menu(), + } + } + + fn set_dock_visible(&self, visible: bool) { + laufey::set_dock_visible(visible); + } + + fn create_tray(&self) -> u32 { + let tray = laufey::TrayIcon::new(); + let tray_id = tray.id(); + if tray_id == 0 { + return 0; + } + let click_tx = self.event_tx.clone(); + let tray = tray.on_click(move || { + let _ = click_tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::TrayClick { tray_id }, + ); + }); + let dblclick_tx = self.event_tx.clone(); + tray.set_double_click_handler(move || { + let _ = dblclick_tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::TrayDoubleClick { tray_id }, + ); + }); + self.trays.lock().unwrap().insert(tray_id, tray); + tray_id + } + + fn destroy_tray(&self, tray_id: u32) { + self.trays.lock().unwrap().remove(&tray_id); + } + + fn set_tray_icon(&self, tray_id: u32, png_bytes: &[u8]) { + if let Some(tray) = self.trays.lock().unwrap().get(&tray_id) { + tray.set_icon(png_bytes); + } + } + + fn set_tray_icon_dark(&self, tray_id: u32, png_bytes: Option<&[u8]>) { + if let Some(tray) = self.trays.lock().unwrap().get(&tray_id) { + tray.set_icon_dark(png_bytes.unwrap_or(&[])); + } + } + + fn set_tray_tooltip(&self, tray_id: u32, text: Option<&str>) { + if let Some(tray) = self.trays.lock().unwrap().get(&tray_id) { + tray.set_tooltip(text); + } + } + + fn set_tray_menu( + &self, + tray_id: u32, + menu: Option>, + ) { + let trays = self.trays.lock().unwrap(); + let Some(tray) = trays.get(&tray_id) else { + return; + }; + match menu { + Some(menu) => { + let menu = menu + .into_iter() + .map(desktop_menu_item_to_laufey_menu_item) + .collect::>(); + let tx = self.event_tx.clone(); + tray.set_menu(&menu, move |id: &str| { + let _ = tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::TrayMenuClick { + tray_id, + id: id.to_string(), + }, + ); + }); + } + None => tray.clear_menu(), + } + } + + fn get_tray_bounds(&self, tray_id: u32) -> Option<(i32, i32, i32, i32)> { + let trays = self.trays.lock().unwrap(); + trays.get(&tray_id)?.get_bounds() + } + + fn show_notification( + &self, + title: &str, + body: Option<&str>, + icon: Option<&[u8]>, + tag: Option<&str>, + silent: Option, + require_interaction: Option, + ) -> u32 { + let mut builder = laufey::Notification::new(title); + if let Some(body) = body { + builder = builder.body(body); + } + if let Some(icon) = icon { + builder = builder.icon(icon.to_vec()); + } + if let Some(tag) = tag { + builder = builder.tag(tag); + } + if let Some(silent) = silent { + builder = builder.silent(silent); + } + if let Some(require) = require_interaction { + builder = builder.require_interaction(require); + } + + // The laufey handler closure receives only the event; it needs the + // notification id to route the event through the desktop channel. + // We can't know the id until `on_event` returns, so we capture it + // through a shared slot populated immediately after. + let id_slot: Arc> = + Arc::new(std::sync::OnceLock::new()); + let id_for_handler = id_slot.clone(); + let tx = self.event_tx.clone(); + let notifications = self.notifications.clone(); + + let handle = builder.on_event(move |event| { + let Some(&nid) = id_for_handler.get() else { + return; + }; + use laufey::NotificationEvent; + let desktop_event = match event { + NotificationEvent::Shown => { + deno_runtime::ops::desktop::DesktopEvent::NotificationShow { + notification_id: nid, + } + } + NotificationEvent::Clicked => { + deno_runtime::ops::desktop::DesktopEvent::NotificationClick { + notification_id: nid, + } + } + NotificationEvent::Closed => { + deno_runtime::ops::desktop::DesktopEvent::NotificationClose { + notification_id: nid, + } + } + // The Web Notification API has no "action" event in window context; + // surface action button clicks as a click event for compatibility. + NotificationEvent::Action(_) => { + deno_runtime::ops::desktop::DesktopEvent::NotificationClick { + notification_id: nid, + } + } + }; + let is_terminal = matches!(event, laufey::NotificationEvent::Closed); + let _ = tx.try_send(desktop_event); + if is_terminal { + notifications.lock().unwrap().remove(&nid); + } + }); + + let id = handle.id(); + if id == 0 { + // Backend doesn't support notifications. Emit a synthetic error + // event so the user can observe the failure. + let _ = self.event_tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::NotificationError { + notification_id: 0, + }, + ); + return 0; + } + let _ = id_slot.set(id); + self.notifications.lock().unwrap().insert(id, handle); + id + } + + fn close_notification(&self, notification_id: u32) { + if let Some(handle) = + self.notifications.lock().unwrap().get(¬ification_id) + { + handle.close(); + } + } + + fn request_notification_permission( + &self, + cb: Box< + dyn FnOnce(deno_runtime::ops::desktop::PermissionState) + Send + 'static, + >, + ) { + laufey::request_permission( + laufey::PermissionKind::Notifications, + move |status| cb(map_permission_status(status)), + ); + } + + fn query_notification_permission( + &self, + cb: Box< + dyn FnOnce(deno_runtime::ops::desktop::PermissionState) + Send + 'static, + >, + ) { + laufey::query_permission( + laufey::PermissionKind::Notifications, + move |status| cb(map_permission_status(status)), + ); + } +} + +fn map_permission_status( + status: laufey::PermissionStatus, +) -> deno_runtime::ops::desktop::PermissionState { + use deno_runtime::ops::desktop::PermissionState; + match status { + laufey::PermissionStatus::Granted => PermissionState::Granted, + laufey::PermissionStatus::Denied => PermissionState::Denied, + laufey::PermissionStatus::Prompt => PermissionState::Prompt, + laufey::PermissionStatus::Unsupported => PermissionState::Unsupported, + } +} + +fn desktop_menu_item_to_laufey_menu_item( + item: denort::desktop::MenuItem, +) -> laufey::MenuItem { + match item { + denort::desktop::MenuItem::Item { + label, + id, + accelerator, + enabled, + } => laufey::MenuItem::Item { + label, + id, + accelerator, + enabled, + }, + denort::desktop::MenuItem::Submenu { label, items } => { + laufey::MenuItem::Submenu { + label, + items: items + .into_iter() + .map(desktop_menu_item_to_laufey_menu_item) + .collect(), + } + } + denort::desktop::MenuItem::Separator => laufey::MenuItem::Separator, + denort::desktop::MenuItem::Role { role } => laufey::MenuItem::Role { role }, + } +} + +#[allow(dead_code, reason = "kept alongside v8_to_laufey_value for symmetry")] +fn laufey_value_to_v8<'a>( + scope: &v8::PinScope<'a, '_>, + val: laufey::Value, +) -> v8::Local<'a, v8::Value> { + match val { + laufey::Value::Null => v8::null(scope).into(), + laufey::Value::Bool(bool) => v8::Boolean::new(scope, bool).into(), + laufey::Value::Int(int) => v8::Integer::new(scope, int).into(), + laufey::Value::Double(double) => v8::Number::new(scope, double).into(), + laufey::Value::String(str) => v8::String::new(scope, &str).unwrap().into(), + laufey::Value::List(list) => { + let elements = list + .into_iter() + .map(|v| laufey_value_to_v8(scope, v)) + .collect::>(); + v8::Array::new_with_elements(scope, &elements).into() + } + laufey::Value::Dict(dict) => { + let mut names = Vec::with_capacity(dict.len()); + let mut values = Vec::with_capacity(dict.len()); + + for (k, v) in dict { + names.push(v8::String::new(scope, &k).unwrap().into()); + values.push(laufey_value_to_v8(scope, v)); + } + + let prototype = v8::null(scope).into(); + v8::Object::with_prototype_and_properties( + scope, prototype, &names, &values, + ) + .into() + } + laufey::Value::Binary(bin) => { + let len = bin.len(); + let backing_store = v8::ArrayBuffer::new_backing_store_from_vec(bin); + let backing_store = backing_store.make_shared(); + let ab = v8::ArrayBuffer::with_backing_store(scope, &backing_store); + let uint8_array = v8::Uint8Array::new(scope, ab, 0, len).unwrap(); + uint8_array.into() + } + } +} + +#[allow(dead_code, reason = "kept alongside laufey_value_to_v8 for symmetry")] +fn v8_to_laufey_value<'a>( + scope: &v8::PinScope<'a, '_>, + val: v8::Local<'a, v8::Value>, +) -> laufey::Value { + if val.is_null_or_undefined() { + laufey::Value::Null + } else if val.is_boolean() { + laufey::Value::Bool(val.boolean_value(scope)) + } else if val.is_int32() { + laufey::Value::Int(val.int32_value(scope).unwrap_or(0)) + } else if val.is_number() { + laufey::Value::Double(val.number_value(scope).unwrap_or(0.0)) + } else if val.is_string() { + let s = val.to_rust_string_lossy(scope); + laufey::Value::String(s) + } else if val.is_array_buffer_view() { + let view: v8::Local = val.try_into().unwrap(); + let len = view.byte_length(); + let mut buf = vec![0u8; len]; + view.copy_contents(&mut buf); + laufey::Value::Binary(buf) + } else if val.is_array() { + let arr: v8::Local = val.try_into().unwrap(); + let len = arr.length(); + let mut list = Vec::with_capacity(len as usize); + for i in 0..len { + if let Some(elem) = arr.get_index(scope, i) { + list.push(v8_to_laufey_value(scope, elem)); + } + } + laufey::Value::List(list) + } else if val.is_object() { + let obj: v8::Local = val.try_into().unwrap(); + let mut map = std::collections::HashMap::new(); + if let Some(names) = + obj.get_own_property_names(scope, v8::GetPropertyNamesArgs::default()) + { + for i in 0..names.length() { + if let Some(key) = names.get_index(scope, i) { + let key_str = key.to_rust_string_lossy(scope); + if let Some(value) = obj.get(scope, key) { + map.insert(key_str, v8_to_laufey_value(scope, value)); + } + } + } + } + laufey::Value::Dict(map) + } else { + // Fallback: coerce to string + laufey::Value::String(val.to_rust_string_lossy(scope)) + } +} + +/// Promote this dylib's symbols to the global symbol scope so that +/// native addons loaded via `dlopen` (e.g. next-swc.node) can resolve +/// NAPI function symbols from our library. +/// +/// By default, Laufey loads this dylib without `RTLD_GLOBAL`, so its symbols +/// are only visible within the dylib itself. NAPI addons use +/// `-undefined dynamic_lookup` (macOS) and expect NAPI symbols to be +/// in the global symbol table. Re-opening ourselves with `RTLD_GLOBAL` +/// promotes our exports to global scope. +#[cfg(unix)] +fn promote_dylib_symbols_to_global() { + #[repr(C)] + struct DlInfo { + dli_fname: *const std::ffi::c_char, + dli_fbase: *mut std::ffi::c_void, + dli_sname: *const std::ffi::c_char, + dli_saddr: *mut std::ffi::c_void, + } + unsafe extern "C" { + fn dladdr( + addr: *const std::ffi::c_void, + info: *mut DlInfo, + ) -> std::ffi::c_int; + fn dlopen( + path: *const std::ffi::c_char, + flags: std::ffi::c_int, + ) -> *mut std::ffi::c_void; + } + const RTLD_LAZY: std::ffi::c_int = 0x1; + const RTLD_NOLOAD: std::ffi::c_int = 0x10; + const RTLD_GLOBAL: std::ffi::c_int = 0x8; + + // SAFETY: + // - `dladdr` reads the metadata of the function passed in; a known + // function pointer in this dylib is always a valid argument. + // - `dlopen` with RTLD_NOLOAD doesn't load anything new — it only + // bumps the refcount of an already-loaded image, then sets + // RTLD_GLOBAL on its symbols. We pass the path returned by + // `dladdr` (our own dylib) which is guaranteed loaded since + // we're executing in it. On NULL return there's nothing for us + // to clean up — the global-symbol-promotion just didn't happen + // and any NAPI addon needing those symbols will fail with a + // clear "symbol not found" later. + unsafe { + let mut info: DlInfo = std::mem::zeroed(); + let addr = promote_dylib_symbols_to_global as *const std::ffi::c_void; + if dladdr(addr, &mut info) != 0 && !info.dli_fname.is_null() { + let handle = + dlopen(info.dli_fname, RTLD_LAZY | RTLD_NOLOAD | RTLD_GLOBAL); + if handle.is_null() { + log::debug!( + "[desktop] dlopen(self, RTLD_NOLOAD|RTLD_GLOBAL) returned NULL; \ + NAPI symbols will not be globally visible" + ); + } + } + } +} + +/// Get the filesystem path of this dylib using `dladdr`. +#[cfg(unix)] +fn get_dylib_path() -> Option { + #[repr(C)] + struct DlInfo { + dli_fname: *const std::ffi::c_char, + dli_fbase: *mut std::ffi::c_void, + dli_sname: *const std::ffi::c_char, + dli_saddr: *mut std::ffi::c_void, + } + unsafe extern "C" { + fn dladdr( + addr: *const std::ffi::c_void, + info: *mut DlInfo, + ) -> std::ffi::c_int; + } + // SAFETY: `dladdr` is given a valid function pointer and a stack-allocated + // `DlInfo`; on success we only read `dli_fname` after null-checking it. + unsafe { + let mut info: DlInfo = std::mem::zeroed(); + let addr = get_dylib_path as *const std::ffi::c_void; + if dladdr(addr, &mut info) != 0 && !info.dli_fname.is_null() { + let c_str = std::ffi::CStr::from_ptr(info.dli_fname); + Some(PathBuf::from(c_str.to_string_lossy().into_owned())) + } else { + None + } + } +} + +/// Manages pending updates and rollback on startup. +/// +/// Uses a sentinel file (`.update-ok`) to detect if the last update +/// booted successfully: +/// +/// - `.update` exists → apply it (current → `.backup`, `.update` → current) +/// - `.backup` exists but `.update-ok` doesn't → last update crashed, rollback +/// - `.backup` exists and `.update-ok` exists → previous update succeeded, clean up +/// +/// Returns `true` if a rollback occurred (so we can dispatch an event in JS). +// Only invoked from the unix `laufey::main!` startup path; gated to match its +// sole call site so it isn't flagged as dead code on other platforms. +#[cfg(unix)] +#[allow(clippy::print_stderr, reason = "runs before logging is initialized")] +fn apply_pending_update(dylib_path: &Path) -> bool { + let ext = dylib_path.extension().unwrap_or_default().to_string_lossy(); + let update_path = dylib_path.with_extension(format!("{}.update", ext)); + let backup_path = dylib_path.with_extension(format!("{}.backup", ext)); + let sentinel_path = dylib_path.with_extension(format!("{}.update-ok", ext)); + + if update_path.exists() { + // New update pending — apply it. + // Remove stale sentinel so we can detect if *this* update fails. + let _ = std::fs::remove_file(&sentinel_path); + let _ = std::fs::remove_file(&backup_path); + + // Hard-link (or copy) the live dylib aside as a backup *without* + // unlinking the original. If the subsequent swap-in of the update + // fails (perms, disk full, EXDEV), the running dylib is still in place + // — previously a rename-then-rename pair could leave the app with no + // dylib at all on rename #2 failure. + let backup_ok = std::fs::hard_link(dylib_path, &backup_path).is_ok() + || std::fs::copy(dylib_path, &backup_path).is_ok(); + if !backup_ok { + eprintln!("[desktop] could not stage backup, skipping update"); + return false; + } + + if std::fs::rename(&update_path, dylib_path).is_err() { + // Rename failed (cross-filesystem / perms / etc.). Fall back to a + // temp-then-rename copy so the dylib is never observed half-written: + // copy the update to `.update.tmp` on the same filesystem as + // the dylib, then atomic-rename into place. Only on full success do + // we consume the staged `.update`. + let tmp_path = dylib_path.with_extension(format!("{}.update.tmp", ext)); + let copy_ok = std::fs::copy(&update_path, &tmp_path).is_ok() + && std::fs::rename(&tmp_path, dylib_path).is_ok(); + if copy_ok { + let _ = std::fs::remove_file(&update_path); + } else { + // Couldn't apply the update by rename or copy. Leave `.update` in + // place so the next launch retries, drop the stale `.tmp`, and + // delete the unused `.backup` — otherwise the next launch would + // see backup-without-sentinel and trigger a spurious "rollback" + // even though we never swapped anything in. + let _ = std::fs::remove_file(&tmp_path); + let _ = std::fs::remove_file(&backup_path); + eprintln!( + "[desktop] failed to apply staged update; will retry on next launch" + ); + } + } + return false; + } + + if backup_path.exists() && !sentinel_path.exists() { + // Last update didn't write the sentinel → it crashed. Rollback. + eprintln!("[desktop] Last update failed to start, rolling back..."); + let _ = std::fs::rename(&backup_path, dylib_path); + return true; + } + + if backup_path.exists() && sentinel_path.exists() { + // Previous update booted fine — clean up backup and sentinel. + let _ = std::fs::remove_file(&backup_path); + let _ = std::fs::remove_file(&sentinel_path); + } + + false +} + +laufey::main!(|| { + // Apply any pending update before anything else. + #[cfg(unix)] + #[allow(clippy::print_stderr, reason = "runs before logging is initialized")] + let update_rolled_back = { + match std::panic::catch_unwind(|| { + if let Some(ref dylib_path) = get_dylib_path() { + eprintln!("[desktop] dylib path: {:?}", dylib_path); + apply_pending_update(dylib_path) + } else { + eprintln!("[desktop] could not determine dylib path"); + false + } + }) { + Ok(v) => v, + Err(e) => { + eprintln!("[desktop] update check panicked: {:?}", e); + false + } + } + }; + #[cfg(not(unix))] + let update_rolled_back = false; + + // Make NAPI symbols visible to native addons (e.g. next-swc). + #[cfg(unix)] + promote_dylib_symbols_to_global(); + + // Guard against re-entry: when a framework dev server (e.g. Next.js) + // forks child/worker processes, they re-execute this dylib. Detect + // forked workers and run them headless (no Laufey window). + // + // A forked worker is recognized by any of: + // 1. A standalone (compiled) `child_process.fork()`: the parent passes + // the target module via the internal `DENO_INTERNAL_CHILD_ENTRYPOINT` + // env var (see ext/node/polyfills/child_process.ts) — there is no + // `run` subcommand for a compiled binary — together with the + // `NODE_CHANNEL_FD` that every fork() wires up for IPC. This is the + // desktop case: the dylib is a standalone binary, so a framework dev + // server (e.g. `next dev`, which forks its HTTP server into a child) + // lands here. Without it the child would boot the full windowed + // runtime and crash creating a second CEF window. + // 2. argv shaped like ` run [flags…] script.js …` (i.e. + // `extract_fork_script_path` returns `Some`), OR + // 3. argv shaped like ` run …` *and* one of the worker env + // vars set by the parent dev server (NODE_CHANNEL_FD, + // NEXT_PRIVATE_WORKER). + // + // The bare env-var check used to be enough, but a user shell that + // already had NODE_CHANNEL_FD set (e.g. running inside another forked + // process, Jest, pnpm) would silently take the headless path and + // never show a window. Requiring either the internal fork marker or the + // `run` argv shape rules that out: the Laufey backend never sets + // `DENO_INTERNAL_CHILD_ENTRYPOINT` nor invokes us with `run` as argv[1]. + let args: Vec<_> = env::args_os().collect(); + let argv_run = args + .get(1) + .and_then(|a| a.to_str()) + .map(|s| s == "run") + .unwrap_or(false); + let is_standalone_fork = env::var("DENO_INTERNAL_CHILD_ENTRYPOINT") + .is_ok_and(|v| !v.is_empty()) + && env::var("NODE_CHANNEL_FD").is_ok(); + let is_worker = is_standalone_fork + || extract_fork_script_path(&args).is_some() + || (argv_run + && (env::var("NODE_CHANNEL_FD").is_ok() + || env::var("NEXT_PRIVATE_WORKER").is_ok())); + if is_worker { + run_headless_worker(); + return; + } + + // Set up panic hook for desktop error reporting. The error reporting + // URL is only known after binary metadata is parsed (in run_desktop), + // so the hook reads from a global that gets set later. + { + let orig_hook = std::panic::take_hook(); + #[allow(clippy::print_stderr, reason = "panic hook")] + std::panic::set_hook(Box::new(move |panic_info| { + use deno_runtime::ops::desktop::error_report_config; + use deno_runtime::ops::desktop::send_error_report; + + if let Some((url, app_version)) = error_report_config() { + let message = if let Some(s) = + panic_info.payload().downcast_ref::<&str>() + { + (*s).to_string() + } else if let Some(s) = panic_info.payload().downcast_ref::() { + s.clone() + } else { + "Deno runtime panicked".to_string() + }; + + let location = panic_info + .location() + .map(|l| format!("at {}:{}:{}", l.file(), l.line(), l.column())); + + let body = deno_core::serde_json::json!({ + "version": 1, + "message": message, + "stack": location, + "appVersion": app_version, + "platform": env::consts::OS, + "arch": env::consts::ARCH, + }); + send_error_report(url, &body.to_string()); + } + + eprintln!( + "\n============================================================" + ); + eprintln!("Deno has panicked. This is a bug in Deno. Please report this"); + eprintln!("at https://github.com/denoland/deno/issues/new."); + eprintln!(); + eprintln!("Platform: {} {}", env::consts::OS, env::consts::ARCH); + eprintln!(); + + orig_hook(panic_info); + deno_runtime::exit(1); + })); + } + + denort::init_logging(None, None); + + deno_runtime::deno_permissions::mark_standalone(); + + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .unwrap(); + + laufey::set_js_namespace("bindings"); + + // Allocate the desktop serve port and publish it via DENO_SERVE_ADDRESS + // BEFORE the tokio runtime is built. Once the runtime spins up its + // mio IO thread (and, optionally, the inspector server thread), + // `setenv` is no longer thread-safe on glibc — Rust 1.81+ marks it + // unsafe for that reason. We're still single-threaded up to here: + // the worker-fork path has already returned, and the init calls + // above (init_logging, mark_standalone, rustls install_default, + // set_js_namespace) don't spawn threads. + let desktop_serve_port = match allocate_random_port() { + Ok(p) => p, + Err(e) => { + log::error!("[desktop] failed to allocate serve port: {}", e); + return; + } + }; + // SAFETY: see the block comment above — single-threaded at this point. + unsafe { + std::env::set_var( + "DENO_SERVE_ADDRESS", + format!("tcp:127.0.0.1:{}", desktop_serve_port), + ); + } + + // Read the embedded standalone section, extract the VFS, and chdir + // into the extraction dir — all BEFORE the tokio runtime starts. + // chdir is process-wide; doing it after the runtime build (and any + // worker / async tasks it spawns) would race with code that resolves + // relative paths. + let args: Vec<_> = env::args_os().collect(); + let data = match denort::binary::extract_standalone_with_finder( + Cow::Owned(args), + find_section_in_dylib, + ) { + Ok(data) => data, + Err(e) => { + log::error!("[desktop] failed to read standalone section: {:?}", e); + return; + } + }; + if data.metadata.self_extracting.is_some() { + if let Err(e) = + denort::binary::extract_vfs_to_disk(&data.vfs, &data.root_path) + { + log::error!("[desktop] failed to extract VFS: {:?}", e); + return; + } + // Frameworks like Next.js look for build output (e.g. .next/) + // relative to CWD. + if let Err(e) = std::env::set_current_dir(&data.root_path) { + log::error!( + "[desktop] failed to chdir to {}: {}", + data.root_path.display(), + e + ); + return; + } + } + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + log::debug!("[desktop] run_desktop starting"); + match run_desktop(update_rolled_back, desktop_serve_port, data).await { + Ok(()) => log::debug!("[desktop] run_desktop completed OK"), + Err(error) => { + let is_js_error = js_error_downcast_ref(&error).is_some(); + let error_string = match js_error_downcast_ref(&error) { + Some(js_error) => format_js_error(js_error, None), + None => format!("{:?}", error), + }; + log::error!( + "{}: {}", + colors::red_bold("error"), + error_string.trim_start_matches("error: ") + ); + // Only show native alert for non-JS errors (startup crashes). + // JS errors are already handled by the error reporting JS listener. + if !is_js_error { + laufey::alert( + "Application Error", + error_string.trim_start_matches("error: "), + ); + } + } + } + }); +}); + +/// Run as a headless worker (no Laufey window). Used when a framework dev +/// server forks child processes that re-execute this dylib. +fn run_headless_worker() { + denort::init_logging(None, None); + deno_runtime::deno_permissions::mark_standalone(); + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .unwrap(); + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async { + let args: Vec<_> = env::args_os().collect(); + log::debug!("[worker] args: {:?}", args); + #[allow( + clippy::disallowed_methods, + reason = "debug trace of the worker's current directory" + )] + let cwd = env::current_dir(); + log::debug!("[worker] cwd: {:?}", cwd); + + // Detect if this is a child_process.fork() invocation. + // fork() translates args to: ["run", "-A", "--unstable-...", "script.js", ...] + // Extract the script path so the forked worker runs the correct module + // instead of the embedded entrypoint. + let fork_module = extract_fork_script_path(&args); + log::debug!("[worker] fork_module: {:?}", fork_module); + + let data = match denort::binary::extract_standalone_with_finder( + Cow::Owned(args), + find_section_in_dylib, + ) { + Ok(data) => data, + Err(e) => { + log::error!("Worker failed to load standalone data: {:?}", e); + return; + } + }; + + denort::load_env_vars(&data.metadata.env_vars_from_env_file); + + let sys = if data.metadata.self_extracting.is_some() { + // VFS should already be extracted by the parent process. + // In dev mode, keep the source directory as CWD (inherited from parent). + // In production mode, set CWD to extraction directory. + if env::var("DENO_DESKTOP_DEV").is_err() { + let _ = std::env::set_current_dir(&data.root_path); + } + denort::file_system::DenoRtSys::new_self_extracting(data.vfs.clone()) + } else { + denort::file_system::DenoRtSys::new(data.vfs.clone()) + }; + + let options = denort::run::RunOptions { + override_main_module: fork_module, + ..Default::default() + }; + + log::debug!("[worker] starting run_with_options"); + + match denort::run::run_with_options( + Arc::new(sys.clone()), + sys, + data, + options, + ) + .await + { + Ok(exit_code) => { + log::debug!( + "[worker] run_with_options completed with exit code: {}", + exit_code + ); + } + Err(error) => { + let error_string = match js_error_downcast_ref(&error) { + Some(js_error) => format_js_error(js_error, None), + None => format!("{:?}", error), + }; + log::debug!("[worker] run_with_options error: {}", error_string); + log::error!( + "{}: {}", + colors::red_bold("error"), + error_string.trim_start_matches("error: ") + ); + } + } + log::debug!("[worker] block_on finished"); + }); + log::debug!("[worker] run_headless_worker returning"); +} + +/// Extract the script path from fork'd process arguments. +/// +/// When `child_process.fork(scriptPath)` is called, the args are translated +/// to Deno CLI args: `["", "run", "-A", "--unstable-...", "script.js", ...]` +/// This function finds the script path (first non-flag arg after "run"). +fn extract_fork_script_path( + args: &[std::ffi::OsString], +) -> Option { + let args: Vec = args + .iter() + .filter_map(|a| a.to_str().map(String::from)) + .collect(); + + // Skip argv[0] (the executable), expect "run" as the subcommand + if args.len() < 3 || args[1] != "run" { + return None; + } + + // Find the first arg after "run" that isn't a flag + for arg in &args[2..] { + if arg.starts_with('-') { + continue; + } + // This is the script path + let path = PathBuf::from(arg); + let path = if path.is_absolute() { + path + } else { + #[allow( + clippy::disallowed_methods, + reason = "denort_desktop has no resolve_cwd helper; resolves a relative fork-script path" + )] + let cwd = env::current_dir().ok()?; + cwd.join(path) + }; + return deno_core::url::Url::from_file_path(path).ok(); + } + None +} + +/// Convert a laufey::Value to a DesktopValue for direct V8 conversion. +fn laufey_value_to_desktop_value( + v: laufey::Value, +) -> deno_runtime::ops::desktop::DesktopValue { + use deno_runtime::ops::desktop::DesktopValue; + match v { + laufey::Value::Null => DesktopValue::Null, + laufey::Value::Bool(b) => DesktopValue::Bool(b), + laufey::Value::Int(i) => DesktopValue::Int(i), + laufey::Value::Double(d) => DesktopValue::Double(d), + laufey::Value::String(s) => DesktopValue::String(s), + laufey::Value::List(l) => DesktopValue::List( + l.into_iter().map(laufey_value_to_desktop_value).collect(), + ), + laufey::Value::Dict(d) => DesktopValue::Dict( + d.into_iter() + .map(|(k, v)| (k, laufey_value_to_desktop_value(v))) + .collect(), + ), + laufey::Value::Binary(b) => DesktopValue::Binary(b), + } +} + +/// Convert a laufey::Value to a serde_json::Value for channel transport. +fn laufey_value_to_json(v: &laufey::Value) -> serde_json::Value { + match v { + laufey::Value::Null => serde_json::Value::Null, + laufey::Value::Bool(b) => serde_json::Value::Bool(*b), + laufey::Value::Int(i) => serde_json::json!(*i), + laufey::Value::Double(d) => serde_json::json!(*d), + laufey::Value::String(s) => serde_json::Value::String(s.clone()), + laufey::Value::List(l) => { + serde_json::Value::Array(l.iter().map(laufey_value_to_json).collect()) + } + laufey::Value::Dict(d) => { + let mut map = serde_json::Map::new(); + for (k, v) in d { + map.insert(k.clone(), laufey_value_to_json(v)); + } + serde_json::Value::Object(map) + } + laufey::Value::Binary(b) => serde_json::json!(b), + } +} + +/// Convert a serde_json::Value to a laufey::Value for the menu template. +fn json_to_laufey_value(v: &serde_json::Value) -> laufey::Value { + match v { + serde_json::Value::Null => laufey::Value::Null, + serde_json::Value::Bool(b) => laufey::Value::Bool(*b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + laufey::Value::Int(i as i32) + } else { + laufey::Value::Double(n.as_f64().unwrap_or(0.0)) + } + } + serde_json::Value::String(s) => laufey::Value::String(s.clone()), + serde_json::Value::Array(arr) => { + laufey::Value::List(arr.iter().map(json_to_laufey_value).collect()) + } + serde_json::Value::Object(obj) => { + let mut map = std::collections::HashMap::new(); + for (k, v) in obj { + map.insert(k.clone(), json_to_laufey_value(v)); + } + laufey::Value::Dict(map) + } + } +} + +/// Find the embedded data section in this dylib (not the main executable). +fn find_section_in_dylib() -> Result<&'static [u8], AnyError> { + match libsui::find_section_in_current_image("d3n0l4nd") + .context("Failed reading standalone binary section from dylib.") + { + Ok(Some(data)) => Ok(data), + Ok(None) => { + bail!("Could not find standalone binary section in dylib.") + } + Err(err) => Err(err), + } +} + +async fn run_desktop( + update_rolled_back: bool, + desktop_serve_port: u16, + data: denort::binary::StandaloneData, +) -> Result<(), AnyError> { + // Make the error reporting URL available to the panic hook. + if let Some(ref url) = data.metadata.error_reporting_url { + deno_runtime::ops::desktop::set_error_report_config( + url.clone(), + data.metadata.app_version.clone(), + ); + } + + // The VFS extract + chdir for self-extracting bundles happens in + // `laufey::main!` before the tokio runtime is built — chdir is + // process-wide and isn't safe to do once async tasks are running. + let sys = if data.metadata.self_extracting.is_some() { + denort::file_system::DenoRtSys::new_self_extracting(data.vfs.clone()) + } else { + denort::file_system::DenoRtSys::new(data.vfs.clone()) + }; + + deno_runtime::deno_telemetry::init( + &sys, + otel_runtime_config(), + data.metadata.otel_config.clone(), + )?; + denort::init_logging( + data.metadata.log_level, + Some(data.metadata.otel_config.clone()), + ); + denort::load_env_vars(&data.metadata.env_vars_from_env_file); + + // Wire up the Deno-side inspector when launched under + // `deno desktop --inspect[-brk|-wait]`. The parent process binds the + // user-visible port and runs a multiplexer that fronts both this + // inspector and the CEF renderer's debug port; we just listen on the + // internal port that the parent allocated for us. + // Surface a malformed value loudly: previously a typoed port silently + // disabled the inspector and the user wondered why DevTools showed + // nothing. Bail rather than no-op so the failure is visible. + let inspect_internal_port = match env::var( + "DENO_DESKTOP_INSPECT_INTERNAL_PORT", + ) { + Ok(s) => match s.parse::() { + Ok(addr) => Some(addr), + Err(e) => { + bail!( + "DENO_DESKTOP_INSPECT_INTERNAL_PORT={s:?} is not a valid SocketAddr: {e}" + ); + } + }, + Err(_) => None, + }; + let inspect_brk = env::var("DENO_DESKTOP_INSPECT_BRK").is_ok(); + let inspect_wait = env::var("DENO_DESKTOP_INSPECT_WAIT").is_ok(); + if let Some(addr) = inspect_internal_port { + deno_runtime::deno_inspector_server::create_inspector_server( + addr, + "deno-desktop", + // Don't print the ws:// URL ourselves — DevTools attaches via the + // parent mux's user-visible port. + deno_runtime::deno_inspector_server::InspectPublishUid { + console: false, + http: true, + }, + )?; + log::debug!("[desktop] inspector server bound on {addr}"); + } + + // DENO_SERVE_ADDRESS is published by `laufey::main!` before the + // tokio runtime is built — see the comment there for why we can't + // do it from here. `desktop_serve_port` is the port we put into it. + + // Enable HMR if DENO_DESKTOP_HMR is set to a directory path + // (set by `deno compile --desktop --hmr`). + let hmr_watch_dir = env::var("DENO_DESKTOP_HMR").ok().map(PathBuf::from); + + // Framework dev servers handle their own HMR via websocket. + // For non-framework apps, V8-level HMR reloads the webview. + let is_framework_dev = env::var("DENO_DESKTOP_DEV").is_ok(); + + // In dev mode, restore CWD to the source directory so the framework + // dev server watches the original source files, not the extracted VFS. + if is_framework_dev && let Ok(source_dir) = env::var("DENO_DESKTOP_HMR") { + std::env::set_current_dir(&source_dir)?; + } + + // Shared initial window ID for navigate_fut and HMR reload. + let initial_window_id = Arc::new(AtomicU32::new(0)); + let initial_window_id_for_navigate = initial_window_id.clone(); + + // Track every live window so HMR can refresh secondary windows too, + // not just the initial one. The same Arc is handed to WefDesktopApi + // below; create_window / close_window keep it in sync. + let open_windows: Arc>> = + Arc::new(Mutex::new(HashSet::new())); + let open_windows_for_api = open_windows.clone(); + let open_windows_for_hmr = open_windows.clone(); + + let hmr_on_reload: Option = + if hmr_watch_dir.is_some() && !is_framework_dev { + Some(Box::new(move || { + let ids: Vec = open_windows_for_hmr + .lock() + .unwrap() + .iter() + .copied() + .collect(); + for id in ids { + laufey::Window::from_id(id) + .execute_js::)>( + "location.reload()", + None, + ); + } + })) + } else { + None + }; + + // Desktop extension: provides Deno.desktop.* APIs + auto-update + #[cfg(unix)] + let auto_update_state = get_dylib_path().map(|p| { + denort::desktop::AutoUpdateState { + dylib_path: p, + app_version: data.metadata.app_version.clone(), + rolled_back: update_rolled_back, // from laufey::main! startup check + } + }); + #[cfg(not(unix))] + let auto_update_state: Option = { + // Auto-update rollback is only wired up on unix; consume the flag so the + // parameter isn't flagged as unused on other platforms. + let _ = update_rolled_back; + None + }; + + let auto_update_version = auto_update_state + .as_ref() + .and_then(|s| s.app_version.clone()); + let auto_update_rolled_back = + auto_update_state.as_ref().is_some_and(|s| s.rolled_back); + + let run_opts = RunOptions { + auto_serve: true, + serve_port: Some(desktop_serve_port), + serve_host: Some("127.0.0.1".to_string()), + hmr_watch_dir: if is_framework_dev { + None + } else { + hmr_watch_dir + }, + hmr_on_reload, + op_state_init: Some(Box::new(move |state| { + let (event_tx, event_rx) = + denort::desktop::create_desktop_event_channel(); + let pending_responses = denort::desktop::PendingBindResponses::new(); + let api = WefDesktopApi { + event_tx: event_tx.0.clone(), + pending_responses: pending_responses.clone(), + closed_windows: Arc::new(Mutex::new(HashSet::new())), + open_windows: open_windows_for_api.clone(), + trays: Arc::new(Mutex::new(HashMap::new())), + notifications: Arc::new(Mutex::new(HashMap::new())), + devtools_window: Mutex::new(None), + }; + + // Forward macOS dock-reopen callbacks (clicking the dock icon while + // no windows are visible) into the shared event channel so JS can + // observe them as `Deno.dock` "reopen" events. + { + let reopen_tx = event_tx.0.clone(); + laufey::on_dock_reopen(move |has_visible_windows| { + let _ = reopen_tx.try_send( + deno_runtime::ops::desktop::DesktopEvent::DockReopen { + has_visible_windows, + }, + ); + }); + } + + // Create the initial window and wire up event handlers. + let window_id = api.create_window(800, 600, false, false, false); + initial_window_id.store(window_id, Ordering::Release); + + denort::desktop::init_desktop_state( + state, + Box::new(api), + auto_update_state, + ); + state.put(event_rx); + state.put(event_tx); + state.put(pending_responses); + state.put(denort::desktop::InitialWindowId(std::sync::Mutex::new( + Some(window_id), + ))); + })), + override_main_module: None, + auto_update_version, + auto_update_rolled_back, + error_reporting_url: data.metadata.error_reporting_url.clone(), + release_base_url: data.metadata.release_base_url.clone(), + inspect_brk, + inspect_wait, + is_inspecting: inspect_internal_port.is_some(), + }; + + // Run the Deno runtime and Laufey event loop concurrently. + // We spawn the runtime first, wait for the server to be ready, + // then navigate the webview. + let url = format!("http://127.0.0.1:{}", desktop_serve_port); + log::debug!("[desktop] starting runtime and laufey event loop"); + let run_fut = + denort::run::run_with_options(Arc::new(sys.clone()), sys, data, run_opts); + let laufey_fut = laufey::run(); + + // Wait for the server to be ready, then navigate the initial window. + // Do a full HTTP request instead of just a TCP connect — frameworks + // like Vite accept connections before they're ready to serve. + let wait_for_debugger = inspect_brk || inspect_wait; + let mux_addr = env::var("DENO_DESKTOP_MUX_WS").ok(); + let navigate_fut = async move { + use tokio::io::AsyncReadExt; + use tokio::io::AsyncWriteExt; + + // When --inspect-wait or --inspect-brk: block until a DevTools + // client has connected to the mux. This prevents the renderer + // from racing ahead while the developer is still opening DevTools. + if wait_for_debugger && let Some(ref mux) = mux_addr { + log::info!("[desktop] Waiting for debugger to attach on ws://{mux} …"); + loop { + if let Ok(mut stream) = + tokio::net::TcpStream::connect(mux.as_str()).await + { + let req = format!( + "GET /debugger-attached HTTP/1.1\r\nHost: {mux}\r\nConnection: close\r\n\r\n", + ); + if stream.write_all(req.as_bytes()).await.is_ok() { + let mut buf = vec![0u8; 128]; + if let Ok(n) = stream.read(&mut buf).await { + let resp = String::from_utf8_lossy(&buf[..n]); + if resp.starts_with("HTTP/1.1 200") + || resp.starts_with("HTTP/1.0 200") + { + log::info!("[desktop] Debugger attached"); + break; + } + } + } + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + } + + for i in 0..60 { + if let Ok(mut stream) = + tokio::net::TcpStream::connect(("127.0.0.1", desktop_serve_port)).await + { + let req = format!( + "GET / HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nConnection: close\r\n\r\n", + desktop_serve_port + ); + if stream.write_all(req.as_bytes()).await.is_ok() { + let mut buf = vec![0u8; 256]; + if let Ok(n) = stream.read(&mut buf).await { + let response = String::from_utf8_lossy(&buf[..n]); + if response.starts_with("HTTP/1.1 2") + || response.starts_with("HTTP/1.1 3") + || response.starts_with("HTTP/1.0 2") + || response.starts_with("HTTP/1.0 3") + { + log::debug!( + "[desktop] Server ready after {} attempts, navigating to {}", + i + 1, + &url + ); + let id = initial_window_id_for_navigate.load(Ordering::Acquire); + laufey::Window::from_id(id).navigate(&url); + return; + } + } + } + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + log::warn!("Server not ready after 15s, navigating anyway"); + let id = initial_window_id_for_navigate.load(Ordering::Acquire); + laufey::Window::from_id(id).navigate(&url); + }; + + // Hold the JoinHandle so we can abort it when the runtime / Laufey + // event loop ends — otherwise the navigate poll keeps trying for up + // to 15s after window close, writing warnings to a stderr that may + // already be torn down. + let navigate_handle = tokio::spawn(navigate_fut); + + tokio::select! { + result = run_fut => { + match result { + Ok(exit_code) => { + log::debug!("[desktop] Deno runtime exited with code {}", exit_code); + } + Err(err) => { + log::error!("[desktop] Deno runtime error: {:?}", err); + navigate_handle.abort(); + return Err(err); + } + } + } + _ = laufey_fut => { + log::debug!("[desktop] Laufey event loop ended (window closed)"); + } + } + navigate_handle.abort(); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::ffi::OsString; + + use deno_runtime::ops::desktop::PermissionState; + + use super::desktop_menu_item_to_laufey_menu_item; + use super::extract_fork_script_path; + use super::json_to_laufey_value; + use super::laufey_value_to_desktop_value; + use super::laufey_value_to_json; + use super::map_permission_status; + + // --- extract_fork_script_path --- + // + // Framework dev servers (e.g. Next.js) call `child_process.fork()` to + // re-exec this binary as a worker process. We detect that pattern by + // argv shape: ` run [flags…] script.js …`. Mis-classifying the + // main launch as a worker would mean no window appears at all. + + fn args(parts: &[&str]) -> Vec { + parts.iter().map(|s| OsString::from(*s)).collect() + } + + #[test] + fn fork_argv_shape_returns_script_url() { + let v = + extract_fork_script_path(&args(&["myapp", "run", "/tmp/worker.js"])); + let url = v.expect("argv with `run ` is a worker"); + assert!(url.as_str().ends_with("worker.js")); + assert_eq!(url.scheme(), "file"); + } + + #[test] + fn fork_argv_skips_flags_before_script() { + let v = extract_fork_script_path(&args(&[ + "myapp", + "run", + "--allow-net", + "-A", + "/tmp/worker.js", + ])); + assert!(v.is_some(), "flags before the script path must be skipped"); + } + + #[test] + fn fork_argv_rejects_non_run_subcommand() { + // Only ` run …` is the worker shape. The main desktop launch + // never has a subcommand at argv[1] — laufey passes no argv at all. + assert!(extract_fork_script_path(&args(&["myapp"])).is_none()); + assert!( + extract_fork_script_path(&args(&["myapp", "task", "build"])).is_none() + ); + assert!(extract_fork_script_path(&args(&["myapp", "test"])).is_none()); + } + + #[test] + fn fork_argv_run_with_only_flags_is_not_a_fork() { + // ` run --help` has no script path — don't claim this is a + // worker. + let v = extract_fork_script_path(&args(&["myapp", "run", "--help"])); + assert!( + v.is_none(), + "argv with only flags after `run` is not a fork worker" + ); + } + + #[test] + fn fork_argv_bare_run_is_not_a_fork() { + // ` run` with no further args isn't enough to be a fork. + assert!(extract_fork_script_path(&args(&["myapp", "run"])).is_none()); + } + + // --- map_permission_status --- + + #[test] + fn permission_status_maps_one_to_one() { + // Every laufey PermissionStatus must round-trip to the matching + // deno PermissionState. Off-by-one swaps here would surface in JS + // as "granted shows up as denied" — silent and very confusing. + assert!(matches!( + map_permission_status(laufey::PermissionStatus::Granted), + PermissionState::Granted + )); + assert!(matches!( + map_permission_status(laufey::PermissionStatus::Denied), + PermissionState::Denied + )); + assert!(matches!( + map_permission_status(laufey::PermissionStatus::Prompt), + PermissionState::Prompt + )); + assert!(matches!( + map_permission_status(laufey::PermissionStatus::Unsupported), + PermissionState::Unsupported + )); + } + + // --- desktop_menu_item_to_laufey_menu_item --- + + #[test] + fn menu_item_conversion_preserves_fields() { + let item = denort::desktop::MenuItem::Item { + label: "Save".into(), + id: Some("file.save".into()), + accelerator: Some("CmdOrCtrl+S".into()), + enabled: true, + }; + match desktop_menu_item_to_laufey_menu_item(item) { + laufey::MenuItem::Item { + label, + id, + accelerator, + enabled, + } => { + assert_eq!(label, "Save"); + assert_eq!(id.as_deref(), Some("file.save")); + assert_eq!(accelerator.as_deref(), Some("CmdOrCtrl+S")); + assert!(enabled); + } + _ => panic!("expected Item"), + } + } + + #[test] + fn menu_item_conversion_recurses_into_submenus() { + let item = denort::desktop::MenuItem::Submenu { + label: "File".into(), + items: vec![ + denort::desktop::MenuItem::Item { + label: "Open".into(), + id: Some("open".into()), + accelerator: None, + enabled: false, + }, + denort::desktop::MenuItem::Separator, + denort::desktop::MenuItem::Role { + role: "quit".into(), + }, + ], + }; + let converted = desktop_menu_item_to_laufey_menu_item(item); + let laufey::MenuItem::Submenu { items, .. } = converted else { + panic!("expected Submenu"); + }; + assert_eq!(items.len(), 3); + // First child is an Item with enabled=false preserved. + match &items[0] { + laufey::MenuItem::Item { enabled, label, .. } => { + assert_eq!(label, "Open"); + assert!(!enabled, "enabled=false must propagate through recursion"); + } + _ => panic!("first child should be Item"), + } + assert!(matches!(items[1], laufey::MenuItem::Separator)); + match &items[2] { + laufey::MenuItem::Role { role } => assert_eq!(role, "quit"), + _ => panic!("third child should be Role"), + } + } + + // --- laufey_value_to_desktop_value / laufey_value_to_json / json_to_laufey_value --- + + #[test] + fn laufey_value_to_desktop_value_covers_every_variant() { + use deno_runtime::ops::desktop::DesktopValue; + assert!(matches!( + laufey_value_to_desktop_value(laufey::Value::Null), + DesktopValue::Null + )); + assert!(matches!( + laufey_value_to_desktop_value(laufey::Value::Bool(true)), + DesktopValue::Bool(true) + )); + assert!(matches!( + laufey_value_to_desktop_value(laufey::Value::Int(7)), + DesktopValue::Int(7) + )); + assert!(matches!( + laufey_value_to_desktop_value(laufey::Value::Double(1.5)), + DesktopValue::Double(d) if d == 1.5 + )); + match laufey_value_to_desktop_value(laufey::Value::String("hi".into())) { + DesktopValue::String(s) => assert_eq!(s, "hi"), + _ => panic!(), + } + match laufey_value_to_desktop_value(laufey::Value::Binary(vec![1, 2, 3])) { + DesktopValue::Binary(b) => assert_eq!(b, vec![1, 2, 3]), + _ => panic!(), + } + } + + #[test] + fn laufey_value_to_desktop_value_recurses() { + use deno_runtime::ops::desktop::DesktopValue; + let v = laufey::Value::List(vec![ + laufey::Value::Int(1), + laufey::Value::List(vec![laufey::Value::String("nested".into())]), + ]); + let dv = laufey_value_to_desktop_value(v); + let DesktopValue::List(outer) = dv else { + panic!("outer must be List") + }; + assert_eq!(outer.len(), 2); + let DesktopValue::List(inner) = &outer[1] else { + panic!("second element must be a nested List") + }; + match &inner[0] { + DesktopValue::String(s) => assert_eq!(s, "nested"), + _ => panic!("inner element should be String"), + } + } + + #[test] + fn laufey_value_to_json_roundtrip() { + use std::collections::HashMap; + let mut dict = HashMap::new(); + dict.insert("name".to_string(), laufey::Value::String("ada".into())); + dict.insert("count".to_string(), laufey::Value::Int(42)); + dict.insert("ok".to_string(), laufey::Value::Bool(true)); + let v = laufey::Value::Dict(dict); + let j = laufey_value_to_json(&v); + assert_eq!(j["name"], "ada"); + assert_eq!(j["count"], 42); + assert_eq!(j["ok"], true); + + // Round-trip back through json_to_laufey_value to confirm symmetry on + // the simple types. + let back = json_to_laufey_value(&j); + let laufey::Value::Dict(d) = back else { + panic!("round-trip must yield Dict") + }; + match d.get("name") { + Some(laufey::Value::String(s)) => assert_eq!(s, "ada"), + _ => panic!("name must round-trip as String"), + } + match d.get("count") { + // json_to_laufey_value maps integer numbers to Int via as_i64() — so + // it should land in the Int branch. + Some(laufey::Value::Int(42)) => {} + _ => panic!("count round-trip should yield laufey::Value::Int(42)"), + } + } + + #[test] + fn json_to_laufey_value_distinguishes_int_and_double() { + let n_int = serde_json::json!(42); + let n_float = serde_json::json!(1.5); + assert!(matches!( + json_to_laufey_value(&n_int), + laufey::Value::Int(42) + )); + assert!(matches!( + json_to_laufey_value(&n_float), + laufey::Value::Double(d) if d == 1.5 + )); + } + + // --- apply_pending_update --- + // + // State machine: presence/absence of `.update`, `.backup`, and + // `.update-ok` sentinel decides what apply_pending_update does next. + // The four combinations have non-obvious effects (commit / rollback / + // cleanup / no-op) and the consequences of getting them wrong are + // serious (booting a half-applied dylib, or boot-looping on rollback). + // Tests below exercise each branch using a tempdir as the live dylib + // location. + + // apply_pending_update is unix-only (see its #[cfg(unix)] gate), so its + // tests and helpers live in a unix-only submodule. + #[cfg(unix)] + mod pending_update_tests { + use super::super::apply_pending_update; + + fn touch(path: &std::path::Path, content: &str) { + std::fs::write(path, content).unwrap(); + } + + fn read(path: &std::path::Path) -> String { + std::fs::read_to_string(path).unwrap() + } + + fn paths( + tmp: &std::path::Path, + ) -> ( + std::path::PathBuf, + std::path::PathBuf, + std::path::PathBuf, + std::path::PathBuf, + ) { + let dylib = tmp.join("app.dylib"); + let update = tmp.join("app.dylib.update"); + let backup = tmp.join("app.dylib.backup"); + let sentinel = tmp.join("app.dylib.update-ok"); + (dylib, update, backup, sentinel) + } + + #[test] + fn pending_update_no_files_is_noop() { + let tmp = tempfile::tempdir().unwrap(); + let (dylib, _, _, _) = paths(tmp.path()); + touch(&dylib, "live"); + let rolled_back = apply_pending_update(&dylib); + assert!(!rolled_back, "no .update / .backup → no rollback"); + assert_eq!(read(&dylib), "live", "dylib must be untouched"); + } + + #[test] + fn pending_update_swaps_in_new_dylib_when_update_exists() { + let tmp = tempfile::tempdir().unwrap(); + let (dylib, update, backup, sentinel) = paths(tmp.path()); + touch(&dylib, "old"); + touch(&update, "new"); + let rolled_back = apply_pending_update(&dylib); + + assert!(!rolled_back, "applying a fresh update is not a rollback"); + assert_eq!( + read(&dylib), + "new", + "dylib must now hold the update contents" + ); + assert!(backup.exists(), "live dylib must be preserved as .backup"); + assert_eq!(read(&backup), "old", "backup must be the *previous* dylib"); + // Stale sentinel from a prior update is cleared so the next launch + // can detect THIS update's success/failure. + assert!(!sentinel.exists()); + // The staged update file is consumed. + assert!(!update.exists()); + } + + #[test] + fn pending_update_clears_stale_sentinel_before_swap() { + let tmp = tempfile::tempdir().unwrap(); + let (dylib, update, backup, sentinel) = paths(tmp.path()); + touch(&dylib, "old"); + touch(&update, "new"); + touch(&sentinel, "ok"); // sentinel left over from a previous launch + touch(&backup, "very-old"); // and a backup, also stale + apply_pending_update(&dylib); + // After applying a new update: backup is the just-replaced 'old', + // sentinel was cleared. The previous backup ("very-old") must not + // persist, or rollback would resurrect a two-version-old dylib. + assert_eq!(read(&backup), "old"); + assert!(!sentinel.exists()); + } + + #[test] + fn pending_update_rolls_back_when_sentinel_missing() { + // .backup present + no sentinel = previous update booted but crashed + // before writing the sentinel. Rollback to backup. + let tmp = tempfile::tempdir().unwrap(); + let (dylib, _, backup, _) = paths(tmp.path()); + touch(&dylib, "new-but-broken"); + touch(&backup, "old-but-known-good"); + let rolled_back = apply_pending_update(&dylib); + assert!(rolled_back, "missing sentinel must trigger rollback"); + assert_eq!( + read(&dylib), + "old-but-known-good", + "rollback must restore the backup contents over the live dylib" + ); + } + + #[test] + fn pending_update_cleans_up_after_successful_boot() { + // .backup + .update-ok sentinel = previous update was applied AND + // booted at least once. Clean up; we don't need to keep the backup + // around forever. + let tmp = tempfile::tempdir().unwrap(); + let (dylib, _, backup, sentinel) = paths(tmp.path()); + touch(&dylib, "new"); + touch(&backup, "old"); + touch(&sentinel, "ok"); + let rolled_back = apply_pending_update(&dylib); + assert!(!rolled_back); + assert!(!backup.exists(), "successful-boot path must delete .backup"); + assert!( + !sentinel.exists(), + "successful-boot path must delete sentinel" + ); + assert_eq!(read(&dylib), "new", "dylib untouched on cleanup path"); + } + + #[test] + fn pending_update_with_only_sentinel_is_noop() { + // Sentinel without backup or update — orphaned. Don't roll back + // (there's nothing to roll back to) and don't touch the dylib. + let tmp = tempfile::tempdir().unwrap(); + let (dylib, _, _, sentinel) = paths(tmp.path()); + touch(&dylib, "live"); + touch(&sentinel, "ok"); + let rolled_back = apply_pending_update(&dylib); + assert!(!rolled_back); + assert_eq!(read(&dylib), "live"); + } + } + + #[test] + fn json_to_laufey_value_handles_nested_arrays_and_objects() { + let j = serde_json::json!({ + "list": [1, 2, 3], + "nested": {"key": "value"}, + "null": null, + }); + let v = json_to_laufey_value(&j); + let laufey::Value::Dict(d) = v else { + panic!("expected Dict") + }; + assert!(matches!(d.get("null"), Some(laufey::Value::Null))); + match d.get("list") { + Some(laufey::Value::List(items)) => assert_eq!(items.len(), 3), + _ => panic!("list must convert to List"), + } + match d.get("nested") { + Some(laufey::Value::Dict(inner)) => match inner.get("key") { + Some(laufey::Value::String(s)) => assert_eq!(s, "value"), + _ => panic!("nested.key must be String"), + }, + _ => panic!("nested must convert to Dict"), + } + } +} diff --git a/cli/schemas/config-file.v1.json b/cli/schemas/config-file.v1.json index bb4041a5913581..86779d35641a46 100644 --- a/cli/schemas/config-file.v1.json +++ b/cli/schemas/config-file.v1.json @@ -146,6 +146,78 @@ } } }, + "desktop": { + "type": "object", + "description": "Configuration for `deno desktop`.", + "additionalProperties": false, + "properties": { + "app": { + "type": "object", + "description": "Application metadata.", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "The application name." + }, + "icons": { + "type": "object", + "description": "Platform-specific icon paths.", + "additionalProperties": false, + "properties": { + "macos": { + "type": "string", + "description": "Icon path for macOS (.icns or .png)." + }, + "windows": { + "type": "string", + "description": "Icon path for Windows (.ico)." + }, + "linux": { + "type": "string", + "description": "Icon path for Linux (.png)." + } + } + } + } + }, + "backend": { + "type": "string", + "description": "Backend to use for the desktop app.", + "enum": ["webview", "cef"] + }, + "output": { + "type": "object", + "description": "Platform-specific output paths.", + "additionalProperties": false, + "properties": { + "macos": { + "type": "string", + "description": "Output path for macOS (e.g. MyApp.app, MyApp.dmg)." + }, + "windows": { + "type": "string", + "description": "Output path for Windows (e.g. MyApp.msi, MyApp.exe)." + }, + "linux": { + "type": "string", + "description": "Output path for Linux (e.g. MyApp.AppImage, MyApp.deb)." + } + } + }, + "release": { + "type": "object", + "description": "Release and auto-update configuration.", + "additionalProperties": false, + "properties": { + "baseUrl": { + "type": "string", + "description": "Base URL for auto-update release artifacts." + } + } + } + } + }, "compilerOptions": { "type": "object", "description": "Instructs the TypeScript compiler how to compile .ts files.", diff --git a/cli/standalone/binary.rs b/cli/standalone/binary.rs index 5a505ce27ab791..28f1962ddd8f34 100644 --- a/cli/standalone/binary.rs +++ b/cli/standalone/binary.rs @@ -228,6 +228,7 @@ pub struct DenoCompileBinaryWriter<'a> { npm_resolver: &'a CliNpmResolver, workspace_resolver: &'a WorkspaceResolver, npm_system_info: NpmSystemInfo, + is_desktop: bool, } impl<'a> DenoCompileBinaryWriter<'a> { @@ -243,6 +244,7 @@ impl<'a> DenoCompileBinaryWriter<'a> { npm_resolver: &'a CliNpmResolver, workspace_resolver: &'a WorkspaceResolver, npm_system_info: NpmSystemInfo, + is_desktop: bool, ) -> Self { Self { cjs_module_export_analyzer, @@ -255,6 +257,7 @@ impl<'a> DenoCompileBinaryWriter<'a> { npm_resolver, workspace_resolver, npm_system_info, + is_desktop, } } @@ -279,7 +282,8 @@ impl<'a> DenoCompileBinaryWriter<'a> { } if options.compile_flags.icon.is_some() { let target = options.compile_flags.resolve_target(); - if !target.contains("windows") { + // Desktop builds handle icons during app bundle packaging. + if !target.contains("windows") && !self.is_desktop { bail!( "The `--icon` flag is only available when targeting Windows (current: {})", target, @@ -293,6 +297,10 @@ impl<'a> DenoCompileBinaryWriter<'a> { &self, compile_flags: &CompileFlags, ) -> Result, AnyError> { + if self.is_desktop { + return self.get_desktop_base_binary(compile_flags).await; + } + // Used for testing. // // Phase 2 of the 'min sized' deno compile RFC talks @@ -344,6 +352,71 @@ impl<'a> DenoCompileBinaryWriter<'a> { Ok(base_binary) } + async fn get_desktop_base_binary( + &self, + compile_flags: &CompileFlags, + ) -> Result, AnyError> { + // For development: check DENORT_DESKTOP_BIN env var or look + // for libdenort next to the deno executable. + if let Some(path) = get_dev_desktop_binary_path() { + log::debug!("Resolved libdenort: {}", path.to_string_lossy()); + return std::fs::read(&path).with_context(|| { + format!("Could not find libdenort at '{}'", path.to_string_lossy()) + }); + } + + let target = compile_flags.resolve_target(); + let lib_ext = if target.contains("darwin") { + "dylib" + } else if target.contains("windows") { + "dll" + } else { + "so" + }; + let lib_name = if target.contains("windows") { + format!("denort.{lib_ext}") + } else { + format!("libdenort.{lib_ext}") + }; + let binary_name = format!("libdenort-{target}.zip"); + + let binary_path_suffix = match DENO_VERSION_INFO.release_channel { + ReleaseChannel::Canary => { + format!("canary/{}/{}", DENO_VERSION_INFO.git_hash, binary_name) + } + _ => { + format!("release/v{}/{}", DENO_VERSION_INFO.deno, binary_name) + } + }; + + let download_directory = self.deno_dir.dl_folder_path(); + let binary_path = download_directory.join(&binary_path_suffix); + log::debug!("Resolved libdenort: {}", binary_path.display()); + + let read_file = |path: &Path| -> Result, AnyError> { + std::fs::read(path).with_context(|| format!("Reading {}", path.display())) + }; + let archive_data = if binary_path.exists() { + read_file(&binary_path)? + } else { + self + .download_base_binary(&binary_path, &binary_path_suffix) + .await + .context("Setting up desktop base binary.")? + }; + let temp_dir = tempfile::TempDir::new()?; + let base_binary_path = archive::unpack_into_dir(archive::UnpackArgs { + exe_name: &lib_name, + archive_name: &binary_name, + archive_data: &archive_data, + is_windows: target.contains("windows"), + dest_path: temp_dir.path(), + })?; + let base_binary = read_file(&base_binary_path)?; + drop(temp_dir); + Ok(base_binary) + } + async fn download_base_binary( &self, output_path: &Path, @@ -680,13 +753,42 @@ impl<'a> DenoCompileBinaryWriter<'a> { } let specifier = deno_path_util::url_from_file_path(&file_path)?; let media_type = MediaType::from_specifier(&specifier); + // Only script-flavored files can carry CJS exports. Extensions answer + // this for everything except extensionless files (`MediaType::Unknown`), + // which may be real modules (an npm `"main"` with no extension — see + // test-module-main-extension-lookup); those are disambiguated by content + // below rather than skipped outright. + if !matches!( + media_type, + MediaType::JavaScript + | MediaType::Mjs + | MediaType::Cjs + | MediaType::Jsx + | MediaType::TypeScript + | MediaType::Mts + | MediaType::Cts + | MediaType::Tsx + | MediaType::Dts + | MediaType::Dmts + | MediaType::Dcts + | MediaType::Unknown + ) { + continue; + } if self.cjs_tracker.is_maybe_cjs(&specifier, media_type)? { - let maybe_source = vfs - .file_bytes(file.offset) - .map(|text| String::from_utf8_lossy(text)); + // Strict UTF-8 (not `from_utf8_lossy`): binary assets (images, + // fonts, …) that resolve to `Unknown` are skipped rather than + // mangled into garbage that panics swc. Extensionless *text* + // modules still flow through. + let Some(bytes) = vfs.file_bytes(file.offset) else { + continue; + }; + let Ok(source) = std::str::from_utf8(bytes) else { + continue; + }; let cjs_analysis_result = self .cjs_module_export_analyzer - .analyze_all_exports(&specifier, maybe_source) + .analyze_all_exports(&specifier, Some(source.into())) .await; let analysis = match cjs_analysis_result { Ok(ResolvedCjsAnalysis::Esm(_)) => CjsExportAnalysisEntry::Esm, @@ -909,6 +1011,23 @@ impl<'a> DenoCompileBinaryWriter<'a> { } else { None }, + app_version: self + .cli_options + .workspace() + .root_deno_json() + .and_then(|c| c.json.version.clone()), + error_reporting_url: self + .cli_options + .start_dir + .to_desktop_config() + .ok() + .and_then(|c| c.error_reporting.as_ref()?.url.clone()), + release_base_url: self + .cli_options + .start_dir + .to_desktop_config() + .ok() + .and_then(|c| c.release.as_ref()?.base_url.clone()), }; let (data_section_bytes, section_sizes) = serialize_binary_data_section( @@ -1472,6 +1591,39 @@ fn get_dev_binary_path() -> Option { }) } +fn get_libdenort_path(deno_exe: PathBuf) -> Option { + let mut libdenort = deno_exe; + if cfg!(target_os = "macos") { + libdenort.set_file_name("libdenort.dylib"); + } else if cfg!(windows) { + libdenort.set_file_name("denort.dll"); + } else { + libdenort.set_file_name("libdenort.so"); + } + libdenort.exists().then(|| libdenort.into_os_string()) +} + +fn get_dev_desktop_binary_path() -> Option { + env::var_os("DENORT_DESKTOP_BIN").or_else(|| { + env::current_exe().ok().and_then(|exec_path| { + if exec_path + .components() + .any(|component| component == Component::Normal("target".as_ref())) + { + // Prefer release libdenort (optimized) over debug. + let target_dir = exec_path.parent().and_then(|p| p.parent()); + target_dir + .and_then(|d| { + get_libdenort_path(d.join("release").join("libdenort.dylib")) + }) + .or_else(|| get_libdenort_path(exec_path.clone())) + } else { + None + } + }) + }) +} + /// This function returns the environment variables specified /// in the passed environment file. fn get_file_env_vars( diff --git a/cli/tools/appimage_runtime/README.md b/cli/tools/appimage_runtime/README.md new file mode 100644 index 00000000000000..3916d057b6791b --- /dev/null +++ b/cli/tools/appimage_runtime/README.md @@ -0,0 +1,20 @@ +# AppImage Type-2 Runtime + +Vendored prebuilt ELF runtime stubs from the AppImage project. These are +prepended to the SquashFS payload to form a Type-2 AppImage — when the resulting +AppImage is executed, this runtime mounts the embedded SquashFS (via FUSE / +squashfuse) and execs `AppRun` inside it. + +Source: Release tag: `20251108` +(downloaded 2026-04-21). + +| File | Arch | SHA-256 | +| --------------- | ------- | ---------------------------------------------------------------- | +| runtime-x86_64 | x86_64 | 2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d | +| runtime-aarch64 | aarch64 | 00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 | + +License: MIT (see upstream `LICENSE` at +). + +To refresh, download the matching assets from the same release tag, replace the +files in this directory, and update the SHA-256 table above. diff --git a/cli/tools/appimage_runtime/runtime-aarch64 b/cli/tools/appimage_runtime/runtime-aarch64 new file mode 100644 index 00000000000000..3cbe957458e938 Binary files /dev/null and b/cli/tools/appimage_runtime/runtime-aarch64 differ diff --git a/cli/tools/appimage_runtime/runtime-x86_64 b/cli/tools/appimage_runtime/runtime-x86_64 new file mode 100644 index 00000000000000..c445fbd17cfc52 Binary files /dev/null and b/cli/tools/appimage_runtime/runtime-x86_64 differ diff --git a/cli/tools/compile.rs b/cli/tools/compile.rs index d997666603b698..822de4eb1c3730 100644 --- a/cli/tools/compile.rs +++ b/cli/tools/compile.rs @@ -95,7 +95,7 @@ async fn compile_inner( }; let _framework_entrypoint_file = if let Some(dir) = source_dir { - if let Some(detection) = super::framework::detect_framework(&dir)? { + if let Some(detection) = super::framework::detect_framework(&dir, false)? { log::info!("Detected {} framework", detection.name); // Run the framework's build step if needed. if let Some(build_cmd) = &detection.build_command { @@ -275,12 +275,14 @@ async fn compile_inner( if compile_flags.eszip { compile_eszip(flags, compile_flags, watcher_communicator) .boxed_local() - .await + .await?; } else { - compile_binary(flags, compile_flags, watcher_communicator) + compile_binary(flags, compile_flags, false, watcher_communicator) .boxed_local() - .await + .await?; } + + Ok(()) } struct BundleForCompileResult { @@ -650,11 +652,12 @@ function __internalResolveBundlePath(rel) { } } -async fn compile_binary( +pub async fn compile_binary( flags: Arc, compile_flags: CompileFlags, + is_desktop: bool, watcher_communicator: Option>, -) -> Result<(), AnyError> { +) -> Result { let factory = if let Some(watcher_communicator) = watcher_communicator.clone() { CliFactory::from_flags_for_watcher(flags, watcher_communicator) @@ -663,13 +666,14 @@ async fn compile_binary( }; let cli_options = factory.cli_options()?; let module_graph_creator = factory.module_graph_creator().await?; - let binary_writer = factory.create_compile_binary_writer().await?; + let binary_writer = factory.create_compile_binary_writer(is_desktop).await?; let entrypoint = cli_options.resolve_main_module()?; let bin_name_resolver = factory.bin_name_resolver()?; let output_path = resolve_compile_executable_output_path( &bin_name_resolver, &compile_flags, cli_options.initial_cwd(), + is_desktop, ) .await?; let compile_config = cli_options.start_dir.to_compile_config()?; @@ -725,6 +729,20 @@ async fn compile_binary( ); validate_output_path(&output_path)?; + // Clean up stale temp files from previous interrupted compilations. + if let Some(parent) = output_path.parent() + && let Some(stem) = output_path.file_name() + { + let prefix = format!("{}.tmp-", stem.to_string_lossy()); + if let Ok(entries) = std::fs::read_dir(parent) { + for entry in entries.flatten() { + if entry.file_name().to_string_lossy().starts_with(&prefix) { + let _ = std::fs::remove_file(entry.path()); + } + } + } + } + let mut temp_filename = output_path.file_name().unwrap().to_owned(); temp_filename.push(format!( ".tmp-{}", @@ -797,6 +815,107 @@ async fn compile_binary( return Err(err); } + Ok(output_path) +} + +/// Convert a PNG image to macOS .icns format using `sips` and `iconutil`. +pub fn convert_png_to_icns( + png_path: &Path, + icns_path: &Path, +) -> Result<(), AnyError> { + let iconset_dir = icns_path.with_extension("iconset"); + std::fs::create_dir_all(&iconset_dir)?; + + let sizes: &[(u32, &str)] = &[ + (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"), + ]; + + for (size, name) in sizes { + let dest = iconset_dir.join(name); + let status = std::process::Command::new("sips") + .args([ + "-z", + &size.to_string(), + &size.to_string(), + &png_path.display().to_string(), + "--out", + &dest.display().to_string(), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + if status.map_or(true, |s| !s.success()) { + std::fs::copy(png_path, &dest)?; + } + } + + let status = std::process::Command::new("iconutil") + .args([ + "-c", + "icns", + &iconset_dir.display().to_string(), + "-o", + &icns_path.display().to_string(), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status()?; + + let _ = std::fs::remove_dir_all(&iconset_dir); + + if !status.success() { + bail!( + "Failed to convert PNG to ICNS. Provide an .icns file directly or ensure iconutil is available." + ); + } + + Ok(()) +} + +pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), AnyError> { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src) + .with_context(|| format!("Reading directory '{}'", src.display()))? + { + let entry = entry?; + let ty = entry.file_type()?; + let dest = dst.join(entry.file_name()); + if ty.is_dir() { + copy_dir_all(&entry.path(), &dest)?; + } else if ty.is_symlink() { + let target = std::fs::read_link(entry.path())?; + #[cfg(unix)] + std::os::unix::fs::symlink(&target, &dest)?; + #[cfg(windows)] + { + if target.is_dir() { + std::os::windows::fs::symlink_dir(&target, &dest)?; + } else { + std::os::windows::fs::symlink_file(&target, &dest)?; + } + } + } else { + std::fs::copy(entry.path(), &dest)?; + // Ensure the copied file is writable (nix store files are read-only). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let meta = std::fs::metadata(&dest)?; + let mut perms = meta.permissions(); + perms.set_mode(perms.mode() | 0o200); + std::fs::set_permissions(&dest, perms)?; + } + } + } Ok(()) } @@ -821,6 +940,7 @@ async fn compile_eszip( &bin_name_resolver, &compile_flags, cli_options.initial_cwd(), + false, ) .await?; output_path.set_extension("eszip"); @@ -1178,6 +1298,7 @@ async fn resolve_compile_executable_output_path( bin_name_resolver: &BinNameResolver<'_>, compile_flags: &CompileFlags, current_dir: &Path, + is_desktop: bool, ) -> Result { let module_specifier = resolve_url_or_path(&compile_flags.source_file, current_dir)?; @@ -1211,10 +1332,35 @@ async fn resolve_compile_executable_output_path( output_path.ok_or_else(|| anyhow!( "An executable name was not provided. One could not be inferred from the URL. Aborting.", )).map(|output_path| { - get_os_specific_filepath(output_path, &compile_flags.target) + if is_desktop { + get_desktop_specific_filepath(output_path, &compile_flags.target) + } else { + get_os_specific_filepath(output_path, &compile_flags.target) + } }) } +fn get_desktop_specific_filepath( + output: PathBuf, + target: &Option, +) -> PathBuf { + let is_windows = match target { + Some(target) => target.contains("windows"), + None => cfg!(windows), + }; + let is_darwin = match target { + Some(target) => target.contains("darwin"), + None => cfg!(target_os = "macos"), + }; + if is_windows { + output.with_extension("dll") + } else if is_darwin { + output.with_extension("dylib") + } else { + output.with_extension("so") + } +} + fn get_os_specific_filepath( output: PathBuf, target: &Option, @@ -1302,6 +1448,7 @@ mod test { exclude_unused_npm: false, }, &resolve_cwd(None).unwrap(), + false, ) .await .unwrap(); @@ -1338,6 +1485,7 @@ mod test { exclude_unused_npm: false, }, &resolve_cwd(None).unwrap(), + false, ) .await .unwrap(); diff --git a/cli/tools/desktop.rs b/cli/tools/desktop.rs new file mode 100644 index 00000000000000..d0981f22a737d1 --- /dev/null +++ b/cli/tools/desktop.rs @@ -0,0 +1,4204 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +use std::net::SocketAddr; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use deno_core::anyhow::Context; +use deno_core::anyhow::bail; +use deno_core::error::AnyError; +use deno_core::url::Url; +use deno_terminal::colors; +use sha2::Digest; + +use crate::args::CliOptions; +use crate::args::CompileFlags; +use crate::args::DenoSubcommand; +use crate::args::DesktopFlags; +use crate::args::Flags; +use crate::args::TypeCheckMode; +use crate::factory::CliFactory; +use crate::http_util::HttpClientProvider; +use crate::util::progress_bar::ProgressBar; +use crate::util::progress_bar::ProgressBarStyle; + +/// Version of the `laufey` capi crate pinned in the workspace Cargo.lock. +/// Populated by `cli/build.rs` and used to resolve matching prebuilt backend +/// binaries from `github.com/littledivy/laufey/releases/tag/v{LAUFEY_VERSION}`. +const LAUFEY_VERSION: &str = env!("LAUFEY_VERSION"); + +/// Rustc target triple the deno binary was built for. Used as the default +/// target when selecting a prebuilt laufey backend archive. +const LAUFEY_NATIVE_TARGET: &str = env!("TARGET"); + +/// Trust anchor for LAUFEY backend downloads: SHA-256 digests of every archive +/// for the pinned `LAUFEY_VERSION`. Checked into the repo so `SHA256SUMS` does +/// not need to be fetched (and trusted) at runtime — that file's integrity +/// previously rested on TOFU against the GitHub releases page. See +/// `cli/laufey_sums.lock` for the format. +const LAUFEY_PINNED_SUMS: &str = include_str!("../laufey_sums.lock"); + +pub async fn desktop( + flags: Flags, + mut desktop_flags: DesktopFlags, +) -> Result<(), AnyError> { + log::warn!( + "{}", + colors::yellow_bold("⚠ deno desktop is experimental and subject to change") + ); + + let all_targets = desktop_flags.all_targets; + + let config_flags = flags.clone(); + let factory = CliFactory::from_flags(Arc::new(config_flags)); + let cli_options = factory.cli_options()?; + let desktop_config = cli_options.start_dir.to_desktop_config()?.clone(); + let laufey_resolver = Arc::new(LaufeyBackendResolver::new(&factory)?); + let deno_dir_root = factory.deno_dir()?.root.clone(); + + if let Some(output) = desktop_config.output + && desktop_flags.output.is_none() + { + desktop_flags.output = if cfg!(target_os = "macos") { + output.macos + } else if cfg!(target_os = "windows") { + output.windows + } else { + output.linux + }; + } + + if let Some(app_config) = desktop_config.app { + if let Some(icons) = app_config.icons + && desktop_flags.icon.is_none() + { + use deno_config::deno_json::DesktopIconValue; + let platform_icon = if cfg!(target_os = "macos") { + icons.macos + } else if cfg!(target_os = "windows") { + icons.windows + } else { + icons.linux + }; + desktop_flags.icon = platform_icon.map(|v| match v { + DesktopIconValue::Single(s) => crate::args::IconConfig::Single(s), + DesktopIconValue::Set(entries) => crate::args::IconConfig::Set( + entries + .into_iter() + .map(|e| crate::args::IconSetEntry { + path: e.path, + size: e.size, + }) + .collect(), + ), + }); + } + + if let Some(name) = app_config.name + && desktop_flags.output.is_none() + { + desktop_flags.output = Some(name); + } + + if let Some(identifier) = app_config.identifier + && desktop_flags.identifier.is_none() + { + desktop_flags.identifier = Some(identifier); + } + } + + if let Some(backend) = desktop_config.backend + && desktop_flags.backend.is_none() + { + desktop_flags.backend = Some(backend); + } + + if let Some(macos_config) = desktop_config.macos + && let Some(identity) = macos_config.codesign_identity + && desktop_flags.codesign_identity.is_none() + { + desktop_flags.codesign_identity = Some(identity); + } + + if all_targets { + let targets = [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", + ]; + for target in targets { + log::info!("Building for target: {}", target); + let mut desktop_flags = desktop_flags.clone(); + desktop_flags.target = Some(target.to_string()); + Box::pin(compile_desktop( + flags.clone(), + desktop_flags, + cli_options, + &laufey_resolver, + &deno_dir_root, + )) + .await?; + } + Ok(()) + } else { + Box::pin(compile_desktop( + flags, + desktop_flags, + cli_options, + &laufey_resolver, + &deno_dir_root, + )) + .await + } +} + +async fn compile_desktop( + mut flags: Flags, + mut desktop_flags: DesktopFlags, + cli_options: &Arc, + laufey_resolver: &LaufeyBackendResolver, + deno_dir_root: &Path, +) -> Result<(), AnyError> { + // If the user asked for a `.dmg` (macOS) installer via `--output`, strip + // the extension for the intermediate compile/bundle step and remember the + // original so we can wrap the resulting .app in a DMG at the end. + let dmg_output = desktop_flags + .output + .as_ref() + .filter(|o| o.to_lowercase().ends_with(".dmg")) + .cloned(); + if let Some(ref dmg) = dmg_output { + if !cfg!(target_os = "macos") { + bail!( + "Building a .dmg requires a macOS build host (uses hdiutil). \ + Requested output: {dmg}. Build on macOS, or choose a different output \ + format.", + ); + } + let stem = Path::new(dmg) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "App".to_string()); + let parent = Path::new(dmg) + .parent() + .filter(|p| !p.as_os_str().is_empty()); + desktop_flags.output = Some(match parent { + Some(p) => p.join(&stem).to_string_lossy().into_owned(), + None => stem, + }); + } + + // Same for `.AppImage` on Linux — strip extension, wrap app dir in an + // AppImage at the end. + let appimage_output = desktop_flags + .output + .as_ref() + .filter(|o| o.to_lowercase().ends_with(".appimage")) + .cloned(); + if let Some(ref appimage) = appimage_output { + let stem = Path::new(appimage) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "App".to_string()); + let parent = Path::new(appimage) + .parent() + .filter(|p| !p.as_os_str().is_empty()); + desktop_flags.output = Some(match parent { + Some(p) => p.join(&stem).to_string_lossy().into_owned(), + None => stem, + }); + } + + // Desktop framework detection: when --desktop is used and the source is + // "." (a directory), detect the framework and generate the entrypoint. + // The cwd resolved from CliOptions is reused for the HMR launch below so + // framework detection is single-sourced and can't drift between the two. + let detection_cwd = cli_options.initial_cwd().to_path_buf(); + let detected_framework = if desktop_flags.source_file == "." { + super::framework::detect_framework(&detection_cwd, desktop_flags.hmr)? + } else { + None + }; + let desktop_entrypoint_file = if desktop_flags.source_file == "." { + let cwd = &detection_cwd; + if let Some(detection) = detected_framework.as_ref() { + let entrypoint_code = detection.entrypoint_code.clone(); + let includes = detection.include_paths.clone(); + log::info!("Detected {} framework", detection.name); + // Enable CJS detection for Node-based frameworks. + flags.unstable_config.detect_cjs = true; + if detection.name == "Next.js" + && !matches!(flags.type_check_mode, TypeCheckMode::None) + { + log::info!( + "Disabling Deno type checking for Next.js desktop compile; Next handles app compilation itself" + ); + flags.type_check_mode = TypeCheckMode::None; + } + // Sweep stale entrypoints leaked by previous interrupted runs. The + // NamedTempFile below cleans up on drop, but a Ctrl-C delivers SIGINT + // to the whole process group (see `run_desktop_hmr`) and the parent + // exits without running destructors — so the dev loop would otherwise + // accumulate `.deno_desktop_entry-*.ts` files in the project root. + const ENTRY_PREFIX: &str = ".deno_desktop_entry-"; + if let Ok(entries) = std::fs::read_dir(cwd) { + for entry in entries.flatten() { + if entry + .file_name() + .to_string_lossy() + .starts_with(ENTRY_PREFIX) + { + let _ = std::fs::remove_file(entry.path()); + } + } + } + // Write a temporary entrypoint file. tempfile gives us a unique + // name (no collision between concurrent `deno desktop` runs in + // the same project) and 0600 mode (no symlink-pre-creation + // attack); cleanup-on-drop replaces the explicit guard. + let entrypoint_temp = tempfile::Builder::new() + .prefix(ENTRY_PREFIX) + .suffix(".ts") + .tempfile_in(cwd) + .with_context(|| { + format!("failed to create temp entrypoint file in {}", cwd.display()) + })?; + { + use std::io::Write; + entrypoint_temp + .as_file() + .write_all(entrypoint_code.as_bytes())?; + } + let entrypoint_path = entrypoint_temp.path().to_path_buf(); + desktop_flags.source_file = entrypoint_path.display().to_string(); + if desktop_flags.output.is_none() + && let Some(dir_name) = cwd.file_name() + { + desktop_flags.output = Some(dir_name.to_string_lossy().into_owned()); + } + // Add framework build output to includes. + for inc in includes { + if !desktop_flags.include.contains(&inc) { + desktop_flags.include.push(inc.clone()); + } + } + Some(entrypoint_temp) + } else { + bail!( + "Could not detect a supported framework in the current directory.\nSupported frameworks: Next.js, Astro\nProvide an explicit entrypoint instead." + ); + } + } else { + None + }; + + let self_extracting = desktop_entrypoint_file.is_some(); + // `desktop_entrypoint_file` (a NamedTempFile) keeps the file alive while + // `compile_binary` reads it. It is explicitly closed right after compilation + // (see below) rather than on drop: the long-running `run_desktop_hmr` wait + // exits on Ctrl-C without running destructors, so a drop-only guard would + // leak the entrypoint for the whole dev session. + + // No explicit icon, but a framework was detected — try to use its + // favicon (e.g. `public/favicon.ico`, `app/icon.png`) as the app icon + // so the bundle gets the project's branding for free. + if desktop_flags.icon.is_none() + && let Some(detection) = detected_framework.as_ref() + { + let target_os = match desktop_flags.target.as_deref() { + Some(t) if t.contains("apple-darwin") => "macos", + Some(t) if t.contains("windows") => "windows", + Some(_) => "linux", + None => { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else { + "linux" + } + } + }; + if let Some(path) = super::framework::find_framework_favicon( + &detection_cwd, + detection, + target_os, + ) { + let display = path + .strip_prefix(&detection_cwd) + .unwrap_or(&path) + .display() + .to_string(); + log::info!("Using {} favicon as icon: {}", detection.name, display); + desktop_flags.icon = + Some(crate::args::IconConfig::Single(path.display().to_string())); + } + } + + let inspector_requested = flags.inspect.is_some() + || flags.inspect_brk.is_some() + || flags.inspect_wait.is_some(); + + // In HMR/inspector mode the compiled dylib is a throwaway dev artifact: we + // load it directly rather than packaging it into a `.app`. Writing it into + // the cwd litters the project with `.dylib`, its compile temp file + // (`.dylib.tmp-*`) and the runtime auto-update sidecars + // (`.update-ok`, `.backup`). Redirect it into a stable per-project dir under + // `deno_dir` so the cwd stays clean. The path is keyed by the project dir so + // it's stable across relaunches (the auto-update / rollback sentinels rely on + // a consistent dylib path). + let hmr_output_override = if desktop_flags.hmr || inspector_requested { + let name = desktop_flags + .output + .as_deref() + .map(Path::new) + .and_then(|p| p.file_stem()) + .map(|s| s.to_string_lossy().into_owned()) + .or_else(|| { + detection_cwd + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + }) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "app".to_string()); + let key = faster_hex::hex_string(&sha2::Sha256::digest( + detection_cwd.to_string_lossy().as_bytes(), + )); + let dir = deno_dir_root.join("desktop").join(&key[..16]); + std::fs::create_dir_all(&dir).with_context(|| { + format!("failed to create desktop dev dir {}", dir.display()) + })?; + Some(dir.join(name).to_string_lossy().into_owned()) + } else { + None + }; + + let compile_flags = CompileFlags { + source_file: desktop_flags.source_file.clone(), + output: hmr_output_override + .clone() + .or_else(|| desktop_flags.output.clone()), + args: desktop_flags.args.clone(), + target: desktop_flags.target.clone(), + watch: None, + no_terminal: false, + icon: match &desktop_flags.icon { + Some(crate::args::IconConfig::Single(s)) => Some(s.clone()), + _ => None, + }, + include: desktop_flags.include.clone(), + exclude: desktop_flags.exclude.clone(), + eszip: false, + self_extracting, + bundle: false, + minify: false, + exclude_unused_npm: false, + }; + + let mut temp_flags = flags.clone(); + temp_flags.subcommand = DenoSubcommand::Compile(compile_flags.clone()); + temp_flags.internal.is_desktop = true; + + let output_path = super::compile::compile_binary( + Arc::new(temp_flags), + compile_flags, + true, + None, + ) + .await?; + + // The temp entrypoint is embedded in the compiled dylib's VFS now; nothing + // downstream reads it from disk. Remove it deterministically here so the + // long-running HMR session (which exits on Ctrl-C without running the + // drop guard) can't leave it behind in the project root. + if let Some(entrypoint_file) = desktop_entrypoint_file { + let _ = entrypoint_file.close(); + } + + if desktop_flags.hmr || inspector_requested { + let backend = desktop_flags.backend.as_deref().unwrap_or("webview"); + run_desktop_hmr( + &output_path, + &detection_cwd, + detected_framework.as_ref(), + backend, + laufey_resolver, + &flags, + &desktop_flags, + ) + .await?; + } else { + // Package the dylib into a platform-specific app bundle. + let bundle_path = package_desktop_app( + &output_path, + &desktop_flags, + cli_options, + laufey_resolver, + ) + .await?; + + // If the user requested a .dmg, wrap the .app in one and report the DMG. + // If the user requested a .AppImage, wrap the Linux app dir in one. + let final_path = if let Some(dmg) = dmg_output.as_deref() { + let dmg_abs = cli_options.initial_cwd().join(dmg); + create_macos_dmg(&bundle_path, &dmg_abs)?; + dmg_abs + } else if let Some(appimage) = appimage_output.as_deref() { + let appimage_abs = cli_options.initial_cwd().join(appimage); + create_linux_appimage( + &bundle_path, + &appimage_abs, + desktop_flags.target.as_deref(), + )?; + appimage_abs + } else { + bundle_path + }; + + let initial_cwd = + deno_path_util::url_from_directory_path(cli_options.initial_cwd())?; + log::info!( + "{} {}", + colors::green("Bundle"), + if let Ok(bundle_url) = deno_path_util::url_from_file_path(&final_path) { + crate::util::path::relative_specifier_path_for_display( + &initial_cwd, + &bundle_url, + ) + } else { + final_path.display().to_string() + } + ); + } + + Ok(()) +} + +/// Resolve `icon` (a `.png` or `.icns` path, possibly relative to +/// `initial_cwd`) into an absolute path suitable for `LAUFEY_APP_ICON`, which +/// laufey passes to `-[NSImage initWithContentsOfFile:]` (both formats are +/// accepted, so no conversion is needed). +#[cfg(target_os = "macos")] +fn resolve_hmr_icon_path( + icon: &crate::args::IconConfig, + initial_cwd: &Path, +) -> Result { + let icon_path = match icon { + crate::args::IconConfig::Single(p) => initial_cwd.join(p), + crate::args::IconConfig::Set(_) => { + deno_core::anyhow::bail!("icon sets are not supported in --hmr mode yet") + } + }; + if !icon_path.exists() { + deno_core::anyhow::bail!("icon '{}' not found", icon_path.display()); + } + match icon_path.extension().and_then(|e| e.to_str()) { + Some("icns") | Some("png") => {} + _ => deno_core::anyhow::bail!( + "icon '{}' must be .icns or .png", + icon_path.display() + ), + } + Ok(crate::util::fs::canonicalize_path(&icon_path).unwrap_or(icon_path)) +} + +/// Launch the desktop app with HMR enabled after compilation. +/// +/// Framework dev servers provide HMR via websocket. Since they run inside +/// the Deno desktop runtime, `Deno.desktop` APIs remain available. +/// `child_process.fork()` works because forked workers use +/// `override_main_module` to run the target script instead of the +/// embedded entrypoint. +async fn run_desktop_hmr( + dylib_path: &Path, + source_dir: &Path, + framework: Option<&super::framework::FrameworkDetection>, + backend: &str, + laufey_resolver: &LaufeyBackendResolver, + flags: &Flags, + desktop_flags: &DesktopFlags, +) -> Result<(), AnyError> { + let laufey_backend = laufey_resolver + .find_binary(backend, LAUFEY_NATIVE_TARGET) + .await?; + let dylib_abs = crate::util::fs::canonicalize_path(dylib_path) + .unwrap_or(dylib_path.to_path_buf()); + let source_abs = crate::util::fs::canonicalize_path(source_dir) + .unwrap_or(source_dir.to_path_buf()); + + // In HMR/inspector mode we launch the prebuilt laufey.app, so a user + // `--icon` (or framework-detected favicon) would otherwise be ignored + // and the Dock would show laufey's own icon. We can't rely on the bundle's + // `CFBundleIconFile` (the dev bundle has none) or on swapping the bundled + // `laufey.icns` (LaunchServices caches the icon for an already-registered + // bundle id), so instead we pass the icon path to laufey and let it call + // `-[NSApp setApplicationIconImage:]` at launch, which bypasses both. + #[cfg(target_os = "macos")] + let laufey_app_icon = desktop_flags.icon.as_ref().and_then(|icon| { + resolve_hmr_icon_path(icon, &source_abs) + .map_err(|e| log::warn!("Could not apply custom icon: {e}")) + .ok() + }); + + // The prebuilt laufey bundle would otherwise present itself as "laufey" in the + // menu bar, Dock and Cmd-Tab switcher. Pass a clearer name (the configured + // app name / project directory) so laufey can override the process name at + // launch. `desktop_flags.output` is already resolved from `--output`, + // deno.json `desktop.app.name`, or the project dir before we get here. + let app_name = desktop_flags + .output + .as_deref() + .map(Path::new) + .and_then(|p| p.file_stem()) + .map(|s| s.to_string_lossy().into_owned()) + .or_else(|| { + source_abs + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + }) + .filter(|s| !s.is_empty()); + + if let Some(fw) = framework + && desktop_flags.hmr + { + log::info!( + "{} {} dev server with HMR in desktop mode", + colors::green("Running"), + fw.name, + ); + } + + if desktop_flags.hmr { + log::info!( + "{} desktop app with HMR (watching {})", + colors::green("Running"), + source_abs.display(), + ); + } else { + log::info!("{} desktop app under inspector", colors::green("Running"),); + } + + let mut cmd = std::process::Command::new(&laufey_backend); + cmd + .arg("--runtime") + .arg(&dylib_abs) + .env("LAUFEY_RUNTIME_PATH", &dylib_abs) + .current_dir(&source_abs); + #[cfg(target_os = "macos")] + if let Some(icon_path) = laufey_app_icon.as_ref() { + cmd.env("LAUFEY_APP_ICON", icon_path); + } + if let Some(name) = app_name.as_ref() { + cmd.env("LAUFEY_APP_NAME", name); + } + // Only enable the file watcher + setScriptSource pipeline when the user + // actually asked for HMR. `deno desktop --inspect` alone used to spin up + // both, surprising users (and burning the inspector channel on hot + // reloads they didn't request). + if desktop_flags.hmr { + cmd.env("DENO_DESKTOP_HMR", &source_abs); + // For a detected framework, HMR is owned by the framework's own dev + // server (over websocket) — the entrypoint runs e.g. `next dev`. + // DENO_DESKTOP_DEV tells rt_desktop to skip Deno's V8-level HMR and to + // restore the CWD to the source dir so the dev server watches the real + // project files. Without it, rt_desktop would try (and fail) to hot-swap + // the framework's npm entrypoint, which isn't where the app code lives. + if framework.is_some() { + cmd.env("DENO_DESKTOP_DEV", "1"); + } + } + + // Wire up the unified DevTools multiplexer when --inspect is set. + // The mux runs in this (parent) process and fronts both the Deno runtime + // inspector (in the LAUFEY subprocess) and the CEF renderer's debug port + // (in CEF's child process). We allocate two internal ports here, hand + // them to the subprocess via env vars, and bind the user-visible port + // for DevTools to attach to. + let user_inspect = flags.inspect.or(flags.inspect_brk).or(flags.inspect_wait); + let mux_handle = if let Some(user_addr) = user_inspect { + let deno_internal: SocketAddr = format!( + "127.0.0.1:{}", + crate::tools::desktop_devtools::allocate_random_port()? + ) + .parse() + .unwrap(); + let cef_internal: SocketAddr = match desktop_flags.inspect_renderer { + Some(addr) => addr, + None => format!( + "127.0.0.1:{}", + crate::tools::desktop_devtools::allocate_random_port()? + ) + .parse() + .unwrap(), + }; + let wait_for_debugger = + flags.inspect_brk.is_some() || flags.inspect_wait.is_some(); + let handle = crate::tools::desktop_devtools::spawn_mux( + crate::tools::desktop_devtools::MuxConfig { + listen: user_addr, + deno_internal, + cef_internal, + inspect_brk: flags.inspect_brk.is_some(), + wait_for_debugger, + }, + ) + .await?; + + log::info!( + "{} DevTools on ws://{} (open chrome://inspect)", + colors::green("Inspector"), + handle.listen, + ); + log::debug!( + "[desktop] internal upstream ports: deno={} cef={}", + deno_internal, + cef_internal, + ); + + cmd + .env( + "DENO_DESKTOP_INSPECT_INTERNAL_PORT", + deno_internal.to_string(), + ) + // Exposed so rt_desktop's `openDevtools()` can launch a browser + // pointed at the unified DevTools frontend instead of CEF's + // renderer-only native window. + .env("DENO_DESKTOP_MUX_WS", handle.listen.to_string()) + .env( + "LAUFEY_REMOTE_DEBUGGING_PORT", + cef_internal.port().to_string(), + ); + if flags.inspect_brk.is_some() { + cmd.env("DENO_DESKTOP_INSPECT_BRK", "1"); + } + if flags.inspect_wait.is_some() { + cmd.env("DENO_DESKTOP_INSPECT_WAIT", "1"); + } + Some(handle) + } else { + None + }; + + // `kill_on_drop` is a safety net: if the parent panics or exits via any + // path that doesn't reach the explicit `wait` below, the LAUFEY backend + // (and its CEF renderer subprocesses) get SIGKILLed on `Child` drop + // rather than being orphaned. Normal Ctrl-C delivers SIGINT to the + // whole process group so this rarely matters in practice; it covers + // the abnormal-exit cases. + // + // On macOS we go through posix_spawn with TCC responsibility disclaimed + // (see `disclaim_spawn`) so the laufey child is its own permission principal. + // Without this, the kernel attributes notification/location/etc requests + // to whatever started deno (typically the terminal), which has no bundle + // id and causes `UNUserNotificationCenter.requestAuthorization` to fail + // with UNErrorCodeNotificationsNotAllowed before any user prompt. + #[cfg(target_os = "macos")] + let status = { + let mut child = disclaim_spawn::spawn(&cmd).with_context(|| { + format!( + "Failed to launch LAUFEY backend: {}", + laufey_backend.display() + ) + })?; + child + .wait() + .await + .context("Failed waiting for LAUFEY backend")? + }; + #[cfg(not(target_os = "macos"))] + let status = { + let mut child = tokio::process::Command::from(cmd) + .kill_on_drop(true) + .spawn() + .with_context(|| { + format!( + "Failed to launch LAUFEY backend: {}", + laufey_backend.display() + ) + })?; + child + .wait() + .await + .context("Failed waiting for LAUFEY backend")? + }; + + // Keep the mux alive until the subprocess exits, then drop it. + drop(mux_handle); + + if !status.success() { + bail!("LAUFEY backend exited with status: {}", status); + } + Ok(()) +} + +/// Package a compiled desktop dylib into a platform-specific app bundle. +async fn package_desktop_app( + dylib_path: &Path, + desktop_flags: &DesktopFlags, + cli_options: &CliOptions, + laufey_resolver: &LaufeyBackendResolver, +) -> Result { + let target = desktop_flags.target.as_deref(); + let is_darwin = match target { + Some(target) => target.contains("darwin"), + None => cfg!(target_os = "macos"), + }; + let is_windows = match target { + Some(target) => target.contains("windows"), + None => cfg!(target_os = "windows"), + }; + + if is_darwin { + package_macos_app_bundle( + dylib_path, + desktop_flags, + cli_options, + laufey_resolver, + ) + .await + } else if is_windows { + package_windows_app_dir( + dylib_path, + desktop_flags, + cli_options, + laufey_resolver, + ) + .await + } else { + package_linux_app_dir( + dylib_path, + desktop_flags, + cli_options, + laufey_resolver, + ) + .await + } +} + +/// Create a Windows app directory from the compiled desktop dylib. +/// +/// Directory structure: +/// ```text +/// AppName/ +/// AppName.bat (launcher) +/// laufey.exe (LAUFEY backend binary) +/// libcef.dll, ... (CEF support files, if any) +/// denort.dll (compiled Deno runtime + user code) +/// AppIcon.ico (optional) +/// ``` +async fn package_windows_app_dir( + dylib_path: &Path, + desktop_flags: &DesktopFlags, + cli_options: &CliOptions, + laufey_resolver: &LaufeyBackendResolver, +) -> Result { + let parts = dylib_parts(dylib_path)?; + let app_name = parts.app_name; + let app_dir = parts.parent.join(&app_name); + + let backend = desktop_flags.backend.as_deref().unwrap_or("cef"); + let target = laufey_target_for(desktop_flags); + let laufey_binary = laufey_resolver.find_binary(backend, target).await?; + let laufey_dir = laufey_resolver.find_binary_dir(backend, target).await?; + let laufey_binary_name = laufey_binary + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + + if app_dir.exists() { + std::fs::remove_dir_all(&app_dir)?; + } + + // Copy LAUFEY backend directory (binary + CEF support files) as the shell. + crate::tools::compile::copy_dir_all(&laufey_dir, &app_dir)?; + + // Drop any self-extracting runtime cache dir that tagged along. + let laufey_exe_stem = Path::new(&laufey_binary_name) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| laufey_binary_name.clone()); + let cache_dir = app_dir.join(format!(".{}", laufey_exe_stem)); + if cache_dir.exists() { + let _ = std::fs::remove_dir_all(&cache_dir); + } + let cache_file = app_dir.join(format!(".{}.cache", laufey_exe_stem)); + if cache_file.exists() { + let _ = std::fs::remove_file(&cache_file); + } + + // Copy the compiled dylib (denort.dll) alongside the backend binary. + let dylib_filename = parts.file_name; + let dest_dylib = app_dir.join(dylib_filename); + std::fs::copy(dylib_path, &dest_dylib)?; + + // Create a .bat launcher that invokes the backend with --runtime. + // Validate every name we interpolate: cmd.exe expands `%VAR%` and + // treats `^` `&` etc. as command separators even inside `"..."`. + let dylib_filename_str = dylib_filename.to_string_lossy(); + validate_launcher_name(&app_name, "app name")?; + validate_launcher_name(&laufey_binary_name, "LAUFEY backend binary name")?; + validate_launcher_name(&dylib_filename_str, "dylib filename")?; + let launcher_path = app_dir.join(format!("{}.bat", app_name)); + std::fs::write( + &launcher_path, + format!( + "@echo off\r\n\ + set DIR=%~dp0\r\n\ + \"%DIR%{laufey_binary}\" --runtime \"%DIR%{dylib}\" %*\r\n", + laufey_binary = laufey_binary_name, + dylib = dylib_filename_str, + ), + )?; + + // Handle icon — drop an .ico next to the launcher. Embedding the icon + // into the .exe itself requires rcedit or equivalent and is out of scope. + if let Some(ref icon) = desktop_flags.icon { + let dest = app_dir.join("AppIcon.ico"); + match icon { + crate::args::IconConfig::Single(path) => { + let icon_path = cli_options.initial_cwd().join(path); + if icon_path.exists() { + match icon_path.extension().and_then(|e| e.to_str()) { + Some("ico") => { + std::fs::copy(&icon_path, &dest)?; + } + _ => { + log::warn!( + "Icon '{}' is not .ico, skipping", + icon_path.display() + ); + } + } + } else { + log::warn!("Icon '{}' not found, skipping", icon_path.display()); + } + } + crate::args::IconConfig::Set(entries) => { + convert_icon_set_to_ico(cli_options.initial_cwd(), entries, &dest)?; + } + } + } + + // Remove the standalone dylib (it's now inside the app dir). + let _ = std::fs::remove_file(dylib_path); + + Ok(app_dir) +} + +/// Create a Linux app directory from the compiled desktop dylib. +/// +/// Directory structure: +/// ```text +/// AppName/ +/// AppName (launcher shell script) +/// laufey (LAUFEY backend binary) +/// libcef.so, ... (CEF support files, if any) +/// libdenort.so (compiled Deno runtime + user code) +/// AppIcon.png (optional) +/// ``` +async fn package_linux_app_dir( + dylib_path: &Path, + desktop_flags: &DesktopFlags, + cli_options: &CliOptions, + laufey_resolver: &LaufeyBackendResolver, +) -> Result { + let parts = dylib_parts(dylib_path)?; + // `file_stem` on "libdenort.so" returns "libdenort" — strip the "lib" prefix + // so the app directory is named after the app, not the runtime library. + let app_name = parts + .app_name + .strip_prefix("lib") + .map(|s| s.to_string()) + .unwrap_or(parts.app_name); + let app_dir = parts.parent.join(&app_name); + + let backend = desktop_flags.backend.as_deref().unwrap_or("cef"); + let target = laufey_target_for(desktop_flags); + let laufey_binary = laufey_resolver.find_binary(backend, target).await?; + let laufey_dir = laufey_resolver.find_binary_dir(backend, target).await?; + let laufey_binary_name = laufey_binary + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + + if app_dir.exists() { + std::fs::remove_dir_all(&app_dir)?; + } + + // Copy LAUFEY backend directory (binary + CEF support files) as the shell. + crate::tools::compile::copy_dir_all(&laufey_dir, &app_dir)?; + + // Drop any self-extracting runtime cache dir that tagged along. + let laufey_exe_stem = Path::new(&laufey_binary_name) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| laufey_binary_name.clone()); + let cache_dir = app_dir.join(format!(".{}", laufey_exe_stem)); + if cache_dir.exists() { + let _ = std::fs::remove_dir_all(&cache_dir); + } + let cache_file = app_dir.join(format!(".{}.cache", laufey_exe_stem)); + if cache_file.exists() { + let _ = std::fs::remove_file(&cache_file); + } + + // Copy the compiled dylib alongside the backend binary. + let dylib_filename = parts.file_name; + let dest_dylib = app_dir.join(dylib_filename); + std::fs::copy(dylib_path, &dest_dylib)?; + + // Create a shell launcher that invokes the backend with --runtime. + // --ozone-platform=x11 forces CEF to create X11 windows (via XWayland on + // Wayland sessions). The Linux LAUFEY mouse/focus/resize event monitor uses + // XI2 on X11 and does not support Wayland. + // GDK_BACKEND=x11 aligns GDK with Ozone so GDK_IS_X11_DISPLAY is true. + // + // Validate every name we interpolate: bash expands `$VAR`, backticks, + // and `$(...)` even inside `"..."`. + let dylib_filename_str = dylib_filename.to_string_lossy(); + validate_launcher_name(&app_name, "app name")?; + validate_launcher_name(&laufey_binary_name, "LAUFEY backend binary name")?; + validate_launcher_name(&dylib_filename_str, "dylib filename")?; + let launcher_path = app_dir.join(&app_name); + std::fs::write( + &launcher_path, + format!( + "#!/bin/sh\n\ + DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ + export GDK_BACKEND=x11\n\ + exec \"$DIR/{laufey_binary}\" --ozone-platform=x11 --runtime \"$DIR/{dylib}\" \"$@\"\n", + laufey_binary = laufey_binary_name, + dylib = dylib_filename_str, + ), + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &launcher_path, + std::fs::Permissions::from_mode(0o755), + )?; + } + + // Handle icon — copy a .png next to the launcher. + if let Some(ref icon) = desktop_flags.icon { + let dest = app_dir.join("AppIcon.png"); + match icon { + crate::args::IconConfig::Single(path) => { + let icon_path = cli_options.initial_cwd().join(path); + if icon_path.exists() { + match icon_path.extension().and_then(|e| e.to_str()) { + Some("png") => { + std::fs::copy(&icon_path, &dest)?; + } + _ => { + log::warn!( + "Icon '{}' is not .png, skipping", + icon_path.display() + ); + } + } + } else { + log::warn!("Icon '{}' not found, skipping", icon_path.display()); + } + } + crate::args::IconConfig::Set(entries) => { + // Pick the largest provided size as the single icon file. + if let Some(largest) = entries.iter().max_by_key(|e| e.size) { + let src = cli_options.initial_cwd().join(&largest.path); + if src.exists() { + std::fs::copy(&src, &dest)?; + } else { + log::warn!("Icon '{}' not found, skipping", src.display()); + } + } + } + } + } + + // Write a `.desktop` entry alongside the launcher so a user dropping + // the app dir into `~/.local/share/applications/` gets the right + // name/icon attribution on notifications and in the taskbar. laufey + // doesn't read this file — only the OS does — but libnotify and + // GNOME Shell key notification attribution on the desktop file's + // `StartupWMClass` and `Icon` fields. + let desktop_id = desktop_flags + .identifier + .clone() + .unwrap_or_else(|| format!("com.deno.desktop.{}", app_name.to_lowercase())); + if let Err(e) = validate_bundle_identifier(&desktop_id) { + log::warn!( + "skipping .desktop file: {e} (desktop file IDs follow the same reverse-DNS rules as macOS bundle IDs)" + ); + } else { + let desktop_entry = format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name={app_name}\n\ + Exec={app_name}\n\ + Icon=AppIcon\n\ + StartupWMClass={desktop_id}\n\ + Categories=Utility;\n", + ); + std::fs::write( + app_dir.join(format!("{desktop_id}.desktop")), + desktop_entry, + )?; + } + + // Remove the standalone dylib (it's now inside the app dir). + let _ = std::fs::remove_file(dylib_path); + + Ok(app_dir) +} + +/// Environment variable pointing at a local laufey checkout, used to bypass the +/// download path during development. Build-tree subpaths under this directory +/// are searched the same way the old sibling-checkout heuristic searched. +const LAUFEY_DEV_DIR_ENV: &str = "LAUFEY_DEV_DIR"; + +/// Resolves LAUFEY backend binaries and `.app` bundles, falling back to +/// downloading prebuilt archives from the laufey GitHub releases when +/// `LAUFEY_DEV_DIR` is not set. +struct LaufeyBackendResolver { + http_client_provider: Arc, + /// `/laufey//` + cache_root: PathBuf, +} + +impl LaufeyBackendResolver { + fn new(factory: &CliFactory) -> Result { + let cache_root = + factory.deno_dir()?.root.join("laufey").join(LAUFEY_VERSION); + Ok(Self { + http_client_provider: factory.http_client_provider().clone(), + cache_root, + }) + } + + fn backend_cache_dir(&self, backend: &str, target: &str) -> PathBuf { + self.cache_root.join(backend).join(target) + } + + /// Download + verify + extract a backend archive if it isn't already in + /// `/laufey////`. + async fn ensure_downloaded( + &self, + backend: &str, + target: &str, + ) -> Result { + let dir = self.backend_cache_dir(backend, target); + let marker = dir.join(".downloaded"); + if marker.exists() { + return Ok(dir); + } + + let archive = laufey_archive_name(backend, target); + let client = self.http_client_provider.get_or_create()?; + + // Use the in-tree pinned digests rather than fetching SHA256SUMS from the + // release page. The latter is unsigned, so trusting it would let anyone + // who can write to the laufey release host swap both archive and sums + // together (TOFU). The lock file is reviewed in PRs when LAUFEY_VERSION + // bumps, so this is the trust anchor. That the lock file's pinned version + // matches LAUFEY_VERSION is asserted at build time (see cli/build.rs). + let expected = parse_sha256sum(LAUFEY_PINNED_SUMS, &archive).ok_or_else(|| { + deno_core::anyhow::anyhow!( + "no pinned SHA-256 for {archive} in cli/laufey_sums.lock \ + (regenerate when bumping LAUFEY_VERSION to v{LAUFEY_VERSION}; \ + laufey v{LAUFEY_VERSION} release may not include backend '{backend}' for target '{target}')" + ) + })?; + + log::info!( + "{} laufey {} backend for {} (v{})", + colors::green("Downloading"), + backend, + target, + LAUFEY_VERSION, + ); + + let url = Url::parse(&laufey_release_url(&archive))?; + let progress_bar = ProgressBar::new(ProgressBarStyle::DownloadBars); + let progress = progress_bar.update(&archive); + // Send a real User-Agent — some CDNs (incl. parts of GitHub + // releases) start rate-limiting empty UAs aggressively. + let mut headers = http::HeaderMap::new(); + if let Ok(ua) = http::HeaderValue::from_str(&format!( + "deno-desktop/{} (+https://deno.com)", + env!("CARGO_PKG_VERSION") + )) { + headers.insert(http::header::USER_AGENT, ua); + } + let response = client + .download_with_progress_and_retries(url.clone(), &headers, &progress) + .await + .with_context(|| format!("failed to download {url}"))?; + let data = response + .into_maybe_bytes()? + .ok_or_else(|| deno_core::anyhow::anyhow!("empty response from {url}"))?; + + let actual = + faster_hex::hex_string(&sha2::Sha256::digest(&data)).to_lowercase(); + let expected_lc = expected.to_lowercase(); + if actual != expected_lc { + // Include the URL in the bail: an attacker who poisoned a redirect + // would otherwise be invisible in the failure log. + bail!( + "checksum mismatch for {archive} (downloaded from {url})\n expected: {expected_lc}\n actual: {actual}" + ); + } + + let parent = dir.parent().ok_or_else(|| { + deno_core::anyhow::anyhow!( + "LAUFEY cache dir has no parent: {}", + dir.display() + ) + })?; + std::fs::create_dir_all(parent)?; + + // Stage extraction in a sibling tempdir so concurrent `deno desktop` + // builds don't see (or stomp on) a half-populated `dir` while one + // is mid-extract. tempfile's cleanup-on-drop covers panic / + // early-return paths; on the happy path we consume the TempDir via + // `into_path` so the rename below sees a real directory. + let staging = tempfile::Builder::new() + .prefix(".staging-") + .tempdir_in(parent) + .with_context(|| { + format!("failed to stage tempdir in {}", parent.display()) + })?; + + extract_laufey_archive(&archive, &data, staging.path()) + .with_context(|| format!("failed to extract {archive}"))?; + // Marker is written into the staging dir so it lands atomically with + // the rest of the contents — a SIGKILL during extraction can never + // leave a marker-without-payload state. + std::fs::write( + staging.path().join(".downloaded"), + format!("v{LAUFEY_VERSION}\n"), + )?; + + // Another process may have raced us and finished its extract while + // we were downloading. Use theirs and let staging clean up on drop. + if marker.exists() { + return Ok(dir); + } + + // Discard any prior failed-extract debris (no marker ⇒ partial). + // `rename` refuses a non-empty target. + if dir.exists() { + std::fs::remove_dir_all(&dir)?; + } + + let staging_path = staging.into_path(); + if let Err(e) = std::fs::rename(&staging_path, &dir) { + // Lost a rename race with a concurrent process — its dir is at + // our target path now. Drop our staged copy and use theirs. + let _ = std::fs::remove_dir_all(&staging_path); + if marker.exists() { + return Ok(dir); + } + return Err(deno_core::anyhow::anyhow!( + "failed to atomic-rename LAUFEY cache to {}: {e}", + dir.display(), + )); + } + + // The laufey release archive linker-signs the `laufey` binary with + // identifier=`laufey` rather than the .app's CFBundleIdentifier. UN + // (UNUserNotificationCenter) rejects authorization requests when + // the running binary's signed identifier disagrees with the host + // bundle's id (UNErrorCodeNotificationsNotAllowed, error code 1). + // In HMR mode the user's project bundle isn't involved — we run + // laufey.app directly — so we have to fix the cached copy itself. + // Re-sign every Mach-O inside the cached laufey.app with the bundle + // id so the running identity is internally consistent. No-op on + // non-macOS targets / hosts (no `codesign(1)`). + #[cfg(target_os = "macos")] + if target.contains("apple-darwin") + && let Err(e) = harmonize_cached_laufey_identifiers(&dir, backend) + { + log::warn!( + "[desktop] could not re-sign cached laufey backend: {e} \ + (notifications may not work in HMR mode until you re-download)" + ); + } + + Ok(dir) + } + + /// Locate the LAUFEY backend binary for `backend` on `target`. + /// + /// Resolution order: `LAUFEY_DEV_DIR` checkout → cached download → + /// fresh download. + async fn find_binary( + &self, + backend: &str, + target: &str, + ) -> Result { + if let Some(dev_dir) = laufey_dev_dir() { + let binary = + locate_dev_backend_binary(&dev_dir, backend).ok_or_else(|| { + deno_core::anyhow::anyhow!( + "could not find '{backend}' backend binary under {} (set via {})", + dev_dir.display(), + LAUFEY_DEV_DIR_ENV + ) + })?; + // Re-sign the dev laufey build so its identifier matches the bundle + // id — same fix as the download path, applied every launch + // because a fresh `cargo build` of laufey restores the linker's + // default `Identifier=laufey`. The harmonize call is idempotent: + // already-correct binaries are skipped. + #[cfg(target_os = "macos")] + if let Some(laufey_app) = laufey_app_for_binary(&binary) + && let Err(e) = harmonize_laufey_app_identifiers(&laufey_app) + { + log::warn!( + "[desktop] could not re-sign dev laufey backend: {e} \ + (notifications may not work in HMR mode)" + ); + } + return Ok(binary); + } + + let dir = self.ensure_downloaded(backend, target).await?; + locate_backend_binary(&dir, backend, target).ok_or_else(|| { + deno_core::anyhow::anyhow!( + "could not find '{backend}' backend binary inside {}", + dir.display() + ) + }) + } + + /// Locate the LAUFEY `.app` bundle for `backend` on a macOS `target`. + async fn find_app_bundle( + &self, + backend: &str, + target: &str, + ) -> Result { + if let Some(dev_dir) = laufey_dev_dir() { + return locate_dev_app_bundle(&dev_dir, backend).ok_or_else(|| { + deno_core::anyhow::anyhow!( + "could not find '{backend}' .app bundle under {} (set via {})", + dev_dir.display(), + LAUFEY_DEV_DIR_ENV + ) + }); + } + + let dir = self.ensure_downloaded(backend, target).await?; + locate_app_bundle(&dir, backend).ok_or_else(|| { + deno_core::anyhow::anyhow!( + "could not find '{backend}' .app bundle inside {} (backend may not ship as an app for target '{target}')", + dir.display() + ) + }) + } + + /// Directory containing the backend binary and its support files (used on + /// Windows / Linux where support files sit alongside the binary). + async fn find_binary_dir( + &self, + backend: &str, + target: &str, + ) -> Result { + let binary = self.find_binary(backend, target).await?; + let parent = binary.parent().ok_or_else(|| { + deno_core::anyhow::anyhow!( + "LAUFEY backend binary has no parent directory: {}", + binary.display() + ) + })?; + Ok(parent.to_path_buf()) + } +} + +fn laufey_archive_name(backend: &str, target: &str) -> String { + let ext = if target.contains("windows") { + "zip" + } else { + "tar.gz" + }; + // The `raw` backend ships under the `winit` archive name upstream. + let archive_backend = match backend { + "raw" => "winit", + other => other, + }; + format!("laufey-{archive_backend}-{target}.{ext}") +} + +fn laufey_release_url(file: &str) -> String { + format!( + "https://github.com/littledivy/laufey/releases/download/v{LAUFEY_VERSION}/{file}" + ) +} + +/// Pick out the hex digest for `file` from a GNU `sha256sum`-style file. Each +/// line is ` ` (optionally ` *` for binary +/// mode). +fn parse_sha256sum(contents: &str, file: &str) -> Option { + for line in contents.lines() { + let mut parts = line.split_whitespace(); + // Skip blank / whitespace-only lines instead of bailing out of the + // whole parse — `?` would terminate the function early on the first + // empty line and quietly miss every subsequent entry. + let Some(hex) = parts.next() else { continue }; + let Some(name) = parts.next() else { continue }; + if name.trim_start_matches('*') == file { + return Some(hex.to_string()); + } + } + None +} + +fn extract_laufey_archive( + name: &str, + data: &[u8], + dest: &Path, +) -> Result<(), AnyError> { + if name.ends_with(".tar.gz") { + let decoder = flate2::read::GzDecoder::new(data); + let mut archive = tar::Archive::new(decoder); + // Strip mode bits from archive entries: a tampered archive could + // otherwise ship setuid/setgid binaries into the deno cache. Files keep + // the umask-applied default; we re-add execute bits below for entries + // that need them. + archive.set_preserve_permissions(false); + for entry in archive.entries()? { + let mut entry = entry?; + let entry_path = entry.path()?.into_owned(); + // Defence in depth — pre-check `..` / root before handing to + // `unpack_in`, since we want a hard error rather than the silent + // skip that `unpack_in` does for a rejected entry. + if entry_path.components().any(|c| { + matches!( + c, + std::path::Component::ParentDir | std::path::Component::RootDir + ) + }) { + bail!( + "refusing tar entry with traversal path: {}", + entry_path.display() + ); + } + // `unpack_in` (vs. `unpack(absolute_path)`) makes tar enforce its + // symlink + hardlink target containment too: a tar with entry A as + // symlink `foo -> ../../etc` followed by entry B writing + // `foo/passwd` would otherwise escape `dest`. + if !entry.unpack_in(dest)? { + bail!( + "refusing tar entry that would unpack outside dest: {}", + entry_path.display() + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let dest_path = dest.join(&entry_path); + // `symlink_metadata` so we don't follow a just-extracted symlink + // and chmod its target. + if let Ok(meta) = std::fs::symlink_metadata(&dest_path) + && meta.file_type().is_file() + { + // Was the entry executable? If so, mask to 0o755; otherwise 0o644. + let mode = entry.header().mode().unwrap_or(0o644); + let safe = if mode & 0o111 != 0 { 0o755 } else { 0o644 }; + let mut perms = meta.permissions(); + perms.set_mode(safe); + let _ = std::fs::set_permissions(&dest_path, perms); + } + } + } + } else if name.ends_with(".zip") { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(data))?; + // Iterate entries manually rather than `archive.extract(dest)`: that + // helper has the same shape as the tar `unpack` we deliberately + // avoided (no perm masking; no defence-in-depth against zip-slip + // beyond the crate's own checks). Treat the archive as untrusted. + for i in 0..archive.len() { + let mut entry = archive.by_index(i)?; + // `enclosed_name` rejects drive labels, absolute paths and `..` + // components. Anything that fails this check is a zip-slip attempt + // (or a legitimately weird archive we don't want to handle). + let Some(rel_path) = entry.enclosed_name() else { + bail!("refusing zip entry with unsafe path: {}", entry.name()); + }; + // Defence in depth — re-check the components ourselves. + if rel_path.components().any(|c| { + matches!( + c, + std::path::Component::ParentDir | std::path::Component::RootDir + ) + }) { + bail!( + "refusing zip entry with traversal path: {}", + rel_path.display() + ); + } + // Refuse symlinks: with prior entries already extracted, a + // symlink-then-write pair is the standard zip-slip-via-symlink + // escape, and LAUFEY Windows archives have no legitimate need for + // them. + if entry.is_symlink() { + bail!( + "refusing symlink entry in laufey archive: {}", + rel_path.display() + ); + } + let dest_path = dest.join(&rel_path); + if entry.is_dir() { + std::fs::create_dir_all(&dest_path)?; + continue; + } + if let Some(parent) = dest_path.parent() { + std::fs::create_dir_all(parent)?; + } + let mut out = std::fs::File::create(&dest_path)?; + std::io::copy(&mut entry, &mut out)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Mask to 0o755 / 0o644 — same policy as the tar branch. + // setuid/setgid/sticky bits are dropped; world-writable bits too. + let mode = entry.unix_mode().unwrap_or(0o644); + let safe = if mode & 0o111 != 0 { 0o755 } else { 0o644 }; + let _ = std::fs::set_permissions( + &dest_path, + std::fs::Permissions::from_mode(safe), + ); + } + } + } else { + bail!("unsupported archive format: {name}"); + } + Ok(()) +} + +/// Resolve the backend binary path inside an extracted archive directory. +fn locate_backend_binary( + dir: &Path, + backend: &str, + target: &str, +) -> Option { + let is_windows = target.contains("windows"); + let is_macos = target.contains("apple-darwin"); + match backend { + "cef" if is_macos => { + let p = dir.join("laufey.app/Contents/MacOS/laufey"); + p.exists().then_some(p) + } + "webview" if is_macos => { + let p = dir.join("laufey_webview.app/Contents/MacOS/laufey_webview"); + p.exists().then_some(p) + } + _ => { + let stem = match backend { + "cef" => "laufey", + "raw" => "laufey_winit", + _ => "laufey_webview", + }; + let exe = if is_windows { + format!("{stem}.exe") + } else { + stem.to_string() + }; + let p = dir.join(&exe); + p.exists().then_some(p) + } + } +} + +fn locate_app_bundle(dir: &Path, backend: &str) -> Option { + let name = match backend { + "cef" => "laufey.app", + _ => "laufey_webview.app", + }; + let p = dir.join(name); + p.exists().then_some(p) +} + +/// Target triple to use when selecting a laufey backend archive. Honors +/// `desktop_flags.target` (for cross-target packaging); otherwise defaults to +/// the host triple this deno binary was built for. +fn laufey_target_for(desktop_flags: &DesktopFlags) -> &str { + desktop_flags + .target + .as_deref() + .unwrap_or(LAUFEY_NATIVE_TARGET) +} + +/// Resolve `LAUFEY_DEV_DIR` to a directory path if set and present on disk. +fn laufey_dev_dir() -> Option { + let raw = std::env::var(LAUFEY_DEV_DIR_ENV).ok()?; + let p = PathBuf::from(raw); + p.is_dir().then_some(p) +} + +/// Find a built backend binary inside a laufey checkout. Mirrors the well-known +/// build-tree paths produced by laufey's Makefile + Nix flakes. +fn locate_dev_backend_binary(laufey: &Path, backend: &str) -> Option { + let candidates: Vec = match backend { + "cef" => vec![ + laufey.join("result-cef/Applications/laufey.app/Contents/MacOS/laufey"), + laufey.join("result/Applications/laufey.app/Contents/MacOS/laufey"), + laufey.join("cef/build/Release/laufey.app/Contents/MacOS/laufey"), + laufey.join("cef/build/laufey.app/Contents/MacOS/laufey"), + laufey.join("cef/build/Release/laufey"), + laufey.join("cef/build/laufey"), + ], + "raw" => vec![ + laufey.join("target/release/laufey_winit"), + laufey.join("target/debug/laufey_winit"), + ], + _ => vec![ + laufey.join( + "result-1/Applications/laufey_webview.app/Contents/MacOS/laufey_webview", + ), + laufey + .join("result/Applications/laufey_webview.app/Contents/MacOS/laufey_webview"), + laufey.join("webview/build/laufey_webview.app/Contents/MacOS/laufey_webview"), + laufey.join("webview/build/laufey_webview"), + ], + }; + candidates.into_iter().find(|p| p.exists()) +} + +/// Find a built backend `.app` bundle inside a laufey checkout. +fn locate_dev_app_bundle(laufey: &Path, backend: &str) -> Option { + let candidates: Vec = match backend { + "cef" => vec![ + laufey.join("result-cef/Applications/laufey.app"), + laufey.join("result/Applications/laufey.app"), + laufey.join("cef/build/Release/laufey.app"), + laufey.join("cef/build/laufey.app"), + ], + "raw" => return None, + _ => vec![ + laufey.join("result-1/Applications/laufey_webview.app"), + laufey.join("result/Applications/laufey_webview.app"), + laufey.join("webview/build/laufey_webview.app"), + ], + }; + candidates.into_iter().find(|p| p.exists()) +} + +/// Read a top-level string from a plist file (XML or binary). +/// +/// Uses the `plist` crate so a hostile or just-non-trivial Info.plist +/// (CDATA, entities, binary plist format, key reordered, etc.) parses +/// correctly — the previous string-scan implementation could be tricked +/// or silently mis-extract. Returns `None` on any read or parse failure. +fn read_plist_string(path: &Path, key: &str) -> Option { + let dict: plist::Dictionary = plist::from_file(path).ok()?; + dict.get(key)?.as_string().map(|s| s.to_string()) +} + +/// Validate a reverse-DNS bundle identifier (Apple `CFBundleIdentifier`, +/// also used for Linux `.desktop` filenames and Windows AppUserModelID). +/// +/// Apple's rules: ASCII alphanumerics, hyphens, and dots; must have at +/// least one dot (so it looks like reverse DNS); each dot-separated +/// segment must be non-empty and not start with a digit. We don't +/// enforce the segment-leading-letter rule strictly (some legacy apps +/// use digits) but we do reject empty segments and obvious shell +/// metacharacters — the identifier ends up as a `codesign` argument and +/// a path component of the helper bundles. +fn validate_bundle_identifier(id: &str) -> Result<(), AnyError> { + if id.is_empty() { + bail!("bundle identifier is empty"); + } + if id.len() > 155 { + // Apple's documented limit for CFBundleIdentifier on receipts is + // 155 chars; bigger values quietly truncate elsewhere in the + // toolchain. + bail!("bundle identifier {id:?} is longer than 155 characters"); + } + if !id.contains('.') { + bail!( + "bundle identifier {id:?} must be in reverse-DNS form (e.g. com.acme.foo)" + ); + } + for c in id.chars() { + if !(c.is_ascii_alphanumeric() || c == '.' || c == '-') { + bail!( + "bundle identifier {id:?} must match [A-Za-z0-9.-]+, but contains {c:?}", + ); + } + } + if id.split('.').any(|seg| seg.is_empty()) { + bail!("bundle identifier {id:?} has an empty segment"); + } + Ok(()) +} + +/// Walk every `.app` under `Contents/Frameworks/` and rewrite its +/// `CFBundleIdentifier` so it's a strict suffix of `main_bundle_id`. +/// +/// CEF's process model: when the browser process spawns a helper for a +/// child role (gpu, renderer, plugin, …), the helper inspects its own +/// `CFBundleIdentifier` and refuses to attach to a parent whose id +/// doesn't match it as a prefix. laufey's default helper plists ship with +/// `com.example.laufey.helper.*` — which is inconsistent with whatever id +/// we wrote into the main bundle, so we'd get a launch-time refusal +/// (the helper exits silently and the browser hangs waiting for it). +/// +/// We compute the new id by extracting the "kind" suffix from the +/// existing id (everything from the last `helper` segment onward) and +/// concatenating it onto the main id. So `com.example.laufey.helper` → +/// `
.helper`, `com.example.laufey.helper.gpu` → `
.helper.gpu`. +fn rewrite_cef_helper_bundle_ids( + contents_dir: &Path, + main_bundle_id: &str, +) -> Result<(), AnyError> { + let frameworks = contents_dir.join("Frameworks"); + if !frameworks.exists() { + return Ok(()); + } + let entries = match std::fs::read_dir(&frameworks) { + Ok(e) => e, + Err(_) => return Ok(()), + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + // Only touch helper apps. The CEF framework directory + // (`Chromium Embedded Framework.framework`) also lives here and + // has its own bundle id that we must not rewrite — touching it + // would invalidate its embedded code signature. + if !path.is_dir() || !name.ends_with(".app") || !name.contains("Helper") { + continue; + } + let plist_path = path.join("Contents/Info.plist"); + if !plist_path.exists() { + continue; + } + rewrite_helper_plist_identifier(&plist_path, main_bundle_id).with_context( + || format!("failed to rewrite helper plist at {}", plist_path.display()), + )?; + } + Ok(()) +} + +/// Rewrite a single helper's `CFBundleIdentifier` based on the existing +/// value's "kind" suffix. +fn rewrite_helper_plist_identifier( + plist_path: &Path, + main_bundle_id: &str, +) -> Result<(), AnyError> { + let mut dict: plist::Dictionary = plist::from_file(plist_path) + .with_context(|| format!("failed to parse {}", plist_path.display()))?; + let existing = dict + .get("CFBundleIdentifier") + .and_then(|v| v.as_string()) + .ok_or_else(|| { + deno_core::anyhow::anyhow!( + "helper plist {} has no CFBundleIdentifier", + plist_path.display() + ) + })?; + // Extract the suffix from the last `helper` segment onward. Falls back + // to a bare `helper` if the existing id doesn't contain that token + // (defensive — every laufey helper plist has it today). + let suffix = existing + .find("helper") + .map(|i| &existing[i..]) + .unwrap_or("helper"); + let new_id = format!("{main_bundle_id}.{suffix}"); + dict.insert( + "CFBundleIdentifier".to_string(), + plist::Value::String(new_id), + ); + // Write XML format for stability and human-diffability. Helper plists + // are tiny so we don't gain anything by switching to binary plist + // format; XML is what laufey ships and what `codesign` expects to find. + plist::to_file_xml(plist_path, &dict) + .with_context(|| format!("failed to write {}", plist_path.display()))?; + Ok(()) +} + +/// Codesign the macOS bundle in place. Signs every helper `.app` and +/// the embedded CEF framework first, then the main bundle (signatures +/// nest: the outer signature's CodeDirectory hashes the inner ones, so +/// outer-last is the only order that works). +/// +/// Re-uses the JIT entitlements that ship with the laufey CEF bundle +/// (`Contents/Frameworks/.app/Contents/Resources/...entitlements...` +/// or, more robustly, the per-helper entitlements laufey bundles next to +/// each helper). When entitlements aren't present we fall back to +/// signing without them — the binary will still launch but V8 won't +/// get JIT permission. +fn codesign_macos_bundle( + app_bundle: &Path, + identity: &str, +) -> Result<(), AnyError> { + if !cfg!(target_os = "macos") { + bail!( + "codesigning requires a macOS build host (uses `codesign(1)`). \ + Run `deno desktop` on macOS, or drop `macos.codesignIdentity` \ + from your deno.json when cross-building." + ); + } + if identity.is_empty() { + bail!("macos.codesignIdentity is empty"); + } + log::info!( + "{} bundle with identity {:?}", + colors::green("Codesigning"), + identity, + ); + + // Read the bundle id from the main Info.plist so we can override the + // signed identifier on `Contents/MacOS/laufey`. The default identifier + // codesign infers from a bare Mach-O binary is `laufey` (the basename), + // which doesn't match the .app's CFBundleIdentifier — and UN refuses + // notification authorization when the running binary's signed id + // doesn't match the bundle's id. Forcing `--identifier=` + // makes them match. + let bundle_id = read_bundle_identifier(app_bundle)?; + + // Sign helpers first (inside → outside). The order within helpers + // doesn't matter — they don't nest into each other. + let frameworks = app_bundle.join("Contents/Frameworks"); + if frameworks.exists() + && let Ok(entries) = std::fs::read_dir(&frameworks) + { + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = + path.file_name().map(|s| s.to_string_lossy().into_owned()) + else { + continue; + }; + if path.is_dir() && name.ends_with(".app") { + let helper_entitlements = locate_helper_entitlements(&path); + codesign_one(&path, identity, helper_entitlements.as_deref(), None)?; + } else if path.is_dir() && name.ends_with(".framework") { + // Frameworks (notably the CEF framework) have an embedded + // signature already — `codesign --force` re-signs the + // versioned directory in place. + codesign_one(&path, identity, None, None)?; + } + } + } + + // Sign `Contents/MacOS/laufey` (and the launcher shim, the dylib) with + // the bundle's id as the signed identifier. This is what UN keys + // notification permission to — without it, UN sees `laufey` requesting + // permission for `com.example.app` and rejects with + // `UNErrorCodeNotificationsNotAllowed`. + let macos_dir = app_bundle.join("Contents/MacOS"); + if let Ok(entries) = std::fs::read_dir(&macos_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + // Skip non-Mach-O files: the auto-update sentinel (`*.update-ok`), + // staged updates (`*.update`, `*.backup`), and the POSIX shell + // launcher (`Contents/MacOS/` is a shell script that execs + // laufey). codesign can't sign a text file and would fail the + // whole signing pass. + if !is_macho_file(&path) { + continue; + } + codesign_one(&path, identity, None, Some(&bundle_id))?; + } + } + + // Finally the main bundle. Use the browser-process entitlements if + // laufey shipped them; otherwise sign with no entitlements (still + // launches, just no JIT for V8 in the browser process — which doesn't + // host V8 anyway, so this is fine). + let browser_entitlements = locate_browser_entitlements(app_bundle); + codesign_one(app_bundle, identity, browser_entitlements.as_deref(), None)?; + Ok(()) +} + +/// Read `CFBundleIdentifier` out of `Contents/Info.plist` via `plutil`. +fn read_bundle_identifier(app_bundle: &Path) -> Result { + let plist = app_bundle.join("Contents/Info.plist"); + let output = std::process::Command::new("plutil") + .arg("-extract") + .arg("CFBundleIdentifier") + .arg("raw") + .arg("-o") + .arg("-") + .arg(&plist) + .output() + .context("failed to invoke plutil(1) to read CFBundleIdentifier")?; + if !output.status.success() { + bail!( + "plutil could not read CFBundleIdentifier from {}: {}", + plist.display(), + String::from_utf8_lossy(&output.stderr).trim(), + ); + } + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if id.is_empty() { + bail!("CFBundleIdentifier is empty in {}", plist.display()); + } + Ok(id) +} + +/// Cheap Mach-O sniff: a Mach-O file starts with one of the well-known +/// magic numbers (32-bit, 64-bit, fat — both endians). Used to skip +/// non-binaries inside `Contents/MacOS/` (shell launchers, update +/// sentinels) before passing them to `codesign(1)`, which would reject +/// them and abort the signing pass. +fn is_macho_file(path: &Path) -> bool { + let Ok(mut f) = std::fs::File::open(path) else { + return false; + }; + use std::io::Read; + let mut magic = [0u8; 4]; + if f.read_exact(&mut magic).is_err() { + return false; + } + matches!( + u32::from_be_bytes(magic), + // FAT_MAGIC / FAT_CIGAM / FAT_MAGIC_64 / FAT_CIGAM_64 + 0xcafebabe | 0xbebafeca | 0xcafebabf | 0xbfbafeca + // MH_MAGIC / MH_CIGAM (32-bit) / MH_MAGIC_64 / MH_CIGAM_64 + | 0xfeedface | 0xcefaedfe | 0xfeedfacf | 0xcffaedfe + ) +} + +/// Search for a per-helper entitlements plist that laufey bundled with this +/// helper. Returns the path if found, `None` otherwise. +fn locate_helper_entitlements(helper_app: &Path) -> Option { + // laufey ships these alongside the helper binaries; the exact filename + // pattern depends on the laufey build. Probe the well-known names; fall + // back to the generic `entitlements-helper.plist` next to Contents. + let candidates = [ + helper_app.join("Contents/Resources/entitlements-helper.plist"), + helper_app + .parent() + .map(|p| p.join("entitlements-helper.plist")) + .unwrap_or_default(), + ]; + candidates.into_iter().find(|p| p.exists()) +} + +/// Locate the browser-process entitlements plist for the main bundle. +fn locate_browser_entitlements(app_bundle: &Path) -> Option { + let candidates = [ + app_bundle.join("Contents/Resources/entitlements-browser.plist"), + app_bundle.join("Contents/Resources/entitlements.plist"), + ]; + candidates.into_iter().find(|p| p.exists()) +} + +/// Run `codesign --force [--timestamp --options runtime] --sign [--entitlements ] `. +/// +/// `--options runtime` enables the Hardened Runtime, which is required +/// for notarization. `--force` overwrites any existing signature; the +/// helpers come pre-signed by laufey with an ad-hoc signature that we +/// always need to replace. +/// +/// Ad-hoc identity (`-`) skips `--timestamp` (no cert to anchor a +/// timestamp to) and `--options runtime` (Hardened Runtime + ad-hoc is +/// a Gatekeeper-rejected combo). Ad-hoc is what we use when the user +/// hasn't configured a real signing identity — it's enough for macOS +/// to grant the bundle a stable code identity, which UN requires +/// before it will hand out notification permission. +fn codesign_one( + target: &Path, + identity: &str, + entitlements: Option<&Path>, + signing_identifier: Option<&str>, +) -> Result<(), AnyError> { + let adhoc = identity == "-"; + let mut cmd = std::process::Command::new("codesign"); + cmd.arg("--force"); + if !adhoc { + cmd.arg("--timestamp").arg("--options").arg("runtime"); + } + if let Some(ident) = signing_identifier { + cmd.arg("--identifier").arg(ident); + } + cmd.arg("--sign").arg(identity); + if let Some(ent) = entitlements { + cmd.arg("--entitlements").arg(ent); + } + cmd.arg(target); + let status = cmd + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .status() + .context("failed to invoke codesign(1)")?; + if !status.success() { + bail!( + "codesign failed for {} (identity {:?})", + target.display(), + identity, + ); + } + Ok(()) +} + +/// Re-sign the cached laufey.app's binaries so the running binary's +/// identifier matches its host bundle's `CFBundleIdentifier`. Run once +/// per fresh download; HMR mode runs laufey.app directly (no per-project +/// wrapper), so without this UN sees `Identifier=laufey` / +/// `CFBundleIdentifier=com.deno.desktop` and refuses notification +/// authorization. Best-effort: failures here are logged but don't +/// abort the install, since most desktop features still work without +/// notifications. +#[cfg(target_os = "macos")] +fn harmonize_cached_laufey_identifiers( + install_dir: &Path, + backend: &str, +) -> Result<(), AnyError> { + let laufey_app = locate_laufey_app_in_install(install_dir, backend) + .ok_or_else(|| { + deno_core::anyhow::anyhow!( + "could not find laufey.app under {} to re-sign", + install_dir.display() + ) + })?; + harmonize_laufey_app_identifiers(&laufey_app) +} + +/// Idempotently re-sign every Mach-O in `/Contents/MacOS/` so +/// its code-signing identifier matches the bundle's `CFBundleIdentifier`. +/// macOS `usernoted` rejects UN authorization with +/// `UNErrorCodeNotificationsNotAllowed` when the running binary's +/// signed identifier disagrees with the bundle id ("Legacy client X +/// connecting to modern client" in the daemon log). The linker-signed +/// default for the laufey binary is `Identifier=laufey`, which never matches. +/// Safe to call on every launch — binaries already at the correct +/// identifier are skipped without a re-sign. +#[cfg(target_os = "macos")] +fn harmonize_laufey_app_identifiers(laufey_app: &Path) -> Result<(), AnyError> { + let bundle_id = read_bundle_identifier(laufey_app)?; + let macos_dir = laufey_app.join("Contents/MacOS"); + + // laufey writes a `.laufey/` runtime-data cache directly inside the bundle's + // `Contents/MacOS/` at runtime. When codesign(1) signs the main + // executable (`Contents/MacOS/laufey`) it seals the *whole* bundle and trips + // over that stray directory with "bundle format unrecognized", aborting + // the re-sign — which silently breaks notification permission on every + // launch after the first run created the cache. Park it just outside the + // bundle while we sign and move it back afterwards (it would regenerate + // anyway, but preserving it avoids a cold-start penalty). The guard + // restores it even if signing errors out below. + let _parked = park_bundle_cache_dir(&macos_dir); + + for entry in std::fs::read_dir(&macos_dir)?.flatten() { + let path = entry.path(); + // Skip non-Mach-O files (`Contents/MacOS/` legitimately contains + // shell launchers and update sentinels). `is_file` filters both + // directories and shell scripts; `is_macho_file` rejects the rest. + // Skipping these is critical: handing them to codesign would fail + // with "bundle format unrecognized" and abort the run. + if !path.is_file() || !is_macho_file(&path) { + continue; + } + if signed_identifier_matches(&path, &bundle_id) { + continue; + } + codesign_one(&path, "-", None, Some(&bundle_id))?; + } + Ok(()) +} + +/// RAII guard that moves a directory parked by [`park_bundle_cache_dir`] +/// back into the bundle on drop. Best-effort: a failed restore is ignored +/// (the cache regenerates on next launch). +#[cfg(target_os = "macos")] +struct ParkedCacheDir { + parked_at: PathBuf, + restore_to: PathBuf, +} + +#[cfg(target_os = "macos")] +impl Drop for ParkedCacheDir { + fn drop(&mut self) { + if self.parked_at.exists() { + let _ = std::fs::rename(&self.parked_at, &self.restore_to); + } + } +} + +/// If `/.laufey` exists, move it just outside the `.app` bundle so +/// codesign's bundle sealing doesn't choke on it, returning a guard that +/// restores it on drop. Returns `None` when there's nothing to park or it +/// can't be relocated — in which case signing proceeds as before (and may +/// fail loudly, same as the previous behaviour). +#[cfg(target_os = "macos")] +fn park_bundle_cache_dir(macos_dir: &Path) -> Option { + let cache = macos_dir.join(".laufey"); + if !cache.exists() { + return None; + } + // Park it next to the `.app` (outside `Contents/`, so it's not part of + // what codesign seals). `macos_dir` is `/Contents/MacOS`, so three + // parents up is the directory containing the `.app`. Staying on the same + // volume keeps the rename atomic and cheap. + let app_parent = macos_dir.parent()?.parent()?.parent()?; + let parked_at = app_parent.join(".laufey-harmonize-parked"); + // Clear any debris from a previously interrupted run. + if parked_at.exists() { + let _ = std::fs::remove_dir_all(&parked_at); + } + match std::fs::rename(&cache, &parked_at) { + Ok(()) => Some(ParkedCacheDir { + parked_at, + restore_to: cache, + }), + Err(_) => None, + } +} + +/// Read the code-signing identifier of `path` (`codesign -dv` → +/// `Identifier=...`). Returns true if it equals `expected`. False if +/// we can't read it (unsigned binary, codesign missing) — that means +/// "needs a re-sign," which is the correct fall-through for the +/// caller. +#[cfg(target_os = "macos")] +fn signed_identifier_matches(path: &Path, expected: &str) -> bool { + let Ok(output) = std::process::Command::new("codesign") + .arg("-dv") + .arg(path) + .output() + else { + return false; + }; + // codesign writes its display info to stderr, not stdout. + let stderr = String::from_utf8_lossy(&output.stderr); + stderr + .lines() + .filter_map(|l| l.strip_prefix("Identifier=")) + .any(|id| id.trim() == expected) +} + +/// Given a path to the laufey Mach-O binary (`laufey.app/Contents/MacOS/laufey`), +/// return the containing `.app` bundle. Returns `None` if the path +/// doesn't sit inside a `.app` bundle in the expected layout. +#[cfg(target_os = "macos")] +fn laufey_app_for_binary(binary: &Path) -> Option { + let app = binary.parent()?.parent()?.parent()?; + if app.extension().and_then(|s| s.to_str()) == Some("app") { + Some(app.to_path_buf()) + } else { + None + } +} + +/// Locate the laufey backend's `.app` bundle within an extracted install +/// dir. The release layout varies by backend (`cef/Release/laufey.app`, +/// `webview/Release/laufey_webview.app`, etc.) so probe the well-known +/// names rather than hardcoding one. +#[cfg(target_os = "macos")] +fn locate_laufey_app_in_install(dir: &Path, backend: &str) -> Option { + let candidates = match backend { + "cef" => vec![ + dir.join("laufey.app"), + dir.join("Release/laufey.app"), + dir.join("cef/Release/laufey.app"), + ], + "webview" => vec![ + dir.join("laufey_webview.app"), + dir.join("Release/laufey_webview.app"), + dir.join("webview/Release/laufey_webview.app"), + ], + _ => vec![ + dir.join(format!("laufey_{backend}.app")), + dir.join("laufey.app"), + ], + }; + candidates.into_iter().find(|p| p.exists()) +} + +/// Reject any name we'd interpolate into a generated launcher script +/// (POSIX shell on macOS/Linux, `.bat` on Windows). Even the +/// double-quoted positions take expansions: `$`, backticks, `\` in +/// bash; `%` and `^` in cmd.exe. The launcher kind context (`kind`) +/// is included in the error to make the failure easy to act on. +fn validate_launcher_name(name: &str, kind: &str) -> Result<(), AnyError> { + if name.is_empty() { + bail!("invalid {kind}: name is empty"); + } + // ASCII-only, alphanumerics + a small whitelist of harmless + // punctuation. Spaces are allowed because real macOS .app bundles + // commonly have spaces in their executable names. + let bad = name.chars().find(|c| { + !(c.is_ascii_alphanumeric() || matches!(c, ' ' | '.' | '_' | '-')) + }); + if let Some(c) = bad { + bail!( + "invalid {kind} {name:?}: must match [A-Za-z0-9 ._-]+, but contains {c:?}", + ); + } + Ok(()) +} + +/// The pieces of `dylib_path` we feed into the bundlers, with proper +/// error messages instead of `unwrap` panics on degenerate inputs like +/// `--output /` or `--output .`. +struct DylibParts<'a> { + parent: &'a Path, + file_name: &'a std::ffi::OsStr, + app_name: String, +} + +fn dylib_parts(dylib_path: &Path) -> Result, AnyError> { + let parent = dylib_path.parent().ok_or_else(|| { + deno_core::anyhow::anyhow!( + "invalid --output: dylib path has no parent dir: {}", + dylib_path.display() + ) + })?; + let file_name = dylib_path.file_name().ok_or_else(|| { + deno_core::anyhow::anyhow!( + "invalid --output: dylib path has no file name: {}", + dylib_path.display() + ) + })?; + let app_name = dylib_path + .file_stem() + .ok_or_else(|| { + deno_core::anyhow::anyhow!( + "invalid --output: dylib path has no file stem: {}", + dylib_path.display() + ) + })? + .to_string_lossy() + .into_owned(); + Ok(DylibParts { + parent, + file_name, + app_name, + }) +} + +/// Create a macOS .app bundle from the compiled desktop dylib. +/// +/// Bundle structure: +/// ```text +/// AppName.app/ +/// Contents/ +/// Info.plist +/// MacOS/ +/// AppName (launcher script) +/// laufey_webview (LAUFEY backend binary) +/// libapp.dylib (compiled Deno runtime + user code) +/// Resources/ +/// AppIcon.icns (optional) +/// ``` +async fn package_macos_app_bundle( + dylib_path: &Path, + desktop_flags: &DesktopFlags, + cli_options: &CliOptions, + laufey_resolver: &LaufeyBackendResolver, +) -> Result { + let parts = dylib_parts(dylib_path)?; + let app_name = parts.app_name.clone(); + let app_bundle = parts.parent.join(format!("{}.app", app_name)); + + // Find the LAUFEY backend .app and its main executable. + let backend = desktop_flags.backend.as_deref().unwrap_or("cef"); + let target = laufey_target_for(desktop_flags); + let laufey_app = laufey_resolver.find_app_bundle(backend, target).await?; + let laufey_executable_name = read_plist_string( + &laufey_app.join("Contents/Info.plist"), + "CFBundleExecutable", + ) + .unwrap_or_else(|| "laufey_webview".to_string()); + let laufey_binary = laufey_app + .join("Contents/MacOS") + .join(&laufey_executable_name); + if !laufey_binary.exists() { + bail!( + "LAUFEY backend executable not found at '{}'", + laufey_binary.display() + ); + } + + // Remove existing bundle. + if app_bundle.exists() { + std::fs::remove_dir_all(&app_bundle)?; + } + + // Copy the entire LAUFEY .app as the shell (CEF needs Frameworks/, Resources/, etc.). + crate::tools::compile::copy_dir_all(&laufey_app, &app_bundle)?; + + let contents_dir = app_bundle.join("Contents"); + let macos_dir = contents_dir.join("MacOS"); + let resources_dir = contents_dir.join("Resources"); + std::fs::create_dir_all(&resources_dir)?; + + // The backend binary extracts its self-extracting VFS to a sibling + // `.` dir on first run. If the source laufey.app was ever run, that dir + // gets copied along with it — drop any such runtime caches. + let laufey_exe_stem = Path::new(&laufey_executable_name) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| laufey_executable_name.clone()); + let cache_dir = macos_dir.join(format!(".{}", laufey_exe_stem)); + if cache_dir.exists() { + let _ = std::fs::remove_dir_all(&cache_dir); + } + let cache_file = macos_dir.join(format!(".{}.cache", laufey_exe_stem)); + if cache_file.exists() { + let _ = std::fs::remove_file(&cache_file); + } + + // Strip unnecessary bulk from the CEF framework. + strip_cef_bloat(&contents_dir); + + // Copy the compiled dylib. + let dylib_filename = parts.file_name; + let dest_dylib = macos_dir.join(dylib_filename); + std::fs::copy(dylib_path, &dest_dylib)?; + + // Create launcher script as the main executable. Validate every name + // we interpolate: bash expands `$VAR`, backticks, and `$(...)` even + // inside `"..."`. + let dylib_filename_str = dylib_filename.to_string_lossy(); + validate_launcher_name(&app_name, "app name")?; + validate_launcher_name( + &laufey_executable_name, + "LAUFEY backend executable name", + )?; + validate_launcher_name(&dylib_filename_str, "dylib filename")?; + let launcher_path = macos_dir.join(&app_name); + std::fs::write( + &launcher_path, + format!( + "#!/bin/sh\n\ + DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ + exec \"$DIR/{laufey_binary}\" --runtime \"$DIR/{dylib}\" \"$@\"\n", + laufey_binary = laufey_executable_name, + dylib = dylib_filename_str, + ), + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + &launcher_path, + std::fs::Permissions::from_mode(0o755), + )?; + } + + // Resolve the bundle identifier. The user-configured `identifier` is + // preferred; otherwise we synthesize one from the app name. The + // synthetic form is fine for `deno run`-like local use, but real + // distribution wants a stable reverse-DNS identifier — notification + // permission (and other tcc-keyed permissions) are decided per + // (bundle id, code signature), so a synthetic id changes the user's + // grant whenever they rename the app. + let bundle_id = match desktop_flags.identifier.as_deref() { + Some(id) => { + validate_bundle_identifier(id)?; + id.to_string() + } + None => { + let slug = app_name.to_lowercase().replace(' ', "-"); + format!("com.deno.desktop.{slug}") + } + }; + + // Generate Info.plist. + let has_icon = desktop_flags.icon.is_some(); + let info_plist = format!( + r#" + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + {app_name} + CFBundleIconFile + {icon_file} + CFBundleIdentifier + {bundle_id} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + {app_name} + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1.0.0 + LSMinimumSystemVersion + 10.15 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + +"#, + app_name = app_name, + bundle_id = bundle_id, + icon_file = if has_icon { "AppIcon" } else { "" }, + ); + std::fs::write(contents_dir.join("Info.plist"), info_plist)?; + + // Rewrite each CEF helper's CFBundleIdentifier to be a strict suffix + // of the main bundle id. CEF's process model requires this — the + // browser process matches a helper's bundle id prefix against its own + // to verify the helper is part of the same app, and the laufey defaults + // (`com.example.laufey.helper.*`) don't share a prefix with whatever + // identifier we just wrote into the main plist. + rewrite_cef_helper_bundle_ids(&contents_dir, &bundle_id)?; + + // Codesign the assembled bundle. Helpers must be signed before the + // main bundle (Gatekeeper / CEF verify them in that order), and the + // main bundle's signature seals the helpers' signatures into its + // CodeDirectory. + // + // When the user provides an identity we use it (path to a real + // Developer ID for distribution). Otherwise — on a macOS host — we + // ad-hoc sign with `-`. Ad-hoc is required for the bundle to receive + // a stable code identity from the OS, which gates: + // - UNUserNotificationCenter authorization (without signing, + // `requestAuthorization` silently fails and the user sees + // "denied" with no prompt — this is why Notification.permission + // stays "denied" for unsigned dev builds); + // - LaunchServices registration under a stable bundle id; + // - TCC entries (microphone/camera/automation) attaching to a + // persistent identity rather than re-prompting on every rebuild. + // We skip on non-macOS hosts (cross-build) since `codesign(1)` only + // exists on macOS. + let codesign_identity = desktop_flags.codesign_identity.as_deref().or( + if cfg!(target_os = "macos") { + Some("-") + } else { + None + }, + ); + if let Some(identity) = codesign_identity { + codesign_macos_bundle(&app_bundle, identity)?; + } + + // Handle icon. + if let Some(ref icon) = desktop_flags.icon { + let dest = resources_dir.join("AppIcon.icns"); + match icon { + crate::args::IconConfig::Single(path) => { + let icon_path = cli_options.initial_cwd().join(path); + if icon_path.exists() { + match icon_path.extension().and_then(|e| e.to_str()) { + Some("icns") => { + std::fs::copy(&icon_path, &dest)?; + } + Some("png") => { + crate::tools::compile::convert_png_to_icns(&icon_path, &dest)?; + } + _ => { + log::warn!( + "Icon '{}' is not .icns or .png, skipping", + icon_path.display() + ); + } + } + } else { + log::warn!("Icon '{}' not found, skipping", icon_path.display()); + } + } + crate::args::IconConfig::Set(entries) => { + convert_icon_set_to_icns(cli_options.initial_cwd(), entries, &dest)?; + } + } + } + + // Remove the standalone dylib (it's now inside the .app). + let _ = std::fs::remove_file(dylib_path); + + Ok(app_bundle) +} + +/// Wrap a macOS `.app` bundle in a drag-to-Applications `.dmg` installer. +/// +/// Builds a staging directory containing the `.app` plus a symlink to +/// `/Applications`, then invokes `hdiutil` to create a compressed read-only +/// disk image. +fn create_macos_dmg( + app_bundle: &Path, + dmg_path: &Path, +) -> Result<(), AnyError> { + let app_name = app_bundle + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "App".to_string()); + + // Stage in a sibling temp directory so hdiutil doesn't traverse + // unrelated files. tempfile gives us a unique name (no collision + // with concurrent builds) and 0700 mode (no other-user racing or + // pre-creating it as a symlink), and cleans up on drop. + let parent = dmg_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent)?; + let staging = tempfile::Builder::new() + .prefix(".dmg-staging-") + .tempdir_in(parent) + .with_context(|| { + format!("failed to stage DMG tempdir in {}", parent.display()) + })?; + + // Copy the .app into the staging dir and add an /Applications symlink so + // users can drag the app across in the mounted DMG window. + let staged_app = staging.path().join( + app_bundle + .file_name() + .ok_or_else(|| deno_core::anyhow::anyhow!("app bundle has no name"))?, + ); + crate::tools::compile::copy_dir_all(app_bundle, &staged_app)?; + #[cfg(unix)] + { + let _ = std::os::unix::fs::symlink( + "/Applications", + staging.path().join("Applications"), + ); + } + + if dmg_path.exists() { + std::fs::remove_file(dmg_path)?; + } + + let status = std::process::Command::new("hdiutil") + .args([ + "create", + "-volname", + &app_name, + "-srcfolder", + &staging.path().display().to_string(), + "-ov", + "-format", + "UDZO", + &dmg_path.display().to_string(), + ]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::inherit()) + .status() + .context("Failed to run hdiutil")?; + + // staging tempdir is removed by its Drop impl when this fn returns. + + if !status.success() { + bail!("hdiutil failed to create DMG at {}", dmg_path.display()); + } + Ok(()) +} + +/// AppImage Type-2 runtime ELF stubs, vendored from +/// github.com/AppImage/type2-runtime at tag `20251108`. Prepended verbatim to +/// the SquashFS payload to form the final AppImage. +const APPIMAGE_RUNTIME_X86_64: &[u8] = + include_bytes!("appimage_runtime/runtime-x86_64"); +const APPIMAGE_RUNTIME_AARCH64: &[u8] = + include_bytes!("appimage_runtime/runtime-aarch64"); + +/// 1×1 transparent PNG, used when the caller didn't supply an icon. +/// appimagetool-built AppImages expect a top-level `.png` to exist. +const STUB_ICON_PNG: &[u8] = &[ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, + 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, + 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, + 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0d, + 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, + 0x60, 0x82, +]; + +/// Pick the Type-2 runtime stub for the requested target. Falls back to the +/// host arch when `target` is None. The triple's leading component is the +/// arch (e.g. `x86_64-unknown-linux-gnu` → `x86_64`). +fn appimage_runtime_for_target( + target: Option<&str>, +) -> Result<&'static [u8], AnyError> { + let arch = target + .and_then(|t| t.split('-').next()) + .unwrap_or(std::env::consts::ARCH); + match arch { + "x86_64" => Ok(APPIMAGE_RUNTIME_X86_64), + "aarch64" => Ok(APPIMAGE_RUNTIME_AARCH64), + other => bail!( + "No bundled AppImage runtime for arch '{other}'; supported: x86_64, aarch64" + ), + } +} + +/// Unix mode bits for a filesystem entry. On non-Unix hosts (cross-compiling +/// a Linux AppImage from Windows/macOS) we don't have real mode bits, so fall +/// back to a reasonable default: 0o755 for dirs, 0o644 for files. +fn unix_mode_of(meta: &std::fs::Metadata) -> u16 { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + (meta.permissions().mode() & 0o7777) as u16 + } + #[cfg(not(unix))] + { + if meta.is_dir() { 0o755 } else { 0o644 } + } +} + +fn node_header(mode: u16) -> backhand::NodeHeader { + backhand::NodeHeader { + permissions: mode, + uid: 0, + gid: 0, + mtime: 0, + } +} + +/// Walk `fs_root` and push every entry into `writer` at the SquashFS root. +/// Directories are pushed before their contents (required by backhand). +fn push_dir_contents_to_squashfs( + writer: &mut backhand::FilesystemWriter<'_, '_, '_>, + fs_root: &Path, +) -> Result<(), AnyError> { + let mut stack: Vec = vec![fs_root.to_path_buf()]; + while let Some(dir) = stack.pop() { + let mut entries: Vec<_> = + std::fs::read_dir(&dir)?.collect::>()?; + entries.sort_by_key(|e| e.file_name()); + for entry in entries { + let path = entry.path(); + let rel = path.strip_prefix(fs_root)?; + let arc_path = Path::new("/").join(rel); + let meta = std::fs::symlink_metadata(&path)?; + let mode = unix_mode_of(&meta); + let ft = meta.file_type(); + if ft.is_symlink() { + let target = std::fs::read_link(&path)?; + writer.push_symlink(target, &arc_path, node_header(mode))?; + } else if ft.is_dir() { + writer.push_dir(&arc_path, node_header(mode))?; + stack.push(path); + } else { + let f = std::fs::File::open(&path)?; + writer.push_file(f, &arc_path, node_header(mode))?; + } + } + } + Ok(()) +} + +/// Wrap a Linux app directory in an `.AppImage` single-file executable. +/// +/// Packs the app dir into a SquashFS image via the `backhand` crate, adds the +/// AppDir-required entries (`AppRun`, `.desktop`, top-level icon), then +/// prepends the vendored AppImage Type-2 runtime ELF for the target arch. +/// Pure Rust; works on any build host. +fn create_linux_appimage( + app_dir: &Path, + appimage_path: &Path, + target: Option<&str>, +) -> Result<(), AnyError> { + use std::io::Cursor; + use std::io::Write as _; + + let app_name = app_dir + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "App".to_string()); + + let runtime_elf = appimage_runtime_for_target(target)?; + + let mut writer = backhand::FilesystemWriter::default(); + + // Pack everything from the staged app dir into the SquashFS root. + push_dir_contents_to_squashfs(&mut writer, app_dir)?; + + // AppRun is what the AppImage invokes on launch. Thin shell shim that + // delegates to the existing launcher (which already sets $DIR and execs + // the backend with the right args). + let apprun = format!( + "#!/bin/sh\n\ + DIR=\"$(cd \"$(dirname \"$0\")\" && pwd)\"\n\ + exec \"$DIR/{app_name}\" \"$@\"\n", + ); + writer.push_file( + Cursor::new(apprun.into_bytes()), + "/AppRun", + node_header(0o755), + )?; + + // .desktop entry at the AppDir root. + let desktop_entry = format!( + "[Desktop Entry]\n\ + Type=Application\n\ + Name={app_name}\n\ + Exec={app_name}\n\ + Icon={app_name}\n\ + Categories=Utility;\n", + ); + writer.push_file( + Cursor::new(desktop_entry.into_bytes()), + format!("/{app_name}.desktop"), + node_header(0o644), + )?; + + // Icon at AppDir root named after the app. package_linux_app_dir writes the + // user icon as AppIcon.png; if absent, fall back to a 1×1 transparent PNG. + let icon_src = app_dir.join("AppIcon.png"); + let icon_bytes = if icon_src.exists() { + std::fs::read(&icon_src)? + } else { + STUB_ICON_PNG.to_vec() + }; + writer.push_file( + Cursor::new(icon_bytes), + format!("/{app_name}.png"), + node_header(0o644), + )?; + + // Serialize the SquashFS to memory. + let mut squashfs = Cursor::new(Vec::::new()); + writer + .write(&mut squashfs) + .context("Failed to write SquashFS image")?; + drop(writer); + + // Assemble: runtime ELF + SquashFS, then mark executable. + if let Some(parent) = appimage_path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } + let mut out = std::fs::File::create(appimage_path).with_context(|| { + format!("Failed to create AppImage at {}", appimage_path.display()) + })?; + out.write_all(runtime_elf)?; + out.write_all(&squashfs.into_inner())?; + drop(out); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + appimage_path, + std::fs::Permissions::from_mode(0o755), + )?; + } + + Ok(()) +} + +/// Recursively copy a directory tree, ensuring writable permissions on the +/// destination. This is needed because source files from the Nix store are +/// read-only. +/// Strip unnecessary files from a CEF-based app bundle to reduce size. +/// +/// Removes: +/// - Non-English locale packs (~47MB) +/// - SwiftShader software Vulkan renderer (~16MB, not needed on macOS with Metal) +/// - OpenGL ES emulation library (~6.5MB, not needed on macOS with Metal) +fn strip_cef_bloat(contents_dir: &Path) { + let frameworks_dir = contents_dir.join("Frameworks"); + let cef_framework = frameworks_dir + .join("Chromium Embedded Framework.framework") + .join("Versions") + .join("A"); + + if !cef_framework.exists() { + return; + } + + let cef_resources = cef_framework.join("Resources"); + let cef_libraries = cef_framework.join("Libraries"); + + // Remove non-English locale packs. + if let Ok(entries) = std::fs::read_dir(&cef_resources) { + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if name.ends_with(".lproj") && name != "en.lproj" { + let _ = std::fs::remove_dir_all(entry.path()); + } + } + } + + // Remove SwiftShader (software Vulkan fallback, not needed on macOS with Metal). + let _ = std::fs::remove_file(cef_libraries.join("libvk_swiftshader.dylib")); + let _ = std::fs::remove_file(cef_libraries.join("vk_swiftshader_icd.json")); +} + +/// Build a macOS `.icns` from an icon set (multiple PNGs at specified sizes). +/// +/// Writes the ICNS container format directly (macOS 10.7+ accepts PNG bytes +/// as the payload for every modern OSType code, so no re-encoding needed). +/// Each pixel size maps to one or two OSType codes — the 1x and 2x slots +/// that share that pixel dimension: +/// 16px → icp4 +/// 32px → ic11 (16×16@2x) + icp5 (32×32) +/// 64px → ic12 (32×32@2x) +/// 128px → ic07 +/// 256px → ic13 (128×128@2x) + ic08 (256×256) +/// 512px → ic14 (256×256@2x) + ic09 (512×512) +/// 1024px→ ic10 (512×512@2x) +fn convert_icon_set_to_icns( + cwd: &Path, + entries: &[crate::args::IconSetEntry], + icns_path: &Path, +) -> Result<(), deno_core::error::AnyError> { + use std::io::Write; + + let mut icons: Vec<(&'static [u8; 4], Vec)> = Vec::new(); + for entry in entries { + let src = cwd.join(&entry.path); + if !src.exists() { + log::warn!("Icon '{}' not found, skipping", src.display()); + continue; + } + let data = std::fs::read(&src)?; + for code in icns_ostypes_for_size(entry.size) { + icons.push((*code, data.clone())); + } + } + + if icons.is_empty() { + deno_core::anyhow::bail!("No valid icon images found for .icns"); + } + + // File header (8 bytes) + for each icon: 4-byte OSType + 4-byte length + payload. + let total_size: u32 = icons + .iter() + .map(|(_, data)| 8 + data.len() as u32) + .sum::() + + 8; + + let mut buf = Vec::with_capacity(total_size as usize); + buf.write_all(b"icns")?; + buf.write_all(&total_size.to_be_bytes())?; + for (code, data) in &icons { + buf.write_all(*code)?; + let entry_len = 8 + data.len() as u32; + buf.write_all(&entry_len.to_be_bytes())?; + buf.write_all(data)?; + } + + std::fs::write(icns_path, &buf)?; + Ok(()) +} + +/// Returns the ICNS OSType codes a given pixel size maps to. Multiple codes +/// mean the same PNG is embedded under each (matching how the staged +/// `.iconset` approach duplicated files across 1x/2x filename slots). +fn icns_ostypes_for_size(size: u32) -> &'static [&'static [u8; 4]] { + match size { + 16 => &[b"icp4"], + 32 => &[b"ic11", b"icp5"], + 64 => &[b"ic12"], + 128 => &[b"ic07"], + 256 => &[b"ic13", b"ic08"], + 512 => &[b"ic14", b"ic09"], + 1024 => &[b"ic10"], + _ => { + log::warn!( + "Icon size {}px doesn't map to a standard macOS iconset slot, skipping", + size + ); + &[] + } + } +} + +/// Build a Windows `.ico` from an icon set (multiple PNGs at specified sizes). +/// +/// The ICO format stores PNG images directly (Vista+ supports PNG-compressed +/// entries). We write the ICO header, one directory entry per image, then the +/// raw PNG data for each. +pub fn convert_icon_set_to_ico( + cwd: &Path, + entries: &[crate::args::IconSetEntry], + ico_path: &Path, +) -> Result<(), deno_core::error::AnyError> { + use std::io::Write; + + let mut images: Vec<(u32, Vec)> = Vec::new(); + for entry in entries { + let src = cwd.join(&entry.path); + if !src.exists() { + log::warn!("Icon '{}' not found, skipping", src.display()); + continue; + } + let data = std::fs::read(&src)?; + images.push((entry.size, data)); + } + + if images.is_empty() { + deno_core::anyhow::bail!("No valid icon images found for .ico"); + } + + let count = images.len() as u16; + // ICO header: 6 bytes + // Each directory entry: 16 bytes + let header_size = 6 + (count as u32) * 16; + + let mut buf = Vec::new(); + // ICO header + buf.write_all(&0u16.to_le_bytes())?; // reserved + buf.write_all(&1u16.to_le_bytes())?; // type: 1 = ICO + buf.write_all(&count.to_le_bytes())?; // image count + + // Directory entries + let mut data_offset = header_size; + for (size, data) in &images { + // Width/height: 0 means 256 in ICO format + let dim = if *size >= 256 { 0u8 } else { *size as u8 }; + buf.push(dim); // width + buf.push(dim); // height + buf.push(0); // color palette count + buf.push(0); // reserved + buf.write_all(&1u16.to_le_bytes())?; // color planes + buf.write_all(&32u16.to_le_bytes())?; // bits per pixel + buf.write_all(&(data.len() as u32).to_le_bytes())?; // image data size + buf.write_all(&data_offset.to_le_bytes())?; // offset to image data + data_offset += data.len() as u32; + } + + // Image data + for (_, data) in &images { + buf.write_all(data)?; + } + + std::fs::write(ico_path, &buf)?; + Ok(()) +} + +/// Spawn a child with macOS TCC "responsibility" disclaimed, so the child +/// is its own permission principal instead of inheriting attribution from +/// the calling chain (terminal → deno → laufey). +/// +/// Without this, requests like `UNUserNotificationCenter.requestAuthorization` +/// fail immediately with `UNErrorCodeNotificationsNotAllowed` because TCC +/// resolves "who's asking" to a process that has no notification bundle id. +/// `responsibility_spawnattrs_setdisclaim` is the same SPI `open(1)` uses +/// internally — it tells the kernel "this child decides its own permissions". +#[cfg(target_os = "macos")] +mod disclaim_spawn { + use std::ffi::CString; + use std::ffi::OsString; + use std::os::unix::ffi::OsStrExt; + use std::process::ExitStatus; + + // SPI in libsystem (10.14+). Not in libc's bindings. + unsafe extern "C" { + fn responsibility_spawnattrs_setdisclaim( + attrs: *mut libc::posix_spawnattr_t, + disclaim: libc::c_int, + ) -> libc::c_int; + // macOS 10.15+. libc has the *_addclose family but not *_addchdir_np yet. + fn posix_spawn_file_actions_addchdir_np( + actions: *mut libc::posix_spawn_file_actions_t, + path: *const libc::c_char, + ) -> libc::c_int; + } + + pub struct Child { + pid: libc::pid_t, + exited: bool, + } + + impl Child { + pub async fn wait(&mut self) -> std::io::Result { + use std::os::unix::process::ExitStatusExt; + let pid = self.pid; + let status_raw = tokio::task::spawn_blocking(move || { + let mut status: libc::c_int = 0; + loop { + // Safety: waiting on our own child pid. + let rc = unsafe { libc::waitpid(pid, &mut status, 0) }; + if rc < 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EINTR) { + continue; + } + return Err(err); + } + return Ok(status); + } + }) + .await + .map_err(std::io::Error::other)??; + self.exited = true; + Ok(ExitStatus::from_raw(status_raw)) + } + } + + impl Drop for Child { + fn drop(&mut self) { + // Mirrors tokio's kill_on_drop: if we didn't observe an exit, SIGKILL + // the orphan. Polite escalation (TERM-then-KILL) isn't possible inside + // sync Drop, and the existing tokio path uses SIGKILL too. + if !self.exited { + // SAFETY: `kill(2)` with a pid we spawned is always safe to call; a + // stale pid simply returns ESRCH, which we ignore. + unsafe { + libc::kill(self.pid, libc::SIGKILL); + } + } + } + } + + /// Flattened `(program, argv, envp, cwd)` for `posix_spawn`. + type SpawnArgs = (CString, Vec, Vec, Option); + + /// Convert a std::process::Command into the argv/envp/cwd tuple posix_spawn + /// needs. Inherits the parent's env, then applies Command::env() overrides + /// (matching what std::process::Command does internally). + fn flatten(cmd: &std::process::Command) -> std::io::Result { + let program = CString::new(cmd.get_program().as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "program path contains NUL", + ) + })?; + let mut argv: Vec = Vec::with_capacity(cmd.get_args().len() + 1); + argv.push(program.clone()); + for a in cmd.get_args() { + argv.push(CString::new(a.as_bytes()).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "argv contains NUL", + ) + })?); + } + let mut env_map: std::collections::BTreeMap = + std::env::vars_os().collect(); + for (k, v) in cmd.get_envs() { + match v { + Some(v) => { + env_map.insert(k.to_os_string(), v.to_os_string()); + } + None => { + env_map.remove(k); + } + } + } + let envp: Vec = env_map + .into_iter() + .map(|(k, v)| { + let mut s = + Vec::with_capacity(k.as_bytes().len() + 1 + v.as_bytes().len()); + s.extend_from_slice(k.as_bytes()); + s.push(b'='); + s.extend_from_slice(v.as_bytes()); + CString::new(s).map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "env contains NUL", + ) + }) + }) + .collect::>()?; + let cwd = match cmd.get_current_dir() { + Some(p) => { + Some(CString::new(p.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "cwd has NUL") + })?) + } + None => None, + }; + Ok((program, argv, envp, cwd)) + } + + pub fn spawn(cmd: &std::process::Command) -> std::io::Result { + let (program, argv, envp, cwd) = flatten(cmd)?; + let mut argv_ptrs: Vec<*mut libc::c_char> = + argv.iter().map(|c| c.as_ptr() as *mut _).collect(); + argv_ptrs.push(std::ptr::null_mut()); + let mut envp_ptrs: Vec<*mut libc::c_char> = + envp.iter().map(|c| c.as_ptr() as *mut _).collect(); + envp_ptrs.push(std::ptr::null_mut()); + + // SAFETY: posix_spawn FFI. We initialize attrs/actions before use, + // destroy them on every exit path, and keep argv/envp CString backing + // alive until posix_spawn returns. Spawn inherits fds 0/1/2 by default + // (no file action redirects them), which is the stdio behavior the + // caller expects. + unsafe { + let mut attrs: libc::posix_spawnattr_t = std::mem::zeroed(); + if libc::posix_spawnattr_init(&mut attrs) != 0 { + return Err(std::io::Error::last_os_error()); + } + let res = (|| -> std::io::Result { + let mut actions: libc::posix_spawn_file_actions_t = std::mem::zeroed(); + if libc::posix_spawn_file_actions_init(&mut actions) != 0 { + return Err(std::io::Error::last_os_error()); + } + let inner = (|| -> std::io::Result { + // The disclaim. Ignored if the SPI is missing (hypothetical + // older system) — caller just falls back to inherited TCC. + let _ = responsibility_spawnattrs_setdisclaim(&mut attrs, 1); + + if let Some(cwd) = cwd.as_ref() { + let rc = + posix_spawn_file_actions_addchdir_np(&mut actions, cwd.as_ptr()); + if rc != 0 { + return Err(std::io::Error::from_raw_os_error(rc)); + } + } + + let mut pid: libc::pid_t = 0; + let rc = libc::posix_spawn( + &mut pid, + program.as_ptr(), + &actions, + &attrs, + argv_ptrs.as_mut_ptr(), + envp_ptrs.as_mut_ptr(), + ); + if rc != 0 { + return Err(std::io::Error::from_raw_os_error(rc)); + } + Ok(pid) + })(); + libc::posix_spawn_file_actions_destroy(&mut actions); + inner + })(); + libc::posix_spawnattr_destroy(&mut attrs); + let pid = res?; + Ok(Child { pid, exited: false }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- laufey_archive_name / laufey_release_url --- + + #[test] + fn archive_name_extensions() { + assert_eq!( + laufey_archive_name("cef", "aarch64-apple-darwin"), + "laufey-cef-aarch64-apple-darwin.tar.gz" + ); + assert_eq!( + laufey_archive_name("webview", "x86_64-pc-windows-msvc"), + "laufey-webview-x86_64-pc-windows-msvc.zip", + "windows targets must use zip, not tar.gz — the bundled 7z step assumes it" + ); + } + + #[test] + fn archive_name_raw_aliases_to_winit() { + // `raw` is the public name; the GitHub releases ship under `winit`. + // A regression here would 404 every backend download for raw users. + assert_eq!( + laufey_archive_name("raw", "x86_64-unknown-linux-gnu"), + "laufey-winit-x86_64-unknown-linux-gnu.tar.gz" + ); + } + + #[test] + fn release_url_uses_v_prefix() { + let url = laufey_release_url("laufey-cef-aarch64-apple-darwin.tar.gz"); + assert!( + url.starts_with( + "https://github.com/littledivy/laufey/releases/download/v" + ) + ); + assert!(url.ends_with("/laufey-cef-aarch64-apple-darwin.tar.gz")); + // No spaces, no shell metachars — this string is fed to `curl` and to + // log messages. + assert!(!url.contains(' ')); + } + + // --- parse_sha256sum --- + + #[test] + fn parse_sha256sum_basic() { + let contents = "\ +abc123 file-a.tar.gz +def456 file-b.zip +"; + assert_eq!( + parse_sha256sum(contents, "file-a.tar.gz").as_deref(), + Some("abc123") + ); + assert_eq!( + parse_sha256sum(contents, "file-b.zip").as_deref(), + Some("def456") + ); + assert_eq!(parse_sha256sum(contents, "missing").as_deref(), None); + } + + #[test] + fn parse_sha256sum_handles_binary_mode_star() { + // GNU sha256sum's binary mode emits ` *filename`. We must + // strip the leading star or we'll fail to match every Windows + // artifact line. + let contents = "abc123 *file-a.zip\n"; + assert_eq!( + parse_sha256sum(contents, "file-a.zip").as_deref(), + Some("abc123") + ); + } + + #[test] + fn parse_sha256sum_tolerates_blank_and_extra_whitespace() { + let contents = "abc123 file.tar.gz + \t + +def456 other.zip +"; + assert_eq!( + parse_sha256sum(contents, "file.tar.gz").as_deref(), + Some("abc123") + ); + assert_eq!( + parse_sha256sum(contents, "other.zip").as_deref(), + Some("def456") + ); + } + + // --- validate_bundle_identifier --- + + #[test] + fn bundle_id_accepts_canonical() { + for ok in &[ + "com.deno.app", + "com.deno.my-app", + "com.deno.app.v2", + "A.B", + "com.deno.app.helper", + ] { + assert!( + validate_bundle_identifier(ok).is_ok(), + "{ok:?} should be accepted" + ); + } + } + + #[test] + fn bundle_id_rejects_bad_shapes() { + let cases = &[ + ("", "empty"), + ("noseparator", "no dot"), + ("com.deno.", "trailing empty segment"), + (".com.deno", "leading empty segment"), + ("com..deno", "doubled dot"), + ("com.deno.app/foo", "slash"), + ("com.deno.app foo", "space"), + ("com.deno.app$bar", "shell metachar"), + ("com.deno.app;rm", "semicolon"), + ("com.deno.app\nx", "newline"), + ]; + for (bad, why) in cases { + assert!( + validate_bundle_identifier(bad).is_err(), + "{bad:?} should be rejected ({why})" + ); + } + } + + #[test] + fn bundle_id_rejects_too_long() { + let long = format!("com.deno.{}", "x".repeat(200)); + assert!(validate_bundle_identifier(&long).is_err()); + // Right at the boundary. + let just_too_long = format!("com.deno.{}", "x".repeat(155)); + assert!( + validate_bundle_identifier(&just_too_long).is_err(), + "ids longer than 155 chars must be rejected (Apple receipt limit)" + ); + } + + // --- validate_launcher_name --- + + #[test] + fn launcher_name_accepts_canonical() { + for ok in &["my-app", "My App", "app_1.0", "FooBar", "a"] { + assert!( + validate_launcher_name(ok, "test").is_ok(), + "{ok:?} should be accepted" + ); + } + } + + #[test] + fn launcher_name_rejects_shell_metachars() { + // Every char that could let a value escape an unquoted .bat / .sh + // launcher line must be rejected. + let cases = &[ + ("", "empty"), + ("a&b", "ampersand"), + ("a;b", "semicolon"), + ("a|b", "pipe"), + ("a$b", "dollar"), + ("a`b", "backtick"), + ("a'b", "single quote"), + ("a\"b", "double quote"), + ("a\\b", "backslash"), + ("a/b", "slash"), + ("a\nb", "newline"), + ("a\rb", "carriage return"), + ("a\tb", "tab"), + ("a\0b", "NUL"), + ("café", "non-ascii"), + ]; + for (bad, why) in cases { + assert!( + validate_launcher_name(bad, "test").is_err(), + "{bad:?} should be rejected ({why})" + ); + } + } + + #[test] + fn launcher_name_error_message_includes_kind() { + let err = validate_launcher_name("bad/name", "LAUFEY backend binary name") + .unwrap_err() + .to_string(); + assert!( + err.contains("LAUFEY backend binary name"), + "error should label what was invalid; got: {err}" + ); + assert!( + err.contains("/"), + "error should name the bad char; got: {err}" + ); + } + + // --- dylib_parts --- + + #[test] + fn dylib_parts_basic() { + let p = std::path::PathBuf::from("/tmp/app/myapp.dylib"); + let parts = dylib_parts(&p).expect("parts"); + assert_eq!(parts.parent, std::path::Path::new("/tmp/app")); + assert_eq!(parts.file_name, "myapp.dylib"); + assert_eq!(parts.app_name, "myapp"); + } + + #[test] + fn dylib_parts_strips_libdenort_prefix() { + // .so on Linux carries the `lib` prefix in the file stem; the + // resulting bundle name shouldn't include it (the user typed + // `--output myapp`, not `libmyapp`). + let p = std::path::PathBuf::from("/tmp/libdenort.so"); + let parts = dylib_parts(&p).expect("parts"); + // dylib_parts itself doesn't strip; that's downstream. But the + // pieces should at least round-trip cleanly. + assert_eq!(parts.file_name, "libdenort.so"); + assert_eq!(parts.app_name, "libdenort"); + } + + #[test] + fn dylib_parts_rejects_degenerate_inputs() { + // The whole point of this helper is to surface friendly errors + // instead of the `file_stem().unwrap()` panic the old code had + // on `--output /` and `--output .`. + assert!(dylib_parts(std::path::Path::new("/")).is_err()); + // `dylib_parts` doesn't reject "" by itself — Path::new("") still + // has a parent (None on some platforms, Some("") on others). Spot + // a degenerate case that previously panicked. + let empty = std::path::Path::new(""); + let _ = dylib_parts(empty); // not panicking is the regression test + } + + // --- appimage_runtime_for_target --- + + #[test] + fn appimage_runtime_target_arch_lookup() { + assert!( + appimage_runtime_for_target(Some("x86_64-unknown-linux-gnu")).is_ok() + ); + assert!( + appimage_runtime_for_target(Some("aarch64-unknown-linux-gnu")).is_ok() + ); + } + + #[test] + fn appimage_runtime_rejects_unknown_arch() { + let err = appimage_runtime_for_target(Some("powerpc64-unknown-linux-gnu")) + .unwrap_err() + .to_string(); + assert!( + err.contains("powerpc64"), + "error should name the arch: {err}" + ); + assert!( + err.contains("x86_64") && err.contains("aarch64"), + "error should list supported arches: {err}" + ); + } + + // --- icns_ostypes_for_size --- + + #[test] + fn icns_ostypes_known_sizes() { + // These OSType codes are part of the macOS iconset spec; a change + // here means Finder will silently skip the icon. Pin them. + assert_eq!(icns_ostypes_for_size(16), &[b"icp4"]); + assert_eq!(icns_ostypes_for_size(32), &[b"ic11", b"icp5"]); + assert_eq!(icns_ostypes_for_size(64), &[b"ic12"]); + assert_eq!(icns_ostypes_for_size(128), &[b"ic07"]); + assert_eq!(icns_ostypes_for_size(256), &[b"ic13", b"ic08"]); + assert_eq!(icns_ostypes_for_size(512), &[b"ic14", b"ic09"]); + assert_eq!(icns_ostypes_for_size(1024), &[b"ic10"]); + } + + #[test] + fn icns_ostypes_unknown_size_returns_empty() { + assert!(icns_ostypes_for_size(0).is_empty()); + assert!(icns_ostypes_for_size(48).is_empty()); + assert!(icns_ostypes_for_size(999).is_empty()); + } + + // --- extract_laufey_archive --- + // + // These tests build tar.gz fixtures in-memory and feed them through + // extract_laufey_archive. They mirror the manual checklist items 1.33–1.37: + // a malicious archive must be rejected, a normal one must extract, + // and setuid/world-writable bits must never reach disk. + + use std::io::Read; + use std::io::Write; + + fn make_tar_gz(entries: &[(&str, tar::EntryType, &[u8], u32)]) -> Vec { + let mut tar_buf: Vec = Vec::new(); + { + let mut builder = tar::Builder::new(&mut tar_buf); + for (name, ty, data, mode) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(*mode); + header.set_mtime(0); + header.set_entry_type(*ty); + header.set_cksum(); + builder + .append_data(&mut header, name, &data[..]) + .expect("tar append"); + } + builder.finish().expect("tar finish"); + } + let mut gz = Vec::new(); + let mut enc = + flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default()); + enc.write_all(&tar_buf).expect("gz write"); + enc.finish().expect("gz finish"); + gz + } + + fn make_tar_gz_symlink(name: &str, target: &str) -> Vec { + let mut tar_buf: Vec = Vec::new(); + { + let mut builder = tar::Builder::new(&mut tar_buf); + let mut header = tar::Header::new_gnu(); + header.set_size(0); + header.set_mode(0o777); + header.set_mtime(0); + header.set_entry_type(tar::EntryType::Symlink); + header.set_link_name(target).expect("set_link_name"); + header.set_cksum(); + builder + .append_data(&mut header, name, std::io::empty()) + .expect("tar append symlink"); + builder.finish().expect("tar finish"); + } + let mut gz = Vec::new(); + let mut enc = + flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default()); + enc.write_all(&tar_buf).expect("gz write"); + enc.finish().expect("gz finish"); + gz + } + + #[test] + fn extract_tar_normal_succeeds() { + let tmp = tempfile::tempdir().unwrap(); + let gz = make_tar_gz(&[ + ("greet", tar::EntryType::Regular, b"hello", 0o755), + ("notes/readme.txt", tar::EntryType::Regular, b"docs", 0o644), + ]); + extract_laufey_archive("ok.tar.gz", &gz, tmp.path()).expect("extract"); + + let mut out = String::new(); + std::fs::File::open(tmp.path().join("greet")) + .unwrap() + .read_to_string(&mut out) + .unwrap(); + assert_eq!(out, "hello"); + assert!(tmp.path().join("notes/readme.txt").exists()); + } + + #[test] + fn extract_tar_rejects_parent_dir_traversal() { + // Hand-craft a tar block with `../escape.txt` as the name: the + // `tar` crate's `Builder` refuses to *write* such a path (a safety + // feature in the producer), so a unit test that goes through it + // wouldn't actually exercise extract_laufey_archive's reader-side + // check. We construct the 512-byte ustar header directly to get a + // genuinely-malicious archive on the wire. + fn ustar_block(name: &str, body: &[u8]) -> Vec { + let mut hdr = [0u8; 512]; + // name (offset 0..100) + let nb = name.as_bytes(); + hdr[..nb.len()].copy_from_slice(nb); + // mode "000644 \0" octal (offset 100..108) + hdr[100..108].copy_from_slice(b"000644 \0"); + // uid/gid zeroed via spaces+NUL + hdr[108..116].copy_from_slice(b"000000 \0"); + hdr[116..124].copy_from_slice(b"000000 \0"); + // size in octal + let sz = format!("{:011o} ", body.len()); + hdr[124..136].copy_from_slice(sz.as_bytes()); + // mtime + hdr[136..148].copy_from_slice(b"00000000000 "); + // checksum placeholder (8 spaces) + hdr[148..156].copy_from_slice(b" "); + // typeflag '0' = regular file + hdr[156] = b'0'; + // magic "ustar" then null then version "00" + hdr[257..263].copy_from_slice(b"ustar\0"); + hdr[263..265].copy_from_slice(b"00"); + // checksum: unsigned sum of all bytes, written as 6 octal digits + // + NUL + space at offset 148..156. + let sum: u32 = hdr.iter().map(|&b| b as u32).sum(); + let cs = format!("{:06o}\0 ", sum); + hdr[148..156].copy_from_slice(cs.as_bytes()); + + let mut out = Vec::with_capacity(512 + body.len().div_ceil(512) * 512); + out.extend_from_slice(&hdr); + out.extend_from_slice(body); + // pad body to 512. + let pad = (512 - body.len() % 512) % 512; + out.extend(std::iter::repeat_n(0u8, pad)); + // two zero blocks for end-of-archive marker. + out.extend(std::iter::repeat_n(0u8, 1024)); + out + } + + let raw_tar = ustar_block("../escape.txt", b"oops"); + let mut gz = Vec::new(); + let mut enc = + flate2::write::GzEncoder::new(&mut gz, flate2::Compression::default()); + enc.write_all(&raw_tar).unwrap(); + enc.finish().unwrap(); + + let tmp = tempfile::tempdir().unwrap(); + let err = extract_laufey_archive("evil.tar.gz", &gz, tmp.path()) + .expect_err("malicious `..` path must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("traversal") || msg.contains("outside"), + "error must indicate the rejection reason; got: {msg}" + ); + // The sibling file must not exist — defence-in-depth check passed. + assert!(!tmp.path().parent().unwrap().join("escape.txt").exists()); + } + + #[test] + fn extract_tar_rejects_symlink_escape() { + let tmp = tempfile::tempdir().unwrap(); + // A bare symlink whose target escapes the dest. unpack_in must + // refuse: the test name documents the canonical zip-slip-via-symlink + // pattern (entry A = symlink escape, entry B writes through it). + let gz = make_tar_gz_symlink("foo", "../../etc/passwd"); + let _ = extract_laufey_archive("evil.tar.gz", &gz, tmp.path()); + // Tar's `unpack_in` is allowed to either error or skip the entry — + // both behaviours mean the symlink didn't land in dest. Either is + // acceptable; what matters is that nothing escaped. + assert!( + !tmp.path().join("foo").exists() + || std::fs::symlink_metadata(tmp.path().join("foo")) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false), + "if a symlink was extracted it must live inside dest" + ); + // The target itself never came into existence under dest. + assert!(!tmp.path().join("etc/passwd").exists()); + } + + #[cfg(unix)] + #[test] + fn extract_tar_strips_setuid_bits() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + // Setuid + setgid + world-writable + executable. All the bad bits + // we never want extracted to disk. + let gz = make_tar_gz(&[( + "exe", + tar::EntryType::Regular, + b"#!/bin/sh\necho gotcha\n", + 0o7777, + )]); + extract_laufey_archive("perm.tar.gz", &gz, tmp.path()).expect("extract"); + let meta = std::fs::metadata(tmp.path().join("exe")).unwrap(); + let mode = meta.permissions().mode() & 0o7777; + // We normalize execute-bit-set files to 0o755. setuid/setgid/sticky + // bits must be gone, world-writable must be gone. + assert_eq!( + mode, 0o755, + "extracted mode must be exactly 0o755 (was {:o})", + mode + ); + } + + #[cfg(unix)] + #[test] + fn extract_tar_keeps_non_executable_as_0644() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + // A doc file with overly permissive 0o666 mode — must be downgraded + // to 0o644 (no world-writable). + let gz = + make_tar_gz(&[("readme.txt", tar::EntryType::Regular, b"hello", 0o666)]); + extract_laufey_archive("doc.tar.gz", &gz, tmp.path()).expect("extract"); + let mode = std::fs::metadata(tmp.path().join("readme.txt")) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!( + mode, 0o644, + "non-executable mode must be 0o644 (was {:o})", + mode + ); + } + + #[test] + fn extract_unknown_format_errors() { + let tmp = tempfile::tempdir().unwrap(); + let err = extract_laufey_archive("evil.rar", b"PK\x03\x04", tmp.path()) + .expect_err("unknown extensions must error"); + assert!(err.to_string().contains("unsupported archive format")); + } + + // --- rewrite_helper_plist_identifier --- + + fn write_helper_plist(path: &std::path::Path, bundle_id: &str) { + let xml = format!( + r#" + + + + CFBundleIdentifier + {bundle_id} + + +"# + ); + std::fs::write(path, xml).unwrap(); + } + + fn read_bundle_id(path: &std::path::Path) -> String { + let d: plist::Dictionary = plist::from_file(path).unwrap(); + d.get("CFBundleIdentifier") + .and_then(|v| v.as_string()) + .unwrap() + .to_string() + } + + #[test] + fn rewrite_helper_plist_keeps_helper_suffix() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("Info.plist"); + write_helper_plist(&p, "com.example.laufey.helper"); + rewrite_helper_plist_identifier(&p, "com.acme.myapp").unwrap(); + assert_eq!(read_bundle_id(&p), "com.acme.myapp.helper"); + } + + #[test] + fn rewrite_helper_plist_keeps_subhelper_suffix() { + // CEF spawns multiple helper variants (Renderer, GPU, Plugin, + // Alerts). Each Info.plist's existing id has the role baked in + // after `helper`; we must preserve everything from `helper` onward. + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("Info.plist"); + write_helper_plist(&p, "com.example.laufey.helper.gpu"); + rewrite_helper_plist_identifier(&p, "com.acme.myapp").unwrap(); + assert_eq!(read_bundle_id(&p), "com.acme.myapp.helper.gpu"); + } + + #[test] + fn rewrite_helper_plist_falls_back_when_no_helper_token() { + // Defensive path: every laufey helper plist today has `helper` in its + // id, but if a hypothetical future bundle drops it we still emit + // something reasonable. + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("Info.plist"); + write_helper_plist(&p, "com.example.laufey.misc"); + rewrite_helper_plist_identifier(&p, "com.acme.myapp").unwrap(); + assert_eq!(read_bundle_id(&p), "com.acme.myapp.helper"); + } + + // --- extract_laufey_archive ZIP path --- + + fn build_zip< + F: FnOnce(&mut zip::ZipWriter>>), + >( + f: F, + ) -> Vec { + let mut buf: Vec = Vec::new(); + { + let cursor = std::io::Cursor::new(&mut buf); + let mut writer = zip::ZipWriter::new(cursor); + f(&mut writer); + writer.finish().unwrap(); + } + buf + } + + #[test] + fn extract_zip_normal_succeeds() { + use zip::write::SimpleFileOptions; + let zip = build_zip(|w| { + w.start_file("greet.txt", SimpleFileOptions::default()) + .unwrap(); + w.write_all(b"hello zip").unwrap(); + w.start_file("nested/readme.md", SimpleFileOptions::default()) + .unwrap(); + w.write_all(b"docs").unwrap(); + }); + let tmp = tempfile::tempdir().unwrap(); + extract_laufey_archive("ok.zip", &zip, tmp.path()).expect("extract"); + assert_eq!( + std::fs::read(tmp.path().join("greet.txt")).unwrap(), + b"hello zip" + ); + assert!(tmp.path().join("nested/readme.md").exists()); + } + + #[test] + fn extract_zip_rejects_absolute_path() { + use zip::write::SimpleFileOptions; + let zip = build_zip(|w| { + // `enclosed_name` rejects absolute paths. + w.start_file("/etc/escape.txt", SimpleFileOptions::default()) + .unwrap(); + w.write_all(b"oops").unwrap(); + }); + let tmp = tempfile::tempdir().unwrap(); + let err = extract_laufey_archive("evil.zip", &zip, tmp.path()) + .expect_err("absolute path in zip must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("unsafe") || msg.contains("traversal"), + "error must indicate rejection; got: {msg}" + ); + // /etc/escape.txt mustn't exist on the host. + assert!(!std::path::Path::new("/etc/escape.txt").exists()); + } + + #[test] + fn extract_zip_rejects_parent_traversal() { + use zip::write::SimpleFileOptions; + // ZIP without "/" prefix but with `..` segments — must also be + // refused by enclosed_name OR the defence-in-depth components check. + let zip = build_zip(|w| { + w.start_file("../escape.txt", SimpleFileOptions::default()) + .unwrap(); + w.write_all(b"oops").unwrap(); + }); + let tmp = tempfile::tempdir().unwrap(); + let err = extract_laufey_archive("evil.zip", &zip, tmp.path()) + .expect_err("parent-dir traversal in zip must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("unsafe") || msg.contains("traversal"), + "error must indicate rejection; got: {msg}" + ); + assert!(!tmp.path().parent().unwrap().join("escape.txt").exists()); + } + + #[test] + fn extract_zip_rejects_symlink_entries() { + use zip::write::SimpleFileOptions; + let zip = build_zip(|w| { + // Use `add_symlink` so the entry is genuinely typed as a symlink + // in the ZIP central directory. `entry.is_symlink()` in the + // reader keys off that — a regression that removed our `if + // entry.is_symlink() { bail!(...) }` check would let a follow-on + // entry write through the symlink to escape `dest`. + w.add_symlink("link", "../../etc", SimpleFileOptions::default()) + .unwrap(); + }); + let tmp = tempfile::tempdir().unwrap(); + let err = extract_laufey_archive("evil.zip", &zip, tmp.path()) + .expect_err("symlink in zip must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("symlink"), + "error must name symlink rejection; got: {msg}" + ); + // And nothing landed at the symlink path. + assert!(!tmp.path().join("link").exists()); + } + + #[cfg(unix)] + #[test] + fn extract_zip_strips_setuid_bits() { + use std::os::unix::fs::PermissionsExt; + + use zip::write::SimpleFileOptions; + let zip = build_zip(|w| { + let opts = SimpleFileOptions::default().unix_permissions(0o7777); + w.start_file("exe", opts).unwrap(); + w.write_all(b"#!/bin/sh\necho gotcha\n").unwrap(); + }); + let tmp = tempfile::tempdir().unwrap(); + extract_laufey_archive("perm.zip", &zip, tmp.path()).expect("extract"); + let mode = std::fs::metadata(tmp.path().join("exe")) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o755, "mode must be exactly 0o755 (was {:o})", mode); + } + + #[cfg(unix)] + #[test] + fn extract_zip_keeps_non_exec_at_0644() { + use std::os::unix::fs::PermissionsExt; + + use zip::write::SimpleFileOptions; + let zip = build_zip(|w| { + let opts = SimpleFileOptions::default().unix_permissions(0o666); + w.start_file("doc.txt", opts).unwrap(); + w.write_all(b"hello").unwrap(); + }); + let tmp = tempfile::tempdir().unwrap(); + extract_laufey_archive("perm.zip", &zip, tmp.path()).expect("extract"); + let mode = std::fs::metadata(tmp.path().join("doc.txt")) + .unwrap() + .permissions() + .mode() + & 0o7777; + assert_eq!(mode, 0o644); + } + + #[test] + fn extract_zip_creates_nested_directories() { + use zip::write::SimpleFileOptions; + let zip = build_zip(|w| { + w.start_file("a/b/c/leaf.txt", SimpleFileOptions::default()) + .unwrap(); + w.write_all(b"deep").unwrap(); + }); + let tmp = tempfile::tempdir().unwrap(); + extract_laufey_archive("nest.zip", &zip, tmp.path()).expect("extract"); + assert_eq!( + std::fs::read(tmp.path().join("a/b/c/leaf.txt")).unwrap(), + b"deep" + ); + } + + // --- read_plist_string --- + + #[test] + fn read_plist_string_returns_value_for_existing_key() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("Info.plist"); + std::fs::write( + &p, + r#" + + + CFBundleExecutablemy_app + CFBundleIdentifiercom.example.foo + +"#, + ) + .unwrap(); + assert_eq!( + read_plist_string(&p, "CFBundleExecutable").as_deref(), + Some("my_app") + ); + assert_eq!( + read_plist_string(&p, "CFBundleIdentifier").as_deref(), + Some("com.example.foo") + ); + } + + #[test] + fn read_plist_string_none_for_missing_key() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("Info.plist"); + std::fs::write( + &p, + r#" + +Xy +"#, + ) + .unwrap(); + assert_eq!(read_plist_string(&p, "CFBundleExecutable"), None); + } + + #[test] + fn read_plist_string_none_for_non_string_value() { + // A key with a non-string value (e.g. an array) must yield None + // rather than panic or return some stringified form. + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("Info.plist"); + std::fs::write( + &p, + r#" + +Kx +"#, + ) + .unwrap(); + assert_eq!(read_plist_string(&p, "K"), None); + } + + #[test] + fn read_plist_string_none_for_unreadable_or_corrupt_file() { + let tmp = tempfile::tempdir().unwrap(); + // Non-existent file. + assert_eq!( + read_plist_string(&tmp.path().join("missing.plist"), "K"), + None + ); + // Garbage contents. + let p = tmp.path().join("bad.plist"); + std::fs::write(&p, b"this is not a plist").unwrap(); + assert_eq!(read_plist_string(&p, "K"), None); + } + + // --- rewrite_cef_helper_bundle_ids --- + + fn setup_cef_frameworks(tmp: &std::path::Path) -> std::path::PathBuf { + let contents = tmp.join("Contents"); + let fw = contents.join("Frameworks"); + std::fs::create_dir_all(&fw).unwrap(); + let make_helper_app = |name: &str, id: &str| { + let app = fw.join(format!("{name}.app")); + let plist_dir = app.join("Contents"); + std::fs::create_dir_all(&plist_dir).unwrap(); + write_helper_plist(&plist_dir.join("Info.plist"), id); + }; + make_helper_app("laufey Helper", "com.example.laufey.helper"); + make_helper_app("laufey Helper (GPU)", "com.example.laufey.helper.gpu"); + make_helper_app( + "laufey Helper (Renderer)", + "com.example.laufey.helper.renderer", + ); + // A non-helper .app — must NOT be rewritten. + let other = fw.join("Other.app/Contents"); + std::fs::create_dir_all(&other).unwrap(); + write_helper_plist(&other.join("Info.plist"), "com.example.other"); + // The CEF framework directory itself — also must NOT be touched + // (rewriting it invalidates its embedded code signature). + let cef_fw = + fw.join("Chromium Embedded Framework.framework/Versions/A/Resources"); + std::fs::create_dir_all(&cef_fw).unwrap(); + write_helper_plist( + &cef_fw.join("Info.plist"), + "org.chromium.embedded.framework", + ); + contents + } + + #[test] + fn rewrite_helpers_rewrites_only_helper_apps() { + let tmp = tempfile::tempdir().unwrap(); + let contents = setup_cef_frameworks(tmp.path()); + rewrite_cef_helper_bundle_ids(&contents, "com.acme.myapp").unwrap(); + + let fw = contents.join("Frameworks"); + assert_eq!( + read_bundle_id(&fw.join("laufey Helper.app/Contents/Info.plist")), + "com.acme.myapp.helper" + ); + assert_eq!( + read_bundle_id(&fw.join("laufey Helper (GPU).app/Contents/Info.plist")), + "com.acme.myapp.helper.gpu" + ); + assert_eq!( + read_bundle_id( + &fw.join("laufey Helper (Renderer).app/Contents/Info.plist") + ), + "com.acme.myapp.helper.renderer" + ); + // Other.app does not contain "Helper" → must NOT be rewritten. + assert_eq!( + read_bundle_id(&fw.join("Other.app/Contents/Info.plist")), + "com.example.other" + ); + // CEF framework: also untouched. + assert_eq!( + read_bundle_id(&fw.join( + "Chromium Embedded Framework.framework/Versions/A/Resources/Info.plist" + )), + "org.chromium.embedded.framework" + ); + } + + #[test] + fn rewrite_helpers_is_noop_when_frameworks_missing() { + // Some backends (winit) don't ship a Frameworks/ subdir. The + // helper-rewriter must tolerate that without erroring. + let tmp = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(tmp.path().join("Contents")).unwrap(); + rewrite_cef_helper_bundle_ids( + &tmp.path().join("Contents"), + "com.acme.myapp", + ) + .expect("absent Frameworks must not error"); + } + + // --- locate_dev_backend_binary / locate_dev_app_bundle --- + + #[test] + fn locate_dev_webview_returns_first_existing_candidate() { + let tmp = tempfile::tempdir().unwrap(); + // Set up TWO candidates; the function should return whichever it + // finds first in its candidate list. We create the SECOND one to + // prove the function actually walks the list (rather than blindly + // returning candidate[0] which would not exist). + let p = tmp + .path() + .join("webview/build/laufey_webview.app/Contents/MacOS/laufey_webview"); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, b"binary").unwrap(); + let found = locate_dev_backend_binary(tmp.path(), "webview"); + assert_eq!(found.as_deref(), Some(p.as_path())); + } + + #[test] + fn locate_dev_returns_none_when_nothing_exists() { + let tmp = tempfile::tempdir().unwrap(); + assert!(locate_dev_backend_binary(tmp.path(), "cef").is_none()); + assert!(locate_dev_backend_binary(tmp.path(), "webview").is_none()); + assert!(locate_dev_backend_binary(tmp.path(), "raw").is_none()); + } + + #[test] + fn locate_dev_backend_winit_target_paths() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("target/release/laufey_winit"); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, b"binary").unwrap(); + // `raw` is the public backend name; the binary file is `laufey_winit`. + let found = locate_dev_backend_binary(tmp.path(), "raw"); + assert_eq!(found.as_deref(), Some(p.as_path())); + } + + #[test] + fn locate_dev_app_bundle_skips_winit() { + // winit don't ship as .app bundles. locate_dev_app_bundle + // must short-circuit to None for those, never touching the + // filesystem (so a misleading directory with the right name can't + // accidentally match). + let tmp = tempfile::tempdir().unwrap(); + assert!(locate_dev_app_bundle(tmp.path(), "raw").is_none()); + } + + #[test] + fn locate_dev_app_bundle_finds_webview() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("webview/build/laufey_webview.app"); + std::fs::create_dir_all(&p).unwrap(); + let found = locate_dev_app_bundle(tmp.path(), "webview"); + assert_eq!(found.as_deref(), Some(p.as_path())); + } + + // --- convert_icon_set_to_ico --- + // + // ICO is a binary container with a strict header layout. A regression + // that flips a width/height byte, or computes data_offset wrong, + // produces a visually-fine file that Windows refuses to display. + + fn fake_png(label: &[u8]) -> Vec { + // Not a real PNG, just bytes the converter reads verbatim into the + // ICO entry body. The converter doesn't validate the PNG contents. + let mut v = Vec::with_capacity(8 + label.len()); + v.extend_from_slice(b"\x89PNG\r\n\x1a\n"); // PNG magic + v.extend_from_slice(label); + v + } + + fn read_u16(buf: &[u8], off: usize) -> u16 { + u16::from_le_bytes([buf[off], buf[off + 1]]) + } + + fn read_u32(buf: &[u8], off: usize) -> u32 { + u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) + } + + #[test] + fn ico_writes_correct_header_for_multiple_sizes() { + let cwd = tempfile::tempdir().unwrap(); + let png16 = fake_png(b"size16"); + let png32 = fake_png(b"size32"); + let png256 = fake_png(b"size256"); + std::fs::write(cwd.path().join("a.png"), &png16).unwrap(); + std::fs::write(cwd.path().join("b.png"), &png32).unwrap(); + std::fs::write(cwd.path().join("c.png"), &png256).unwrap(); + + let entries = vec![ + crate::args::IconSetEntry { + path: "a.png".into(), + size: 16, + }, + crate::args::IconSetEntry { + path: "b.png".into(), + size: 32, + }, + crate::args::IconSetEntry { + path: "c.png".into(), + size: 256, + }, + ]; + let out = cwd.path().join("icon.ico"); + convert_icon_set_to_ico(cwd.path(), &entries, &out).expect("ok"); + let buf = std::fs::read(&out).unwrap(); + + // ICO header: reserved=0, type=1 (ICO), count=3. + assert_eq!(read_u16(&buf, 0), 0); + assert_eq!(read_u16(&buf, 2), 1); + assert_eq!(read_u16(&buf, 4), 3); + + // Three directory entries follow, 16 bytes each. + // Entry 0: 16x16 PNG. dim=16, planes=1, bpp=32, size, offset. + assert_eq!(buf[6], 16); + assert_eq!(buf[7], 16); + assert_eq!(read_u16(&buf, 6 + 4), 1, "planes"); + assert_eq!(read_u16(&buf, 6 + 6), 32, "bits per pixel"); + let sz0 = read_u32(&buf, 6 + 8) as usize; + let off0 = read_u32(&buf, 6 + 12) as usize; + assert_eq!(sz0, png16.len()); + // First entry's data starts right after the header + 3 directory + // entries (6 + 48 = 54). + assert_eq!(off0, 54); + + // Entry 2: 256 is encoded as 0 in the dim byte per ICO convention. + assert_eq!(buf[6 + 32], 0, "256px width must be encoded as 0"); + assert_eq!(buf[6 + 33], 0, "256px height must be encoded as 0"); + + // The three PNG bodies appear at the offsets the directory entries + // claim, in the order they were listed. + assert_eq!(&buf[off0..off0 + sz0], png16.as_slice()); + let off1 = read_u32(&buf, 6 + 16 + 12) as usize; + let sz1 = read_u32(&buf, 6 + 16 + 8) as usize; + assert_eq!(&buf[off1..off1 + sz1], png32.as_slice()); + let off2 = read_u32(&buf, 6 + 32 + 12) as usize; + let sz2 = read_u32(&buf, 6 + 32 + 8) as usize; + assert_eq!(&buf[off2..off2 + sz2], png256.as_slice()); + } + + #[test] + fn ico_skips_missing_files_with_warning() { + let cwd = tempfile::tempdir().unwrap(); + let png = fake_png(b"only"); + std::fs::write(cwd.path().join("there.png"), &png).unwrap(); + let entries = vec![ + crate::args::IconSetEntry { + path: "missing.png".into(), + size: 16, + }, + crate::args::IconSetEntry { + path: "there.png".into(), + size: 32, + }, + ]; + let out = cwd.path().join("icon.ico"); + convert_icon_set_to_ico(cwd.path(), &entries, &out).expect("ok"); + let buf = std::fs::read(&out).unwrap(); + // Only one image survived; the count must reflect that, otherwise + // the directory would name an entry with bogus offset/size. + assert_eq!(read_u16(&buf, 4), 1, "count must skip missing entries"); + } + + #[test] + fn ico_errors_when_no_inputs_resolve() { + let cwd = tempfile::tempdir().unwrap(); + let entries = vec![ + crate::args::IconSetEntry { + path: "absent1.png".into(), + size: 16, + }, + crate::args::IconSetEntry { + path: "absent2.png".into(), + size: 32, + }, + ]; + let out = cwd.path().join("icon.ico"); + let err = convert_icon_set_to_ico(cwd.path(), &entries, &out).unwrap_err(); + assert!(err.to_string().contains("No valid icon")); + // Output file must not exist if we never wrote anything. + assert!(!out.exists()); + } + + #[test] + fn ico_data_offsets_are_monotonically_increasing() { + // The data_offset accumulator in the producer increments by each + // image's byte length. A regression that forgot to advance the + // offset would cause later entries to alias earlier ones. + let cwd = tempfile::tempdir().unwrap(); + let a = fake_png(b"aaaaaaaaaa"); // distinct lengths + let b = fake_png(b"bbbbbbbbbbbbbbbb"); + let c = fake_png(b"cccc"); + std::fs::write(cwd.path().join("a.png"), &a).unwrap(); + std::fs::write(cwd.path().join("b.png"), &b).unwrap(); + std::fs::write(cwd.path().join("c.png"), &c).unwrap(); + + let entries = vec![ + crate::args::IconSetEntry { + path: "a.png".into(), + size: 16, + }, + crate::args::IconSetEntry { + path: "b.png".into(), + size: 32, + }, + crate::args::IconSetEntry { + path: "c.png".into(), + size: 64, + }, + ]; + let out = cwd.path().join("icon.ico"); + convert_icon_set_to_ico(cwd.path(), &entries, &out).expect("ok"); + let buf = std::fs::read(&out).unwrap(); + let off0 = read_u32(&buf, 6 + 12) as usize; + let off1 = read_u32(&buf, 6 + 16 + 12) as usize; + let off2 = read_u32(&buf, 6 + 32 + 12) as usize; + let sz0 = read_u32(&buf, 6 + 8) as usize; + let sz1 = read_u32(&buf, 6 + 16 + 8) as usize; + assert_eq!(off1, off0 + sz0, "second offset = first off + first size"); + assert_eq!(off2, off1 + sz1, "third offset = second off + second size"); + // And the file must be at least long enough to hold all images. + let total = read_u32(&buf, 6 + 32 + 8) as usize + off2; + assert_eq!(buf.len(), total); + } + + #[test] + fn rewrite_helper_plist_errors_on_missing_key() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("Info.plist"); + // Plist without CFBundleIdentifier at all — the rewrite must fail + // loudly rather than silently insert a wrong id. + std::fs::write( + &p, + r#" + +Otherx +"#, + ) + .unwrap(); + let err = + rewrite_helper_plist_identifier(&p, "com.acme.myapp").unwrap_err(); + assert!(err.to_string().contains("CFBundleIdentifier")); + } +} diff --git a/cli/tools/desktop_devtools.rs b/cli/tools/desktop_devtools.rs new file mode 100644 index 00000000000000..7d4f35f780227a --- /dev/null +++ b/cli/tools/desktop_devtools.rs @@ -0,0 +1,2214 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +//! CDP multiplexer for `deno desktop --inspect`. +//! +//! Architecture: +//! +//! ```text +//! ┌──────────────────────────────────────────┐ +//! │ CDP Multiplexer (this file) │ +//! DevTools ◄──► │ HTTP: /json/{version,list,protocol} │ +//! (single ws) │ /debugger-attached (gate) │ +//! │ WS: /unified (primary entry) │ +//! │ /deno, /cef (direct bypass) │ +//! │ HTTP: /devtools/* (frontend proxy) │ +//! └──────┬─────────────────────────┬─────────┘ +//! │ │ +//! Deno inspector CEF renderer +//! (internal port) (internal port) +//! ``` +//! +//! ## Unified session +//! +//! `/unified` is the primary session. CEF is the default (un-sessioned) +//! target; the Deno runtime appears as an attached child via a +//! synthetic `Target.attachedToTarget` event with a stable `sessionId` +//! the mux invents. Frame routing: +//! +//! - Client → mux frames with `sessionId == deno_session_id` have the +//! field stripped and are forwarded to Deno. +//! - Deno → client frames get the `sessionId` re-injected. +//! - `Target.attachToTarget`/`detachFromTarget` for the Deno child are +//! answered locally — CEF never learns of the synthetic session. +//! - `Runtime.executionContextCreated` gets its `context.name` +//! rewritten ("Renderer" on the CEF leg, "Deno" on the Deno leg) so +//! the Console dropdown shows meaningful labels instead of V8's +//! defaults. +//! - The synthetic `Target.attachedToTarget` event is emitted lazily, +//! on the first `Target.setAutoAttach`/`setDiscoverTargets` from the +//! client — firing earlier loses the event to the frontend's +//! not-yet-ready auto-attach manager. +//! +//! DevTools sees both isolates in one window: the Console dropdown +//! shows "Renderer" / "Deno", and the Sources panel Threads sidebar +//! lists both. +//! +//! ## `--inspect-brk` / `--inspect-wait` +//! +//! The child process blocks on `GET /debugger-attached` before +//! navigating CEF. The mux flips that endpoint to 200 only after the +//! DevTools client has connected AND — under `--inspect-brk` — the +//! `Debugger.enable` + `Debugger.pause` injection against CEF has +//! acked both responses. This guarantees the renderer pauses on the +//! first JS statement of the loaded page instead of racing past it. +//! Deno's own `--inspect-brk` handles the Deno isolate separately. +//! +//! ## Direct / frontend endpoints +//! +//! `/deno` and `/cef` are direct passthrough WebSockets for debugging +//! each isolate in isolation when the unified session misbehaves. +//! `/devtools/*` proxies CEF's bundled DevTools frontend assets +//! through the mux port so `openDevtools()` can pop a CEF window +//! without triggering CEF's own remote-debugging-port frontend +//! interception. + +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use deno_core::anyhow::Context; +use deno_core::anyhow::anyhow; +use deno_core::anyhow::bail; +use deno_core::error::AnyError; +use deno_core::serde_json; +use deno_core::serde_json::Value; +use deno_core::serde_json::json; +use fastwebsockets::Frame; +use fastwebsockets::OpCode; +use fastwebsockets::WebSocket; +use fastwebsockets::WebSocketError; +use fastwebsockets::handshake; +use http_body_util::BodyExt; +use http_body_util::Empty; +use http_body_util::Full; +use hyper::body::Bytes; +use hyper::body::Incoming; +use hyper_util::rt::TokioIo; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use uuid::Uuid; + +/// Configuration for the CDP multiplexer. +#[derive(Clone, Debug)] +pub struct MuxConfig { + /// User-visible listen address (from `--inspect`). + pub listen: SocketAddr, + /// Internal listen address for Deno's native inspector. + pub deno_internal: SocketAddr, + /// Internal listen address for the CEF renderer debug port. + pub cef_internal: SocketAddr, + /// `true` when `--inspect-brk` was passed — the mux will inject + /// `Debugger.enable` + `Debugger.pause` into the CEF session so the + /// renderer breaks on the first JS statement after navigation. + pub inspect_brk: bool, + /// `true` when `--inspect-wait` or `--inspect-brk` was passed. + /// Not read by the mux itself — the child process uses env vars to + /// decide whether to poll `/debugger-attached` before navigating. + #[allow( + dead_code, + reason = "read by the child process via env vars, not by the mux itself" + )] + pub wait_for_debugger: bool, +} + +/// A running multiplexer. Dropping the handle shuts the server down. +pub struct MuxHandle { + pub listen: SocketAddr, + _shutdown_tx: oneshot::Sender<()>, +} + +pub use deno_lib::util::net::allocate_random_port; + +/// Spawn the mux on a background task. Returns once the listener is +/// bound — connection handling continues in the spawned task. The +/// upstream servers (Deno inspector, CEF) do not need to be up yet; +/// the mux polls them on demand when DevTools makes a request. +pub async fn spawn_mux(config: MuxConfig) -> Result { + let listener = TcpListener::bind(config.listen).await.with_context(|| { + format!("failed to bind CDP multiplexer to {}", config.listen) + })?; + let listen = listener.local_addr()?; + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + + let state = Arc::new(MuxState::new(config.clone(), listen)); + + tokio::spawn(async move { + let mut shutdown_rx = shutdown_rx; + let mut consecutive_accept_errors: u32 = 0; + loop { + tokio::select! { + _ = &mut shutdown_rx => { + log::debug!("[devtools-mux] shutdown requested"); + break; + } + accept = listener.accept() => { + match accept { + Ok((stream, _)) => { + consecutive_accept_errors = 0; + let state = state.clone(); + tokio::spawn(async move { + if let Err(err) = serve_connection(stream, state).await { + log::debug!("[devtools-mux] connection error: {err:?}"); + } + }); + } + Err(err) => { + // A persistent failure (EMFILE, etc.) used to spin forever + // logging at 5Hz. Throttle the log to every Nth attempt + // and back off harder. + consecutive_accept_errors = + consecutive_accept_errors.saturating_add(1); + if consecutive_accept_errors == 1 + || consecutive_accept_errors.is_power_of_two() + { + log::error!( + "[devtools-mux] accept failed (attempt {}): {err:?}", + consecutive_accept_errors, + ); + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + } + } + } + } + }); + + Ok(MuxHandle { + listen, + _shutdown_tx: shutdown_tx, + }) +} + +/// Per-target identification. Kept small and stable so DevTools' +/// `webSocketDebuggerUrl` doesn't change across polls. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum TargetKind { + /// Single CDP session fronting both isolates. CEF is the primary; + /// Deno appears as an attached child target via `Target.*`. + Unified, + /// Direct passthrough to the Deno inspector. + Deno, + /// Direct passthrough to the CEF renderer's debug port. + Cef, +} + +impl TargetKind { + fn path(self) -> &'static str { + match self { + TargetKind::Unified => "/unified", + TargetKind::Deno => "/deno", + TargetKind::Cef => "/cef", + } + } + + fn title(self) -> &'static str { + match self { + TargetKind::Unified => "Deno Desktop (unified)", + TargetKind::Deno => "Deno Runtime", + TargetKind::Cef => "CEF Renderer", + } + } +} + +/// Synthetic CDP target id for the Deno isolate inside the unified +/// session. Stable per process; DevTools uses it in `Target.attachToTarget`. +const DENO_CHILD_TARGET_ID: &str = "deno-runtime-isolate"; + +struct MuxState { + config: MuxConfig, + listen: SocketAddr, + // Stable UUIDs per target so repeated /json/list calls return the + // same IDs. DevTools caches these. + unified_id: Uuid, + deno_id: Uuid, + cef_id: Uuid, + // Stable session id we hand DevTools when it attaches to the Deno + // child target inside the unified session. + deno_session_id: String, + // Set to `true` when a DevTools client has connected to any session. + // The child process polls `/debugger-attached` to gate navigation + // when `--inspect-wait` or `--inspect-brk` is active. + debugger_attached: Arc, +} + +impl MuxState { + fn new(config: MuxConfig, listen: SocketAddr) -> Self { + Self { + config, + listen, + unified_id: Uuid::new_v4(), + deno_id: Uuid::new_v4(), + cef_id: Uuid::new_v4(), + deno_session_id: Uuid::new_v4().to_string(), + debugger_attached: Arc::new(std::sync::atomic::AtomicBool::new(false)), + } + } + + fn target_for_path(&self, path: &str) -> Option { + if path == TargetKind::Unified.path() { + Some(TargetKind::Unified) + } else if path == TargetKind::Deno.path() { + Some(TargetKind::Deno) + } else if path == TargetKind::Cef.path() { + Some(TargetKind::Cef) + } else { + None + } + } +} + +async fn serve_connection( + stream: TcpStream, + state: Arc, +) -> Result<(), AnyError> { + let io = TokioIo::new(stream); + let service = hyper::service::service_fn(move |req| { + let state = state.clone(); + async move { Ok::<_, Infallible>(handle_request(req, state).await) } + }); + + hyper::server::conn::http1::Builder::new() + .serve_connection(io, service) + .with_upgrades() + .await + .map_err(|e| anyhow!("hyper serve error: {e}"))?; + Ok(()) +} + +async fn handle_request( + req: hyper::Request, + state: Arc, +) -> hyper::Response> { + if req.method() != http::Method::GET { + return simple_response( + http::StatusCode::METHOD_NOT_ALLOWED, + "Not Allowed", + ); + } + let path = req.uri().path().to_string(); + match path.as_str() { + "/json/version" => json_version(&state), + "/json" | "/json/list" => json_list(&state), + "/json/protocol" => json_protocol(), + "/debugger-attached" => { + if state + .debugger_attached + .load(std::sync::atomic::Ordering::SeqCst) + { + simple_response(http::StatusCode::OK, "attached") + } else { + simple_response(http::StatusCode::SERVICE_UNAVAILABLE, "waiting") + } + } + other => { + if let Some(kind) = state.target_for_path(other) { + match handle_upgrade(req, kind, state.clone()) { + Ok(resp) => resp, + Err(err) => { + log::error!("[devtools-mux] upgrade failed for {other}: {err:?}"); + simple_response(http::StatusCode::BAD_REQUEST, "upgrade failed") + } + } + } else if other.starts_with("/devtools/") { + // Proxy DevTools frontend assets from CEF's bundled HTTP server. + // Serving them through our port means the CEF window navigating + // to the DevTools URL sees only `127.0.0.1:`, so it + // doesn't special-case the remote-debugging port and steal the + // frontend for the new window's own renderer. + match proxy_devtools_asset(&req, state.config.cef_internal).await { + Ok(resp) => resp, + Err(err) => { + log::error!("[devtools-mux] devtools asset proxy failed: {err:?}"); + simple_response(http::StatusCode::BAD_GATEWAY, "proxy failed") + } + } + } else { + simple_response(http::StatusCode::NOT_FOUND, "Not Found") + } + } + } +} + +/// GET `http://` and return the response verbatim. +/// Used to proxy the bundled DevTools frontend (inspector.html + its +/// JS/CSS assets) through the mux's own port, bypassing CEF's +/// remote-debugging-port special-case that would otherwise wire the +/// frontend to the requesting window's renderer. +async fn proxy_devtools_asset( + req: &hyper::Request, + cef_internal: SocketAddr, +) -> Result>, AnyError> { + let path_and_query = req + .uri() + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or("/"); + + let stream = TcpStream::connect(cef_internal).await?; + let io = TokioIo::new(stream); + let (mut sender, conn) = hyper::client::conn::http1::handshake(io) + .await + .map_err(|e| anyhow!("devtools asset handshake failed: {e}"))?; + tokio::spawn(async move { + if let Err(err) = conn.await { + log::trace!("[devtools-mux] devtools asset conn closed: {err:?}"); + } + }); + + let upstream_req = hyper::Request::builder() + .method(http::Method::GET) + .uri(path_and_query) + .header(http::header::HOST, cef_internal.to_string()) + .body(Empty::::new())?; + let resp = sender.send_request(upstream_req).await?; + let (parts, body) = resp.into_parts(); + let bytes = body.collect().await?.to_bytes(); + + let mut builder = hyper::Response::builder().status(parts.status); + // Drop hop-by-hop headers that don't apply to our re-packaged body. + for (name, value) in parts.headers.iter() { + let skip = matches!( + name.as_str().to_ascii_lowercase().as_str(), + "transfer-encoding" + | "content-length" + | "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "upgrade" + ); + if !skip { + builder = builder.header(name, value); + } + } + Ok( + builder + .header(http::header::CONTENT_LENGTH, bytes.len()) + .body(Full::new(bytes)) + .unwrap(), + ) +} + +fn json_version(state: &MuxState) -> hyper::Response> { + let body = json!({ + "Browser": format!("deno-desktop/{}", env!("CARGO_PKG_VERSION")), + "Protocol-Version": "1.3", + "V8-Version": deno_core::v8::VERSION_STRING, + // Advertise one of the two upstream WS URLs as the "browser" URL. + // DevTools' `chrome://inspect` uses this to drive auto-attach; the + // CEF upstream exposes the richer Target.* domain. + "webSocketDebuggerUrl": format!( + "ws://{}{}", + state.listen, + TargetKind::Cef.path(), + ), + }); + json_response(body) +} + +fn json_list(state: &MuxState) -> hyper::Response> { + let listen = state.listen.to_string(); + let unified_url = format!("ws://{listen}{}", TargetKind::Unified.path()); + let deno_url = format!("ws://{listen}{}", TargetKind::Deno.path()); + let cef_url = format!("ws://{listen}{}", TargetKind::Cef.path()); + + // Primary entry: the unified session. DevTools opens one window + // (inspector.html — full browser DevTools) and gets both isolates as + // attached targets in the Sources panel. + let unified_entry = json!({ + "id": state.unified_id.to_string(), + "type": "page", + "title": TargetKind::Unified.title(), + "description": "Unified DevTools (CEF page + Deno runtime)", + "url": "deno-desktop://unified", + "faviconUrl": "https://deno.land/favicon.ico", + "devtoolsFrontendUrl": format!( + "devtools://devtools/bundled/inspector.html?ws={}", + strip_scheme(&unified_url), + ), + "webSocketDebuggerUrl": unified_url, + }); + // Fallback entries: direct passthrough to each isolate. Useful when + // the unified session misbehaves and you want to debug each side in + // isolation. + let deno_entry = json!({ + "id": state.deno_id.to_string(), + "type": "node", + "title": TargetKind::Deno.title(), + "description": "Deno runtime V8 isolate (direct)", + "url": format!("deno://{}", state.config.deno_internal), + "faviconUrl": "https://deno.land/favicon.ico", + "devtoolsFrontendUrl": format!( + "devtools://devtools/bundled/js_app.html?ws={}&experiments=true&v8only=true", + strip_scheme(&deno_url), + ), + "webSocketDebuggerUrl": deno_url, + }); + let cef_entry = json!({ + "id": state.cef_id.to_string(), + "type": "page", + "title": TargetKind::Cef.title(), + "description": "CEF renderer V8 isolate (direct)", + "url": format!("cef://{}", state.config.cef_internal), + "faviconUrl": "https://deno.land/favicon.ico", + "devtoolsFrontendUrl": format!( + "devtools://devtools/bundled/inspector.html?ws={}", + strip_scheme(&cef_url), + ), + "webSocketDebuggerUrl": cef_url, + }); + + json_response(Value::Array(vec![unified_entry, deno_entry, cef_entry])) +} + +/// Return an empty protocol descriptor. DevTools tolerates this and +/// falls back to the built-in protocol. +fn json_protocol() -> hyper::Response> { + json_response(json!({ + "version": { "major": "1", "minor": "3" }, + "domains": [], + })) +} + +fn json_response(value: Value) -> hyper::Response> { + let body = Full::new(Bytes::from(serde_json::to_vec(&value).unwrap())); + hyper::Response::builder() + .status(http::StatusCode::OK) + .header(http::header::CONTENT_TYPE, "application/json") + .body(body) + .unwrap() +} + +fn simple_response( + status: http::StatusCode, + msg: &'static str, +) -> hyper::Response> { + hyper::Response::builder() + .status(status) + .body(Full::new(Bytes::from(msg))) + .unwrap() +} + +fn strip_scheme(ws_url: &str) -> String { + ws_url + .strip_prefix("ws://") + .or_else(|| ws_url.strip_prefix("wss://")) + .unwrap_or(ws_url) + .to_string() +} + +/// Upgrade an incoming HTTP request to a WebSocket, open a matching +/// WebSocket to the upstream target, and shuttle frames in both +/// directions. +fn handle_upgrade( + mut req: hyper::Request, + kind: TargetKind, + state: Arc, +) -> Result>, AnyError> { + let (resp, upgrade_fut) = fastwebsockets::upgrade::upgrade(&mut req) + .map_err(|e| anyhow!("not a valid websocket upgrade: {e}"))?; + + tokio::spawn(async move { + let client = match upgrade_fut.await { + Ok(ws) => ws, + Err(err) => { + log::error!("[devtools-mux] client upgrade failed: {err:?}"); + return; + } + }; + + // `debugger_attached` is what releases the child process from its + // `/debugger-attached` poll so it can navigate CEF. Under + // `--inspect-brk` we MUST inject `Debugger.enable` + `Debugger.pause` + // into the CEF isolate BEFORE the child navigates, otherwise the + // page's JS executes before the pause request arrives. So each + // target-specific handler signals attachment at its own right moment. + + match kind { + TargetKind::Unified => { + if let Err(err) = run_unified_session(client, state).await { + log::debug!("[devtools-mux] unified session ended: {err:?}"); + } + } + TargetKind::Deno => { + // Deno has its own `--inspect-brk` mechanism that blocks the + // isolate until a client attaches — nothing to inject here. + mark_debugger_attached(&state); + match connect_upstream(&state, kind).await { + Ok(upstream) => { + if let Err(err) = proxy_frames(client, upstream).await { + log::debug!("[devtools-mux] proxy ended: {err:?}"); + } + } + Err(err) => { + log::error!( + "[devtools-mux] failed to connect upstream for Deno: {err:?}" + ); + } + } + } + TargetKind::Cef => match connect_upstream(&state, kind).await { + Ok(mut upstream) => { + if state.config.inspect_brk + && let Err(err) = inject_cef_pause(&mut upstream).await + { + log::error!( + "[devtools-mux] failed to inject pause into CEF: {err:?}" + ); + } + mark_debugger_attached(&state); + if let Err(err) = proxy_frames(client, upstream).await { + log::debug!("[devtools-mux] proxy ended: {err:?}"); + } + } + Err(err) => { + log::error!( + "[devtools-mux] failed to connect upstream for CEF: {err:?}" + ); + } + }, + } + }); + + let (parts, _) = resp.into_parts(); + Ok(hyper::Response::from_parts(parts, Full::new(Bytes::new()))) +} + +/// Open a WebSocket to the right upstream target, performing target +/// discovery as needed. For CEF we hit `/json/list` to find the live +/// `webSocketDebuggerUrl`; for Deno we call the inspector server's +/// same endpoint. The upstream is retried briefly since the renderer +/// may not have finished booting. +async fn connect_upstream( + state: &MuxState, + kind: TargetKind, +) -> Result>, AnyError> { + let upstream_host = match kind { + TargetKind::Deno => state.config.deno_internal, + TargetKind::Cef => state.config.cef_internal, + TargetKind::Unified => { + bail!("connect_upstream cannot be called with the Unified target") + } + }; + + // Poll until the upstream has a live WebSocket debugger URL. + let mut last_err: Option = None; + let deadline = tokio::time::Instant::now() + Duration::from_secs(30); + while tokio::time::Instant::now() < deadline { + match fetch_upstream_ws_url(upstream_host).await { + Ok(ws_url) => match connect_ws(&ws_url).await { + Ok(ws) => return Ok(ws), + Err(err) => { + last_err = Some(err); + } + }, + Err(err) => { + last_err = Some(err); + } + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + Err(last_err.unwrap_or_else(|| anyhow!("upstream connect timed out"))) +} + +/// GET `http:///json/list` and pick the first entry's +/// `webSocketDebuggerUrl`. Both Deno's inspector server and CEF's +/// remote-debugging endpoint implement this. +async fn fetch_upstream_ws_url(host: SocketAddr) -> Result { + let stream = TcpStream::connect(host).await?; + let io = TokioIo::new(stream); + let (mut sender, conn) = hyper::client::conn::http1::handshake(io) + .await + .map_err(|e| anyhow!("http handshake to {host} failed: {e}"))?; + tokio::spawn(async move { + if let Err(err) = conn.await { + log::trace!("[devtools-mux] upstream conn closed: {err:?}"); + } + }); + + let req = hyper::Request::builder() + .method(http::Method::GET) + .uri("/json/list") + .header(http::header::HOST, host.to_string()) + .body(Empty::::new())?; + let resp = sender.send_request(req).await?; + if !resp.status().is_success() { + bail!("upstream /json/list at {host} returned {}", resp.status()); + } + let body = resp.collect().await?.to_bytes(); + let value: Value = serde_json::from_slice(&body) + .with_context(|| format!("upstream /json/list at {host} not JSON"))?; + + let ws_url = value + .as_array() + .and_then(|arr| { + arr.iter().find_map(|v| { + // Skip targets that are our own DevTools frontend window — when + // openDevtools() creates a CEF window pointed at inspector.html, + // CEF registers it as a debuggable target. Connecting to it + // instead of the real app window would show "DevTools for + // DevTools". + let url = v.get("url").and_then(|u| u.as_str()).unwrap_or(""); + if url.contains("/devtools/") || url.contains("devtools://") { + return None; + } + v.get("webSocketDebuggerUrl") + }) + }) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + anyhow!("no webSocketDebuggerUrl in /json/list at {host}") + })?; + + // Upstream responds with its own listen host; some backends return + // `0.0.0.0` or `localhost`. Force to the target host so we connect + // to the right address. + let rewritten = rewrite_ws_host(ws_url, host); + Ok(rewritten) +} + +fn rewrite_ws_host(ws_url: &str, host: SocketAddr) -> String { + let rest = ws_url + .strip_prefix("ws://") + .or_else(|| ws_url.strip_prefix("wss://")) + .unwrap_or(ws_url); + let path = rest.find('/').map(|i| &rest[i..]).unwrap_or("/"); + format!("ws://{host}{path}") +} + +async fn connect_ws( + ws_url: &str, +) -> Result>, AnyError> { + let url: http::Uri = ws_url.parse()?; + let host = url + .host() + .ok_or_else(|| anyhow!("ws url missing host: {ws_url}"))?; + // Don't fall back to port 80: a malformed `/json/list` response + // missing the port would otherwise route the WS connect to whatever + // is listening on port 80 of the upstream host. + let port = url + .port_u16() + .ok_or_else(|| anyhow!("ws url missing port: {ws_url}"))?; + let authority = format!("{host}:{port}"); + + let stream = TcpStream::connect(&authority).await?; + let req = hyper::Request::builder() + .method(http::Method::GET) + .uri(url.path_and_query().map(|p| p.as_str()).unwrap_or("/")) + .header(http::header::HOST, &authority) + .header(http::header::UPGRADE, "websocket") + .header(http::header::CONNECTION, "upgrade") + .header("Sec-WebSocket-Key", handshake::generate_key()) + .header("Sec-WebSocket-Version", "13") + .body(Empty::::new())?; + + let (ws, _) = handshake::client(&TokioExec, req, stream).await?; + Ok(ws) +} + +struct TokioExec; +impl hyper::rt::Executor for TokioExec +where + F: std::future::Future + Send + 'static, + F::Output: Send + 'static, +{ + fn execute(&self, fut: F) { + tokio::spawn(fut); + } +} + +/// Bidirectionally forward frames between the DevTools client and the +/// upstream inspector. The loop ends when either side closes or errors. +async fn proxy_frames( + mut client: WebSocket>, + mut upstream: WebSocket>, +) -> Result<(), AnyError> { + // We forward control frames (ping/pong/close) verbatim between the + // two peers, so disable fastwebsockets' built-in handling. + client.set_auto_close(false); + client.set_auto_pong(false); + upstream.set_auto_close(false); + upstream.set_auto_pong(false); + + // Split both sides so the two pump directions can run concurrently + // without holding a single mutex across `.await` points. + let (mut client_rx, mut client_tx) = client.split(tokio::io::split); + let (mut up_rx, mut up_tx) = upstream.split(tokio::io::split); + + let client_to_up = async { + // The send_fn is used by fastwebsockets to auto-respond to control + // frames; with auto_close/auto_pong disabled it is never called. + let mut noop = |_: Frame<'_>| async { Ok::<(), WebSocketError>(()) }; + loop { + let frame = match client_rx.read_frame(&mut noop).await { + Ok(f) => f, + Err(err) => { + log::debug!("[devtools-mux] client read: {err:?}"); + return; + } + }; + let is_close = frame.opcode == OpCode::Close; + if let Err(err) = up_tx.write_frame(frame).await { + log::debug!("[devtools-mux] upstream write: {err:?}"); + return; + } + if is_close { + return; + } + } + }; + + let up_to_client = async { + let mut noop = |_: Frame<'_>| async { Ok::<(), WebSocketError>(()) }; + loop { + let frame = match up_rx.read_frame(&mut noop).await { + Ok(f) => f, + Err(err) => { + log::debug!("[devtools-mux] upstream read: {err:?}"); + return; + } + }; + let is_close = frame.opcode == OpCode::Close; + if let Err(err) = client_tx.write_frame(frame).await { + log::debug!("[devtools-mux] client write: {err:?}"); + return; + } + if is_close { + return; + } + } + }; + + tokio::join!(client_to_up, up_to_client); + Ok(()) +} + +// ─── Unified session (v2) ────────────────────────────────────────── +// +// One DevTools window, two isolates. CEF is the primary session; +// Deno appears as an "attached" child target via the standard +// `Target.attachedToTarget` event with a synthetic `sessionId`. +// Frames flow through CDP-aware routers that strip/inject `sessionId` +// on the Deno leg and pass everything else through to CEF. + +/// Owned representation of a WebSocket frame, suitable for sending +/// through an mpsc channel. `fastwebsockets::Frame` borrows from the +/// reader's internal buffer and so can't cross task boundaries. +struct OwnedFrame { + opcode: OpCode, + payload: Vec, +} + +impl OwnedFrame { + fn text(payload: Vec) -> Self { + Self { + opcode: OpCode::Text, + payload, + } + } + + fn into_frame(self) -> Frame<'static> { + Frame::new( + true, + self.opcode, + None, + fastwebsockets::Payload::Owned(self.payload), + ) + } +} + +/// Set `debugger_attached` so the child process's `/debugger-attached` +/// poll returns 200 and it can proceed with navigation. Only call this +/// once the CEF pause injection (if any) has completed. +fn mark_debugger_attached(state: &MuxState) { + state + .debugger_attached + .store(true, std::sync::atomic::Ordering::SeqCst); +} + +/// Send `Debugger.enable` + `Debugger.pause` to the CEF upstream, +/// swallowing their responses. Called before the child is released to +/// navigate, so the renderer stops on the first JS statement of the +/// loaded page. +/// +/// Any CEF → client frames that arrive during the injection window are +/// discarded. This is acceptable because the renderer has not yet +/// navigated to a user page (the child is blocked on +/// `/debugger-attached`), so the only traffic is CEF's own protocol +/// setup (e.g. unsolicited `Target.targetCreated` for about:blank), +/// which DevTools will re-observe via `Target.setDiscoverTargets` once +/// the session opens. +async fn inject_cef_pause(cef: &mut WebSocket) -> Result<(), AnyError> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let enable = json!({"id": -1, "method": "Debugger.enable"}); + cef + .write_frame(Frame::new( + true, + OpCode::Text, + None, + fastwebsockets::Payload::Owned(serde_json::to_vec(&enable).unwrap()), + )) + .await?; + + let pause = json!({"id": -2, "method": "Debugger.pause"}); + cef + .write_frame(Frame::new( + true, + OpCode::Text, + None, + fastwebsockets::Payload::Owned(serde_json::to_vec(&pause).unwrap()), + )) + .await?; + + // Read frames until we've observed both responses. CEF may interleave + // unsolicited events, which we drop. + let mut saw_enable = false; + let mut saw_pause = false; + while !(saw_enable && saw_pause) { + let frame = cef.read_frame().await?; + if frame.opcode != OpCode::Text { + continue; + } + let value: Value = match serde_json::from_slice(&frame.payload) { + Ok(v) => v, + Err(_) => continue, + }; + match value.get("id").and_then(|v| v.as_i64()) { + Some(-1) => saw_enable = true, + Some(-2) => saw_pause = true, + _ => {} + } + } + + log::debug!( + "[devtools-mux] injected Debugger.enable + Debugger.pause into CEF" + ); + Ok(()) +} + +/// Run the unified DevTools session: one client WebSocket fronting two +/// upstreams (CEF as the default session, Deno attached via a +/// synthetic `sessionId`). +async fn run_unified_session( + client: WebSocket>, + state: Arc, +) -> Result<(), AnyError> { + let mut client = client; + client.set_auto_close(false); + client.set_auto_pong(false); + + let (cef, deno) = tokio::try_join!( + connect_upstream(&state, TargetKind::Cef), + connect_upstream(&state, TargetKind::Deno), + )?; + let mut cef = cef; + let mut deno = deno; + cef.set_auto_close(false); + cef.set_auto_pong(false); + deno.set_auto_close(false); + deno.set_auto_pong(false); + + let session_id = state.deno_session_id.clone(); + + // When --inspect-brk is active, inject Debugger.enable + Debugger.pause + // into the CEF session BEFORE the child is allowed to navigate. The + // child polls `/debugger-attached` and will only navigate once we + // signal attachment, so doing the injection first — and releasing + // attachment afterwards — guarantees the renderer pauses on the very + // first JS statement of the loaded page. + if state.config.inspect_brk { + inject_cef_pause(&mut cef).await?; + } + mark_debugger_attached(&state); + + let (mut client_rx, mut client_tx) = client.split(tokio::io::split); + let (mut cef_rx, mut cef_tx) = cef.split(tokio::io::split); + let (mut deno_rx, mut deno_tx) = deno.split(tokio::io::split); + + let (client_send, mut client_recv) = mpsc::unbounded_channel::(); + let (cef_send, mut cef_recv) = mpsc::unbounded_channel::(); + let (deno_send, mut deno_recv) = mpsc::unbounded_channel::(); + + // Deno is announced lazily, the first time DevTools asks about + // targets — firing too early causes the event to be dropped before + // the frontend's auto-attach manager has subscribed. See + // `route_client_text` for the trigger. + let deno_announced = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + // Writer tasks: pump owned frames from a channel into the WS half. + let client_writer = tokio::spawn(async move { + while let Some(owned) = client_recv.recv().await { + let close = owned.opcode == OpCode::Close; + if let Err(err) = client_tx.write_frame(owned.into_frame()).await { + log::debug!("[devtools-mux] unified client write: {err:?}"); + return; + } + if close { + return; + } + } + }); + let cef_writer = tokio::spawn(async move { + while let Some(owned) = cef_recv.recv().await { + let close = owned.opcode == OpCode::Close; + if let Err(err) = cef_tx.write_frame(owned.into_frame()).await { + log::debug!("[devtools-mux] unified cef write: {err:?}"); + return; + } + if close { + return; + } + } + }); + let deno_writer = tokio::spawn(async move { + while let Some(owned) = deno_recv.recv().await { + let close = owned.opcode == OpCode::Close; + if let Err(err) = deno_tx.write_frame(owned.into_frame()).await { + log::debug!("[devtools-mux] unified deno write: {err:?}"); + return; + } + if close { + return; + } + } + }); + + // Reader: client → CEF/Deno (with CDP-aware routing). + let mut client_reader = { + let client_send = client_send.clone(); + let cef_send = cef_send.clone(); + let deno_send = deno_send.clone(); + let session_id = session_id.clone(); + let deno_announced = deno_announced.clone(); + tokio::spawn(async move { + let mut noop = |_: Frame<'_>| async { Ok::<(), WebSocketError>(()) }; + loop { + let frame = match client_rx.read_frame(&mut noop).await { + Ok(f) => f, + Err(err) => { + log::debug!("[devtools-mux] unified client read: {err:?}"); + return; + } + }; + let opcode = frame.opcode; + let payload = frame.payload.to_vec(); + match opcode { + OpCode::Text => { + route_client_text( + &payload, + &session_id, + &client_send, + &cef_send, + &deno_send, + &deno_announced, + ); + } + OpCode::Close => { + let _ = cef_send.send(OwnedFrame { + opcode, + payload: payload.clone(), + }); + let _ = deno_send.send(OwnedFrame { opcode, payload }); + return; + } + _ => { + // Binary, Ping, Pong, Continuation: forward to CEF (the + // primary session). Ping/pong on the client connection is + // for keep-alive; CEF will reply. + let _ = cef_send.send(OwnedFrame { opcode, payload }); + } + } + } + }) + }; + + // Reader: CEF → client. No sessionId injection, but we do rewrite + // the execution-context name so the Console dropdown reads + // "Renderer" instead of V8's default "top". + let mut cef_reader = { + let client_send = client_send.clone(); + tokio::spawn(async move { + let mut noop = |_: Frame<'_>| async { Ok::<(), WebSocketError>(()) }; + loop { + let frame = match cef_rx.read_frame(&mut noop).await { + Ok(f) => f, + Err(err) => { + log::debug!("[devtools-mux] unified cef read: {err:?}"); + return; + } + }; + let opcode = frame.opcode; + let payload = frame.payload.to_vec(); + let owned = if opcode == OpCode::Text { + OwnedFrame::text(rewrite_text_from_upstream( + &payload, None, "Renderer", + )) + } else { + OwnedFrame { opcode, payload } + }; + if client_send.send(owned).is_err() { + return; + } + } + }) + }; + + // Reader: Deno → client. Inject sessionId so DevTools routes frames + // to the synthetic child target, and relabel the execution context + // to "Deno" instead of V8's "main realm". + let mut deno_reader = { + let client_send = client_send.clone(); + let session_id = session_id.clone(); + tokio::spawn(async move { + let mut noop = |_: Frame<'_>| async { Ok::<(), WebSocketError>(()) }; + loop { + let frame = match deno_rx.read_frame(&mut noop).await { + Ok(f) => f, + Err(err) => { + log::debug!("[devtools-mux] unified deno read: {err:?}"); + return; + } + }; + let opcode = frame.opcode; + let payload = frame.payload.to_vec(); + let owned = if opcode == OpCode::Text { + OwnedFrame::text(rewrite_text_from_upstream( + &payload, + Some(&session_id), + "Deno", + )) + } else { + OwnedFrame { opcode, payload } + }; + if client_send.send(owned).is_err() { + return; + } + } + }) + }; + + // Drop the originals so writers exit once readers finish. + drop(client_send); + drop(cef_send); + drop(deno_send); + + // Wait for any reader to exit, then tear down everything else. + tokio::select! { + _ = &mut client_reader => {}, + _ = &mut cef_reader => {}, + _ = &mut deno_reader => {}, + } + + client_reader.abort(); + cef_reader.abort(); + deno_reader.abort(); + client_writer.abort(); + cef_writer.abort(); + deno_writer.abort(); + + Ok(()) +} + +/// CDP-aware routing for a text frame coming from the DevTools client. +/// +/// - `sessionId == deno_session_id` → strip and send to Deno. +/// - `Target.setAutoAttach` / `Target.setDiscoverTargets(true)` → +/// forward to CEF AND lazily emit our synthetic +/// `Target.attachedToTarget` for Deno (once). We piggyback on these +/// calls because they are the frontend's signal that it's ready to +/// process target events; firing earlier causes the event to be +/// silently dropped. +/// - `Target.attachToTarget(deno-id)` → reply locally with the +/// synthetic sessionId; never reaches CEF (which doesn't know it). +/// - `Target.detachFromTarget(deno-session)` → reply locally and +/// synthesize the corresponding `Target.detachedFromTarget` event. +/// - everything else → forward to CEF. +fn route_client_text( + payload: &[u8], + session_id: &str, + client_send: &mpsc::UnboundedSender, + cef_send: &mpsc::UnboundedSender, + deno_send: &mpsc::UnboundedSender, + deno_announced: &Arc, +) { + let mut value: Value = match serde_json::from_slice(payload) { + Ok(v) => v, + Err(_) => { + let _ = cef_send.send(OwnedFrame::text(payload.to_vec())); + return; + } + }; + + let session = value.get("sessionId").and_then(|v| v.as_str()); + if session == Some(session_id) { + if let Some(obj) = value.as_object_mut() { + obj.remove("sessionId"); + } + let bytes = serde_json::to_vec(&value).unwrap_or_else(|_| payload.to_vec()); + let _ = deno_send.send(OwnedFrame::text(bytes)); + return; + } + + let id = value.get("id").and_then(|v| v.as_i64()); + let method = value + .get("method") + .and_then(|v| v.as_str()) + .map(str::to_owned); + + // The frontend is now listening for target events — announce Deno. + if matches!( + method.as_deref(), + Some("Target.setAutoAttach") | Some("Target.setDiscoverTargets") + ) && !deno_announced.swap(true, std::sync::atomic::Ordering::SeqCst) + { + let event = attached_to_target_event(session_id); + let _ = + client_send.send(OwnedFrame::text(serde_json::to_vec(&event).unwrap())); + } + + if method.as_deref() == Some("Target.attachToTarget") { + let target_id = value + .get("params") + .and_then(|p| p.get("targetId")) + .and_then(|v| v.as_str()); + if target_id == Some(DENO_CHILD_TARGET_ID) { + if let Some(rid) = id { + let reply = json!({ + "id": rid, + "result": { "sessionId": session_id }, + }); + let _ = client_send + .send(OwnedFrame::text(serde_json::to_vec(&reply).unwrap())); + } + return; + } + } + + if method.as_deref() == Some("Target.detachFromTarget") { + let detach_session = value + .get("params") + .and_then(|p| p.get("sessionId")) + .and_then(|v| v.as_str()); + if detach_session == Some(session_id) { + if let Some(rid) = id { + let reply = json!({ "id": rid, "result": {} }); + let _ = client_send + .send(OwnedFrame::text(serde_json::to_vec(&reply).unwrap())); + } + let event = json!({ + "method": "Target.detachedFromTarget", + "params": { + "sessionId": session_id, + "targetId": DENO_CHILD_TARGET_ID, + }, + }); + let _ = + client_send.send(OwnedFrame::text(serde_json::to_vec(&event).unwrap())); + return; + } + } + + let _ = cef_send.send(OwnedFrame::text(payload.to_vec())); +} + +/// Rewrite a JSON CDP frame on its way from an upstream to the +/// DevTools client: +/// +/// - Optionally inject `sessionId = session_id` so DevTools attributes +/// the frame to the synthetic Deno child target. +/// - Rename `Runtime.executionContextCreated.params.context.name` to +/// `context_name` so the Console "execution context" dropdown shows +/// a meaningful label instead of V8's defaults (`"top"`, `"main +/// realm"`). +/// +/// If the payload isn't valid JSON, return it unchanged. +fn rewrite_text_from_upstream( + payload: &[u8], + inject_session_id: Option<&str>, + context_name: &str, +) -> Vec { + let mut value: Value = match serde_json::from_slice(payload) { + Ok(v) => v, + Err(_) => return payload.to_vec(), + }; + let Some(obj) = value.as_object_mut() else { + return payload.to_vec(); + }; + if let Some(sid) = inject_session_id { + obj.insert("sessionId".to_string(), Value::String(sid.to_string())); + } + if obj.get("method").and_then(|v| v.as_str()) + == Some("Runtime.executionContextCreated") + && let Some(ctx) = obj + .get_mut("params") + .and_then(|v| v.get_mut("context")) + .and_then(|v| v.as_object_mut()) + { + ctx.insert("name".to_string(), Value::String(context_name.to_string())); + } + serde_json::to_vec(&value).unwrap_or_else(|_| payload.to_vec()) +} + +/// Build a `Target.attachedToTarget` event advertising the Deno +/// runtime isolate as a child of the unified session. +fn attached_to_target_event(session_id: &str) -> Value { + // `inspector.html` only renders attached child targets in the Sources + // panel "Threads" sidebar when their type matches a known + // worker-style kind (`worker`, `shared_worker`, `service_worker`). + // We pick `worker` so the Deno runtime isolate shows up alongside + // the CEF renderer's main thread. + json!({ + "method": "Target.attachedToTarget", + "params": { + "sessionId": session_id, + "targetInfo": { + "targetId": DENO_CHILD_TARGET_ID, + "type": "worker", + "title": "Deno Runtime", + "url": "deno://runtime", + "attached": true, + "canAccessOpener": false, + }, + "waitingForDebugger": false, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rewrite_ws_host_forces_host() { + let host: SocketAddr = "127.0.0.1:9230".parse().unwrap(); + assert_eq!( + rewrite_ws_host("ws://0.0.0.0:9230/devtools/browser/abc", host), + "ws://127.0.0.1:9230/devtools/browser/abc" + ); + assert_eq!( + rewrite_ws_host("ws://localhost/ws/deadbeef", host), + "ws://127.0.0.1:9230/ws/deadbeef" + ); + } + + fn test_config() -> MuxConfig { + MuxConfig { + listen: "127.0.0.1:9229".parse().unwrap(), + deno_internal: "127.0.0.1:9230".parse().unwrap(), + cef_internal: "127.0.0.1:9231".parse().unwrap(), + inspect_brk: false, + wait_for_debugger: false, + } + } + + #[test] + fn target_path_round_trip() { + let state = MuxState::new(test_config(), "127.0.0.1:9229".parse().unwrap()); + assert_eq!(state.target_for_path("/unified"), Some(TargetKind::Unified)); + assert_eq!(state.target_for_path("/deno"), Some(TargetKind::Deno)); + assert_eq!(state.target_for_path("/cef"), Some(TargetKind::Cef)); + assert_eq!(state.target_for_path("/bogus"), None); + } + + // ── sessionId dispatch ──────────────────────────────────────────── + + #[test] + fn route_client_text_strips_session_and_forwards_to_deno() { + let (client_tx, _client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, _cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, mut deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let session_id = "test-session-123"; + let msg = json!({ + "id": 1, + "method": "Debugger.enable", + "sessionId": session_id, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, session_id, &client_tx, &cef_tx, &deno_tx, &announced, + ); + + // Should arrive at Deno with sessionId stripped. + let frame = deno_rx.try_recv().expect("expected frame on deno channel"); + let value: Value = serde_json::from_slice(&frame.payload).unwrap(); + assert_eq!(value.get("id").unwrap(), 1); + assert_eq!(value.get("method").unwrap(), "Debugger.enable"); + assert!( + value.get("sessionId").is_none(), + "sessionId should be stripped" + ); + } + + #[test] + fn route_client_text_forwards_non_session_to_cef() { + let (client_tx, _client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, mut cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let msg = json!({"id": 5, "method": "DOM.getDocument"}); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, + "some-session", + &client_tx, + &cef_tx, + &deno_tx, + &announced, + ); + + let frame = cef_rx.try_recv().expect("expected frame on cef channel"); + let value: Value = serde_json::from_slice(&frame.payload).unwrap(); + assert_eq!(value.get("id").unwrap(), 5); + } + + #[test] + fn route_client_text_attach_to_deno_target_replies_locally() { + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, mut cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let session_id = "deno-sess"; + let msg = json!({ + "id": 10, + "method": "Target.attachToTarget", + "params": { "targetId": DENO_CHILD_TARGET_ID }, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, session_id, &client_tx, &cef_tx, &deno_tx, &announced, + ); + + // Should reply to client with the synthetic sessionId. + let frame = client_rx.try_recv().expect("expected reply on client"); + let value: Value = serde_json::from_slice(&frame.payload).unwrap(); + assert_eq!(value["id"], 10); + assert_eq!(value["result"]["sessionId"], session_id); + + // Should NOT have forwarded to CEF. + assert!(cef_rx.try_recv().is_err()); + } + + #[test] + fn route_client_text_lazily_announces_deno() { + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, _cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let msg = json!({ + "id": 1, + "method": "Target.setAutoAttach", + "params": { "autoAttach": true, "waitForDebuggerOnStart": false }, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, "sess", &client_tx, &cef_tx, &deno_tx, &announced, + ); + + // Should have emitted Target.attachedToTarget event. + let frame = client_rx.try_recv().expect("expected announce event"); + let value: Value = serde_json::from_slice(&frame.payload).unwrap(); + assert_eq!(value["method"], "Target.attachedToTarget"); + assert_eq!(value["params"]["targetInfo"]["type"], "worker"); + assert!(announced.load(std::sync::atomic::Ordering::SeqCst)); + + // Calling again should NOT emit a second event. + route_client_text( + &payload, "sess", &client_tx, &cef_tx, &deno_tx, &announced, + ); + // Only the forwarded-to-cef frame, no second announce. + assert!(client_rx.try_recv().is_err()); + } + + #[test] + fn route_client_text_set_discover_targets_also_triggers_announce() { + // The lazy-announce trigger is either setAutoAttach OR + // setDiscoverTargets — DevTools sometimes uses one, sometimes the + // other, depending on which panel initialised first. + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, _cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(false)); + + let msg = json!({ + "id": 7, + "method": "Target.setDiscoverTargets", + "params": { "discover": true }, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, "sess", &client_tx, &cef_tx, &deno_tx, &announced, + ); + + let frame = client_rx + .try_recv() + .expect("setDiscoverTargets should also trigger announce"); + let value: Value = serde_json::from_slice(&frame.payload).unwrap(); + assert_eq!(value["method"], "Target.attachedToTarget"); + assert!(announced.load(std::sync::atomic::Ordering::SeqCst)); + } + + #[test] + fn route_client_text_attach_to_cef_target_forwards_to_cef() { + // `Target.attachToTarget` for any target ID that isn't ours (e.g. + // a real CEF subframe or worker) must pass through — only the + // synthetic Deno target is answered locally. + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, mut cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let msg = json!({ + "id": 11, + "method": "Target.attachToTarget", + "params": { "targetId": "some-cef-subframe-target" }, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, + "deno-sess", + &client_tx, + &cef_tx, + &deno_tx, + &announced, + ); + + let forwarded = cef_rx.try_recv().expect("expected frame on cef"); + let value: Value = serde_json::from_slice(&forwarded.payload).unwrap(); + assert_eq!(value["method"], "Target.attachToTarget"); + assert_eq!(value["params"]["targetId"], "some-cef-subframe-target"); + assert!(client_rx.try_recv().is_err()); + } + + // ── context name rewriting ──────────────────────────────────────── + + #[test] + fn rewrite_injects_session_id() { + let input = json!({"id": 1, "result": {}}); + let payload = serde_json::to_vec(&input).unwrap(); + let out = rewrite_text_from_upstream(&payload, Some("my-sess"), "Deno"); + let value: Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(value["sessionId"], "my-sess"); + } + + #[test] + fn rewrite_renames_execution_context() { + let input = json!({ + "method": "Runtime.executionContextCreated", + "params": { + "context": { + "id": 1, + "origin": "", + "name": "top", + }, + }, + }); + let payload = serde_json::to_vec(&input).unwrap(); + let out = rewrite_text_from_upstream(&payload, None, "Renderer"); + let value: Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(value["params"]["context"]["name"], "Renderer"); + } + + #[test] + fn rewrite_no_session_leaves_field_absent() { + let input = json!({"method": "Console.messageAdded"}); + let payload = serde_json::to_vec(&input).unwrap(); + let out = rewrite_text_from_upstream(&payload, None, "Renderer"); + let value: Value = serde_json::from_slice(&out).unwrap(); + assert!(value.get("sessionId").is_none()); + } + + #[test] + fn rewrite_leaves_non_execution_context_messages_unchanged() { + // Any `name` field on other methods must not be touched — only + // `Runtime.executionContextCreated.params.context.name` is rewritten. + let input = json!({ + "method": "Target.targetInfoChanged", + "params": { "targetInfo": { "name": "something" } }, + }); + let payload = serde_json::to_vec(&input).unwrap(); + let out = rewrite_text_from_upstream(&payload, None, "Renderer"); + let value: Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(value["params"]["targetInfo"]["name"], "something"); + } + + #[test] + fn rewrite_invalid_json_returned_verbatim() { + let payload = b"totally not json".to_vec(); + let out = rewrite_text_from_upstream(&payload, Some("sid"), "Renderer"); + assert_eq!(out, payload); + } + + #[test] + fn rewrite_non_object_json_returned_verbatim() { + // Arrays / scalars have no "sessionId" slot to inject into — + // they must pass through untouched rather than being wrapped. + let input = json!([1, 2, 3]); + let payload = serde_json::to_vec(&input).unwrap(); + let out = rewrite_text_from_upstream(&payload, Some("sid"), "Renderer"); + assert_eq!(out, payload); + } + + // ── /json/list shape ────────────────────────────────────────────── + + #[tokio::test] + async fn json_list_returns_three_targets() { + let state = MuxState::new(test_config(), "127.0.0.1:9229".parse().unwrap()); + let resp = json_list(&state); + let body = resp.into_body(); + let bytes = body.collect().await.unwrap().to_bytes(); + let list: Vec = serde_json::from_slice(&bytes).unwrap(); + + assert_eq!(list.len(), 3); + + // Unified target. + assert_eq!(list[0]["type"], "page"); + assert_eq!(list[0]["title"], "Deno Desktop (unified)"); + assert!( + list[0]["webSocketDebuggerUrl"] + .as_str() + .unwrap() + .ends_with("/unified") + ); + + // Deno direct target. + assert_eq!(list[1]["type"], "node"); + assert_eq!(list[1]["title"], "Deno Runtime"); + assert!( + list[1]["webSocketDebuggerUrl"] + .as_str() + .unwrap() + .ends_with("/deno") + ); + + // CEF direct target. + assert_eq!(list[2]["type"], "page"); + assert_eq!(list[2]["title"], "CEF Renderer"); + assert!( + list[2]["webSocketDebuggerUrl"] + .as_str() + .unwrap() + .ends_with("/cef") + ); + + // Every entry must expose a devtoolsFrontendUrl that points at + // its own ws:// endpoint — DevTools uses it to launch the right + // frontend (inspector.html vs js_app.html) against the right mux + // route. Every entry must also advertise a stable UUID id and + // include description + faviconUrl, which DevTools renders in the + // target picker. + for entry in &list { + let frontend = entry["devtoolsFrontendUrl"].as_str().unwrap(); + let ws = entry["webSocketDebuggerUrl"].as_str().unwrap(); + let ws_host_path = ws.strip_prefix("ws://").unwrap(); + assert!( + frontend.contains(ws_host_path), + "frontend {frontend} must embed ws host+path {ws_host_path}" + ); + + let id = entry["id"].as_str().unwrap(); + Uuid::parse_str(id).unwrap_or_else(|_| panic!("id {id} is not a UUID")); + assert!(entry["description"].is_string()); + assert!(entry["faviconUrl"].is_string()); + } + + // Deno direct entry uses `js_app.html` (DevTools' node variant) + // while CEF and unified use the full `inspector.html`. + assert!( + list[1]["devtoolsFrontendUrl"] + .as_str() + .unwrap() + .contains("js_app.html"), + "deno direct entry should launch js_app.html" + ); + for page_entry in [&list[0], &list[2]] { + assert!( + page_entry["devtoolsFrontendUrl"] + .as_str() + .unwrap() + .contains("inspector.html"), + ); + } + + // IDs must be stable across calls — DevTools caches them. + let resp2 = json_list(&state); + let bytes2 = resp2.into_body().collect().await.unwrap().to_bytes(); + let list2: Vec = serde_json::from_slice(&bytes2).unwrap(); + for (a, b) in list.iter().zip(list2.iter()) { + assert_eq!( + a["id"], b["id"], + "target id changed across /json/list calls" + ); + } + } + + #[tokio::test] + async fn json_version_shape() { + let state = MuxState::new(test_config(), "127.0.0.1:9229".parse().unwrap()); + let resp = json_version(&state); + assert_eq!(resp.status(), http::StatusCode::OK); + let ct = resp.headers().get(http::header::CONTENT_TYPE).unwrap(); + assert_eq!(ct, "application/json"); + + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let value: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["Protocol-Version"], "1.3"); + assert!( + value["Browser"] + .as_str() + .unwrap() + .starts_with("deno-desktop/") + ); + assert!(value["V8-Version"].is_string()); + // The browser-level webSocketDebuggerUrl must point at the CEF + // target — DevTools' chrome://inspect auto-attach needs the + // richer Target.* domain that CEF implements. + let ws = value["webSocketDebuggerUrl"].as_str().unwrap(); + assert!(ws.ends_with("/cef"), "got {ws}"); + } + + // ── Target.detachFromTarget dispatch ────────────────────────────── + + #[test] + fn route_client_text_detach_from_deno_session_synthesizes_reply_and_event() { + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, mut cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let session_id = "deno-sess-xyz"; + let msg = json!({ + "id": 42, + "method": "Target.detachFromTarget", + "params": { "sessionId": session_id }, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, session_id, &client_tx, &cef_tx, &deno_tx, &announced, + ); + + // First frame: the id=42 reply. + let reply = client_rx.try_recv().expect("expected detach reply"); + let reply_val: Value = serde_json::from_slice(&reply.payload).unwrap(); + assert_eq!(reply_val["id"], 42); + assert!(reply_val["result"].is_object()); + + // Second frame: the synthesized Target.detachedFromTarget event. + let event = client_rx.try_recv().expect("expected detached event"); + let event_val: Value = serde_json::from_slice(&event.payload).unwrap(); + assert_eq!(event_val["method"], "Target.detachedFromTarget"); + assert_eq!(event_val["params"]["sessionId"], session_id); + assert_eq!(event_val["params"]["targetId"], DENO_CHILD_TARGET_ID); + + // Must NOT be forwarded to CEF — CEF has no knowledge of our + // synthetic Deno session. + assert!(cef_rx.try_recv().is_err()); + } + + #[test] + fn route_client_text_detach_from_unknown_session_forwards_to_cef() { + // A detach for a session CEF owns (not our synthetic Deno one) + // must flow through to CEF unchanged. + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, mut cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let msg = json!({ + "id": 99, + "method": "Target.detachFromTarget", + "params": { "sessionId": "some-cef-session" }, + }); + let payload = serde_json::to_vec(&msg).unwrap(); + + route_client_text( + &payload, + "deno-sess", + &client_tx, + &cef_tx, + &deno_tx, + &announced, + ); + + let forwarded = cef_rx.try_recv().expect("expected frame forwarded"); + let value: Value = serde_json::from_slice(&forwarded.payload).unwrap(); + assert_eq!(value["id"], 99); + assert!(client_rx.try_recv().is_err()); + } + + // ── Malformed / edge-case dispatch ──────────────────────────────── + + #[test] + fn route_client_text_invalid_json_forwards_to_cef() { + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (cef_tx, mut cef_rx) = mpsc::unbounded_channel::(); + let (deno_tx, _deno_rx) = mpsc::unbounded_channel::(); + let announced = Arc::new(std::sync::atomic::AtomicBool::new(true)); + + let payload = b"not-json".to_vec(); + route_client_text( + &payload, "sess", &client_tx, &cef_tx, &deno_tx, &announced, + ); + + let frame = cef_rx.try_recv().expect("expected frame on cef"); + assert_eq!(frame.payload, b"not-json"); + assert!(client_rx.try_recv().is_err()); + } + + // ── End-to-end integration ──────────────────────────────────────── + // + // Spin up mock upstream HTTP+WS servers for CEF and Deno, start the + // mux in front of them, then drive a real WebSocket client through + // the `/unified` endpoint to exercise routing, context rewriting, + // and the `--inspect-brk` pause injection. + + /// Simple mock upstream: serves `/json/list` pointing at its own + /// `/ws` path, accepts a WebSocket upgrade there, and exposes + /// unbounded channels so tests can pump frames in/out. + struct MockUpstream { + listen: SocketAddr, + /// Frames received from the mux. + from_mux: tokio::sync::Mutex>, + /// Frames to send to the mux. + to_mux: mpsc::UnboundedSender, + } + + async fn spawn_mock_upstream() -> Arc { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let listen = listener.local_addr().unwrap(); + let (in_tx, in_rx) = mpsc::unbounded_channel::(); + let (out_tx, out_rx) = mpsc::unbounded_channel::(); + + let upstream = Arc::new(MockUpstream { + listen, + from_mux: tokio::sync::Mutex::new(in_rx), + to_mux: out_tx, + }); + + // Shared across every connection the mux makes to this upstream — + // the mux opens one TCP connection for `/json/list` and a separate + // one for the WS upgrade, so the out_rx must only be consumed when + // the upgrade actually happens. + let shared_out_rx = Arc::new(std::sync::Mutex::new(Some(out_rx))); + + tokio::spawn(async move { + loop { + let (stream, _) = match listener.accept().await { + Ok(v) => v, + Err(_) => return, + }; + let in_tx = in_tx.clone(); + let shared_out_rx = shared_out_rx.clone(); + let listen_str = listen.to_string(); + tokio::spawn(async move { + let io = TokioIo::new(stream); + let service = hyper::service::service_fn(move |mut req| { + let in_tx = in_tx.clone(); + let shared_out_rx = shared_out_rx.clone(); + let listen_str = listen_str.clone(); + async move { + let path = req.uri().path().to_string(); + if path == "/json/list" { + let body = serde_json::to_vec(&json!([{ + "id": "mock-target", + "type": "page", + "webSocketDebuggerUrl": format!("ws://{listen_str}/ws"), + }])) + .unwrap(); + return Ok::<_, Infallible>( + hyper::Response::builder() + .status(http::StatusCode::OK) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body))) + .unwrap(), + ); + } + if path == "/ws" { + let Ok((resp, upgrade_fut)) = + fastwebsockets::upgrade::upgrade(&mut req) + else { + return Ok(simple_response( + http::StatusCode::BAD_REQUEST, + "bad upgrade", + )); + }; + let in_tx = in_tx.clone(); + let out_rx = shared_out_rx.lock().unwrap().take(); + tokio::spawn(async move { + let mut ws = match upgrade_fut.await { + Ok(w) => w, + Err(_) => return, + }; + ws.set_auto_close(false); + ws.set_auto_pong(false); + let (mut rx, mut tx) = ws.split(tokio::io::split); + let reader = async move { + let mut noop = + |_: Frame<'_>| async { Ok::<(), WebSocketError>(()) }; + loop { + let frame = match rx.read_frame(&mut noop).await { + Ok(f) => f, + Err(_) => return, + }; + let owned = OwnedFrame { + opcode: frame.opcode, + payload: frame.payload.to_vec(), + }; + let is_close = owned.opcode == OpCode::Close; + if in_tx.send(owned).is_err() || is_close { + return; + } + } + }; + let writer = async move { + let Some(mut out_rx) = out_rx else { return }; + while let Some(owned) = out_rx.recv().await { + let close = owned.opcode == OpCode::Close; + if tx.write_frame(owned.into_frame()).await.is_err() { + return; + } + if close { + return; + } + } + }; + tokio::join!(reader, writer); + }); + let (parts, _) = resp.into_parts(); + return Ok(hyper::Response::from_parts( + parts, + Full::new(Bytes::new()), + )); + } + Ok(simple_response(http::StatusCode::NOT_FOUND, "Not Found")) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(io, service) + .with_upgrades() + .await; + }); + } + }); + + upstream + } + + /// Open a WebSocket from the test to `ws://`. + async fn test_connect_ws( + ws_url: &str, + ) -> WebSocket> { + connect_ws(ws_url).await.unwrap() + } + + async fn read_text_value(ws: &mut WebSocket) -> Value + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + loop { + let frame = ws.read_frame().await.unwrap(); + if frame.opcode != OpCode::Text { + continue; + } + return serde_json::from_slice(&frame.payload).unwrap(); + } + } + + async fn write_json(ws: &mut WebSocket, v: &Value) + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + let payload = serde_json::to_vec(v).unwrap(); + ws.write_frame(Frame::new( + true, + OpCode::Text, + None, + fastwebsockets::Payload::Owned(payload), + )) + .await + .unwrap(); + } + + async fn spawn_test_mux( + cef: &MockUpstream, + deno: &MockUpstream, + inspect_brk: bool, + ) -> MuxHandle { + let listen_port = allocate_random_port().unwrap(); + spawn_mux(MuxConfig { + listen: format!("127.0.0.1:{listen_port}").parse().unwrap(), + deno_internal: deno.listen, + cef_internal: cef.listen, + inspect_brk, + wait_for_debugger: inspect_brk, + }) + .await + .unwrap() + } + + /// GET a path from the mux over raw HTTP and return the body bytes. + async fn http_get(addr: SocketAddr, path: &str) -> (http::StatusCode, Bytes) { + let stream = TcpStream::connect(addr).await.unwrap(); + let io = TokioIo::new(stream); + let (mut sender, conn) = + hyper::client::conn::http1::handshake(io).await.unwrap(); + tokio::spawn(async move { + let _ = conn.await; + }); + let req = hyper::Request::builder() + .method(http::Method::GET) + .uri(path) + .header(http::header::HOST, addr.to_string()) + .body(Empty::::new()) + .unwrap(); + let resp = sender.send_request(req).await.unwrap(); + let status = resp.status(); + let bytes = resp.collect().await.unwrap().to_bytes(); + (status, bytes) + } + + #[tokio::test] + async fn integration_http_endpoints() { + let cef = spawn_mock_upstream().await; + let deno = spawn_mock_upstream().await; + let mux = spawn_test_mux(&cef, &deno, false).await; + + // /json/list — the mux advertises three targets; the HTTP path + // does not need upstream WS to be live. + let (status, body) = http_get(mux.listen, "/json/list").await; + assert_eq!(status, http::StatusCode::OK); + let list: Vec = serde_json::from_slice(&body).unwrap(); + assert_eq!(list.len(), 3); + + // /json/version. + let (status, body) = http_get(mux.listen, "/json/version").await; + assert_eq!(status, http::StatusCode::OK); + let v: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(v["Protocol-Version"], "1.3"); + + // /debugger-attached returns 503 before any client connects. + let (status, _) = http_get(mux.listen, "/debugger-attached").await; + assert_eq!(status, http::StatusCode::SERVICE_UNAVAILABLE); + + // Unknown path → 404. + let (status, _) = http_get(mux.listen, "/nope").await; + assert_eq!(status, http::StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn integration_unified_session_routes_and_rewrites() { + let cef = spawn_mock_upstream().await; + let deno = spawn_mock_upstream().await; + let mux = spawn_test_mux(&cef, &deno, false).await; + + let ws_url = format!("ws://{}/unified", mux.listen); + let mut client = test_connect_ws(&ws_url).await; + client.set_auto_close(false); + client.set_auto_pong(false); + + // Before any client activity, /debugger-attached should flip to 200. + // Give the spawned upgrade task a moment to run. + for _ in 0..50 { + let (status, _) = http_get(mux.listen, "/debugger-attached").await; + if status == http::StatusCode::OK { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + + // 1) Client sends Target.setAutoAttach. Mux should forward it to + // CEF *and* synthesize Target.attachedToTarget to the client. + write_json( + &mut client, + &json!({ + "id": 1, + "method": "Target.setAutoAttach", + "params": { "autoAttach": true, "waitForDebuggerOnStart": false }, + }), + ) + .await; + + let attached = read_text_value(&mut client).await; + assert_eq!(attached["method"], "Target.attachedToTarget"); + let session_id = attached["params"]["sessionId"] + .as_str() + .unwrap() + .to_string(); + assert_eq!(attached["params"]["targetInfo"]["type"], "worker"); + + // CEF upstream should have received the setAutoAttach verbatim. + let received = { + let mut rx = cef.from_mux.lock().await; + tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .unwrap() + .unwrap() + }; + let received_val: Value = + serde_json::from_slice(&received.payload).unwrap(); + assert_eq!(received_val["method"], "Target.setAutoAttach"); + + // 2) Client sends a frame with sessionId=deno. Mux strips it and + // forwards to Deno upstream. + write_json( + &mut client, + &json!({ + "id": 2, + "method": "Debugger.enable", + "sessionId": session_id, + }), + ) + .await; + let deno_received = { + let mut rx = deno.from_mux.lock().await; + tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .unwrap() + .unwrap() + }; + let deno_val: Value = + serde_json::from_slice(&deno_received.payload).unwrap(); + assert_eq!(deno_val["id"], 2); + assert_eq!(deno_val["method"], "Debugger.enable"); + assert!(deno_val["sessionId"].is_null()); + + // 3) Upstream CEF emits Runtime.executionContextCreated. The mux + // rewrites context.name to "Renderer" and forwards to client + // WITHOUT a sessionId. + cef + .to_mux + .send(OwnedFrame::text( + serde_json::to_vec(&json!({ + "method": "Runtime.executionContextCreated", + "params": { "context": { "id": 1, "origin": "", "name": "top" } }, + })) + .unwrap(), + )) + .unwrap(); + let from_cef = read_text_value(&mut client).await; + assert_eq!(from_cef["method"], "Runtime.executionContextCreated"); + assert_eq!(from_cef["params"]["context"]["name"], "Renderer"); + assert!(from_cef["sessionId"].is_null()); + + // 4) Upstream Deno emits the same. Mux rewrites to "Deno" AND + // injects the synthetic sessionId. + deno + .to_mux + .send(OwnedFrame::text( + serde_json::to_vec(&json!({ + "method": "Runtime.executionContextCreated", + "params": { "context": { "id": 1, "origin": "", "name": "main realm" } }, + })) + .unwrap(), + )) + .unwrap(); + let from_deno = read_text_value(&mut client).await; + assert_eq!(from_deno["method"], "Runtime.executionContextCreated"); + assert_eq!(from_deno["params"]["context"]["name"], "Deno"); + assert_eq!(from_deno["sessionId"], session_id); + + drop(client); + drop(mux); + } + + #[tokio::test] + async fn integration_inspect_brk_injects_before_marking_attached() { + // The core contract: under --inspect-brk the mux must send + // Debugger.enable + Debugger.pause to the CEF upstream BEFORE + // /debugger-attached flips to 200. If that order is reversed the + // child process navigates CEF before the pause is in flight and + // the renderer races past the first JS statement. + let cef = spawn_mock_upstream().await; + let deno = spawn_mock_upstream().await; + let mux = spawn_test_mux(&cef, &deno, true).await; + + let ws_url = format!("ws://{}/unified", mux.listen); + let client_connect = + tokio::spawn(async move { test_connect_ws(&ws_url).await }); + + // Observe the first two frames CEF receives. With inspect_brk=true + // they must be Debugger.enable then Debugger.pause. + let enable_frame = { + let mut rx = cef.from_mux.lock().await; + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("no frame received on CEF upstream") + .unwrap() + }; + let enable_val: Value = + serde_json::from_slice(&enable_frame.payload).unwrap(); + assert_eq!(enable_val["method"], "Debugger.enable"); + + // /debugger-attached MUST still be 503 until we ack enable+pause. + // We haven't replied yet, so the mux is still blocked waiting. + let (status, _) = http_get(mux.listen, "/debugger-attached").await; + assert_eq!( + status, + http::StatusCode::SERVICE_UNAVAILABLE, + "debugger_attached must not be signalled until pause injection completes" + ); + + let pause_frame = { + let mut rx = cef.from_mux.lock().await; + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("no pause frame received") + .unwrap() + }; + let pause_val: Value = + serde_json::from_slice(&pause_frame.payload).unwrap(); + assert_eq!(pause_val["method"], "Debugger.pause"); + + // Now ack both from the upstream side, simulating CEF's responses. + cef + .to_mux + .send(OwnedFrame::text( + serde_json::to_vec(&json!({"id": -1, "result": {}})).unwrap(), + )) + .unwrap(); + cef + .to_mux + .send(OwnedFrame::text( + serde_json::to_vec(&json!({"id": -2, "result": {}})).unwrap(), + )) + .unwrap(); + + // With both injection acks in flight, the mux should mark + // attached. Give it a moment to run. + let mut saw_attached = false; + for _ in 0..100 { + let (status, _) = http_get(mux.listen, "/debugger-attached").await; + if status == http::StatusCode::OK { + saw_attached = true; + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!( + saw_attached, + "debugger_attached never flipped to 200 after injection completed" + ); + + let _client = client_connect.await.unwrap(); + drop(mux); + } +} diff --git a/cli/tools/framework.rs b/cli/tools/framework.rs index 12eeb8a758266a..4e621919f88998 100644 --- a/cli/tools/framework.rs +++ b/cli/tools/framework.rs @@ -7,6 +7,7 @@ //! entrypoint and include paths so that `deno compile .` just works. use std::path::Path; +use std::path::PathBuf; use deno_core::error::AnyError; use deno_core::serde_json; @@ -24,20 +25,74 @@ pub struct FrameworkDetection { pub build_command: Option>, } +impl FrameworkDetection { + /// Directories (relative to the project root) where the framework keeps + /// static assets like favicons. Used to auto-detect a desktop app icon + /// when the user didn't supply `--icon`. + pub fn static_asset_dirs(&self) -> &'static [&'static str] { + match self.name { + // Next.js App Router puts `favicon.ico` / `icon.*` directly in `app/` + // (or `src/app/`); the Pages Router and older versions use `public/`. + "Next.js" => &["public", "app", "src/app"], + "Fresh" | "SvelteKit" => &["static"], + _ => &["public"], + } + } +} + +/// Search a framework's static asset directories for a favicon that can +/// double as the desktop app icon. Returns the first match in priority +/// order, restricted to file formats supported by `target_os` (so we +/// don't pick a `.png` for a Windows build where bundling would silently +/// drop it). +pub fn find_framework_favicon( + dir: &Path, + detection: &FrameworkDetection, + target_os: &str, +) -> Option { + let exts: &[&str] = match target_os { + "macos" => &["icns", "png"], + "windows" => &["ico"], + _ => &["png"], + }; + let names = ["icon", "favicon", "apple-touch-icon", "logo"]; + for sub in detection.static_asset_dirs() { + let base = dir.join(sub); + if !base.is_dir() { + continue; + } + for name in names { + for ext in exts { + let candidate = base.join(format!("{name}.{ext}")); + if candidate.is_file() { + return Some(candidate); + } + } + } + } + None +} + /// Detect a web framework in the given directory. /// /// Detection priority: /// 1. Config-file based detection (highest priority) /// 2. Package.json dependency-based detection /// 3. deno.json import-based detection +/// +/// When `dev` is true, frameworks that support it generate a dev-server +/// entrypoint (with hot module reloading) instead of the production one. +/// Frameworks without a dev variant fall back to their production +/// entrypoint regardless of `dev`. pub fn detect_framework( dir: &Path, + dev: bool, ) -> Result, AnyError> { // --- Config-file based detection (highest priority) --- // Next.js: next.config.{js,mjs,ts} if has_config_file(dir, "next.config") { - return Ok(Some(detect_nextjs(dir)?)); + return Ok(Some(detect_nextjs(dir, dev)?)); } // Fresh: fresh.gen.ts or _fresh/ @@ -70,7 +125,7 @@ pub fn detect_framework( if let Some(deps) = read_package_deps(dir) { // Remix if deps.has("@remix-run/react") || deps.has_dev("@remix-run/dev") { - return Ok(Some(detect_remix())); + return Ok(Some(detect_remix(dir))); } // SolidStart @@ -115,8 +170,52 @@ fn deno_task_build() -> Vec { vec![deno_exe(), "task".into(), "build".into()] } -fn detect_nextjs(dir: &Path) -> Result { +fn detect_nextjs( + dir: &Path, + dev: bool, +) -> Result { let version = detect_package_version(dir, "next").unwrap_or(15); + if dev { + // Dev mode (`deno desktop --hmr`): run `next dev` so Next provides its + // own hot module reloading over websocket. The desktop runtime restores + // the CWD to the source directory (see DENO_DESKTOP_DEV in rt_desktop), + // so `next dev` reads project files and writes its `.next` build cache + // there — nothing needs to be embedded in the binary's VFS, and there is + // no build step to run up front. + // + // `nextDev(options, portSource, directory)`: `"default"` for portSource + // lets Next retry ports, and `Deno.cwd()` points it at the real source + // dir rather than the (empty) VFS root. The DENO_SERVE_ADDRESS override + // redirects Next's HTTP server onto the port the webview navigates to. + // + // hostname must be `127.0.0.1`: the desktop webview always loads from + // `http://127.0.0.1:`, and Next 16's dev-resource cross-origin + // guard only allows requests whose host is `localhost`, an + // `allowedDevOrigins` entry, or the configured hostname. With `0.0.0.0` + // the `/_next/webpack-hmr` websocket is blocked and HMR silently stops + // working even though the page renders. + let entrypoint = format!( + r#"// @ts-nocheck +import {{ nextDev }} from "npm:next@^{version}/dist/cli/next-dev.js"; +globalThis.addEventListener("unhandledrejection", (e) => {{ + console.error("[entrypoint] Unhandled rejection:", e.reason); + if (e.reason?.stack) console.error("[entrypoint] Stack:", e.reason.stack); +}}); +// Guard: skip for forked workers (child_process.fork sets NODE_CHANNEL_FD). +// Workers use override_main_module to run their target script directly. +if (!Deno.env.get("NODE_CHANNEL_FD")) {{ + await nextDev({{ hostname: "127.0.0.1" }}, "default", Deno.cwd()); +}} +"#, + ); + return Ok(FrameworkDetection { + name: "Next.js", + entrypoint_code: entrypoint, + // Dev server builds on the fly from the source CWD; nothing to embed. + include_paths: vec![], + build_command: None, + }); + } let entrypoint = format!( r#"// @ts-nocheck import {{ nextStart }} from "npm:next@^{version}/dist/cli/next-start.js"; @@ -133,10 +232,17 @@ if (!Deno.env.get("NODE_CHANNEL_FD")) {{ }} "#, ); + // `next-server` serves files in `public/` at the URL root; without it + // shipped, every `` 404s. Optional in the project + // (some apps put nothing there), so only include when present. + let mut include_paths = vec![".next".into()]; + if dir.join("public").is_dir() { + include_paths.push("public".into()); + } Ok(FrameworkDetection { name: "Next.js", entrypoint_code: entrypoint, - include_paths: vec![".next".into()], + include_paths, build_command: Some(deno_task_build()), }) } @@ -160,6 +266,17 @@ fn detect_fresh(dir: &Path) -> FrameworkDetection { .map(|imports| imports.iter().any(|i| i.starts_with("@fresh/core"))) .unwrap_or(false); if is_fresh2 { + // `_fresh/snapshot.js` records static assets as `filePath: + // "static/foo.png"` and `_fresh/server.js` constructs the + // ProdBuildCache with `root = path.join(import.meta.dirname, "..")`, + // so the runtime reads them via `/static/...`. The `static/` + // directory must therefore land in the VFS alongside `_fresh/` or + // every image / font / video 404s. `static/` is conventional for + // Fresh; if it doesn't exist the include is a harmless no-op. + let mut include_paths = vec!["_fresh".into()]; + if dir.join("static").is_dir() { + include_paths.push("static".into()); + } FrameworkDetection { name: "Fresh", entrypoint_code: r#"// @ts-nocheck @@ -167,7 +284,7 @@ const mod = await import("./_fresh/server.js"); Deno.serve(mod.default.fetch); "# .into(), - include_paths: vec!["_fresh".into()], + include_paths, build_command: Some(vec![deno_exe(), "task".into(), "build".into()]), } } else { @@ -181,12 +298,18 @@ Deno.serve(mod.default.fetch); } } -fn detect_remix() -> FrameworkDetection { +fn detect_remix(dir: &Path) -> FrameworkDetection { + // `remix-serve` serves files from `public/` at the URL root; ship it + // when present so static assets resolve. + let mut include_paths = vec!["build".into()]; + if dir.join("public").is_dir() { + include_paths.push("public".into()); + } FrameworkDetection { name: "Remix", entrypoint_code: "// @ts-nocheck\nimport \"./node_modules/.bin/remix-serve\";\n".into(), - include_paths: vec!["build".into()], + include_paths, build_command: Some(deno_task_build()), } } @@ -408,7 +531,7 @@ mod tests { #[test] fn no_framework_empty_dir() { let dir = setup_dir(); - let result = detect_framework(dir.path()).unwrap(); + let result = detect_framework(dir.path(), false).unwrap(); assert!(result.is_none()); } @@ -423,7 +546,7 @@ mod tests { r#"{"dependencies":{"next":"^15.0.0"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Next.js"); assert_eq!(det.include_paths, vec![".next"]); assert!(det.entrypoint_code.contains("next@^15")); @@ -433,12 +556,33 @@ mod tests { assert_eq!(cmd[1..], vec!["task", "build"]); } + #[test] + fn nextjs_dev_uses_next_dev() { + let dir = setup_dir(); + fs::write(dir.path().join("next.config.js"), "").unwrap(); + fs::write( + dir.path().join("package.json"), + r#"{"dependencies":{"next":"^16.0.0"}}"#, + ) + .unwrap(); + let det = detect_framework(dir.path(), true).unwrap().unwrap(); + assert_eq!(det.name, "Next.js"); + // Dev mode runs `next dev`, not `next start`. + assert!(det.entrypoint_code.contains("nextDev")); + assert!(det.entrypoint_code.contains("next-dev.js")); + assert!(!det.entrypoint_code.contains("nextStart")); + // Dev server builds on the fly from the source CWD — nothing to embed, + // no up-front build step. + assert!(det.include_paths.is_empty()); + assert!(det.build_command.is_none()); + } + #[test] fn nextjs_always_builds() { let dir = setup_dir(); fs::write(dir.path().join("next.config.js"), "").unwrap(); fs::create_dir(dir.path().join(".next")).unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Next.js"); assert!(det.build_command.is_some()); } @@ -447,7 +591,7 @@ mod tests { fn detects_nextjs_with_config_mjs() { let dir = setup_dir(); fs::write(dir.path().join("next.config.mjs"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Next.js"); } @@ -455,7 +599,7 @@ mod tests { fn detects_nextjs_with_config_ts() { let dir = setup_dir(); fs::write(dir.path().join("next.config.ts"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Next.js"); } @@ -468,7 +612,7 @@ mod tests { r#"{"dependencies":{"next":"^14.2.3"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert!(det.entrypoint_code.contains("next@^14")); } @@ -477,7 +621,7 @@ mod tests { let dir = setup_dir(); fs::write(dir.path().join("next.config.js"), "").unwrap(); // no package.json - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert!(det.entrypoint_code.contains("next@^15")); } @@ -485,7 +629,7 @@ mod tests { fn detects_fresh_gen_ts() { let dir = setup_dir(); fs::write(dir.path().join("fresh.gen.ts"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); assert!(det.include_paths.is_empty()); // no _fresh/server.js => Fresh 1.x @@ -497,7 +641,7 @@ mod tests { let dir = setup_dir(); fs::create_dir_all(dir.path().join("_fresh")).unwrap(); fs::write(dir.path().join("_fresh/server.js"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); assert!(det.entrypoint_code.contains("_fresh/server.js")); let cmd = det.build_command.unwrap(); @@ -508,7 +652,7 @@ mod tests { fn fresh1_has_no_build_command() { let dir = setup_dir(); fs::write(dir.path().join("fresh.gen.ts"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); assert!(det.build_command.is_none()); } @@ -524,7 +668,7 @@ mod tests { r#"{"tasks":{"start":"deno run -A main.ts"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); // Fresh 1.x uses main.ts, not _fresh/server.js assert!(det.entrypoint_code.contains("main.ts")); @@ -540,7 +684,7 @@ mod tests { r#"{"imports":{"@fresh/core":"jsr:@fresh/core@^2"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); // Should be Fresh 2 because @fresh/core is in imports assert!(det.entrypoint_code.contains("_fresh/server.js")); @@ -551,7 +695,7 @@ mod tests { fn detects_astro() { let dir = setup_dir(); fs::write(dir.path().join("astro.config.mjs"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Astro"); assert!(det.entrypoint_code.contains("dist/server/entry.mjs")); assert_eq!(det.include_paths, vec!["dist"]); @@ -564,7 +708,7 @@ mod tests { fn detects_nuxt() { let dir = setup_dir(); fs::write(dir.path().join("nuxt.config.ts"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Nuxt"); assert_eq!(det.include_paths, vec![".output"]); let cmd = det.build_command.unwrap(); @@ -577,7 +721,7 @@ mod tests { fs::write(dir.path().join("nuxt.config.ts"), "").unwrap(); fs::create_dir_all(dir.path().join(".output/server")).unwrap(); fs::write(dir.path().join(".output/server/index.ts"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert!(det.entrypoint_code.contains("index.ts")); } @@ -587,7 +731,7 @@ mod tests { fs::write(dir.path().join("svelte.config.js"), "").unwrap(); fs::create_dir_all(dir.path().join(".deno-deploy")).unwrap(); fs::write(dir.path().join(".deno-deploy/server.ts"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "SvelteKit"); assert!(det.entrypoint_code.contains(".deno-deploy/server.ts")); assert_eq!(det.include_paths, vec![".deno-deploy"]); @@ -601,7 +745,7 @@ mod tests { fs::write(dir.path().join("svelte.config.ts"), "").unwrap(); fs::create_dir_all(dir.path().join(".output/server")).unwrap(); fs::write(dir.path().join(".output/server/index.mjs"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "SvelteKit"); assert_eq!(det.include_paths, vec![".output"]); assert!(det.build_command.is_some()); @@ -616,7 +760,7 @@ mod tests { "import adapter from 'svelte-adapter-deno';\nexport default { kit: { adapter: adapter() } };\n", ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "SvelteKit"); assert!(det.entrypoint_code.contains("build/index.js")); assert_eq!(det.include_paths, vec!["build/client"]); @@ -632,7 +776,7 @@ mod tests { fs::create_dir_all(dir.path().join("build/prerendered")).unwrap(); fs::write(dir.path().join("build/index.js"), "").unwrap(); fs::write(dir.path().join("build/handler.js"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "SvelteKit"); assert!(det.entrypoint_code.contains("build/index.js")); assert_eq!(det.include_paths, vec!["build/client", "build/prerendered"]); @@ -647,7 +791,7 @@ mod tests { "// uses a nitro-based adapter\nexport default { kit: {} };\n", ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "SvelteKit"); assert_eq!(det.include_paths, vec![".output"]); } @@ -663,7 +807,7 @@ mod tests { "import adapter from '@sveltejs/adapter-vercel';\nexport default { kit: { adapter: adapter() } };\n", ) .unwrap(); - assert!(detect_framework(dir.path()).unwrap().is_none()); + assert!(detect_framework(dir.path(), false).unwrap().is_none()); } // --- Package.json dependency-based detection --- @@ -676,7 +820,7 @@ mod tests { r#"{"dependencies":{"@remix-run/react":"^2.0.0"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Remix"); assert_eq!(det.include_paths, vec!["build"]); let cmd = det.build_command.unwrap(); @@ -691,7 +835,7 @@ mod tests { r#"{"devDependencies":{"@remix-run/dev":"^2.0.0"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Remix"); } @@ -703,7 +847,7 @@ mod tests { r#"{"dependencies":{"@solidjs/start":"^1.0.0"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "SolidStart"); assert_eq!(det.include_paths, vec![".output"]); let cmd = det.build_command.unwrap(); @@ -718,7 +862,7 @@ mod tests { r#"{"dependencies":{"@tanstack/react-start":"^1.0.0"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "TanStack Start"); } @@ -730,7 +874,7 @@ mod tests { r#"{"dependencies":{"@tanstack/solid-start":"^1.0.0"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "TanStack Start"); } @@ -741,7 +885,7 @@ mod tests { let dir = setup_dir(); fs::write(dir.path().join("vite.config.js"), "").unwrap(); fs::write(dir.path().join("server.js"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Vite"); assert!(det.entrypoint_code.contains("server.js")); assert_eq!(det.include_paths, vec!["dist"]); @@ -754,7 +898,7 @@ mod tests { let dir = setup_dir(); fs::write(dir.path().join("vite.config.ts"), "").unwrap(); fs::write(dir.path().join("server.ts"), "").unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Vite"); assert!(det.entrypoint_code.contains("server.ts")); } @@ -764,7 +908,7 @@ mod tests { let dir = setup_dir(); fs::write(dir.path().join("vite.config.js"), "").unwrap(); // no server.js/ts/mjs - let result = detect_framework(dir.path()).unwrap(); + let result = detect_framework(dir.path(), false).unwrap(); assert!(result.is_none()); } @@ -778,7 +922,7 @@ mod tests { r#"{"imports":{"fresh":"jsr:@fresh/core"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); } @@ -790,7 +934,7 @@ mod tests { r#"{"imports":{"@fresh/core":"jsr:@fresh/core@^2"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); } @@ -802,7 +946,7 @@ mod tests { r#"{"imports":{"fresh":"jsr:@fresh/core"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); } @@ -815,7 +959,7 @@ mod tests { "{\n // This is a comment\n \"imports\": {\n \"fresh\": \"jsr:@fresh/core\"\n }\n}\n", ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Fresh"); } @@ -831,7 +975,7 @@ mod tests { r#"{"dependencies":{"@remix-run/react":"^2.0.0"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Next.js"); } @@ -845,7 +989,7 @@ mod tests { r#"{"dependencies":{"@solidjs/start":"^1.0.0"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "SolidStart"); } @@ -858,7 +1002,7 @@ mod tests { r#"{"imports":{"fresh":"jsr:@fresh/core"}}"#, ) .unwrap(); - let det = detect_framework(dir.path()).unwrap().unwrap(); + let det = detect_framework(dir.path(), false).unwrap().unwrap(); assert_eq!(det.name, "Astro"); } @@ -958,4 +1102,110 @@ mod tests { fs::write(dir.path().join("deno.json"), r#"{"tasks":{}}"#).unwrap(); assert!(read_deno_json_imports(dir.path()).is_none()); } + + // --- Favicon discovery --- + + fn nextjs_detection() -> FrameworkDetection { + FrameworkDetection { + name: "Next.js", + entrypoint_code: String::new(), + include_paths: vec![], + build_command: None, + } + } + + fn fresh_detection() -> FrameworkDetection { + FrameworkDetection { + name: "Fresh", + entrypoint_code: String::new(), + include_paths: vec![], + build_command: None, + } + } + + #[test] + fn finds_favicon_in_public_for_linux() { + let dir = setup_dir(); + fs::create_dir_all(dir.path().join("public")).unwrap(); + fs::write(dir.path().join("public/favicon.png"), "").unwrap(); + let det = nextjs_detection(); + let p = find_framework_favicon(dir.path(), &det, "linux").unwrap(); + assert_eq!(p, dir.path().join("public/favicon.png")); + } + + #[test] + fn finds_ico_for_windows_but_not_png() { + let dir = setup_dir(); + fs::create_dir_all(dir.path().join("public")).unwrap(); + fs::write(dir.path().join("public/favicon.png"), "").unwrap(); + let det = nextjs_detection(); + assert!(find_framework_favicon(dir.path(), &det, "windows").is_none()); + fs::write(dir.path().join("public/favicon.ico"), "").unwrap(); + let p = find_framework_favicon(dir.path(), &det, "windows").unwrap(); + assert_eq!(p, dir.path().join("public/favicon.ico")); + } + + #[test] + fn macos_prefers_icns_over_png() { + let dir = setup_dir(); + fs::create_dir_all(dir.path().join("public")).unwrap(); + fs::write(dir.path().join("public/icon.png"), "").unwrap(); + fs::write(dir.path().join("public/icon.icns"), "").unwrap(); + let det = nextjs_detection(); + let p = find_framework_favicon(dir.path(), &det, "macos").unwrap(); + assert_eq!(p, dir.path().join("public/icon.icns")); + } + + #[test] + fn icon_name_preferred_over_favicon() { + let dir = setup_dir(); + fs::create_dir_all(dir.path().join("public")).unwrap(); + fs::write(dir.path().join("public/favicon.png"), "").unwrap(); + fs::write(dir.path().join("public/icon.png"), "").unwrap(); + let det = nextjs_detection(); + let p = find_framework_favicon(dir.path(), &det, "linux").unwrap(); + assert_eq!(p, dir.path().join("public/icon.png")); + } + + #[test] + fn nextjs_app_router_favicon_in_app_dir() { + let dir = setup_dir(); + // No public/, but app/favicon.ico — Next 13+ App Router layout. + fs::create_dir_all(dir.path().join("app")).unwrap(); + fs::write(dir.path().join("app/favicon.ico"), "").unwrap(); + let det = nextjs_detection(); + let p = find_framework_favicon(dir.path(), &det, "windows").unwrap(); + assert_eq!(p, dir.path().join("app/favicon.ico")); + } + + #[test] + fn fresh_uses_static_dir() { + let dir = setup_dir(); + fs::create_dir_all(dir.path().join("static")).unwrap(); + fs::write(dir.path().join("static/favicon.png"), "").unwrap(); + let det = fresh_detection(); + let p = find_framework_favicon(dir.path(), &det, "linux").unwrap(); + assert_eq!(p, dir.path().join("static/favicon.png")); + } + + #[test] + fn no_favicon_returns_none() { + let dir = setup_dir(); + fs::create_dir_all(dir.path().join("public")).unwrap(); + let det = nextjs_detection(); + assert!(find_framework_favicon(dir.path(), &det, "linux").is_none()); + } + + #[test] + fn public_takes_priority_over_app_for_nextjs() { + let dir = setup_dir(); + fs::create_dir_all(dir.path().join("public")).unwrap(); + fs::create_dir_all(dir.path().join("app")).unwrap(); + fs::write(dir.path().join("public/favicon.png"), "").unwrap(); + fs::write(dir.path().join("app/icon.png"), "").unwrap(); + let det = nextjs_detection(); + let p = find_framework_favicon(dir.path(), &det, "linux").unwrap(); + // public/ is checked before app/. + assert_eq!(p, dir.path().join("public/favicon.png")); + } } diff --git a/cli/tools/mod.rs b/cli/tools/mod.rs index 121cdf0c8a006d..f371618c24f58e 100644 --- a/cli/tools/mod.rs +++ b/cli/tools/mod.rs @@ -8,6 +8,8 @@ pub mod clean; pub mod compile; pub mod coverage; pub mod deploy; +pub mod desktop; +pub mod desktop_devtools; pub mod doc; pub mod fmt; pub mod framework; diff --git a/cli/tsc/dts/lib.deno.desktop.d.ts b/cli/tsc/dts/lib.deno.desktop.d.ts new file mode 100644 index 00000000000000..117426d47374f6 --- /dev/null +++ b/cli/tsc/dts/lib.deno.desktop.d.ts @@ -0,0 +1,734 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +/// +/// +/// +/// +/// +/// +/// +/// + +declare interface UIEventInit extends EventInit { + detail?: number; + view?: null; +} + +declare class UIEvent extends Event { + constructor(type: string, init?: UIEventInit); + readonly detail: number; + readonly view: null; +} + +declare interface FocusEventInit extends UIEventInit { + relatedTarget?: EventTarget | null; +} + +declare class FocusEvent extends UIEvent { + constructor(type: string, init?: FocusEventInit); + readonly relatedTarget: EventTarget | null; +} + +declare interface KeyboardEventInit extends UIEventInit { + key?: string; + code?: string; + location?: number; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; + repeat?: boolean; + isComposing?: boolean; +} + +declare class KeyboardEvent extends UIEvent { + constructor(type: string, init?: KeyboardEventInit); + readonly key: string; + readonly code: string; + readonly location: number; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + readonly repeat: boolean; + readonly isComposing: boolean; + getModifierState(key: string): boolean; +} + +declare interface MouseEventInit extends UIEventInit { + button?: number; + clientX?: number; + clientY?: number; + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +declare class MouseEvent extends UIEvent { + constructor(type: string, init?: MouseEventInit); + readonly button: number; + readonly clientX: number; + readonly clientY: number; + readonly screenX: number; + readonly screenY: number; + readonly ctrlKey: boolean; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + getModifierState(key: string): boolean; +} + +declare interface WheelEventInit extends MouseEventInit { + deltaX?: number; + deltaY?: number; + deltaZ?: number; + deltaMode?: number; +} + +declare class WheelEvent extends MouseEvent { + constructor(type: string, init?: WheelEventInit); + readonly deltaX: number; + readonly deltaY: number; + readonly deltaZ: number; + readonly deltaMode: number; +} + +declare type NotificationPermission = "default" | "denied" | "granted"; +declare type NotificationDirection = "auto" | "ltr" | "rtl"; + +declare interface NotificationOptions { + body?: string; + data?: any; + dir?: NotificationDirection; + icon?: string; + lang?: string; + badge?: string; + requireInteraction?: boolean; + silent?: boolean | null; + tag?: string; +} + +declare interface NotificationPermissionCallback { + (permission: NotificationPermission): void; +} + +declare interface NotificationEventMap { + click: Event; + close: Event; + error: Event; + show: Event; +} + +declare interface Notification extends EventTarget { + readonly title: string; + readonly body: string; + readonly data: any; + readonly dir: NotificationDirection; + readonly icon: string; + readonly lang: string; + readonly badge: string; + readonly tag: string; + readonly silent: boolean | null; + readonly requireInteraction: boolean; + + onclick: ((this: Notification, ev: Event) => any) | null; + onclose: ((this: Notification, ev: Event) => any) | null; + onerror: ((this: Notification, ev: Event) => any) | null; + onshow: ((this: Notification, ev: Event) => any) | null; + + close(): void; + + addEventListener( + type: K, + listener: (this: Notification, ev: NotificationEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: Notification, ev: NotificationEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; +} + +/** Web Notifications API. + * + * Construct a notification to display it. Only available in apps + * compiled with `deno desktop`. + * + * Notification permission is checked against the OS (e.g. macOS User + * Notifications). {@linkcode Notification.permission} reports the + * cached result of the most recent query/request, and + * {@linkcode Notification.requestPermission} triggers a system prompt + * if the user has not yet decided. + * + * The Web Notifications API specifies `icon` as a URL string. The + * desktop runtime can only resolve `data:` URLs synchronously; other + * URL schemes are accepted (the value round-trips through the + * {@linkcode Notification.icon} property) but the OS notification is + * shown without an icon. + */ +declare var Notification: { + prototype: Notification; + new (title: string, options?: NotificationOptions): Notification; + readonly permission: NotificationPermission; + readonly maxActions: number; + requestPermission( + deprecatedCallback?: NotificationPermissionCallback, + ): Promise; +}; + +/** Permissions API state value. Mirrors the Web Permissions API. */ +declare type PermissionState = "granted" | "denied" | "prompt"; + +declare interface PermissionStatusEventMap { + change: Event; +} + +declare interface PermissionStatus extends EventTarget { + readonly name: string; + readonly state: PermissionState; + onchange: ((this: PermissionStatus, ev: Event) => any) | null; + + addEventListener( + type: K, + listener: ( + this: PermissionStatus, + ev: PermissionStatusEventMap[K], + ) => any, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: ( + this: PermissionStatus, + ev: PermissionStatusEventMap[K], + ) => any, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; +} + +declare var PermissionStatus: { + prototype: PermissionStatus; +}; + +declare interface PermissionDescriptor { + name: string; +} + +declare interface Permissions { + query(descriptor: PermissionDescriptor): Promise; +} + +/** Extends the {@linkcode Navigator} provided by `deno.window` with the + * Permissions API surface available to `deno desktop` apps. */ +declare interface Navigator { + readonly permissions: Permissions; +} + +declare namespace Deno { + export {}; // stop default export type behavior + + /** The application version read from `deno.json` at compile time, or + * `null` if no version was configured. Only available in apps compiled + * with `deno desktop`. */ + export const desktopVersion: string | null; + + export interface AutoUpdateOptions { + /** Base URL of the release server hosting `latest.json` and patch + * files. Defaults to `desktop.release.baseUrl` from `deno.json` when + * configured; required otherwise. */ + url?: string; + /** Poll interval in milliseconds. If omitted, only a single check is + * performed ~1s after the call. */ + interval?: number; + /** Called once an update has been downloaded and staged for the next + * launch. */ + onUpdateReady?: (version: string) => void; + /** Called if the previous launch's update failed to start and was + * rolled back. */ + onRollback?: (reason: string) => void; + } + + /** Start polling a release server for binary-diff updates. + * + * The manifest at `/latest.json` is fetched and compared against + * {@linkcode Deno.desktopVersion}. If a newer version is available and + * a patch from the current version exists, the patch is downloaded and + * staged for the next launch, and `onUpdateReady` is invoked. + * + * If the previous launch's update failed and was rolled back, + * `onRollback` is invoked shortly after this call. + * + * Only available in apps compiled with `deno desktop`. + * + * The release server URL may be passed directly, supplied via + * {@linkcode AutoUpdateOptions.url}, or configured once in `deno.json` + * under `desktop.release.baseUrl` (in which case it can be omitted). */ + export function autoUpdate(url: string): void; + export function autoUpdate(options?: AutoUpdateOptions): void; + + export interface OpenDevtoolsOptions { + /** Inspect the CEF renderer isolate. @default {true} */ + renderer?: boolean; + /** Inspect the Deno runtime isolate. @default {true} */ + deno?: boolean; + } + + export interface BrowserWindowOptions { + title?: string; + /** @default {800} */ + width?: number; + /** @default {600} */ + height?: number; + x?: number; + y?: number; + /** @default {true} */ + resizable?: boolean; + /** @default {false} */ + alwaysOnTop?: boolean; + /** Remove the title bar and standard window chrome (border, traffic + * light / caption buttons). Set at creation time only. + * + * @default {false} */ + frameless?: boolean; + /** Create the window as a floating, non-activating utility "panel": it + * floats above normal windows and does not activate the app or steal key + * focus from the foreground app when shown. Combined with + * {@linkcode frameless} and {@linkcode Tray.getBounds}, this is the + * configuration used for tray / menu-bar popovers. Set at creation time + * only. + * + * @default {false} */ + noActivate?: boolean; + transparentTitlebar?: boolean; + } + + interface BrowserWindowObject { + [key: string]: BrowserWindowValue; + } + + type BrowserWindowValue = + | null + | boolean + | number + | string + | BrowserWindowObject + | BrowserWindowValue[] + | Uint8Array; + + /** The value a {@linkcode BrowserWindow.bind} handler may resolve with. + * + * Handler results are serialized to JSON before crossing into the + * webview, so this is intentionally more permissive than + * {@linkcode BrowserWindowValue}: `undefined` (and optional) properties + * are dropped during serialization, and nested objects need not be typed + * as {@linkcode BrowserWindowValue}. This lets handlers return ordinary + * discriminated unions and `Record` shapes without + * casting. */ + type BrowserWindowReturn = + | null + | undefined + | boolean + | number + | string + | Uint8Array + | readonly BrowserWindowReturn[] + | { readonly [key: string]: unknown }; + + export type WindowBindings = Record< + string, + ( + this: BrowserWindow, + ...args: BrowserWindowValue[] + ) => Promise + >; + + /** Constrains T to a record of async binding functions. */ + type ValidBindings = { + [K in keyof T]: ( + this: BrowserWindow, + ...args: BrowserWindowValue[] + ) => Promise; + }; + + export type MenuItem = + | { + item: { + label: string; + id?: string; + accelerator?: string; + enabled: boolean; + }; + } + | { + submenu: { + label: string; + items: MenuItem[]; + }; + } + | "separator" + | { + role: { + role: string; + }; + }; + + interface BrowserWindowResizeDetail { + width: number; + height: number; + } + + interface BrowserWindowMoveDetail { + x: number; + y: number; + } + + interface MenuClickDetail { + id: string; + } + + interface BrowserWindowEventMap { + keydown: KeyboardEvent; + keyup: KeyboardEvent; + mousedown: MouseEvent; + mouseup: MouseEvent; + click: MouseEvent; + dblclick: MouseEvent; + mousemove: MouseEvent; + mouseenter: MouseEvent; + mouseleave: MouseEvent; + wheel: WheelEvent; + focus: FocusEvent; + blur: FocusEvent; + + // non-standard events + resize: CustomEvent; + move: CustomEvent; + close: Event; + menuclick: CustomEvent; + contextmenuclick: CustomEvent; + } + + type BrowserWindowEventHandlers = { + [K in keyof BrowserWindowEventMap as `on${K}`]: + | ((this: BrowserWindow, ev: BrowserWindowEventMap[K]) => any) + | null; + }; + + export interface BrowserWindow = WindowBindings> + extends BrowserWindowEventHandlers {} + + export class BrowserWindow< + T extends ValidBindings = WindowBindings, + > extends EventTarget { + constructor(options?: BrowserWindowOptions); + + readonly windowId: number; + + bind(name: N, fn: T[N]): void; + unbind(name: N): void; + /** @throws {BrowserWindowValue} */ + executeJs(script: string): Promise; + + setTitle(title: string): void; + + getSize(): [number, number]; + setSize(width: number, height: number): void; + + getPosition(): [number, number]; + setPosition(x: number, y: number): void; + + isResizable(): boolean; + setResizable(resizable: boolean): void; + + isAlwaysOnTop(): boolean; + setAlwaysOnTop(alwaysOnTop: boolean): void; + + isClosed(): boolean; + close(): void; + + isVisible(): boolean; + show(): void; + hide(): void; + focus(): void; + navigate(url: string): void; + /** Open a DevTools window. + * + * By default both targets are shown. Pass an options object to + * select which targets to inspect. At least one must be `true`. + */ + openDevtools(options?: OpenDevtoolsOptions): void; + reload(): void; + + setApplicationMenu(menu: MenuItem[]): void; + showContextMenu(x: number, y: number, menu: MenuItem[]): void; + + getNativeWindow(): Deno.UnsafeWindowSurface; + + addEventListener( + type: K, + listener: (this: BrowserWindow, ev: BrowserWindowEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: BrowserWindow, ev: BrowserWindowEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; + } + + interface DockReopenDetail { + hasVisibleWindows: boolean; + } + + interface DockEventMap { + menuclick: CustomEvent; + reopen: CustomEvent; + } + + type DockEventHandlers = { + [K in keyof DockEventMap as `on${K}`]: + | ((this: Dock, ev: DockEventMap[K]) => any) + | null; + }; + + export interface Dock extends DockEventHandlers {} + + /** App-level dock / taskbar handle. + * + * A `"reopen"` event fires on macOS when the user clicks the dock icon; + * the default behavior of showing the last hidden window is swallowed, + * so listeners decide what (if anything) to do. + */ + export class Dock extends EventTarget { + constructor(); + + /** Set a short text badge on the app's dock icon (macOS) or taskbar + * icon (Windows), or prefix the focused window's title on Linux. + * Pass `null` or an empty string to clear the badge. */ + setBadge(text: string | null): void; + + /** Bounce the dock icon (macOS), flash the focused window's taskbar + * button (Windows), or set the urgency hint on the focused window + * (Linux). + * + * When `critical` is `false` (the default) this triggers a single + * bounce; when `true` it bounces continuously until the app is + * focused. */ + bounce(critical?: boolean): void; + + /** Set a custom right-click menu on the app's dock icon. Pass + * `null` to remove any menu previously set. + * + * macOS only. Click events are delivered as `"menuclick"` events on + * {@linkcode Deno.dock}. No-op on Windows and Linux. */ + setMenu(menu: MenuItem[] | null): void; + + /** Show or hide the app's dock icon. + * + * macOS only — controls the app's activation policy. No-op on + * Windows and Linux. */ + setVisible(visible: boolean): void; + + addEventListener( + type: K, + listener: (this: Dock, ev: DockEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: Dock, ev: DockEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; + } + + /** App-level dock / taskbar singleton. */ + export const dock: Dock; + + /** The tray icon's bounding rectangle in screen coordinates, in the same + * top-left-origin space as {@linkcode BrowserWindow.setPosition}. Use it to + * anchor a popover window under the icon. */ + export interface TrayBounds { + x: number; + y: number; + width: number; + height: number; + } + + export interface TrayPanelOptions { + /** URL to load in the panel window. */ + url?: string; + /** Panel width in pixels. @default {360} */ + width?: number; + /** Panel height in pixels. @default {480} */ + height?: number; + /** Hide the panel when it loses focus (click-outside to dismiss). + * @default {true} */ + hideOnBlur?: boolean; + /** Override where the panel is placed. Receives the tray icon's bounds + * and the panel size, and returns the top-left screen position. The + * default centers the panel horizontally under the icon — correct for a + * top menu bar; provide this to place it elsewhere (e.g. above a + * bottom-edge taskbar). */ + position?: ( + trayBounds: TrayBounds, + panelSize: { width: number; height: number }, + ) => { x: number; y: number }; + } + + /** Handle to a tray-attached popover window created by + * {@linkcode Tray.attachPanel}. */ + export interface TrayPanel { + /** The underlying panel window — use it to `bind()`, `executeJs()`, + * open devtools, etc. */ + readonly window: BrowserWindow; + /** Whether the panel is currently shown. */ + readonly visible: boolean; + /** Show the panel, positioned under the tray icon. */ + show(): void; + /** Hide the panel. */ + hide(): void; + /** Toggle the panel's visibility. */ + toggle(): void; + /** Detach the panel: remove the tray/blur listeners and close the + * window. */ + destroy(): void; + } + + interface TrayEventMap { + click: MouseEvent; + dblclick: MouseEvent; + menuclick: CustomEvent; + } + + type TrayEventHandlers = { + [K in keyof TrayEventMap as `on${K}`]: + | ((this: Tray, ev: TrayEventMap[K]) => any) + | null; + }; + + export interface Tray extends TrayEventHandlers {} + + /** A persistent icon in the OS status area (macOS menu bar extras, + * Windows system tray, Linux AppIndicator). + * + * The icon is removed from the OS when {@linkcode Tray.destroy} is + * called. Multiple trays may be created. + */ + export class Tray extends EventTarget implements Disposable { + constructor(); + + readonly trayId: number; + + /** Set the tray icon image from PNG-encoded bytes. */ + setIcon(pngBytes: Uint8Array): void; + + /** Set the tray icon used in OS dark mode. Pass `null` to clear + * it. */ + setIconDark(pngBytes: Uint8Array | null): void; + + /** Set the tooltip shown on hover. Pass `null` or an empty string + * to clear the tooltip. */ + setTooltip(text: string | null): void; + + /** Set the right-click context menu. Click events are delivered as + * `"menuclick"` events on the tray. Pass `null` to remove any + * menu previously set. */ + setMenu(menu: MenuItem[] | null): void; + + /** The tray icon's bounding rectangle in screen coordinates, or `null` + * if the icon has no on-screen position yet or the platform can't report + * it. Typically called from a `"click"` handler to position a popover + * {@linkcode BrowserWindow} (created with `frameless` + `noActivate`) + * under the icon. */ + getBounds(): TrayBounds | null; + + /** Attach a frameless, non-activating popover window to this tray icon + * (the classic menu-bar-app pattern). The returned panel toggles on tray + * click, is positioned under the icon via {@linkcode Tray.getBounds}, and + * hides when it loses focus. + * + * Convenience built on the primitives; for full control create a + * `frameless` + `noActivate` {@linkcode BrowserWindow} yourself. + * + * ```ts + * const tray = new Deno.Tray(); + * tray.setIcon(iconBytes); + * const panel = tray.attachPanel({ url: "https://localhost:8000/panel" }); + * panel.window.bind("doThing", async () => { ... }); + * ``` + * + * Pass a string as shorthand for `{ url }`. On Linux the icon position + * can't be queried, so the panel shows at its last position rather than + * anchored to the icon. */ + attachPanel(options: TrayPanelOptions | string): TrayPanel; + + /** Remove the tray icon from the OS status area. The instance must + * not be used after this call. */ + destroy(): void; + + [Symbol.dispose](): void; + + addEventListener( + type: K, + listener: (this: Tray, ev: TrayEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, + ): void; + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | AddEventListenerOptions, + ): void; + removeEventListener( + type: K, + listener: (this: Tray, ev: TrayEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void; + removeEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + options?: boolean | EventListenerOptions, + ): void; + } +} diff --git a/cli/tsc/mod.rs b/cli/tsc/mod.rs index c7fae3ab96b1af..e920f841be7d8f 100644 --- a/cli/tsc/mod.rs +++ b/cli/tsc/mod.rs @@ -213,6 +213,7 @@ pub static LAZILY_LOADED_STATIC_ASSETS: Lazy< maybe_compressed_lib!("lib.deno.webgpu.d.ts", "lib.deno_webgpu.d.ts"), maybe_compressed_lib!("lib.deno.window.d.ts"), maybe_compressed_lib!("lib.deno.worker.d.ts"), + maybe_compressed_lib!("lib.deno.desktop.d.ts"), maybe_compressed_lib!("lib.deno.shared_globals.d.ts"), maybe_compressed_lib!("lib.deno.ns.d.ts"), maybe_compressed_lib!("lib.deno.unstable.d.ts"), diff --git a/ext/canvas/lib.rs b/ext/canvas/lib.rs index 5f07c11c67949e..ccfa9c4e30c066 100644 --- a/ext/canvas/lib.rs +++ b/ext/canvas/lib.rs @@ -5,7 +5,7 @@ use deno_core::op2; use deno_core::v8; mod bitmaprenderer; -mod byow; +pub mod byow; mod canvas; deno_core::extension!( diff --git a/ext/web/02_event.js b/ext/web/02_event.js index 4b68e914b83616..9cc6066cca672e 100644 --- a/ext/web/02_event.js +++ b/ext/web/02_event.js @@ -6,7 +6,7 @@ // and impossible logic branches based on what Deno currently supports. (function () { -const { core, primordials } = __bootstrap; +const { core, internals, primordials } = __bootstrap; const { ArrayPrototypeIncludes, ArrayPrototypeIndexOf, @@ -1697,6 +1697,9 @@ function reportError(error) { reportException(error); } +internals.defineEventHandler = defineEventHandler; +internals.setEventTargetData = setEventTargetData; + return { CloseEvent, CustomEvent, diff --git a/ext/webidl/00_webidl.js b/ext/webidl/00_webidl.js index 6ce102f669cfb1..5a394b823523a6 100644 --- a/ext/webidl/00_webidl.js +++ b/ext/webidl/00_webidl.js @@ -7,7 +7,7 @@ /// (function () { -const { core, primordials } = __bootstrap; +const { core, internals, primordials } = __bootstrap; const { isArrayBuffer, isDataView, @@ -1528,6 +1528,8 @@ function setlikeObjectWrap(objPrototype, readonly) { } } +internals.webidlBrand = brand; + return { assertBranded, AsyncIterable, diff --git a/libs/config/deno_json/mod.rs b/libs/config/deno_json/mod.rs index aa8d1fdd6d8a44..b945aa7bd0b6a1 100644 --- a/libs/config/deno_json/mod.rs +++ b/libs/config/deno_json/mod.rs @@ -832,6 +832,197 @@ pub struct CompileConfig { pub permissions: Option>, } +#[derive(Clone, Debug, Deserialize, PartialEq)] +struct SerializedDesktopIconEntry { + pub path: String, + pub size: u32, +} + +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(untagged)] +enum SerializedDesktopIconValue { + Single(String), + Set(Vec), +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +struct SerializedDesktopIconsConfig { + pub macos: Option, + pub windows: Option, + pub linux: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +struct SerializedDesktopAppConfig { + pub name: Option, + pub icons: Option, + /// Bundle/application identifier in reverse-DNS form (e.g. + /// `com.acme.foo`). Used as the macOS `CFBundleIdentifier`, the Linux + /// `.desktop` file identifier, and (eventually) the Windows + /// AppUserModelID. Optional; when omitted a synthetic + /// `com.deno.desktop.` is generated from the app name. + pub identifier: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +struct SerializedDesktopOutputConfig { + pub macos: Option, + pub windows: Option, + pub linux: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +struct SerializedDesktopReleaseConfig { + #[serde(rename = "baseUrl")] + pub base_url: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields, rename_all = "camelCase")] +struct SerializedDesktopErrorReportingConfig { + pub url: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +struct SerializedDesktopMacOSConfig { + /// Codesigning identity passed to `codesign --sign`. Typically + /// `Developer ID Application: Acme, Inc. (TEAMID)` for distribution, + /// or `-` for an ad-hoc signature (still required on Apple Silicon to + /// launch unsigned binaries). + #[serde(rename = "codesignIdentity")] + pub codesign_identity: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +struct SerializedDesktopConfig { + pub app: Option, + pub backend: Option, + pub output: Option, + pub release: Option, + #[serde(rename = "errorReporting")] + pub error_reporting: Option, + pub macos: Option, +} + +impl SerializedDesktopConfig { + pub fn into_resolved(self) -> DesktopConfig { + DesktopConfig { + app: self.app.map(|a| DesktopAppConfig { + name: a.name, + identifier: a.identifier, + icons: a.icons.map(|i| { + fn resolve_icon_value( + v: SerializedDesktopIconValue, + ) -> DesktopIconValue { + match v { + SerializedDesktopIconValue::Single(s) => { + DesktopIconValue::Single(s) + } + SerializedDesktopIconValue::Set(entries) => { + DesktopIconValue::Set( + entries + .into_iter() + .map(|e| DesktopIconEntry { + path: e.path, + size: e.size, + }) + .collect(), + ) + } + } + } + DesktopIconsConfig { + macos: i.macos.map(resolve_icon_value), + windows: i.windows.map(resolve_icon_value), + linux: i.linux.map(resolve_icon_value), + } + }), + }), + backend: self.backend, + output: self.output.map(|o| DesktopOutputConfig { + macos: o.macos, + windows: o.windows, + linux: o.linux, + }), + release: self.release.map(|r| DesktopReleaseConfig { + base_url: r.base_url, + }), + error_reporting: self + .error_reporting + .map(|e| DesktopErrorReportingConfig { url: e.url }), + macos: self.macos.map(|m| DesktopMacOSConfig { + codesign_identity: m.codesign_identity, + }), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DesktopIconEntry { + pub path: String, + pub size: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum DesktopIconValue { + /// A single icon file (`.icns`, `.ico`, or `.png`). + Single(String), + /// Multiple PNGs at specific sizes. + Set(Vec), +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopIconsConfig { + pub macos: Option, + pub windows: Option, + pub linux: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopAppConfig { + pub name: Option, + pub identifier: Option, + pub icons: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopOutputConfig { + pub macos: Option, + pub windows: Option, + pub linux: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopReleaseConfig { + pub base_url: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopErrorReportingConfig { + pub url: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopMacOSConfig { + pub codesign_identity: Option, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DesktopConfig { + pub app: Option, + pub backend: Option, + pub output: Option, + pub release: Option, + pub error_reporting: Option, + pub macos: Option, +} + #[derive(Clone, Debug, Deserialize, PartialEq)] #[serde(untagged)] pub enum LockConfig { @@ -1235,6 +1426,7 @@ pub struct ConfigFileJson { pub test: Option, pub bench: Option, pub compile: Option, + pub desktop: Option, pub lock: Option, pub exclude: Option, pub minimum_dependency_age: Option, @@ -1957,6 +2149,24 @@ impl ConfigFile { } } + pub fn to_desktop_config( + &self, + ) -> Result { + match self.json.desktop.clone() { + Some(config) => { + let serialized: SerializedDesktopConfig = + serde_json::from_value(config).map_err(|error| { + ToInvalidConfigError::Parse { + config: "desktop", + source: error, + } + })?; + Ok(serialized.into_resolved()) + } + None => Ok(DesktopConfig::default()), + } + } + pub fn to_fmt_config(&self) -> Result { match self.json.fmt.clone() { Some(config) => { diff --git a/libs/config/workspace/mod.rs b/libs/config/workspace/mod.rs index d2fd9376b4a657..e0242370954573 100644 --- a/libs/config/workspace/mod.rs +++ b/libs/config/workspace/mod.rs @@ -49,6 +49,7 @@ use crate::deno_json::ConfigFileError; use crate::deno_json::ConfigFileRc; use crate::deno_json::ConfigFileReadError; use crate::deno_json::DeployConfig; +use crate::deno_json::DesktopConfig; use crate::deno_json::FmtConfig; use crate::deno_json::FmtOptionsConfig; use crate::deno_json::LinkConfigParseError; @@ -1824,6 +1825,7 @@ pub enum TsTypeLib { #[default] DenoWindow, DenoWorker, + DenoDesktop, } #[derive(Debug, Clone)] @@ -1837,6 +1839,7 @@ struct CachedDirectoryValues { permissions: OnceLock, bench: OnceLock, compile: OnceLock, + desktop: OnceLock, test: OnceLock, } @@ -2435,6 +2438,42 @@ impl WorkspaceDirectory { }) } + pub fn to_desktop_config( + &self, + ) -> Result<&DesktopConfig, ToInvalidConfigError> { + if let Some(config) = &self.cached.desktop.get() { + Ok(config) + } else { + let config = self.to_desktop_config_no_cache()?; + _ = self.cached.desktop.set(config); + Ok(self.cached.desktop.get().unwrap()) + } + } + + fn to_desktop_config_no_cache( + &self, + ) -> Result { + let member_config = match &self.deno_json.member { + Some(member) => member.to_desktop_config()?, + None => Default::default(), + }; + let root_config = match &self.deno_json.root { + Some(root) => root.to_desktop_config()?, + None => Default::default(), + }; + // Member config takes precedence over root for each field. + Ok(DesktopConfig { + app: member_config.app.or(root_config.app), + backend: member_config.backend.or(root_config.backend), + output: member_config.output.or(root_config.output), + release: member_config.release.or(root_config.release), + error_reporting: member_config + .error_reporting + .or(root_config.error_reporting), + macos: member_config.macos.or(root_config.macos), + }) + } + pub fn to_tasks_config( &self, ) -> Result { diff --git a/libs/core/cppgc.rs b/libs/core/cppgc.rs index 82f754544c6489..9c0a5245f327cf 100644 --- a/libs/core/cppgc.rs +++ b/libs/core/cppgc.rs @@ -356,6 +356,32 @@ impl SameObject { .clone() } + /// Like [`SameObject::get`] but the initializer can fail. On error the + /// cache is left empty so a subsequent call can retry. Useful when the + /// underlying construction (e.g. wgpu surface, native handle lookup) + /// can legitimately fail and we want to bubble the error to JS instead + /// of panicking inside the closure. + pub fn try_get( + &self, + scope: &mut v8::PinScope, + f: F, + ) -> Result, E> + where + F: FnOnce(&mut v8::PinScope) -> Result, + { + if let Some(obj) = self.cell.get() { + return Ok(obj.clone()); + } + let v = f(scope)?; + let obj = make_cppgc_object(scope, v); + let global = v8::Global::new(scope, obj); + // `set` returns Err if a re-entrant call beat us to it; in that case + // we discard our freshly-built object and fall through to the now- + // populated cache. Either way `cell.get()` returns Some afterwards. + let _ = self.cell.set(global); + Ok(self.cell.get().unwrap().clone()) + } + pub fn set( &self, scope: &mut v8::PinScope, diff --git a/libs/core/runtime/bindings.rs b/libs/core/runtime/bindings.rs index 5bc0333c4a8a99..c163cd44f187bb 100644 --- a/libs/core/runtime/bindings.rs +++ b/libs/core/runtime/bindings.rs @@ -535,11 +535,8 @@ pub(crate) fn initialize_deno_core_ops_bindings<'s, 'i>( let op_ctxs = &op_ctxs[index..]; for op_ctx in op_ctxs { let constructor_behavior = op_ctx_constructor_behavior(op_ctx); - let mut op_fn = if will_snapshot && !op_ctx.decl.constructable { - op_ctx_plain_function(scope, op_ctx, constructor_behavior) - } else { - op_ctx_function(scope, op_ctx, constructor_behavior, will_snapshot) - }; + let mut op_fn = + op_ctx_function(scope, op_ctx, constructor_behavior, will_snapshot); let key = op_ctx.decl.name_fast.v8_string(scope).unwrap(); // For async ops we need to set them up, by calling `Deno.core.setUpAsyncStub` - @@ -577,6 +574,15 @@ pub(crate) fn upgrade_snapshotted_ops_with_fast_calls<'s, 'i>( op_method_decls: &[OpMethodDecl], methods_ctx_offset: usize, ) { + // Calling build_fast() after deserializing a snapshot that contains cppgc + // objects crashes (SIGABRT) on release linux-x86_64 with fat LTO due to a + // C++ static-initialization guard failure inside V8. When any extension + // registers cppgc class method ops (op_method_decls non-empty), skip the + // entire fast-call upgrade to avoid triggering the recursive GCInfo init. + if !op_method_decls.is_empty() { + return; + } + let global = context.global(scope); let deno_obj = get(scope, global, DENO, "Deno"); let deno_core_obj = get(scope, deno_obj, CORE, "Deno.core"); @@ -657,8 +663,8 @@ pub(crate) fn upgrade_snapshotted_ops_with_fast_calls<'s, 'i>( continue; } - let constructor_behavior = op_ctx_constructor_behavior(op_ctx); - let mut op_fn = op_ctx_function(scope, op_ctx, constructor_behavior, false); + let mut op_fn = + op_ctx_function(scope, op_ctx, v8::ConstructorBehavior::Allow, false); let key = op_ctx.decl.name_fast.v8_string(scope).unwrap(); if op_ctx.decl.is_async { @@ -853,35 +859,6 @@ fn op_ctx_function<'s, 'i>( v8fn } -fn op_ctx_plain_function<'s, 'i>( - scope: &mut v8::PinScope<'s, 'i>, - op_ctx: &OpCtx, - constructor_behaviour: v8::ConstructorBehavior, -) -> v8::Local<'s, v8::Function> { - let op_ctx_ptr = op_ctx as *const OpCtx as *const c_void; - let external = v8::External::new(scope, op_ctx_ptr as *mut c_void); - let slow_fn = if op_ctx.metrics_enabled() { - op_ctx.decl.slow_fn_with_metrics - } else { - op_ctx.decl.slow_fn - }; - - let v8fn = v8::Function::builder_raw(slow_fn) - .data(external.into()) - .constructor_behavior(constructor_behaviour) - .side_effect_type(if op_ctx.decl.no_side_effects { - v8::SideEffectType::HasNoSideEffect - } else { - v8::SideEffectType::HasSideEffect - }) - .length(op_ctx.decl.arg_count as i32) - .build(scope) - .unwrap(); - let v8name = op_ctx.decl.name_fast.v8_string(scope).unwrap(); - v8fn.set_name(v8name); - v8fn -} - type AccessorStore<'a> = HashMap, Option<&'a OpCtx>)>; diff --git a/libs/resolver/cjs/analyzer/mod.rs b/libs/resolver/cjs/analyzer/mod.rs index 8b608090ba8e9f..7c90fb6331ad70 100644 --- a/libs/resolver/cjs/analyzer/mod.rs +++ b/libs/resolver/cjs/analyzer/mod.rs @@ -212,6 +212,35 @@ impl DenoCjsCodeAnalyzer { return Ok(DenoCjsAnalysis::Cjs(Default::default())); } + // Non-script files can't carry CJS exports. Extensions answer this for + // everything except extensionless files (`MediaType::Unknown`), which may + // be real modules (an npm `"main"` with no extension — see + // test-module-main-extension-lookup) OR binary assets a framework happened + // to `require()`. Feeding binary to swc panics (it asserts on a backwards + // span), so for `Unknown` we only proceed when the source looks like text + // rather than blanket-skipping every extensionless module. + let is_definitely_non_script = !matches!( + media_type, + MediaType::JavaScript + | MediaType::Mjs + | MediaType::Cjs + | MediaType::Jsx + | MediaType::TypeScript + | MediaType::Mts + | MediaType::Cts + | MediaType::Tsx + | MediaType::Dts + | MediaType::Dmts + | MediaType::Dcts + | MediaType::Unknown + ); + let looks_binary = source.contains('\0') || source.contains('\u{FFFD}'); + if is_definitely_non_script + || (media_type == MediaType::Unknown && looks_binary) + { + return Ok(DenoCjsAnalysis::Esm); + } + let cjs_tracker = self.cjs_tracker.clone(); let is_maybe_cjs = cjs_tracker .is_maybe_cjs(specifier, media_type) diff --git a/libs/resolver/deno_json.rs b/libs/resolver/deno_json.rs index f7e991538e1c80..b4c688591dd261 100644 --- a/libs/resolver/deno_json.rs +++ b/libs/resolver/deno_json.rs @@ -329,6 +329,8 @@ pub fn get_base_compiler_options_for_emit( (TsTypeLib::DenoWindow, CompilerOptionsSourceKind::TsConfig) => vec!["deno.window", "deno.unstable", "dom", "node"], (TsTypeLib::DenoWorker, CompilerOptionsSourceKind::DenoJson) => vec!["deno.worker", "deno.unstable", "node"], (TsTypeLib::DenoWorker, CompilerOptionsSourceKind::TsConfig) => vec!["deno.worker", "deno.unstable", "dom", "node"], + (TsTypeLib::DenoDesktop, CompilerOptionsSourceKind::DenoJson) => vec!["deno.desktop", "deno.unstable", "node"], + (TsTypeLib::DenoDesktop, CompilerOptionsSourceKind::TsConfig) => vec!["deno.desktop", "deno.unstable", "dom", "node"], }, "module": "NodeNext", "moduleDetection": "force", @@ -490,6 +492,8 @@ struct MemoizedValues { OnceCell>, deno_worker_check_compiler_options: OnceCell>, + deno_desktop_check_compiler_options: + OnceCell>, emit_compiler_options: OnceCell>, #[cfg(feature = "deno_ast")] @@ -583,6 +587,9 @@ impl CompilerOptionsData { CompilerOptionsType::Check { lib: TsTypeLib::DenoWorker, } => &self.memoized.deno_worker_check_compiler_options, + CompilerOptionsType::Check { + lib: TsTypeLib::DenoDesktop, + } => &self.memoized.deno_desktop_check_compiler_options, CompilerOptionsType::Emit => &self.memoized.emit_compiler_options, }; let result = cell.get_or_init(|| { diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index c495b93df2f6c5..8dfad56f54534d 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -29,6 +29,8 @@ name = "deno_runtime" path = "lib.rs" [dependencies] +async-trait.workspace = true +base64.workspace = true boxed_error.workspace = true color-print.workspace = true deno_ast = { workspace = true, optional = true } @@ -67,7 +69,10 @@ deno_webgpu.workspace = true deno_webidl.workspace = true deno_websocket.workspace = true deno_webstorage.workspace = true +ed25519-dalek.workspace = true encoding_rs.workspace = true +faster-hex.workspace = true +fastwebsockets.workspace = true http.workspace = true http-body-util.workspace = true hyper.workspace = true @@ -76,9 +81,13 @@ log.workspace = true node_resolver = { workspace = true, features = ["sync"] } notify.workspace = true once_cell.workspace = true +qbsdiff.workspace = true +raw-window-handle.workspace = true +regex.workspace = true rustyline = { workspace = true, features = ["custom-bindings"] } same-file.workspace = true serde.workspace = true +sha2.workspace = true sys_traits.workspace = true thiserror.workspace = true tokio.workspace = true diff --git a/runtime/js/99_main.js b/runtime/js/99_main.js index 69ee6eca8e31e2..84df18bd79259f 100644 --- a/runtime/js/99_main.js +++ b/runtime/js/99_main.js @@ -647,6 +647,24 @@ const NOT_IMPORTED_OPS = [ "op_set_exit_code", "op_napi_open", + // Related to `Deno.desktop` API (deno compile --desktop) + "BrowserWindow", + "Dock", + "Tray", + "Notification", + "op_desktop_apply_patch", + "op_desktop_confirm_update", + "op_desktop_init", + "op_desktop_recv_event", + "op_desktop_resolve_bind_call", + "op_desktop_reject_bind_call", + "op_desktop_alert", + "op_desktop_confirm", + "op_desktop_prompt", + "op_desktop_send_error_report", + "op_desktop_request_notification_permission", + "op_desktop_query_notification_permission", + // deno deploy subcommand "op_deploy_token_get", "op_deploy_token_set", diff --git a/runtime/ops/desktop.rs b/runtime/ops/desktop.rs new file mode 100644 index 00000000000000..f8ee7c3ad5594b --- /dev/null +++ b/runtime/ops/desktop.rs @@ -0,0 +1,2325 @@ +// Copyright 2018-2026 the Deno authors. MIT license. + +//! Desktop window management ops for `deno compile --desktop`. +//! +//! These ops are included in the V8 snapshot so their external references +//! are stable. When `DesktopApi` is not present in OpState (non-desktop +//! builds), the ops silently no-op. + +use std::borrow::Cow; +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::sync::OnceLock; +use std::sync::atomic::AtomicU32; +use std::sync::atomic::Ordering; + +use deno_core::FromV8; +use deno_core::OpState; +use deno_core::ToV8; +use deno_core::cppgc::SameObject; +use deno_core::op2; +use deno_core::serde_json; +use deno_core::v8; + +/// Thread-safe intermediate value type for crossing the WEF ↔ Deno boundary. +/// Converts directly to V8 values without going through serde. +pub enum DesktopValue { + Null, + Bool(bool), + Int(i32), + Double(f64), + String(String), + List(Vec), + Dict(Vec<(String, DesktopValue)>), + Binary(Vec), +} + +impl<'a> ToV8<'a> for DesktopValue { + type Error = std::convert::Infallible; + + fn to_v8( + self, + scope: &mut v8::PinScope<'a, '_>, + ) -> Result, Self::Error> { + Ok(match self { + DesktopValue::Null => v8::null(scope).into(), + DesktopValue::Bool(b) => v8::Boolean::new(scope, b).into(), + DesktopValue::Int(i) => v8::Integer::new(scope, i).into(), + DesktopValue::Double(d) => v8::Number::new(scope, d).into(), + DesktopValue::String(s) => v8::String::new(scope, &s).unwrap().into(), + DesktopValue::List(l) => { + let arr = v8::Array::new(scope, l.len() as i32); + for (i, v) in l.into_iter().enumerate() { + let val = v.to_v8(scope)?; + arr.set_index(scope, i as u32, val); + } + arr.into() + } + DesktopValue::Dict(d) => { + let obj = v8::Object::new(scope); + for (k, v) in d { + let key: v8::Local = + v8::String::new(scope, &k).unwrap().into(); + let val = v.to_v8(scope)?; + obj.set(scope, key, val); + } + obj.into() + } + DesktopValue::Binary(b) => { + let len = b.len(); + let store = v8::ArrayBuffer::new_backing_store_from_vec(b); + let ab = v8::ArrayBuffer::with_backing_store(scope, &store.into()); + v8::Uint8Array::new(scope, ab, 0, len).unwrap().into() + } + }) + } +} + +/// Wraps a `Result` from `execute_js`. +/// Converts to `{ ok: true, value }` or `{ ok: false, value }`. +pub struct ExecuteJsResult(pub Result); + +impl<'a> ToV8<'a> for ExecuteJsResult { + type Error = std::convert::Infallible; + + fn to_v8( + self, + scope: &mut v8::PinScope<'a, '_>, + ) -> Result, Self::Error> { + let obj = v8::Object::new(scope); + + let ok_key: v8::Local = + v8::String::new(scope, "ok").unwrap().into(); + let value_key: v8::Local = + v8::String::new(scope, "value").unwrap().into(); + + let (ok, val) = match self.0 { + Ok(v) => (true, v.to_v8(scope)?), + Err(v) => (false, v.to_v8(scope)?), + }; + + obj.set(scope, ok_key, v8::Boolean::new(scope, ok).into()); + obj.set(scope, value_key, val); + Ok(obj.into()) + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenDevtoolsOptions { + pub renderer: Option, + pub deno: Option, +} + +/// A single event type that flows from the laufey backend to the Deno runtime. +#[derive(Debug, serde::Serialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum DesktopEvent { + #[serde(rename_all = "camelCase")] + AppMenuClick { window_id: u32, id: String }, + #[serde(rename_all = "camelCase")] + ContextMenuClick { window_id: u32, id: String }, + #[serde(rename_all = "camelCase")] + KeyboardEvent { + window_id: u32, + r#type: String, + key: String, + code: String, + shift: bool, + control: bool, + alt: bool, + meta: bool, + repeat: bool, + }, + #[serde(rename_all = "camelCase")] + BindCall { + window_id: u32, + name: String, + args: serde_json::Value, + call_id: u32, + }, + #[serde(rename_all = "camelCase")] + MouseClick { + window_id: u32, + state: String, + button: i32, + client_x: f64, + client_y: f64, + shift: bool, + control: bool, + alt: bool, + meta: bool, + click_count: i32, + }, + #[serde(rename_all = "camelCase")] + MouseMove { + window_id: u32, + client_x: f64, + client_y: f64, + shift: bool, + control: bool, + alt: bool, + meta: bool, + }, + #[serde(rename_all = "camelCase")] + Wheel { + window_id: u32, + delta_x: f64, + delta_y: f64, + delta_mode: i32, + client_x: f64, + client_y: f64, + shift: bool, + control: bool, + alt: bool, + meta: bool, + }, + #[serde(rename_all = "camelCase")] + CursorEnterLeave { + window_id: u32, + entered: bool, + client_x: f64, + client_y: f64, + shift: bool, + control: bool, + alt: bool, + meta: bool, + }, + #[serde(rename_all = "camelCase")] + FocusChanged { window_id: u32, focused: bool }, + #[serde(rename_all = "camelCase")] + WindowResize { + window_id: u32, + width: i32, + height: i32, + }, + #[serde(rename_all = "camelCase")] + WindowMove { window_id: u32, x: i32, y: i32 }, + #[serde(rename_all = "camelCase")] + CloseRequested { window_id: u32 }, + #[serde(rename_all = "camelCase")] + RuntimeError { + message: String, + stack: Option, + }, + #[serde(rename_all = "camelCase")] + DockMenuClick { id: String }, + #[serde(rename_all = "camelCase")] + DockReopen { has_visible_windows: bool }, + #[serde(rename_all = "camelCase")] + TrayClick { tray_id: u32 }, + #[serde(rename_all = "camelCase")] + TrayDoubleClick { tray_id: u32 }, + #[serde(rename_all = "camelCase")] + TrayMenuClick { tray_id: u32, id: String }, + #[serde(rename_all = "camelCase")] + NotificationShow { notification_id: u32 }, + #[serde(rename_all = "camelCase")] + NotificationClick { notification_id: u32 }, + #[serde(rename_all = "camelCase")] + NotificationClose { notification_id: u32 }, + #[serde(rename_all = "camelCase")] + NotificationError { notification_id: u32 }, +} + +/// Capacity of the runtime-bound event channel. A misbehaving renderer could +/// otherwise flood mouse-move / wheel events fast enough to OOM the runtime +/// (the channel was previously unbounded). When full, low-priority events +/// (motion / wheel) are dropped via `try_send` and a warning is logged. +const DESKTOP_EVENT_CHANNEL_CAPACITY: usize = 1024; + +type DesktopEventRx = + tokio::sync::Mutex>; +pub type DesktopEventTx = tokio::sync::mpsc::Sender; + +pub struct DesktopEventReceiver(pub Arc); +#[derive(Clone)] +pub struct DesktopEventSender(pub DesktopEventTx); + +impl DesktopEventSender { + /// Send an event, dropping it on backpressure rather than blocking or + /// allocating. Use this for high-frequency events (mouse move, wheel). + pub fn try_send(&self, event: DesktopEvent) { + if let Err(tokio::sync::mpsc::error::TrySendError::Full(_)) = + self.0.try_send(event) + { + // Log once per overflow burst would be ideal, but a plain warn is fine + // here — this only fires on pathological event rates. + log::warn!( + "desktop event channel full; dropping event (renderer producing events faster than runtime can drain)" + ); + } + } +} + +pub fn create_desktop_event_channel() +-> (DesktopEventSender, DesktopEventReceiver) { + let (tx, rx) = tokio::sync::mpsc::channel(DESKTOP_EVENT_CHANNEL_CAPACITY); + ( + DesktopEventSender(tx), + DesktopEventReceiver(Arc::new(tokio::sync::Mutex::new(rx))), + ) +} + +/// A pending call from the webview to a bound Deno function. +pub struct PendingBindCall { + pub name: String, + pub args: serde_json::Value, + pub response: tokio::sync::oneshot::Sender>, +} + +type PendingBindResponsesMap = + HashMap>>; + +#[derive(Clone)] +pub struct PendingBindResponses( + pub Arc>, +); + +impl PendingBindResponses { + pub fn new() -> Self { + Self::default() + } +} + +impl Default for PendingBindResponses { + fn default() -> Self { + Self(Arc::new(std::sync::Mutex::new(HashMap::new()))) + } +} + +static BIND_CALL_COUNTER: AtomicU32 = AtomicU32::new(1); + +/// Assign a call_id for a bind call and register its response sender. +/// Returns the call_id to embed in the `DesktopEvent::BindCall`. +pub fn register_bind_call( + responses: &PendingBindResponses, + response: tokio::sync::oneshot::Sender>, +) -> u32 { + let call_id = BIND_CALL_COUNTER.fetch_add(1, Ordering::Relaxed); + responses.0.lock().unwrap().insert(call_id, response); + call_id +} + +/// Trait for desktop window operations. Implemented by the desktop +/// runtime (denort_desktop) to bridge to the laufey backend. +/// +/// All per-window methods take a `window_id` identifying the target window. +pub trait DesktopApi: Send + Sync + 'static { + /// Create a new window with the given dimensions and return its ID. + /// + /// `frameless` drops the title bar and standard window chrome. + /// `no_activate` makes the window a floating, non-activating utility panel + /// (used for tray / menu-bar popovers): it floats above normal windows and + /// does not steal key focus from the foreground app when shown. Both are + /// creation-time properties and cannot be changed afterwards. + fn create_window( + &self, + width: i32, + height: i32, + frameless: bool, + no_activate: bool, + transparent_titlebar: bool, + ) -> u32; + /// Close a specific window. + fn close_window(&self, window_id: u32); + /// Returns true if the given window has been closed (either via + /// `close_window` or because the OS window was destroyed). + fn is_closed(&self, window_id: u32) -> bool; + + fn set_title(&self, window_id: u32, title: &str); + + fn get_window_size(&self, window_id: u32) -> (i32, i32); + fn set_window_size(&self, window_id: u32, width: i32, height: i32); + + fn get_window_position(&self, window_id: u32) -> (i32, i32); + fn set_window_position(&self, window_id: u32, x: i32, y: i32); + + fn is_resizable(&self, window_id: u32) -> bool; + fn set_resizable(&self, window_id: u32, resizable: bool); + + fn is_always_on_top(&self, window_id: u32) -> bool; + fn set_always_on_top(&self, window_id: u32, always_on_top: bool); + fn is_visible(&self, window_id: u32) -> bool; + fn show(&self, window_id: u32); + fn hide(&self, window_id: u32); + fn focus(&self, window_id: u32); + + fn bind(&self, window_id: u32, name: &str); + fn unbind(&self, window_id: u32, name: &str); + + fn navigate(&self, window_id: u32, url: &str); + fn quit(&self); + fn set_application_menu(&self, window_id: u32, menu: Vec); + fn show_context_menu( + &self, + window_id: u32, + x: i32, + y: i32, + menu: Vec, + ); + + /// Best-effort fetch of the OS-level window/display handles for the + /// given window. Returning `Err` instead of panicking matters because + /// this trait method is reachable from a v8 op handler but its + /// implementation is invoked across the laufey C ABI; an unwind through + /// that boundary would be UB. + fn get_raw_window_handle( + &self, + window_id: u32, + ) -> Result< + ( + raw_window_handle::RawWindowHandle, + raw_window_handle::RawDisplayHandle, + ), + deno_error::JsErrorBox, + >; + + fn open_devtools(&self, window_id: u32, renderer: bool, deno: bool); + + fn execute_js( + &self, + window_id: u32, + script: &str, + callback: Box< + dyn FnOnce(Result) + Send + 'static, + >, + ); + + fn alert(&self, title: &str, message: &str); + /// Show a modal confirm dialog. Blocks the calling thread until the + /// user dismisses it; the platform's modal run loop pumps OS events + /// while the dialog is up so other windows continue to render and + /// respond. + fn confirm(&self, title: &str, message: &str) -> bool; + /// Show a modal prompt dialog. Returns the entered text on confirm, + /// `None` on cancel. Blocking semantics as `confirm`. + fn prompt( + &self, + title: &str, + message: &str, + default_value: &str, + ) -> Option; + + /// Set a short text badge on the app's dock / taskbar icon. An empty + /// string clears the badge. + fn set_dock_badge(&self, text: &str); + /// Bounce the dock icon (macOS) or the closest native analog. `critical` + /// maps to a continuous bounce; otherwise a single bounce. + fn bounce_dock(&self, critical: bool); + /// Set a custom right-click menu on the app's dock icon (macOS only). + /// `None` clears any menu previously set. + fn set_dock_menu(&self, menu: Option>); + /// Show or hide the app's dock icon (macOS activation policy). + fn set_dock_visible(&self, visible: bool); + + /// Returns `0` if the backend doesn't support tray icons. + fn create_tray(&self) -> u32; + /// Destroy a tray icon previously created with `create_tray`. + fn destroy_tray(&self, tray_id: u32); + /// Set the tray icon image from PNG-encoded bytes. + fn set_tray_icon(&self, tray_id: u32, png_bytes: &[u8]); + /// Set the tray icon used in OS dark mode. `None` clears it. + fn set_tray_icon_dark(&self, tray_id: u32, png_bytes: Option<&[u8]>); + /// Set the tooltip shown on hover. `None` clears it. + fn set_tray_tooltip(&self, tray_id: u32, text: Option<&str>); + /// Set the right-click context menu on the tray icon. `None` clears + /// any menu previously set. + fn set_tray_menu(&self, tray_id: u32, menu: Option>); + /// The tray icon's screen rectangle `(x, y, width, height)` in the same + /// top-left-origin coordinate space as window positions, or `None` if the + /// icon has no on-screen position yet or the backend can't report it. Used + /// to anchor a popover window under the icon. + fn get_tray_bounds(&self, tray_id: u32) -> Option<(i32, i32, i32, i32)>; + + /// Show an OS notification. Returns the notification id (`0` if the + /// backend doesn't support system notifications). Events for this + /// notification (`Show`, `Click`, `Close`, `Error`) are delivered via + /// the desktop event channel keyed by the returned id. + fn show_notification( + &self, + title: &str, + body: Option<&str>, + icon: Option<&[u8]>, + tag: Option<&str>, + silent: Option, + require_interaction: Option, + ) -> u32; + /// Dismiss a notification previously shown via `show_notification`. + /// No-op if the id is unknown or already dismissed. + fn close_notification(&self, notification_id: u32); + + /// Request OS authorization to show notifications. If the user has not + /// yet decided, this triggers a system prompt; otherwise the cached + /// decision is returned without a re-prompt. The callback fires on the + /// UI thread with one of [`PermissionState::Granted`], + /// [`PermissionState::Denied`], [`PermissionState::Prompt`] (rare — + /// happens if the user dismissed the prompt without deciding) or + /// [`PermissionState::Unsupported`] (backend / platform has no + /// permission model — e.g. an unbundled macOS process, Linux libnotify). + fn request_notification_permission( + &self, + cb: Box, + ); + /// Query the current authorization state without prompting. Same status + /// codes as [`request_notification_permission`]. + fn query_notification_permission( + &self, + cb: Box, + ); +} + +/// Authorization state for a capability that the OS (or a runtime +/// component) gates. Mirrors the Web Permissions API state set with an +/// extra `Unsupported` variant for environments where the capability has +/// no permission model at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermissionState { + Granted, + Denied, + Prompt, + Unsupported, +} + +/// Stores the window ID of the initial window created during runtime init. +/// The first `BrowserWindow` constructor takes this ID to wrap the existing +/// window; subsequent constructors create new windows. +pub struct InitialWindowId(pub std::sync::Mutex>); + +struct BrowserWindow { + api: Arc, + window_id: u32, + surface: SameObject, + /// Set when JS has taken a `getNativeWindow()` surface. Once a webgpu + /// surface holds the underlying raw window handles, destroying the OS + /// window underneath it would dangle those handles in wgpu-internal state + /// (use-after-free at present). We refuse to destroy the window in that + /// case and only hide it; the surface keeps the window alive until JS + /// releases the BrowserWindow (cppgc) and with it the surface. + surface_taken: std::cell::Cell, +} + +// SAFETY: we're sure this can be GCed +unsafe impl deno_core::GarbageCollected for BrowserWindow { + fn trace(&self, _visitor: &mut deno_core::v8::cppgc::Visitor) {} + + fn get_name(&self) -> &'static std::ffi::CStr { + c"BrowserWindow" + } +} + +impl deno_core::Resource for BrowserWindow { + fn name(&self) -> Cow<'_, str> { + "BrowserWindow".into() + } +} + +struct EventTargetSetup { + brand: v8::Global, + set_event_target_data: v8::Global, +} + +#[op2] +impl BrowserWindow { + #[constructor] + fn new( + state: &OpState, + scope: &mut v8::PinScope<'_, '_>, + #[scoped] options: Option, + ) -> v8::Global { + let api = state + .try_borrow::>() + .expect("desktop mode enabled") + .clone(); + + // Use the initial window if this is the first BrowserWindow, + // otherwise create a new one. + let window_id = state + .try_borrow::() + .and_then(|iw| iw.0.lock().unwrap().take()) + .unwrap_or_else(|| { + let width = options.as_ref().and_then(|o| o.width).unwrap_or(800); + let height = options.as_ref().and_then(|o| o.height).unwrap_or(600); + let frameless = + options.as_ref().and_then(|o| o.frameless).unwrap_or(false); + let no_activate = options + .as_ref() + .and_then(|o| o.no_activate) + .unwrap_or(false); + let transparent_titlebar = options + .as_ref() + .and_then(|o| o.transparent_titlebar) + .unwrap_or(false); + api.create_window( + width, + height, + frameless, + no_activate, + transparent_titlebar, + ) + }); + + if let Some(options) = &options { + if let Some(title) = &options.title { + api.set_title(window_id, title); + } + api.set_window_size( + window_id, + options.width.unwrap_or(800), + options.height.unwrap_or(600), + ); + if let (Some(x), Some(y)) = (options.x, options.y) { + api.set_window_position(window_id, x, y); + } + if let Some(resizable) = options.resizable { + api.set_resizable(window_id, resizable); + } + if let Some(always_on_top) = options.always_on_top { + api.set_always_on_top(window_id, always_on_top); + } + } + + let window = BrowserWindow { + api, + window_id, + surface: SameObject::new(), + surface_taken: std::cell::Cell::new(false), + }; + let window = deno_core::cppgc::make_cppgc_object(scope, window); + let event_target_setup = state.borrow::(); + let webidl_brand = v8::Local::new(scope, event_target_setup.brand.clone()); + window.set(scope, webidl_brand, webidl_brand); + let set_event_target_data = + v8::Local::new(scope, event_target_setup.set_event_target_data.clone()) + .cast::(); + let null = v8::null(scope); + set_event_target_data.call(scope, null.into(), &[window.into()]); + let window = window.cast::(); + + v8::Global::new(scope, window) + } + + #[getter] + fn window_id(&self) -> u32 { + self.window_id + } + + #[nofast] + fn bind(&self, #[string] name: &str) { + self.api.bind(self.window_id, name); + } + + #[nofast] + fn unbind(&self, #[string] name: &str) { + self.api.unbind(self.window_id, name); + } + + #[nofast] + fn set_title(&self, #[string] title: &str) { + self.api.set_title(self.window_id, title); + } + + fn get_size(&self) -> (i32, i32) { + self.api.get_window_size(self.window_id) + } + + #[nofast] + fn set_size(&self, #[smi] width: i32, #[smi] height: i32) { + self.api.set_window_size(self.window_id, width, height); + } + + fn get_position(&self) -> (i32, i32) { + self.api.get_window_position(self.window_id) + } + + #[nofast] + fn set_position(&self, #[smi] x: i32, #[smi] y: i32) { + self.api.set_window_position(self.window_id, x, y); + } + + #[nofast] + fn is_resizable(&self) -> bool { + self.api.is_resizable(self.window_id) + } + + #[nofast] + fn set_resizable(&self, resizable: bool) { + self.api.set_resizable(self.window_id, resizable); + } + + #[nofast] + fn is_always_on_top(&self) -> bool { + self.api.is_always_on_top(self.window_id) + } + + #[nofast] + fn set_always_on_top(&self, always_on_top: bool) { + self.api.set_always_on_top(self.window_id, always_on_top); + } + + #[nofast] + fn is_closed(&self) -> bool { + self.api.is_closed(self.window_id) + } + + #[nofast] + fn close(&self) { + if self.surface_taken.get() { + // A WebGPU surface is referencing this window's native handles. + // Destroying the OS window now would dangle those handles. Hide + // instead; cleanup happens when the BrowserWindow is GC'd. + log::warn!( + "BrowserWindow.close(): a WebGPU surface is still attached; hiding window instead of destroying it" + ); + self.api.hide(self.window_id); + return; + } + self.api.close_window(self.window_id); + } + + #[nofast] + fn is_visible(&self) -> bool { + self.api.is_visible(self.window_id) + } + + #[nofast] + fn show(&self) { + self.api.show(self.window_id); + } + + #[nofast] + fn hide(&self) { + self.api.hide(self.window_id); + } + + #[nofast] + fn focus(&self) { + self.api.focus(self.window_id); + } + + #[nofast] + fn navigate(&self, #[string] url: &str) { + self.api.navigate(self.window_id, url); + } + + fn open_devtools( + &self, + #[serde] options: Option, + ) -> Result<(), deno_error::JsErrorBox> { + let (renderer, deno) = match options { + Some(opts) => (opts.renderer.unwrap_or(true), opts.deno.unwrap_or(true)), + None => (true, true), + }; + if !renderer && !deno { + return Err(deno_error::JsErrorBox::type_error( + "At least one of 'renderer' or 'deno' must be true", + )); + } + self.api.open_devtools(self.window_id, renderer, deno); + Ok(()) + } + + #[nofast] + fn reload(&self) { + self + .api + .execute_js(self.window_id, "location.reload()", Box::new(|_| {})); + } + + async fn execute_js( + &self, + #[string] script: String, + ) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.api.execute_js( + self.window_id, + &script, + Box::new(move |result| { + let _ = tx.send(result); + }), + ); + let result = rx.await.map_err(|_| { + deno_error::JsErrorBox::generic("execute_js callback dropped") + })?; + Ok(ExecuteJsResult(result)) + } + + fn set_application_menu(&self, #[serde] menu: Vec) { + self.api.set_application_menu(self.window_id, menu); + } + + fn show_context_menu( + &self, + #[smi] x: i32, + #[smi] y: i32, + #[serde] menu: Vec, + ) { + self.api.show_context_menu(self.window_id, x, y, menu); + } + + fn get_native_window( + &self, + state: &OpState, + scope: &mut v8::PinScope<'_, '_>, + ) -> Result, deno_error::JsErrorBox> { + let instance = state + .try_borrow::() + .ok_or_else(|| { + deno_error::JsErrorBox::type_error( + "Cannot create surface outside of WebGPU context. Did you forget to call `navigator.gpu.requestAdapter()`?", + ) + })? + .clone(); + + let api = self.api.clone(); + let window_id = self.window_id; + + // Hoisted out of the `surface.try_get` closure so the + // `get_raw_window_handle` failure path can bubble before we ever + // touch wgpu (and can't unwind across the laufey C ABI). + let (win_handle, display_handle) = api.get_raw_window_handle(window_id)?; + + let result = self.surface.try_get(scope, move |_| { + // SAFETY: The raw handles are valid for the lifetime of the OS window. + // `BrowserWindow.close()` is suppressed (downgraded to hide) once a + // surface has been taken (`surface_taken`), and the OS window outlives + // both the cached `SameObject` and the + // BrowserWindow itself, so the handles remain valid for the surface's + // lifetime. + let surface_id = unsafe { + instance + .instance_create_surface(Some(display_handle), win_handle, None) + .map_err(|e| { + deno_error::JsErrorBox::generic(format!( + "failed to create wgpu surface: {e}" + )) + })? + }; + let (width, height) = api.get_window_size(window_id); + Ok::<_, deno_error::JsErrorBox>(deno_canvas::byow::UnsafeWindowSurface { + data: std::rc::Rc::new(RefCell::new( + deno_webgpu::canvas::SurfaceData { + id: surface_id, + width: width as u32, + height: height as u32, + instance, + }, + )), + active_context: Default::default(), + }) + })?; + // Only suppress close() once the surface is actually live. If + // surface creation failed above, the window is still safe to close. + self.surface_taken.set(true); + Ok(result) + } +} + +#[derive(FromV8)] +struct BrowserWindowOptions { + title: Option, + width: Option, + height: Option, + x: Option, + y: Option, + resizable: Option, + always_on_top: Option, + frameless: Option, + no_activate: Option, + transparent_titlebar: Option, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum MenuItem { + Item { + label: String, + id: Option, + accelerator: Option, + enabled: bool, + }, + Submenu { + label: String, + items: Vec, + }, + Separator, + Role { + role: String, + }, +} + +/// State for the auto-update system, placed into OpState at init. +pub struct AutoUpdateState { + /// Path to the currently running dylib on disk. + pub dylib_path: std::path::PathBuf, + /// App version from metadata (deno.json `version` field). + pub app_version: Option, + /// Whether we rolled back from a failed update on this launch. + pub rolled_back: bool, +} + +/// Hex-decoded length of a SHA-256 digest. +const SHA256_HEX_LEN: usize = 64; + +fn dylib_magic_ok(bytes: &[u8]) -> bool { + if bytes.len() < 4 { + return false; + } + let m = &bytes[..4]; + // Mach-O (32/64 BE/LE), Mach-O fat, ELF, PE/COFF (MZ). + matches!( + m, + [0xFE, 0xED, 0xFA, 0xCE] + | [0xFE, 0xED, 0xFA, 0xCF] + | [0xCE, 0xFA, 0xED, 0xFE] + | [0xCF, 0xFA, 0xED, 0xFE] + | [0xCA, 0xFE, 0xBA, 0xBE] + | [0xCA, 0xFE, 0xBA, 0xBF] + | [0x7F, b'E', b'L', b'F'] + ) || m.starts_with(b"MZ") +} + +#[allow( + clippy::disallowed_methods, + reason = "privileged auto-update op writes the live dylib outside any user's sandbox by design" +)] +#[op2(nofast)] +pub fn op_desktop_apply_patch( + state: &mut OpState, + #[buffer] patch_bytes: &[u8], + #[string] expected_sha256: &str, +) -> Result<(), deno_error::JsErrorBox> { + let update_state = + state.try_borrow::().ok_or_else(|| { + deno_error::JsErrorBox::generic("Auto-update state not initialized") + })?; + let dylib_path = &update_state.dylib_path; + + // Verify the patch bytes against the SHA-256 declared in the manifest before + // we trust them with `bspatch`. Without this, anyone who can MITM the patch + // download (or compromise the release host) could deliver arbitrary native + // code. The hash itself is only as trustworthy as the manifest delivery + // (TLS) and, when configured, the manifest signature checked in JS. + let expected_sha256 = expected_sha256.trim().to_ascii_lowercase(); + if expected_sha256.len() != SHA256_HEX_LEN + || !expected_sha256.chars().all(|c| c.is_ascii_hexdigit()) + { + return Err(deno_error::JsErrorBox::generic( + "Auto-update: manifest is missing a valid SHA-256 patch hash", + )); + } + let actual_sha256 = { + use sha2::Digest; + faster_hex::hex_string(&sha2::Sha256::digest(patch_bytes)).to_lowercase() + }; + if actual_sha256 != expected_sha256 { + return Err(deno_error::JsErrorBox::generic(format!( + "Auto-update: patch SHA-256 mismatch (expected {expected_sha256}, got {actual_sha256})" + ))); + } + + let original = std::fs::read(dylib_path).map_err(|e| { + deno_error::JsErrorBox::generic(format!( + "Failed to read dylib at {}: {}", + dylib_path.display(), + e + )) + })?; + + let patcher = qbsdiff::Bspatch::new(patch_bytes).map_err(|e| { + deno_error::JsErrorBox::generic(format!("Invalid patch: {}", e)) + })?; + let target_size = patcher.hint_target_size() as usize; + let mut patched = Vec::with_capacity(target_size); + patcher + .apply(&original, std::io::Cursor::new(&mut patched)) + .map_err(|e| { + deno_error::JsErrorBox::generic(format!("bspatch failed: {}", e)) + })?; + + // Sanity-check the patched bytes look like a real native binary. This + // doesn't make the file safe to load (the hash check above does that), but + // it catches a malformed or empty payload before we stage the swap and + // shrinks the window where rename(2) into place could fail and leave the + // app without a working dylib. + if !dylib_magic_ok(&patched) { + return Err(deno_error::JsErrorBox::generic( + "Auto-update: patched dylib does not look like a native binary", + )); + } + + let update_path = dylib_path.with_extension(format!( + "{}.update", + dylib_path.extension().unwrap_or_default().to_string_lossy() + )); + std::fs::write(&update_path, &patched).map_err(|e| { + deno_error::JsErrorBox::generic(format!( + "Failed to write update to {}: {}", + update_path.display(), + e + )) + })?; + + log::info!( + "Update written to {}. Will be applied on next launch.", + update_path.display() + ); + + Ok(()) +} + +/// Verify an Ed25519 signature over `message` using the base64-encoded +/// 32-byte public key and base64-encoded 64-byte signature. Inner pure +/// function so it's directly callable from unit tests (the `#[op2]` +/// wrapper replaces the surface name with an `OpDecl`). +fn verify_ed25519_b64( + public_key_b64: &str, + signature_b64: &str, + message: &[u8], +) -> bool { + use base64::Engine; + let engine = base64::engine::general_purpose::STANDARD; + let Ok(pk_bytes) = engine.decode(public_key_b64.trim()) else { + return false; + }; + let Ok(sig_bytes) = engine.decode(signature_b64.trim()) else { + return false; + }; + let Ok(pk_arr): Result<[u8; 32], _> = pk_bytes.as_slice().try_into() else { + return false; + }; + let Ok(sig_arr): Result<[u8; 64], _> = sig_bytes.as_slice().try_into() else { + return false; + }; + let Ok(verifying_key) = ed25519_dalek::VerifyingKey::from_bytes(&pk_arr) + else { + return false; + }; + let signature = ed25519_dalek::Signature::from_bytes(&sig_arr); + use ed25519_dalek::Verifier; + verifying_key.verify(message, &signature).is_ok() +} + +/// Verify an Ed25519 signature over `message` using the base64-encoded +/// 32-byte public key and base64-encoded 64-byte signature. Used by the JS +/// auto-update path to validate `latest.json` before fetching any patch. +#[op2(nofast)] +pub fn op_desktop_verify_ed25519( + #[string] public_key_b64: &str, + #[string] signature_b64: &str, + #[buffer] message: &[u8], +) -> bool { + verify_ed25519_b64(public_key_b64, signature_b64, message) +} + +#[op2] +#[serde] +async fn op_desktop_recv_event( + state: std::rc::Rc>, +) -> Option { + let rx = { + let s = state.borrow(); + s.try_borrow::().map(|r| r.0.clone()) + }; + if let Some(rx) = rx { + rx.lock().await.recv().await + } else { + std::future::pending().await + } +} + +#[allow( + clippy::disallowed_methods, + reason = "privileged auto-update sentinel write next to the dylib, outside any user sandbox" +)] +#[op2(nofast)] +pub fn op_desktop_confirm_update(state: &mut OpState) { + if let Some(s) = state.try_borrow::() { + let ext = s + .dylib_path + .extension() + .unwrap_or_default() + .to_string_lossy(); + let sentinel = s.dylib_path.with_extension(format!("{}.update-ok", ext)); + let _ = std::fs::write(&sentinel, b"ok"); + } +} + +#[op2] +fn op_desktop_resolve_bind_call( + state: &mut OpState, + #[smi] call_id: u32, + #[serde] result: serde_json::Value, +) { + if let Some(responses) = state.try_borrow::() + && let Some(tx) = responses.0.lock().unwrap().remove(&call_id) + { + let _ = tx.send(Ok(result)); + } +} + +#[op2(nofast)] +fn op_desktop_reject_bind_call( + state: &mut OpState, + #[smi] call_id: u32, + #[string] error: String, +) { + if let Some(responses) = state.try_borrow::() + && let Some(tx) = responses.0.lock().unwrap().remove(&call_id) + { + let _ = tx.send(Err(error)); + } +} + +#[op2(nofast)] +pub fn op_desktop_init( + state: &mut OpState, + scope: &mut v8::PinScope<'_, '_>, + webidl_brand: v8::Local, + set_event_target_data: v8::Local, +) { + state.put(EventTargetSetup { + brand: v8::Global::new(scope, webidl_brand), + set_event_target_data: v8::Global::new(scope, set_event_target_data), + }); +} + +#[op2(nofast)] +fn op_desktop_alert( + state: &mut OpState, + #[string] title: &str, + #[string] message: &str, +) { + if let Some(api) = state.try_borrow::>() { + api.alert(title, message); + } +} + +struct ErrorReportConfig { + url: String, + app_version: Option, +} + +static ERROR_REPORT_CONFIG: OnceLock = OnceLock::new(); + +/// Store the error reporting URL and app version so the panic hook can +/// send reports without access to OpState. +pub fn set_error_report_config(url: String, app_version: Option) { + let _ = ERROR_REPORT_CONFIG.set(ErrorReportConfig { url, app_version }); +} + +/// Returns the error reporting URL and app version, if configured. +pub fn error_report_config() -> Option<(&'static str, Option<&'static str>)> { + ERROR_REPORT_CONFIG + .get() + .map(|c| (c.url.as_str(), c.app_version.as_deref())) +} + +/// Stash of the `OpState` HTTP client for the panic-hook path. The panic +/// hook can't reach `OpState`, so we capture a client when the runtime +/// initializes error reporting and reuse it from both code paths. This +/// keeps a single TLS configuration (the user's roots) — earlier the panic +/// path constructed an ad-hoc `reqwest`/`fetch` client that bypassed it. +static ERROR_REPORT_CLIENT: OnceLock = OnceLock::new(); + +/// Capture the OpState HTTP client for use by the panic hook. +pub fn set_error_report_client(client: deno_fetch::Client) { + let _ = ERROR_REPORT_CLIENT.set(client); +} + +#[allow( + clippy::disallowed_methods, + reason = "best-effort panic-hook error-report append; path is operator-configured via `error_reporting_url` and FileSystem trait isn't reachable from a panic hook" +)] +fn append_to_file(path: &Path, body: &str) { + let mut line = body.to_string(); + line.push('\n'); + let _ = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .and_then(|mut f| std::io::Write::write_all(&mut f, line.as_bytes())); +} + +fn post_error_report(client: deno_fetch::Client, url: String, body: String) { + let _ = std::thread::spawn(move || { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + else { + return; + }; + runtime.block_on(async move { + let Ok(uri) = url.parse::() else { + return; + }; + let mut req = http::Request::new(deno_fetch::ReqBody::full(body.into())); + *req.method_mut() = http::Method::POST; + *req.uri_mut() = uri; + req.headers_mut().insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/json"), + ); + let _ = client.send(req).await; + }); + }) + .join(); +} + +/// Send a JSON error report to the given URL. Best-effort — never panics. +/// Accepts only `file://` and `https://`. Plain `http://` is rejected: +/// error reports usually carry stack traces and runtime context, so +/// anyone on-path could read them. A bare path (or any unparseable +/// string) is also rejected — previously such inputs were silently +/// treated as a local file path, which let a malformed metadata field +/// land error reports at an attacker-chosen location on disk. +pub fn send_error_report(url: &str, body: &str) { + let Ok(parsed) = deno_core::url::Url::parse(url) else { + log::warn!( + "desktop: error_reporting_url is not a valid URL ({:?}); dropping report", + url, + ); + return; + }; + + match parsed.scheme() { + "file" => { + // `url_to_file_path` rejects `file://host/...` URLs (non-local), + // so a local path is the only way to reach `append_to_file`. + let Ok(path) = deno_path_util::url_to_file_path(&parsed) else { + log::warn!( + "desktop: error_reporting_url file:// URL is not a local path ({:?}); dropping report", + url, + ); + return; + }; + append_to_file(&path, body); + } + "https" => { + let Some(client) = ERROR_REPORT_CLIENT.get().cloned() else { + log::warn!( + "desktop: error-report HTTP client not initialized; dropping report" + ); + return; + }; + post_error_report(client, parsed.to_string(), body.to_string()); + } + other => { + log::warn!( + "desktop: refusing to send error report over '{other}' (file:// or https:// only); dropping report", + ); + } + } +} + +#[op2(nofast)] +fn op_desktop_send_error_report( + state: &mut OpState, + #[string] url: &str, + #[string] body: &str, +) { + // Make sure the panic-hook path has a client too. The OpState client is + // the one configured with the user's TLS roots/permissions, so we share + // it across both code paths instead of creating an ad-hoc client. + if ERROR_REPORT_CLIENT.get().is_none() + && let Ok(client) = deno_fetch::get_or_create_client_from_state(state) + { + set_error_report_client(client); + } + send_error_report(url, body); +} + +#[op2(nofast)] +fn op_desktop_confirm(state: &mut OpState, #[string] message: &str) -> bool { + // Sync op: web `confirm()` returns a boolean, not a Promise. The + // backend's `confirm` blocks the calling thread inside the platform's + // modal run loop (NSAlert runModal / MessageBoxW / gtk_dialog_run / + // rfd) which itself pumps OS events, so other windows stay responsive + // while the dialog is up. + match state.try_borrow::>() { + Some(api) => api.confirm("", message), + None => false, + } +} + +#[op2] +#[string] +fn op_desktop_prompt( + state: &mut OpState, + #[string] message: &str, + #[string] default_value: Option, +) -> Option { + // See `op_desktop_confirm` for the sync-blocking rationale. + match state.try_borrow::>() { + Some(api) => { + api.prompt("", message, default_value.as_deref().unwrap_or("")) + } + None => None, + } +} + +fn permission_state_to_web_string(state: PermissionState) -> &'static str { + // Web Permissions API state values; `Notification.requestPermission` + // additionally maps `Prompt` → `"default"` per the Notifications spec. + match state { + PermissionState::Granted => "granted", + PermissionState::Denied => "denied", + PermissionState::Prompt => "prompt", + PermissionState::Unsupported => "unsupported", + } +} + +#[op2] +#[string] +async fn op_desktop_request_notification_permission( + state: std::rc::Rc>, +) -> String { + let api = { + let s = state.borrow(); + s.try_borrow::>().cloned() + }; + let Some(api) = api else { + // No backend wired up (snapshot build or non-desktop runtime). + return "unsupported".to_string(); + }; + let (tx, rx) = tokio::sync::oneshot::channel::(); + api.request_notification_permission(Box::new(move |state| { + let _ = tx.send(state); + })); + // If the backend forgets to invoke the callback (programmer error in a + // hypothetical custom backend), the channel drops and `recv` returns + // `Err` — surface that as "unsupported" so JS gets a stable result. + permission_state_to_web_string( + rx.await.unwrap_or(PermissionState::Unsupported), + ) + .to_string() +} + +#[op2] +#[string] +async fn op_desktop_query_notification_permission( + state: std::rc::Rc>, +) -> String { + let api = { + let s = state.borrow(); + s.try_borrow::>().cloned() + }; + let Some(api) = api else { + return "unsupported".to_string(); + }; + let (tx, rx) = tokio::sync::oneshot::channel::(); + api.query_notification_permission(Box::new(move |state| { + let _ = tx.send(state); + })); + permission_state_to_web_string( + rx.await.unwrap_or(PermissionState::Unsupported), + ) + .to_string() +} + +struct Dock { + api: Arc, +} + +// SAFETY: we're sure this can be GCed +unsafe impl deno_core::GarbageCollected for Dock { + fn trace(&self, _visitor: &mut deno_core::v8::cppgc::Visitor) {} + + fn get_name(&self) -> &'static std::ffi::CStr { + c"Dock" + } +} + +impl deno_core::Resource for Dock { + fn name(&self) -> Cow<'_, str> { + "Dock".into() + } +} + +#[op2] +impl Dock { + #[constructor] + fn new( + state: &OpState, + scope: &mut v8::PinScope<'_, '_>, + ) -> v8::Global { + let api = state + .try_borrow::>() + .expect("desktop mode enabled") + .clone(); + + let dock = Dock { api }; + let dock = deno_core::cppgc::make_cppgc_object(scope, dock); + let event_target_setup = state.borrow::(); + let webidl_brand = v8::Local::new(scope, event_target_setup.brand.clone()); + dock.set(scope, webidl_brand, webidl_brand); + let set_event_target_data = + v8::Local::new(scope, event_target_setup.set_event_target_data.clone()) + .cast::(); + let null = v8::null(scope); + set_event_target_data.call(scope, null.into(), &[dock.into()]); + let dock = dock.cast::(); + + v8::Global::new(scope, dock) + } + + #[nofast] + fn set_badge(&self, #[string] text: &str) { + self.api.set_dock_badge(text); + } + + #[nofast] + fn bounce(&self, critical: bool) { + self.api.bounce_dock(critical); + } + + fn set_menu(&self, #[serde] menu: Option>) { + self.api.set_dock_menu(menu); + } + + #[nofast] + fn set_visible(&self, visible: bool) { + self.api.set_dock_visible(visible); + } +} + +struct Tray { + api: Arc, + tray_id: u32, +} + +// SAFETY: we're sure this can be GCed +unsafe impl deno_core::GarbageCollected for Tray { + fn trace(&self, _visitor: &mut deno_core::v8::cppgc::Visitor) {} + + fn get_name(&self) -> &'static std::ffi::CStr { + c"Tray" + } +} + +impl deno_core::Resource for Tray { + fn name(&self) -> Cow<'_, str> { + "Tray".into() + } +} + +#[op2] +impl Tray { + #[constructor] + fn new( + state: &OpState, + scope: &mut v8::PinScope<'_, '_>, + ) -> v8::Global { + let api = state + .try_borrow::>() + .expect("desktop mode enabled") + .clone(); + + let tray_id = api.create_tray(); + let tray = Tray { api, tray_id }; + let tray = deno_core::cppgc::make_cppgc_object(scope, tray); + let event_target_setup = state.borrow::(); + let webidl_brand = v8::Local::new(scope, event_target_setup.brand.clone()); + tray.set(scope, webidl_brand, webidl_brand); + let set_event_target_data = + v8::Local::new(scope, event_target_setup.set_event_target_data.clone()) + .cast::(); + let null = v8::null(scope); + set_event_target_data.call(scope, null.into(), &[tray.into()]); + let tray = tray.cast::(); + + v8::Global::new(scope, tray) + } + + #[getter] + fn tray_id(&self) -> u32 { + self.tray_id + } + + #[nofast] + fn set_icon(&self, #[buffer] png_bytes: &[u8]) { + self.api.set_tray_icon(self.tray_id, png_bytes); + } + + fn set_icon_dark(&self, #[buffer] png_bytes: Option<&[u8]>) { + self.api.set_tray_icon_dark(self.tray_id, png_bytes); + } + + fn set_tooltip(&self, #[string] text: Option) { + self.api.set_tray_tooltip(self.tray_id, text.as_deref()); + } + + fn set_menu(&self, #[serde] menu: Option>) { + self.api.set_tray_menu(self.tray_id, menu); + } + + #[serde] + fn get_bounds(&self) -> Option { + self + .api + .get_tray_bounds(self.tray_id) + .map(|(x, y, width, height)| TrayBounds { + x, + y, + width, + height, + }) + } + + #[nofast] + fn destroy(&self) { + self.api.destroy_tray(self.tray_id); + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct TrayBounds { + x: i32, + y: i32, + width: i32, + height: i32, +} + +struct Notification { + api: Arc, + notification_id: u32, + title: String, + body: String, + icon: String, + tag: String, + dir: String, + lang: String, + badge: String, + silent: Option, + require_interaction: bool, + data: v8::Global, +} + +// SAFETY: we're sure this can be GCed +unsafe impl deno_core::GarbageCollected for Notification { + fn trace(&self, _visitor: &mut deno_core::v8::cppgc::Visitor) {} + + fn get_name(&self) -> &'static std::ffi::CStr { + c"Notification" + } +} + +impl deno_core::Resource for Notification { + fn name(&self) -> Cow<'_, str> { + "Notification".into() + } +} + +#[derive(FromV8)] +struct NotificationConstructorOptions { + body: Option, + icon: Option, + tag: Option, + dir: Option, + lang: Option, + badge: Option, + silent: Option, + require_interaction: Option, + data: Option>, +} + +#[op2] +impl Notification { + #[constructor] + fn new( + state: &OpState, + scope: &mut v8::PinScope<'_, '_>, + #[string] title: String, + #[scoped] options: Option, + #[buffer] icon_bytes: Option<&[u8]>, + ) -> v8::Global { + let api = state + .try_borrow::>() + .expect("desktop mode enabled") + .clone(); + + let options = options.unwrap_or(NotificationConstructorOptions { + body: None, + icon: None, + tag: None, + dir: None, + lang: None, + badge: None, + silent: None, + require_interaction: None, + data: None, + }); + + let notification_id = api.show_notification( + &title, + options.body.as_deref(), + icon_bytes, + options.tag.as_deref(), + options.silent, + options.require_interaction, + ); + + let data = options.data.unwrap_or_else(|| { + let null: v8::Local = v8::null(scope).into(); + v8::Global::new(scope, null) + }); + + let notification = Notification { + api, + notification_id, + title, + body: options.body.unwrap_or_default(), + icon: options.icon.unwrap_or_default(), + tag: options.tag.unwrap_or_default(), + dir: options.dir.unwrap_or_else(|| "auto".to_string()), + lang: options.lang.unwrap_or_default(), + badge: options.badge.unwrap_or_default(), + silent: options.silent, + require_interaction: options.require_interaction.unwrap_or(false), + data, + }; + let notification = deno_core::cppgc::make_cppgc_object(scope, notification); + let event_target_setup = state.borrow::(); + let webidl_brand = v8::Local::new(scope, event_target_setup.brand.clone()); + notification.set(scope, webidl_brand, webidl_brand); + let set_event_target_data = + v8::Local::new(scope, event_target_setup.set_event_target_data.clone()) + .cast::(); + let null = v8::null(scope); + set_event_target_data.call(scope, null.into(), &[notification.into()]); + let notification = notification.cast::(); + + v8::Global::new(scope, notification) + } + + #[getter] + fn notification_id(&self) -> u32 { + self.notification_id + } + + #[getter] + #[string] + fn title(&self) -> String { + self.title.clone() + } + + #[getter] + #[string] + fn body(&self) -> String { + self.body.clone() + } + + #[getter] + #[string] + fn icon(&self) -> String { + self.icon.clone() + } + + #[getter] + #[string] + fn tag(&self) -> String { + self.tag.clone() + } + + #[getter] + #[string] + fn dir(&self) -> String { + self.dir.clone() + } + + #[getter] + #[string] + fn lang(&self) -> String { + self.lang.clone() + } + + #[getter] + #[string] + fn badge(&self) -> String { + self.badge.clone() + } + + #[getter] + fn silent<'a>( + &self, + scope: &mut v8::PinScope<'a, '_>, + ) -> v8::Local<'a, v8::Value> { + match self.silent { + Some(b) => v8::Boolean::new(scope, b).into(), + None => v8::null(scope).into(), + } + } + + #[getter] + fn require_interaction(&self) -> bool { + self.require_interaction + } + + #[getter] + fn data(&self) -> v8::Global { + self.data.clone() + } + + #[nofast] + fn close(&self) { + if self.notification_id != 0 { + self.api.close_notification(self.notification_id); + } + } +} + +deno_core::extension!( + deno_desktop, + ops = [ + op_desktop_apply_patch, + op_desktop_verify_ed25519, + op_desktop_confirm_update, + op_desktop_init, + op_desktop_recv_event, + op_desktop_resolve_bind_call, + op_desktop_reject_bind_call, + op_desktop_alert, + op_desktop_confirm, + op_desktop_prompt, + op_desktop_send_error_report, + op_desktop_request_notification_permission, + op_desktop_query_notification_permission, + ], + objects = [BrowserWindow, Dock, Tray, Notification], +); + +#[cfg(test)] +mod tests { + use deno_core::serde_json; + use deno_core::serde_json::json; + + use super::DesktopEvent; + use super::PendingBindResponses; + use super::PermissionState; + use super::dylib_magic_ok; + use super::permission_state_to_web_string; + use super::register_bind_call; + use super::verify_ed25519_b64; + + // These tests pin the wire format that the DESKTOP_JS event-loop + // IIFE consumes. Changing any of these shapes is a breaking change + // for in-renderer event listeners — the assertions below should + // fail if you change a field name or remove a `#[serde(rename_all = + // "camelCase")]` so you find out at test time, not at runtime in the + // packaged app. + + #[test] + fn app_menu_click_wire_shape() { + let v = serde_json::to_value(DesktopEvent::AppMenuClick { + window_id: 7, + id: "file.quit".to_string(), + }) + .unwrap(); + assert_eq!( + v, + json!({ + "kind": "appMenuClick", + "windowId": 7, + "id": "file.quit", + }) + ); + } + + #[test] + fn keyboard_event_camelcases_and_keeps_type() { + let v = serde_json::to_value(DesktopEvent::KeyboardEvent { + window_id: 1, + r#type: "keydown".to_string(), + key: "a".to_string(), + code: "KeyA".to_string(), + shift: true, + control: false, + alt: false, + meta: true, + repeat: false, + }) + .unwrap(); + // `type` (a Rust keyword, written `r#type`) must serialize as + // `"type"` — the renderer reads it as `e.type` per Web spec. + assert_eq!(v["type"], "keydown"); + assert_eq!(v["kind"], "keyboardEvent"); + assert_eq!(v["windowId"], 1); + assert_eq!(v["shift"], true); + assert_eq!(v["meta"], true); + } + + #[test] + fn mouse_click_uses_client_xy() { + let v = serde_json::to_value(DesktopEvent::MouseClick { + window_id: 1, + state: "released".to_string(), + button: 0, + client_x: 10.5, + client_y: 20.25, + shift: false, + control: false, + alt: false, + meta: false, + click_count: 1, + }) + .unwrap(); + assert_eq!(v["kind"], "mouseClick"); + // The renderer reads `e.clientX` / `e.clientY` per the Web spec. + // Snake-case names here would silently break the JS side. + assert_eq!(v["clientX"], 10.5); + assert_eq!(v["clientY"], 20.25); + assert_eq!(v["clickCount"], 1); + } + + #[test] + fn window_resize_wire_shape() { + let v = serde_json::to_value(DesktopEvent::WindowResize { + window_id: 1, + width: 800, + height: 600, + }) + .unwrap(); + assert_eq!( + v, + json!({ + "kind": "windowResize", + "windowId": 1, + "width": 800, + "height": 600, + }) + ); + } + + #[test] + fn dock_reopen_camelcase_payload() { + let v = serde_json::to_value(DesktopEvent::DockReopen { + has_visible_windows: true, + }) + .unwrap(); + assert_eq!(v["kind"], "dockReopen"); + assert_eq!(v["hasVisibleWindows"], true); + // The snake_case variant must NOT exist — DESKTOP_JS reads the + // camelCased name. + assert!(v.get("has_visible_windows").is_none()); + } + + #[test] + fn runtime_error_omits_stack_when_none() { + let with_stack = serde_json::to_value(DesktopEvent::RuntimeError { + message: "boom".to_string(), + stack: Some("at foo".to_string()), + }) + .unwrap(); + assert_eq!(with_stack["message"], "boom"); + assert_eq!(with_stack["stack"], "at foo"); + + let no_stack = serde_json::to_value(DesktopEvent::RuntimeError { + message: "boom".to_string(), + stack: None, + }) + .unwrap(); + // None should serialize as JSON null (not be omitted), matching + // what the JS handler currently expects. + assert_eq!(no_stack["stack"], serde_json::Value::Null); + } + + #[test] + fn notification_variants_share_field_name() { + // All four notification variants must use the same `notificationId` + // key so the JS handler can route by `kind` alone. + for ev in [ + DesktopEvent::NotificationShow { + notification_id: 42, + }, + DesktopEvent::NotificationClick { + notification_id: 42, + }, + DesktopEvent::NotificationClose { + notification_id: 42, + }, + DesktopEvent::NotificationError { + notification_id: 42, + }, + ] { + let v = serde_json::to_value(&ev).unwrap(); + assert_eq!(v["notificationId"], 42, "for variant {ev:?}"); + } + } + + // --- Every remaining DesktopEvent variant gets a kind pin --- + + fn kind_of(ev: DesktopEvent) -> String { + serde_json::to_value(ev).unwrap()["kind"] + .as_str() + .expect("kind must be a string") + .to_string() + } + + #[test] + fn every_variant_has_camelcase_kind() { + // The kind discriminator is what DESKTOP_JS switches on. A + // misspelled or accidentally renamed variant would make its events + // silently no-op in the renderer. Pin every kind name. + assert_eq!( + kind_of(DesktopEvent::AppMenuClick { + window_id: 0, + id: "".into() + }), + "appMenuClick" + ); + assert_eq!( + kind_of(DesktopEvent::ContextMenuClick { + window_id: 0, + id: "".into() + }), + "contextMenuClick" + ); + assert_eq!( + kind_of(DesktopEvent::KeyboardEvent { + window_id: 0, + r#type: "".into(), + key: "".into(), + code: "".into(), + shift: false, + control: false, + alt: false, + meta: false, + repeat: false, + }), + "keyboardEvent" + ); + assert_eq!( + kind_of(DesktopEvent::BindCall { + window_id: 0, + name: "".into(), + args: serde_json::Value::Null, + call_id: 0, + }), + "bindCall" + ); + assert_eq!( + kind_of(DesktopEvent::MouseClick { + window_id: 0, + state: "".into(), + button: 0, + client_x: 0.0, + client_y: 0.0, + shift: false, + control: false, + alt: false, + meta: false, + click_count: 0, + }), + "mouseClick" + ); + assert_eq!( + kind_of(DesktopEvent::MouseMove { + window_id: 0, + client_x: 0.0, + client_y: 0.0, + shift: false, + control: false, + alt: false, + meta: false, + }), + "mouseMove" + ); + assert_eq!( + kind_of(DesktopEvent::Wheel { + window_id: 0, + delta_x: 0.0, + delta_y: 0.0, + delta_mode: 0, + client_x: 0.0, + client_y: 0.0, + shift: false, + control: false, + alt: false, + meta: false, + }), + "wheel" + ); + assert_eq!( + kind_of(DesktopEvent::CursorEnterLeave { + window_id: 0, + entered: false, + client_x: 0.0, + client_y: 0.0, + shift: false, + control: false, + alt: false, + meta: false, + }), + "cursorEnterLeave" + ); + assert_eq!( + kind_of(DesktopEvent::FocusChanged { + window_id: 0, + focused: false + }), + "focusChanged" + ); + assert_eq!( + kind_of(DesktopEvent::WindowResize { + window_id: 0, + width: 0, + height: 0 + }), + "windowResize" + ); + assert_eq!( + kind_of(DesktopEvent::WindowMove { + window_id: 0, + x: 0, + y: 0 + }), + "windowMove" + ); + assert_eq!( + kind_of(DesktopEvent::CloseRequested { window_id: 0 }), + "closeRequested" + ); + assert_eq!( + kind_of(DesktopEvent::RuntimeError { + message: "".into(), + stack: None + }), + "runtimeError" + ); + assert_eq!( + kind_of(DesktopEvent::DockMenuClick { id: "".into() }), + "dockMenuClick" + ); + assert_eq!( + kind_of(DesktopEvent::DockReopen { + has_visible_windows: false + }), + "dockReopen" + ); + assert_eq!(kind_of(DesktopEvent::TrayClick { tray_id: 0 }), "trayClick"); + assert_eq!( + kind_of(DesktopEvent::TrayDoubleClick { tray_id: 0 }), + "trayDoubleClick" + ); + assert_eq!( + kind_of(DesktopEvent::TrayMenuClick { + tray_id: 0, + id: "".into() + }), + "trayMenuClick" + ); + assert_eq!( + kind_of(DesktopEvent::NotificationShow { notification_id: 0 }), + "notificationShow" + ); + assert_eq!( + kind_of(DesktopEvent::NotificationClick { notification_id: 0 }), + "notificationClick" + ); + assert_eq!( + kind_of(DesktopEvent::NotificationClose { notification_id: 0 }), + "notificationClose" + ); + assert_eq!( + kind_of(DesktopEvent::NotificationError { notification_id: 0 }), + "notificationError" + ); + } + + // --- BindCall.args round-trip --- + + #[test] + fn bind_call_args_passes_through_arbitrary_json() { + // BindCall carries a serde_json::Value as `args`. We must serialize + // it transparently (not nested under "args.value" or with a Some() + // wrapper) so the renderer sees exactly what was passed. + let ev = DesktopEvent::BindCall { + window_id: 1, + name: "greet".into(), + args: json!([{"name": "ada", "n": 42}]), + call_id: 7, + }; + let v = serde_json::to_value(&ev).unwrap(); + assert_eq!(v["args"][0]["name"], "ada"); + assert_eq!(v["args"][0]["n"], 42); + assert_eq!(v["callId"], 7); + assert_eq!(v["windowId"], 1); + } + + // --- permission_state_to_web_string --- + + #[test] + fn permission_state_strings_match_web_api() { + // These exact strings are surfaced to JS via `Notification.permission` + // and `navigator.permissions.query(...).state`. The Web Permissions + // API specifies "granted" / "denied" / "prompt" verbatim; renaming + // any of them silently breaks feature detection in user code. + assert_eq!( + permission_state_to_web_string(PermissionState::Granted), + "granted" + ); + assert_eq!( + permission_state_to_web_string(PermissionState::Denied), + "denied" + ); + assert_eq!( + permission_state_to_web_string(PermissionState::Prompt), + "prompt" + ); + // "unsupported" is wef-specific (the spec has no such state); DESKTOP_JS + // maps it to a TypeError throw from requestPermission. + assert_eq!( + permission_state_to_web_string(PermissionState::Unsupported), + "unsupported" + ); + } + + // --- dylib_magic_ok --- + + #[test] + fn dylib_magic_accepts_native_formats() { + // 32/64-bit Mach-O, both endians. + assert!(dylib_magic_ok(&[0xFE, 0xED, 0xFA, 0xCE])); + assert!(dylib_magic_ok(&[0xFE, 0xED, 0xFA, 0xCF])); + assert!(dylib_magic_ok(&[0xCE, 0xFA, 0xED, 0xFE])); + assert!(dylib_magic_ok(&[0xCF, 0xFA, 0xED, 0xFE])); + // Fat Mach-O (universal binary). + assert!(dylib_magic_ok(&[0xCA, 0xFE, 0xBA, 0xBE])); + assert!(dylib_magic_ok(&[0xCA, 0xFE, 0xBA, 0xBF])); + // ELF (Linux). + assert!(dylib_magic_ok(&[0x7F, b'E', b'L', b'F'])); + // PE/COFF (Windows): starts with "MZ". + assert!(dylib_magic_ok(b"MZ\x90\x00rest_of_pe_header")); + } + + #[test] + fn dylib_magic_rejects_non_binaries() { + // Plain text — what a malformed bspatch result might decode to. + assert!(!dylib_magic_ok(b"not a dylib")); + // Empty / too short. + assert!(!dylib_magic_ok(b"")); + assert!(!dylib_magic_ok(b"M")); + assert!(!dylib_magic_ok(b"MZ")); + assert!(!dylib_magic_ok(b"MZ\x90")); + // Random gibberish. + assert!(!dylib_magic_ok(&[0xDE, 0xAD, 0xBE, 0xEF])); + // The wrapper check is the last line of defence — failure here means + // we'd write garbage as the staged dylib. + } + + // --- op_desktop_verify_ed25519 --- + // + // The op is the trust anchor for auto-update: the manifest is signed + // and verified against a baked-in pubkey before any patch hash is + // trusted. A regression that returns true for invalid input would + // turn the whole auto-update path into "fetch + apply arbitrary code". + + fn keypair_from_seed( + seed: &[u8; 32], + ) -> (ed25519_dalek::SigningKey, ed25519_dalek::VerifyingKey) { + let sk = ed25519_dalek::SigningKey::from_bytes(seed); + let vk = sk.verifying_key(); + (sk, vk) + } + + fn b64(bytes: &[u8]) -> String { + use base64::Engine; + base64::engine::general_purpose::STANDARD.encode(bytes) + } + + #[test] + fn verify_ed25519_accepts_real_signature() { + use ed25519_dalek::Signer; + let (sk, vk) = keypair_from_seed(&[1u8; 32]); + let message = b"deno desktop update v1.2.3"; + let sig = sk.sign(message); + let ok = + verify_ed25519_b64(&b64(&vk.to_bytes()), &b64(&sig.to_bytes()), message); + assert!(ok, "signature over message must verify"); + } + + #[test] + fn verify_ed25519_rejects_tampered_message() { + use ed25519_dalek::Signer; + let (sk, vk) = keypair_from_seed(&[1u8; 32]); + let original = b"deno desktop update v1.2.3"; + let sig = sk.sign(original); + // Flip a single byte of the message — a correct verifier must reject. + let tampered = b"deno desktop update v1.2.4"; + let ok = + verify_ed25519_b64(&b64(&vk.to_bytes()), &b64(&sig.to_bytes()), tampered); + assert!(!ok, "tampered message must fail verification"); + } + + #[test] + fn verify_ed25519_rejects_wrong_key() { + use ed25519_dalek::Signer; + let (sk_a, _) = keypair_from_seed(&[1u8; 32]); + let (_, vk_b) = keypair_from_seed(&[2u8; 32]); + let message = b"hi"; + let sig = sk_a.sign(message); + let ok = verify_ed25519_b64( + &b64(&vk_b.to_bytes()), + &b64(&sig.to_bytes()), + message, + ); + assert!(!ok, "signature from key A must NOT verify under key B"); + } + + #[test] + fn verify_ed25519_rejects_malformed_inputs() { + let message = b"hi"; + // Empty key. + assert!(!verify_ed25519_b64("", &b64(&[0u8; 64]), message)); + // Empty sig. + assert!(!verify_ed25519_b64(&b64(&[0u8; 32]), "", message)); + // Wrong-length key. + assert!(!verify_ed25519_b64( + &b64(&[0u8; 31]), + &b64(&[0u8; 64]), + message + )); + assert!(!verify_ed25519_b64( + &b64(&[0u8; 33]), + &b64(&[0u8; 64]), + message + )); + // Wrong-length sig. + assert!(!verify_ed25519_b64( + &b64(&[0u8; 32]), + &b64(&[0u8; 63]), + message + )); + assert!(!verify_ed25519_b64( + &b64(&[0u8; 32]), + &b64(&[0u8; 65]), + message + )); + // Invalid base64. + assert!(!verify_ed25519_b64( + "!!! not base64 !!!", + &b64(&[0u8; 64]), + message + )); + assert!(!verify_ed25519_b64( + &b64(&[0u8; 32]), + "@@@ not base64 @@@", + message + )); + } + + // --- register_bind_call + PendingBindResponses --- + + // The op_desktop_resolve_bind_call / op_desktop_reject_bind_call ops + // both reduce to map.remove(&call_id).map(|tx| tx.send(...)). We + // exercise the underlying state machine directly here — the ops + // themselves are #[op2(nofast)] wrappers and aren't callable from a + // unit test, but the bug surface is the map manipulation, not the + // tiny op2 wrapper. + + fn resolve(responses: &PendingBindResponses, id: u32, v: serde_json::Value) { + if let Some(tx) = responses.0.lock().unwrap().remove(&id) { + let _ = tx.send(Ok(v)); + } + } + + fn reject(responses: &PendingBindResponses, id: u32, e: String) { + if let Some(tx) = responses.0.lock().unwrap().remove(&id) { + let _ = tx.send(Err(e)); + } + } + + #[tokio::test] + async fn bind_call_resolve_round_trips_value() { + let responses = PendingBindResponses::new(); + let (tx, rx) = tokio::sync::oneshot::channel(); + let id = register_bind_call(&responses, tx); + // The renderer resolves with a JSON value. + resolve(&responses, id, serde_json::json!({"ok": true, "n": 42})); + let v = rx.await.expect("oneshot recv").expect("Ok variant"); + assert_eq!(v["ok"], true); + assert_eq!(v["n"], 42); + // After resolve, the map entry is gone. + assert!( + responses.0.lock().unwrap().is_empty(), + "responses map must be drained after resolve" + ); + } + + #[tokio::test] + async fn bind_call_reject_delivers_error_string() { + let responses = PendingBindResponses::new(); + let (tx, rx) = tokio::sync::oneshot::channel(); + let id = register_bind_call(&responses, tx); + reject(&responses, id, "binding threw".to_string()); + let e = rx.await.unwrap().expect_err("must be Err"); + assert_eq!(e, "binding threw"); + assert!(responses.0.lock().unwrap().is_empty()); + } + + #[test] + fn bind_call_ids_are_unique_across_concurrent_registers() { + // The id counter is a single AtomicU32 shared across calls. + // Registering many at once must produce distinct ids — duplicates + // would silently route a renderer response to the wrong pending + // call. + let responses = PendingBindResponses::new(); + let ids: Vec = (0..50) + .map(|_| { + let (tx, _rx) = tokio::sync::oneshot::channel(); + register_bind_call(&responses, tx) + }) + .collect(); + let mut seen: std::collections::HashSet = + std::collections::HashSet::new(); + for id in &ids { + assert!(seen.insert(*id), "duplicate bind call id: {id}"); + } + // All 50 are registered in the map. + assert_eq!(responses.0.lock().unwrap().len(), 50); + } + + #[test] + fn bind_call_unknown_id_resolve_is_noop() { + let responses = PendingBindResponses::new(); + // No entry registered — resolve with a random id must not panic + // and must not affect any state. + resolve(&responses, 999_999, serde_json::Value::Null); + reject(&responses, 999_999, "x".to_string()); + assert!(responses.0.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn bind_call_dropped_receiver_doesnt_panic_resolve() { + // The renderer may give up on a bind call before the Deno side + // resolves it (window closed). The resolve path uses `let _ = tx.send(...)` + // explicitly because the receiver might be gone; we pin that + // behaviour here so a future refactor doesn't reintroduce a + // .unwrap() that would crash the runtime. + let responses = PendingBindResponses::new(); + let (tx, rx) = + tokio::sync::oneshot::channel::>(); + let id = register_bind_call(&responses, tx); + drop(rx); + resolve(&responses, id, serde_json::Value::Null); + // If we reach this line without panicking, the test passes. + } + + #[test] + fn verify_ed25519_trims_whitespace_on_b64_inputs() { + use ed25519_dalek::Signer; + let (sk, vk) = keypair_from_seed(&[1u8; 32]); + let message = b"trim me"; + let sig = sk.sign(message); + let pk = format!(" {}\n", b64(&vk.to_bytes())); + let sg = format!("\t{}\n", b64(&sig.to_bytes())); + // The op trims the base64 before decoding so manifest JSON with + // pretty-printed whitespace (or trailing newlines from `\n` literals) + // still verifies. + assert!(verify_ed25519_b64(&pk, &sg, message)); + } +} diff --git a/runtime/ops/mod.rs b/runtime/ops/mod.rs index 9d68dd13c46956..935003db6c1e34 100644 --- a/runtime/ops/mod.rs +++ b/runtime/ops/mod.rs @@ -1,6 +1,7 @@ // Copyright 2018-2026 the Deno authors. MIT license. pub mod bootstrap; +pub mod desktop; pub mod fs_events; pub mod http; pub mod permissions; diff --git a/runtime/snapshot.rs b/runtime/snapshot.rs index 96c07354922ea8..73fac17ac838a2 100644 --- a/runtime/snapshot.rs +++ b/runtime/snapshot.rs @@ -95,6 +95,7 @@ pub fn create_runtime_snapshot( ops::tty::deno_tty::lazy_init(), ops::http::deno_http_runtime::lazy_init(), deno_bundle_runtime::deno_bundle_runtime::lazy_init(), + ops::desktop::deno_desktop::lazy_init(), ops::bootstrap::deno_bootstrap::init(Some(snapshot_options), false), runtime::lazy_init(), ops::web_worker::deno_web_worker::lazy_init(), diff --git a/runtime/snapshot_info.rs b/runtime/snapshot_info.rs index 4b8691cefed769..2d13936de91aa1 100644 --- a/runtime/snapshot_info.rs +++ b/runtime/snapshot_info.rs @@ -64,6 +64,7 @@ pub fn get_extensions_in_snapshot() -> Vec { ops::tty::deno_tty::init(), ops::http::deno_http_runtime::init(), deno_bundle_runtime::deno_bundle_runtime::init(None), + ops::desktop::deno_desktop::init(), ops::bootstrap::deno_bootstrap::init(None, false), runtime::init(), ops::web_worker::deno_web_worker::init(), diff --git a/runtime/tokio_util.rs b/runtime/tokio_util.rs index 93c59510cf922b..3ed1d238ffcc46 100644 --- a/runtime/tokio_util.rs +++ b/runtime/tokio_util.rs @@ -38,6 +38,14 @@ pub fn create_basic_runtime() -> tokio::runtime::Runtime { "DENO_TOKIO_MAX_IO_EVENTS_PER_TICK", max_io_events_per_tick, )) + // In debug builds, blocking tasks (like swc parsing/emitting via + // spawn_blocking) can overflow the default 2MB thread stack due to + // unoptimized stack frames. Use 8MB to match the main thread size. + .thread_stack_size(if cfg!(debug_assertions) { + 8 * 1024 * 1024 + } else { + 2 * 1024 * 1024 + }) // This limits the number of threads for blocking operations (like for // synchronous fs ops) or CPU bound tasks like when we run dprint in // parallel for deno fmt. diff --git a/runtime/web_worker.rs b/runtime/web_worker.rs index 204685ad1cb516..29be3452f286a5 100644 --- a/runtime/web_worker.rs +++ b/runtime/web_worker.rs @@ -586,6 +586,7 @@ impl WebWorker { ops::tty::deno_tty::init(), ops::http::deno_http_runtime::init(), deno_bundle_runtime::deno_bundle_runtime::init(services.bundle_provider), + ops::desktop::deno_desktop::lazy_init(), ops::bootstrap::deno_bootstrap::init( options.startup_snapshot.and_then(|_| Default::default()), false, diff --git a/runtime/worker.rs b/runtime/worker.rs index db5554daa3d42c..a924e0e31b62d4 100644 --- a/runtime/worker.rs +++ b/runtime/worker.rs @@ -593,6 +593,7 @@ impl MainWorker { deno_bundle_runtime::deno_bundle_runtime::args( services.bundle_provider.clone(), ), + ops::desktop::deno_desktop::args(), ]) .unwrap(); @@ -1139,6 +1140,7 @@ fn common_extensions< ops::tty::deno_tty::init(), ops::http::deno_http_runtime::init(), deno_bundle_runtime::deno_bundle_runtime::lazy_init(), + ops::desktop::deno_desktop::lazy_init(), ops::bootstrap::deno_bootstrap::init( has_snapshot.then(Default::default), unconfigured_runtime, diff --git a/tests/integration/watcher_tests.rs b/tests/integration/watcher_tests.rs index 6d1582675778f2..791c0848c86e56 100644 --- a/tests/integration/watcher_tests.rs +++ b/tests/integration/watcher_tests.rs @@ -164,7 +164,52 @@ where fn check_alive_then_kill(mut child: DenoChild) { assert!(child.try_wait().unwrap().is_none()); - child.kill().unwrap(); + // Kill the whole process tree, not just the watched process. A bare + // `child.kill()` only signals the direct child; any subprocess it spawned + // (e.g. a forked worker) outlives it and keeps the inherited stdout/stderr + // pipe open, so the test harness never sees EOF on those readers and hangs + // at shutdown until the CI job's hard timeout. Reaping after killing the + // tree closes the pipes and lets the harness exit. + let pid = child.id(); + #[cfg(unix)] + { + let _ = std::process::Command::new("pkill") + .args(["-9", "-P", &pid.to_string()]) + .output(); + } + #[cfg(not(unix))] + { + let _ = std::process::Command::new("taskkill") + .args(["/F", "/T", "/PID", &pid.to_string()]) + .output(); + } + let _ = child.kill(); + let _ = child.wait(); +} + +/// Run a compiled standalone binary and return its output, failing fast if it +/// does not exit within 10 s. Uses tokio::process::Command with kill_on_drop +/// so that when the timeout cancels the future the child is SIGKILLed +/// immediately — preventing the old spawn_blocking thread from outliving the +/// test and causing the test binary to hang at shutdown until the 30-minute +/// CI hard timeout. +async fn run_compiled( + exe: impl AsRef, +) -> std::process::Output { + let exe = exe.as_ref().to_path_buf(); + let child = tokio::process::Command::new(&exe) + .kill_on_drop(true) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn compiled binary"); + tokio::time::timeout( + std::time::Duration::from_secs(10), + child.wait_with_output(), + ) + .await + .expect("compiled binary did not exit within 10 seconds") + .expect("failed to wait for compiled binary") } fn child_lines( @@ -649,8 +694,13 @@ console.log(Deno.readTextFileSync(new URL("./data.txt", import.meta.url))); wait_for_watcher("message.ts", &mut stderr_lines).await; wait_contains("Compile finished", &mut stderr_lines).await; - let output = std::process::Command::new(&exe).output().unwrap(); - assert!(output.status.success()); + let output = run_compiled(&exe).await; + assert!( + output.status.success(), + "binary (run 1) failed: status={}\nstderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); assert_contains!(String::from_utf8_lossy(&output.stdout), "before"); assert_contains!(String::from_utf8_lossy(&output.stdout), "included before"); @@ -658,8 +708,13 @@ console.log(Deno.readTextFileSync(new URL("./data.txt", import.meta.url))); wait_contains("File change detected", &mut stderr_lines).await; wait_contains("Compile finished", &mut stderr_lines).await; - let output = std::process::Command::new(&exe).output().unwrap(); - assert!(output.status.success()); + let output = run_compiled(&exe).await; + assert!( + output.status.success(), + "binary (run 2) failed: status={}\nstderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); assert_contains!(String::from_utf8_lossy(&output.stdout), "after"); assert_contains!(String::from_utf8_lossy(&output.stdout), "included before"); @@ -667,8 +722,13 @@ console.log(Deno.readTextFileSync(new URL("./data.txt", import.meta.url))); wait_contains("File change detected", &mut stderr_lines).await; wait_contains("Compile finished", &mut stderr_lines).await; - let output = std::process::Command::new(&exe).output().unwrap(); - assert!(output.status.success()); + let output = run_compiled(&exe).await; + assert!( + output.status.success(), + "binary (run 3) failed: status={}\nstderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); assert_contains!(String::from_utf8_lossy(&output.stdout), "after"); assert_contains!(String::from_utf8_lossy(&output.stdout), "included after"); @@ -2958,7 +3018,10 @@ console.log("Listening...") ); wait_contains("Replaced changed module", &mut stderr_lines).await; - util::deno_cmd() + // Bind the fetch child to a local: DenoChild now reaps its process tree on + // drop, so a bare `spawn()` here would be killed before the request lands. + // Keeping it alive until the end of the test lets the fetch reach the server. + let _fetch = util::deno_cmd() .current_dir(t.path()) .arg("eval") .arg("await fetch('http://localhost:11111');") diff --git a/tests/specs/serve/request_signal_streaming/main.out b/tests/specs/serve/request_signal_streaming/main.out index 6a206b2fd408fa..6cad667edf8ffc 100644 --- a/tests/specs/serve/request_signal_streaming/main.out +++ b/tests/specs/serve/request_signal_streaming/main.out @@ -1,4 +1,4 @@ -Deno.serve: request.signal aborts on successful responses (legacy behavior, see https://github.com/denoland/deno/issues/29111). Move cleanup to the handler's return path, or opt in to the new behavior with --unstable-no-legacy-abort. See https://docs.deno.com/runtime/reference/migrate-deprecations/ +Deno.serve: request.signal aborts on successful responses (legacy behavior, see [WILDLINE] body: chunk1;chunk2;chunk3;chunk4;chunk5; aborted during stream: false aborted after completion: true diff --git a/tests/specs/task/signals/sender.ts b/tests/specs/task/signals/sender.ts index 8b3c93c071f7ae..4bcf0f8c2cb9da 100644 --- a/tests/specs/task/signals/sender.ts +++ b/tests/specs/task/signals/sender.ts @@ -36,6 +36,10 @@ class StdoutReader { const command = new Deno.Command(Deno.execPath(), { args: ["task", "listener"], stdout: "piped", + // Pipe stderr (not inherit) so the grandchild process (deno run listener.ts) + // cannot hold the test-harness stderr pipe open and cause a hang when the + // child (deno task listener) is killed but the grandchild survives. + stderr: "null", }); const child = command.spawn(); diff --git a/tests/unit/ops_test.ts b/tests/unit/ops_test.ts index 64fb66ef17baab..3fdee663a8be2c 100644 --- a/tests/unit/ops_test.ts +++ b/tests/unit/ops_test.ts @@ -1,6 +1,6 @@ // Copyright 2018-2026 the Deno authors. MIT license. -const EXPECTED_OP_COUNT = 21; +const EXPECTED_OP_COUNT = 37; Deno.test(function checkExposedOps() { // @ts-ignore TS doesn't allow to index with symbol diff --git a/tests/util/lib/builders.rs b/tests/util/lib/builders.rs index 47785bb6011f0a..28c614d07c0f62 100644 --- a/tests/util/lib/builders.rs +++ b/tests/util/lib/builders.rs @@ -692,7 +692,7 @@ impl TestCommandBuilder { let child = self.build_command().spawn()?; let mut child = DenoChild { _deno_dir: self.deno_dir.clone(), - child, + child: Some(child), }; if let Some(input) = &self.stdin_text { @@ -1011,19 +1011,38 @@ impl TestCommandBuilder { pub struct DenoChild { // keep alive for the duration of the use of this struct _deno_dir: TempDir, - child: Child, + // `Option` so `Drop` can take ownership to reap the process while the + // consuming `wait_*` methods can still move the child out. + child: Option, } impl Deref for DenoChild { type Target = Child; fn deref(&self) -> &Child { - &self.child + self.child.as_ref().expect("DenoChild already consumed") } } impl DerefMut for DenoChild { fn deref_mut(&mut self) -> &mut Child { - &mut self.child + self.child.as_mut().expect("DenoChild already consumed") + } +} + +impl Drop for DenoChild { + fn drop(&mut self) { + // A test may finish (or panic) while a spawned `deno` is still running and + // holding the inherited stdout/stderr pipes open. If we only let the inner + // `Child` drop, the OS process keeps running, the harness's pipe readers + // never see EOF, and the test binary hangs at shutdown until the CI job's + // hard timeout. Kill the whole process tree (so forked workers die too) and + // reap it so the pipes close and the harness can exit. + if let Some(mut child) = self.child.take() { + if matches!(child.try_wait(), Ok(None)) { + kill_process(child.id()); + } + let _ = child.wait(); + } } } @@ -1054,9 +1073,13 @@ impl DenoChild { } pub fn wait_with_output( - self, + mut self, ) -> Result { - self.child.wait_with_output() + self + .child + .take() + .expect("DenoChild already consumed") + .wait_with_output() } pub fn wait_to_test_result(self, test_name: &str) -> TestResult { diff --git a/tests/wpt/runner/expectations/FileAPI.json b/tests/wpt/runner/expectations/FileAPI.json index b72c09284fed80..e64de14f0165c9 100644 --- a/tests/wpt/runner/expectations/FileAPI.json +++ b/tests/wpt/runner/expectations/FileAPI.json @@ -54,8 +54,8 @@ "url-with-xhr.any.worker.html": false, "cross-global-revoke.sub.html": { "expectedFailures": [ - "It is possible to revoke same-origin blob URLs from different frames.", - "It is not possible to revoke cross-origin blob URLs." + "It is not possible to revoke cross-origin blob URLs.", + "It is possible to revoke same-origin blob URLs from different frames." ] }, "multi-global-origin-serialization.sub.html": false, @@ -91,69 +91,69 @@ }, "idlharness.any.html": { "expectedFailures": [ - "FileList interface: existence and properties of interface object", "FileList interface object length", "FileList interface object name", + "FileList interface: attribute length", + "FileList interface: existence and properties of interface object", "FileList interface: existence and properties of interface prototype object", "FileList interface: existence and properties of interface prototype object's \"constructor\" property", "FileList interface: existence and properties of interface prototype object's @@unscopables property", - "FileList interface: operation item(unsigned long)", - "FileList interface: attribute length" + "FileList interface: operation item(unsigned long)" ] }, "idlharness.any.worker.html": { "expectedFailures": [ - "FileList interface: existence and properties of interface object", "FileList interface object length", "FileList interface object name", + "FileList interface: attribute length", + "FileList interface: existence and properties of interface object", "FileList interface: existence and properties of interface prototype object", "FileList interface: existence and properties of interface prototype object's \"constructor\" property", "FileList interface: existence and properties of interface prototype object's @@unscopables property", "FileList interface: operation item(unsigned long)", - "FileList interface: attribute length", - "FileReaderSync interface: existence and properties of interface object", "FileReaderSync interface object length", "FileReaderSync interface object name", + "FileReaderSync interface: existence and properties of interface object", "FileReaderSync interface: existence and properties of interface prototype object", "FileReaderSync interface: existence and properties of interface prototype object's \"constructor\" property", "FileReaderSync interface: existence and properties of interface prototype object's @@unscopables property", "FileReaderSync interface: operation readAsArrayBuffer(Blob)", "FileReaderSync interface: operation readAsBinaryString(Blob)", - "FileReaderSync interface: operation readAsText(Blob, optional DOMString)", - "FileReaderSync interface: operation readAsDataURL(Blob)" + "FileReaderSync interface: operation readAsDataURL(Blob)", + "FileReaderSync interface: operation readAsText(Blob, optional DOMString)" ] }, "FileReaderSync.worker.html": false, "idlharness.worker.html": { "expectedFailures": [ - "FileList interface: existence and properties of interface object", "FileList interface object length", "FileList interface object name", + "FileList interface: attribute length", + "FileList interface: existence and properties of interface object", "FileList interface: existence and properties of interface prototype object", "FileList interface: existence and properties of interface prototype object's \"constructor\" property", "FileList interface: existence and properties of interface prototype object's @@unscopables property", "FileList interface: operation item(unsigned long)", - "FileList interface: attribute length", - "FileReaderSync interface: existence and properties of interface object", "FileReaderSync interface object length", "FileReaderSync interface object name", + "FileReaderSync interface: calling readAsArrayBuffer(Blob) on new FileReaderSync() with too few arguments must throw TypeError", + "FileReaderSync interface: calling readAsBinaryString(Blob) on new FileReaderSync() with too few arguments must throw TypeError", + "FileReaderSync interface: calling readAsDataURL(Blob) on new FileReaderSync() with too few arguments must throw TypeError", + "FileReaderSync interface: calling readAsText(Blob, optional DOMString) on new FileReaderSync() with too few arguments must throw TypeError", + "FileReaderSync interface: existence and properties of interface object", "FileReaderSync interface: existence and properties of interface prototype object", "FileReaderSync interface: existence and properties of interface prototype object's \"constructor\" property", "FileReaderSync interface: existence and properties of interface prototype object's @@unscopables property", + "FileReaderSync interface: new FileReaderSync() must inherit property \"readAsArrayBuffer(Blob)\" with the proper type", + "FileReaderSync interface: new FileReaderSync() must inherit property \"readAsBinaryString(Blob)\" with the proper type", + "FileReaderSync interface: new FileReaderSync() must inherit property \"readAsDataURL(Blob)\" with the proper type", + "FileReaderSync interface: new FileReaderSync() must inherit property \"readAsText(Blob, optional DOMString)\" with the proper type", "FileReaderSync interface: operation readAsArrayBuffer(Blob)", "FileReaderSync interface: operation readAsBinaryString(Blob)", - "FileReaderSync interface: operation readAsText(Blob, optional DOMString)", "FileReaderSync interface: operation readAsDataURL(Blob)", + "FileReaderSync interface: operation readAsText(Blob, optional DOMString)", "FileReaderSync must be primary interface of new FileReaderSync()", - "Stringification of new FileReaderSync()", - "FileReaderSync interface: new FileReaderSync() must inherit property \"readAsArrayBuffer(Blob)\" with the proper type", - "FileReaderSync interface: calling readAsArrayBuffer(Blob) on new FileReaderSync() with too few arguments must throw TypeError", - "FileReaderSync interface: new FileReaderSync() must inherit property \"readAsBinaryString(Blob)\" with the proper type", - "FileReaderSync interface: calling readAsBinaryString(Blob) on new FileReaderSync() with too few arguments must throw TypeError", - "FileReaderSync interface: new FileReaderSync() must inherit property \"readAsText(Blob, optional DOMString)\" with the proper type", - "FileReaderSync interface: calling readAsText(Blob, optional DOMString) on new FileReaderSync() with too few arguments must throw TypeError", - "FileReaderSync interface: new FileReaderSync() must inherit property \"readAsDataURL(Blob)\" with the proper type", - "FileReaderSync interface: calling readAsDataURL(Blob) on new FileReaderSync() with too few arguments must throw TypeError" + "Stringification of new FileReaderSync()" ] }, "Blob-methods-from-detached-frame.html": false, @@ -171,19 +171,19 @@ }, "idlharness.html": { "expectedFailures": [ - "FileList interface: existence and properties of interface object", "FileList interface object length", "FileList interface object name", + "FileList interface: attribute length", + "FileList interface: calling item(unsigned long) on document.querySelector(\"#fileChooser\").files with too few arguments must throw TypeError", + "FileList interface: document.querySelector(\"#fileChooser\").files must inherit property \"item(unsigned long)\" with the proper type", + "FileList interface: document.querySelector(\"#fileChooser\").files must inherit property \"length\" with the proper type", + "FileList interface: existence and properties of interface object", "FileList interface: existence and properties of interface prototype object", "FileList interface: existence and properties of interface prototype object's \"constructor\" property", "FileList interface: existence and properties of interface prototype object's @@unscopables property", "FileList interface: operation item(unsigned long)", - "FileList interface: attribute length", "FileList must be primary interface of document.querySelector(\"#fileChooser\").files", - "Stringification of document.querySelector(\"#fileChooser\").files", - "FileList interface: document.querySelector(\"#fileChooser\").files must inherit property \"item(unsigned long)\" with the proper type", - "FileList interface: calling item(unsigned long) on document.querySelector(\"#fileChooser\").files with too few arguments must throw TypeError", - "FileList interface: document.querySelector(\"#fileChooser\").files must inherit property \"length\" with the proper type" + "Stringification of document.querySelector(\"#fileChooser\").files" ] }, "BlobURL": { diff --git a/tests/wpt/runner/expectations/css.json b/tests/wpt/runner/expectations/css.json index 1c20cbffb0d315..49ea477710e85b 100644 --- a/tests/wpt/runner/expectations/css.json +++ b/tests/wpt/runner/expectations/css.json @@ -12,12 +12,6 @@ "DOMMatrix-newobject.html": true, "DOMMatrix-stringifier.html": { "expectedFailures": [ - "WebKitCSSMatrix stringifier: identity (2d)", - "WebKitCSSMatrix stringifier: identity (3d)", - "WebKitCSSMatrix stringifier: NaN (2d)", - "WebKitCSSMatrix stringifier: NaN (3d)", - "WebKitCSSMatrix stringifier: Infinity (2d)", - "WebKitCSSMatrix stringifier: Infinity (3d)", "WebKitCSSMatrix stringifier: -Infinity (2d)", "WebKitCSSMatrix stringifier: -Infinity (3d)", "WebKitCSSMatrix stringifier: 1/3 (2d)", @@ -28,14 +22,20 @@ "WebKitCSSMatrix stringifier: 1/300000000 (3d)", "WebKitCSSMatrix stringifier: 100000 + (1/3) (2d)", "WebKitCSSMatrix stringifier: 100000 + (1/3) (3d)", + "WebKitCSSMatrix stringifier: Infinity (2d)", + "WebKitCSSMatrix stringifier: Infinity (3d)", "WebKitCSSMatrix stringifier: Math.pow(2, 53) + 1 (2d)", "WebKitCSSMatrix stringifier: Math.pow(2, 53) + 1 (3d)", "WebKitCSSMatrix stringifier: Math.pow(2, 53) + 2 (2d)", "WebKitCSSMatrix stringifier: Math.pow(2, 53) + 2 (3d)", + "WebKitCSSMatrix stringifier: NaN (2d)", + "WebKitCSSMatrix stringifier: NaN (3d)", "WebKitCSSMatrix stringifier: Number.MAX_VALUE (2d)", "WebKitCSSMatrix stringifier: Number.MAX_VALUE (3d)", "WebKitCSSMatrix stringifier: Number.MIN_VALUE (2d)", "WebKitCSSMatrix stringifier: Number.MIN_VALUE (3d)", + "WebKitCSSMatrix stringifier: identity (2d)", + "WebKitCSSMatrix stringifier: identity (3d)", "WebKitCSSMatrix stringifier: throwing getters (2d)", "WebKitCSSMatrix stringifier: throwing getters (3d)" ] @@ -56,75 +56,75 @@ "historical.html": true, "idlharness.any.html": { "expectedFailures": [ - "DOMPointReadOnly interface: existence and properties of interface object", - "DOMPointReadOnly interface: existence and properties of interface prototype object", + "DOMMatrix interface: existence and properties of interface object", + "DOMMatrix interface: existence and properties of interface prototype object", + "DOMMatrixReadOnly interface: existence and properties of interface object", + "DOMMatrixReadOnly interface: existence and properties of interface prototype object", "DOMPoint interface: existence and properties of interface object", "DOMPoint interface: existence and properties of interface prototype object", - "DOMRectReadOnly interface: existence and properties of interface object", - "DOMRectReadOnly interface: existence and properties of interface prototype object", + "DOMPointReadOnly interface: existence and properties of interface object", + "DOMPointReadOnly interface: existence and properties of interface prototype object", + "DOMQuad interface: existence and properties of interface object", + "DOMQuad interface: existence and properties of interface prototype object", "DOMRect interface: existence and properties of interface object", "DOMRect interface: existence and properties of interface prototype object", - "DOMRectList interface: existence and properties of interface object", "DOMRectList interface object length", "DOMRectList interface object name", + "DOMRectList interface: attribute length", + "DOMRectList interface: existence and properties of interface object", "DOMRectList interface: existence and properties of interface prototype object", "DOMRectList interface: existence and properties of interface prototype object's \"constructor\" property", "DOMRectList interface: existence and properties of interface prototype object's @@unscopables property", - "DOMRectList interface: attribute length", "DOMRectList interface: operation item(unsigned long)", - "DOMQuad interface: existence and properties of interface object", - "DOMQuad interface: existence and properties of interface prototype object", - "DOMMatrixReadOnly interface: existence and properties of interface object", - "DOMMatrixReadOnly interface: existence and properties of interface prototype object", - "DOMMatrix interface: existence and properties of interface object", - "DOMMatrix interface: existence and properties of interface prototype object" + "DOMRectReadOnly interface: existence and properties of interface object", + "DOMRectReadOnly interface: existence and properties of interface prototype object" ] }, "idlharness.any.worker.html": { "expectedFailures": [ - "DOMPointReadOnly interface: existence and properties of interface object", - "DOMPointReadOnly interface: existence and properties of interface prototype object", + "DOMMatrix interface: existence and properties of interface object", + "DOMMatrix interface: existence and properties of interface prototype object", + "DOMMatrixReadOnly interface: existence and properties of interface object", + "DOMMatrixReadOnly interface: existence and properties of interface prototype object", "DOMPoint interface: existence and properties of interface object", "DOMPoint interface: existence and properties of interface prototype object", - "DOMRectReadOnly interface: existence and properties of interface object", - "DOMRectReadOnly interface: existence and properties of interface prototype object", - "DOMRect interface: existence and properties of interface object", - "DOMRect interface: existence and properties of interface prototype object", + "DOMPointReadOnly interface: existence and properties of interface object", + "DOMPointReadOnly interface: existence and properties of interface prototype object", "DOMQuad interface: existence and properties of interface object", "DOMQuad interface: existence and properties of interface prototype object", - "DOMMatrixReadOnly interface: existence and properties of interface object", - "DOMMatrixReadOnly interface: existence and properties of interface prototype object", - "DOMMatrix interface: existence and properties of interface object", - "DOMMatrix interface: existence and properties of interface prototype object" + "DOMRect interface: existence and properties of interface object", + "DOMRect interface: existence and properties of interface prototype object", + "DOMRectReadOnly interface: existence and properties of interface object", + "DOMRectReadOnly interface: existence and properties of interface prototype object" ] }, "spec-examples.html": true, "structured-serialization.html": { "expectedFailures": [ - "DOMPointReadOnly clone: basic", - "DOMPointReadOnly clone: custom property", - "DOMPointReadOnly clone: non-initial values", + "DOMMatrix clone: basic", + "DOMMatrix clone: custom property", + "DOMMatrix clone: non-initial values (2d)", + "DOMMatrix clone: non-initial values (3d)", + "DOMMatrixReadOnly clone: basic", + "DOMMatrixReadOnly clone: custom property", + "DOMMatrixReadOnly clone: non-initial values (2d)", + "DOMMatrixReadOnly clone: non-initial values (3d)", "DOMPoint clone: basic", "DOMPoint clone: custom property", "DOMPoint clone: non-initial values", - "DOMRectReadOnly clone: basic", - "DOMRectReadOnly clone: custom property", - "DOMRectReadOnly clone: non-initial values", - "DOMRect clone: basic", - "DOMRect clone: custom property", - "DOMRect clone: non-initial values", + "DOMPointReadOnly clone: basic", + "DOMPointReadOnly clone: custom property", + "DOMPointReadOnly clone: non-initial values", "DOMQuad clone: basic", "DOMQuad clone: custom property", "DOMQuad clone: non-initial values", - "DOMMatrixReadOnly clone: basic", - "DOMMatrixReadOnly clone: custom property", - "DOMMatrixReadOnly clone: non-initial values (2d)", - "DOMMatrixReadOnly clone: non-initial values (3d)", - "DOMMatrix clone: basic", - "DOMMatrix clone: custom property", - "DOMMatrix clone: non-initial values (2d)", - "DOMMatrix clone: non-initial values (3d)", - "DOMRectList clone" + "DOMRect clone: basic", + "DOMRect clone: custom property", + "DOMRect clone: non-initial values", + "DOMRectList clone", + "DOMRectReadOnly clone: basic", + "DOMRectReadOnly clone: custom property", + "DOMRectReadOnly clone: non-initial values" ] }, "DOMMatrix-invertSelf.html": true diff --git a/tests/wpt/runner/expectations/dom.json b/tests/wpt/runner/expectations/dom.json index b4e10fb97a4c61..f8026daa5939b1 100644 --- a/tests/wpt/runner/expectations/dom.json +++ b/tests/wpt/runner/expectations/dom.json @@ -91,8 +91,8 @@ "Event-type-empty.html": false, "Event-type.html": { "expectedFailures": [ - "Event.type should initially be the empty string", - "Event.type should be initialized by initEvent" + "Event.type should be initialized by initEvent", + "Event.type should initially be the empty string" ] }, "EventListener-handleEvent-cross-realm.html": false, @@ -156,12 +156,12 @@ "passive-wheel-event-listener-on-window.html": false, "synthetic-events-cancelable.html": { "expectedFailures": [ - "Synthetic wheel event with interface WheelEvent is not cancelable", "Synthetic mousewheel event with interface WheelEvent is not cancelable", - "Synthetic touchstart event with interface TouchEvent is not cancelable", - "Synthetic touchmove event with interface TouchEvent is not cancelable", + "Synthetic touchcancel event with interface TouchEvent is not cancelable", "Synthetic touchend event with interface TouchEvent is not cancelable", - "Synthetic touchcancel event with interface TouchEvent is not cancelable" + "Synthetic touchmove event with interface TouchEvent is not cancelable", + "Synthetic touchstart event with interface TouchEvent is not cancelable", + "Synthetic wheel event with interface WheelEvent is not cancelable" ] } }, @@ -211,595 +211,595 @@ }, "idlharness.window.html?exclude=Node": { "expectedFailures": [ - "Event interface: attribute srcElement", - "Event interface: operation composedPath()", - "Event interface: operation stopPropagation()", - "Event interface: attribute cancelBubble", - "Event interface: operation stopImmediatePropagation()", - "Event interface: attribute returnValue", - "Event interface: operation preventDefault()", - "Event interface: attribute defaultPrevented", - "Event interface: operation initEvent(DOMString, optional boolean, optional boolean)", - "CustomEvent interface: operation initCustomEvent(DOMString, optional boolean, optional boolean, optional any)", - "EventTarget interface: operation addEventListener(DOMString, EventListener?, optional (AddEventListenerOptions or boolean))", - "EventTarget interface: operation removeEventListener(DOMString, EventListener?, optional (EventListenerOptions or boolean))", "AbortController interface: operation abort(optional any)", "AbortSignal interface: attribute onabort", - "NodeList interface: existence and properties of interface object", - "NodeList interface object length", - "NodeList interface object name", - "NodeList interface: existence and properties of interface prototype object", - "NodeList interface: existence and properties of interface prototype object's \"constructor\" property", - "NodeList interface: existence and properties of interface prototype object's @@unscopables property", - "NodeList interface: operation item(unsigned long)", - "NodeList interface: attribute length", - "NodeList interface: iterable", - "HTMLCollection interface: existence and properties of interface object", - "HTMLCollection interface object length", - "HTMLCollection interface object name", - "HTMLCollection interface: existence and properties of interface prototype object", - "HTMLCollection interface: existence and properties of interface prototype object's \"constructor\" property", - "HTMLCollection interface: existence and properties of interface prototype object's @@unscopables property", - "HTMLCollection interface: attribute length", - "HTMLCollection interface: operation item(unsigned long)", - "HTMLCollection interface: operation namedItem(DOMString)", - "MutationObserver interface: existence and properties of interface object", - "MutationObserver interface object length", - "MutationObserver interface object name", - "MutationObserver interface: existence and properties of interface prototype object", - "MutationObserver interface: existence and properties of interface prototype object's \"constructor\" property", - "MutationObserver interface: existence and properties of interface prototype object's @@unscopables property", - "MutationObserver interface: operation observe(Node, optional MutationObserverInit)", - "MutationObserver interface: operation disconnect()", - "MutationObserver interface: operation takeRecords()", - "MutationRecord interface: existence and properties of interface object", - "MutationRecord interface object length", - "MutationRecord interface object name", - "MutationRecord interface: existence and properties of interface prototype object", - "MutationRecord interface: existence and properties of interface prototype object's \"constructor\" property", - "MutationRecord interface: existence and properties of interface prototype object's @@unscopables property", - "MutationRecord interface: attribute type", - "MutationRecord interface: attribute target", - "MutationRecord interface: attribute addedNodes", - "MutationRecord interface: attribute removedNodes", - "MutationRecord interface: attribute previousSibling", - "MutationRecord interface: attribute nextSibling", - "MutationRecord interface: attribute attributeName", - "MutationRecord interface: attribute attributeNamespace", - "MutationRecord interface: attribute oldValue", - "Document interface: existence and properties of interface object", + "AbstractRange interface object length", + "AbstractRange interface object name", + "AbstractRange interface: attribute collapsed", + "AbstractRange interface: attribute endContainer", + "AbstractRange interface: attribute endOffset", + "AbstractRange interface: attribute startContainer", + "AbstractRange interface: attribute startOffset", + "AbstractRange interface: existence and properties of interface object", + "AbstractRange interface: existence and properties of interface prototype object", + "AbstractRange interface: existence and properties of interface prototype object's \"constructor\" property", + "AbstractRange interface: existence and properties of interface prototype object's @@unscopables property", + "Attr interface object length", + "Attr interface object name", + "Attr interface: attribute localName", + "Attr interface: attribute name", + "Attr interface: attribute namespaceURI", + "Attr interface: attribute ownerElement", + "Attr interface: attribute prefix", + "Attr interface: attribute specified", + "Attr interface: attribute value", + "Attr interface: existence and properties of interface object", + "Attr interface: existence and properties of interface prototype object", + "Attr interface: existence and properties of interface prototype object's \"constructor\" property", + "Attr interface: existence and properties of interface prototype object's @@unscopables property", + "CDATASection interface object length", + "CDATASection interface object name", + "CDATASection interface: existence and properties of interface object", + "CDATASection interface: existence and properties of interface prototype object", + "CDATASection interface: existence and properties of interface prototype object's \"constructor\" property", + "CDATASection interface: existence and properties of interface prototype object's @@unscopables property", + "CharacterData interface object length", + "CharacterData interface object name", + "CharacterData interface: attribute data", + "CharacterData interface: attribute length", + "CharacterData interface: attribute nextElementSibling", + "CharacterData interface: attribute previousElementSibling", + "CharacterData interface: existence and properties of interface object", + "CharacterData interface: existence and properties of interface prototype object", + "CharacterData interface: existence and properties of interface prototype object's \"constructor\" property", + "CharacterData interface: existence and properties of interface prototype object's @@unscopables property", + "CharacterData interface: operation after((Node or DOMString)...)", + "CharacterData interface: operation appendData(DOMString)", + "CharacterData interface: operation before((Node or DOMString)...)", + "CharacterData interface: operation deleteData(unsigned long, unsigned long)", + "CharacterData interface: operation insertData(unsigned long, DOMString)", + "CharacterData interface: operation remove()", + "CharacterData interface: operation replaceData(unsigned long, unsigned long, DOMString)", + "CharacterData interface: operation replaceWith((Node or DOMString)...)", + "CharacterData interface: operation substringData(unsigned long, unsigned long)", + "Comment interface object length", + "Comment interface object name", + "Comment interface: existence and properties of interface object", + "Comment interface: existence and properties of interface prototype object", + "Comment interface: existence and properties of interface prototype object's \"constructor\" property", + "Comment interface: existence and properties of interface prototype object's @@unscopables property", + "CustomEvent interface: operation initCustomEvent(DOMString, optional boolean, optional boolean, optional any)", + "DOMImplementation interface object length", + "DOMImplementation interface object name", + "DOMImplementation interface: existence and properties of interface object", + "DOMImplementation interface: existence and properties of interface prototype object", + "DOMImplementation interface: existence and properties of interface prototype object's \"constructor\" property", + "DOMImplementation interface: existence and properties of interface prototype object's @@unscopables property", + "DOMImplementation interface: operation createDocument(DOMString?, DOMString, optional DocumentType?)", + "DOMImplementation interface: operation createDocumentType(DOMString, DOMString, DOMString)", + "DOMImplementation interface: operation createHTMLDocument(optional DOMString)", + "DOMImplementation interface: operation hasFeature()", + "DOMTokenList interface object length", + "DOMTokenList interface object name", + "DOMTokenList interface: attribute length", + "DOMTokenList interface: attribute value", + "DOMTokenList interface: existence and properties of interface object", + "DOMTokenList interface: existence and properties of interface prototype object", + "DOMTokenList interface: existence and properties of interface prototype object's \"constructor\" property", + "DOMTokenList interface: existence and properties of interface prototype object's @@unscopables property", + "DOMTokenList interface: iterable", + "DOMTokenList interface: operation add(DOMString...)", + "DOMTokenList interface: operation contains(DOMString)", + "DOMTokenList interface: operation item(unsigned long)", + "DOMTokenList interface: operation remove(DOMString...)", + "DOMTokenList interface: operation replace(DOMString, DOMString)", + "DOMTokenList interface: operation supports(DOMString)", + "DOMTokenList interface: operation toggle(DOMString, optional boolean)", + "DOMTokenList interface: stringifier", "Document interface object length", "Document interface object name", - "Document interface: existence and properties of interface prototype object", - "Document interface: existence and properties of interface prototype object's \"constructor\" property", - "Document interface: existence and properties of interface prototype object's @@unscopables property", - "Document interface: attribute implementation", "Document interface: attribute URL", - "Document interface: attribute documentURI", - "Document interface: attribute compatMode", "Document interface: attribute characterSet", "Document interface: attribute charset", - "Document interface: attribute inputEncoding", + "Document interface: attribute childElementCount", + "Document interface: attribute children", + "Document interface: attribute compatMode", "Document interface: attribute contentType", + "Document interface: attribute customElementRegistry", "Document interface: attribute doctype", "Document interface: attribute documentElement", - "Document interface: operation getElementsByTagName(DOMString)", - "Document interface: operation getElementsByTagNameNS(DOMString?, DOMString)", - "Document interface: operation getElementsByClassName(DOMString)", - "Document interface: operation createElement(DOMString, optional (DOMString or ElementCreationOptions))", - "Document interface: operation createElementNS(DOMString?, DOMString, optional (DOMString or ElementCreationOptions))", - "Document interface: operation createDocumentFragment()", - "Document interface: operation createTextNode(DOMString)", - "Document interface: operation createCDATASection(DOMString)", - "Document interface: operation createComment(DOMString)", - "Document interface: operation createProcessingInstruction(DOMString, DOMString)", - "Document interface: operation importNode(Node, optional (boolean or ImportNodeOptions))", + "Document interface: attribute documentURI", + "Document interface: attribute firstElementChild", + "Document interface: attribute fullscreen", + "Document interface: attribute fullscreenElement", + "Document interface: attribute fullscreenEnabled", + "Document interface: attribute implementation", + "Document interface: attribute inputEncoding", + "Document interface: attribute lastElementChild", + "Document interface: attribute onfullscreenchange", + "Document interface: attribute onfullscreenerror", + "Document interface: existence and properties of interface object", + "Document interface: existence and properties of interface prototype object", + "Document interface: existence and properties of interface prototype object's \"constructor\" property", + "Document interface: existence and properties of interface prototype object's @@unscopables property", "Document interface: operation adoptNode(Node)", + "Document interface: operation append((Node or DOMString)...)", "Document interface: operation createAttribute(DOMString)", "Document interface: operation createAttributeNS(DOMString?, DOMString)", + "Document interface: operation createCDATASection(DOMString)", + "Document interface: operation createComment(DOMString)", + "Document interface: operation createDocumentFragment()", + "Document interface: operation createElement(DOMString, optional (DOMString or ElementCreationOptions))", + "Document interface: operation createElementNS(DOMString?, DOMString, optional (DOMString or ElementCreationOptions))", "Document interface: operation createEvent(DOMString)", - "Document interface: operation createRange()", + "Document interface: operation createExpression(DOMString, optional XPathNSResolver?)", + "Document interface: operation createNSResolver(Node)", "Document interface: operation createNodeIterator(Node, optional unsigned long, optional NodeFilter?)", + "Document interface: operation createProcessingInstruction(DOMString, DOMString)", + "Document interface: operation createRange()", + "Document interface: operation createTextNode(DOMString)", "Document interface: operation createTreeWalker(Node, optional unsigned long, optional NodeFilter?)", - "Document interface: attribute fullscreenEnabled", - "Document interface: attribute fullscreen", + "Document interface: operation evaluate(DOMString, Node, optional XPathNSResolver?, optional unsigned short, optional XPathResult?)", "Document interface: operation exitFullscreen()", - "Document interface: attribute onfullscreenchange", - "Document interface: attribute onfullscreenerror", "Document interface: operation getElementById(DOMString)", - "Document interface: attribute customElementRegistry", - "Document interface: attribute fullscreenElement", - "Document interface: attribute children", - "Document interface: attribute firstElementChild", - "Document interface: attribute lastElementChild", - "Document interface: attribute childElementCount", - "Document interface: operation prepend((Node or DOMString)...)", - "Document interface: operation append((Node or DOMString)...)", - "Document interface: operation replaceChildren((Node or DOMString)...)", + "Document interface: operation getElementsByClassName(DOMString)", + "Document interface: operation getElementsByTagName(DOMString)", + "Document interface: operation getElementsByTagNameNS(DOMString?, DOMString)", + "Document interface: operation importNode(Node, optional (boolean or ImportNodeOptions))", "Document interface: operation moveBefore(Node, Node?)", + "Document interface: operation prepend((Node or DOMString)...)", "Document interface: operation querySelector(DOMString)", "Document interface: operation querySelectorAll(DOMString)", - "Document interface: operation createExpression(DOMString, optional XPathNSResolver?)", - "Document interface: operation createNSResolver(Node)", - "Document interface: operation evaluate(DOMString, Node, optional XPathNSResolver?, optional unsigned short, optional XPathResult?)", - "XMLDocument interface: existence and properties of interface object", - "XMLDocument interface object length", - "XMLDocument interface object name", - "XMLDocument interface: existence and properties of interface prototype object", - "XMLDocument interface: existence and properties of interface prototype object's \"constructor\" property", - "XMLDocument interface: existence and properties of interface prototype object's @@unscopables property", - "DOMImplementation interface: existence and properties of interface object", - "DOMImplementation interface object length", - "DOMImplementation interface object name", - "DOMImplementation interface: existence and properties of interface prototype object", - "DOMImplementation interface: existence and properties of interface prototype object's \"constructor\" property", - "DOMImplementation interface: existence and properties of interface prototype object's @@unscopables property", - "DOMImplementation interface: operation createDocumentType(DOMString, DOMString, DOMString)", - "DOMImplementation interface: operation createDocument(DOMString?, DOMString, optional DocumentType?)", - "DOMImplementation interface: operation createHTMLDocument(optional DOMString)", - "DOMImplementation interface: operation hasFeature()", - "DocumentType interface: existence and properties of interface object", - "DocumentType interface object length", - "DocumentType interface object name", - "DocumentType interface: existence and properties of interface prototype object", - "DocumentType interface: existence and properties of interface prototype object's \"constructor\" property", - "DocumentType interface: existence and properties of interface prototype object's @@unscopables property", - "DocumentType interface: attribute name", - "DocumentType interface: attribute publicId", - "DocumentType interface: attribute systemId", - "DocumentType interface: operation before((Node or DOMString)...)", - "DocumentType interface: operation after((Node or DOMString)...)", - "DocumentType interface: operation replaceWith((Node or DOMString)...)", - "DocumentType interface: operation remove()", - "DocumentFragment interface: existence and properties of interface object", + "Document interface: operation replaceChildren((Node or DOMString)...)", "DocumentFragment interface object length", "DocumentFragment interface object name", - "DocumentFragment interface: existence and properties of interface prototype object", - "DocumentFragment interface: existence and properties of interface prototype object's \"constructor\" property", - "DocumentFragment interface: existence and properties of interface prototype object's @@unscopables property", - "DocumentFragment interface: operation getElementById(DOMString)", + "DocumentFragment interface: attribute childElementCount", "DocumentFragment interface: attribute children", "DocumentFragment interface: attribute firstElementChild", "DocumentFragment interface: attribute lastElementChild", - "DocumentFragment interface: attribute childElementCount", - "DocumentFragment interface: operation prepend((Node or DOMString)...)", + "DocumentFragment interface: existence and properties of interface object", + "DocumentFragment interface: existence and properties of interface prototype object", + "DocumentFragment interface: existence and properties of interface prototype object's \"constructor\" property", + "DocumentFragment interface: existence and properties of interface prototype object's @@unscopables property", "DocumentFragment interface: operation append((Node or DOMString)...)", - "DocumentFragment interface: operation replaceChildren((Node or DOMString)...)", + "DocumentFragment interface: operation getElementById(DOMString)", "DocumentFragment interface: operation moveBefore(Node, Node?)", + "DocumentFragment interface: operation prepend((Node or DOMString)...)", "DocumentFragment interface: operation querySelector(DOMString)", "DocumentFragment interface: operation querySelectorAll(DOMString)", - "ShadowRoot interface: existence and properties of interface object", - "ShadowRoot interface object length", - "ShadowRoot interface object name", - "ShadowRoot interface: existence and properties of interface prototype object", - "ShadowRoot interface: existence and properties of interface prototype object's \"constructor\" property", - "ShadowRoot interface: existence and properties of interface prototype object's @@unscopables property", - "ShadowRoot interface: attribute mode", - "ShadowRoot interface: attribute delegatesFocus", - "ShadowRoot interface: attribute slotAssignment", - "ShadowRoot interface: attribute clonable", - "ShadowRoot interface: attribute serializable", - "ShadowRoot interface: attribute host", - "ShadowRoot interface: attribute onslotchange", - "ShadowRoot interface: attribute customElementRegistry", - "ShadowRoot interface: attribute fullscreenElement", - "Element interface: existence and properties of interface object", + "DocumentFragment interface: operation replaceChildren((Node or DOMString)...)", + "DocumentType interface object length", + "DocumentType interface object name", + "DocumentType interface: attribute name", + "DocumentType interface: attribute publicId", + "DocumentType interface: attribute systemId", + "DocumentType interface: existence and properties of interface object", + "DocumentType interface: existence and properties of interface prototype object", + "DocumentType interface: existence and properties of interface prototype object's \"constructor\" property", + "DocumentType interface: existence and properties of interface prototype object's @@unscopables property", + "DocumentType interface: operation after((Node or DOMString)...)", + "DocumentType interface: operation before((Node or DOMString)...)", + "DocumentType interface: operation remove()", + "DocumentType interface: operation replaceWith((Node or DOMString)...)", "Element interface object length", "Element interface object name", - "Element interface: existence and properties of interface prototype object", - "Element interface: existence and properties of interface prototype object's \"constructor\" property", - "Element interface: existence and properties of interface prototype object's @@unscopables property", + "Element interface: attribute assignedSlot", + "Element interface: attribute attributes", + "Element interface: attribute childElementCount", + "Element interface: attribute children", + "Element interface: attribute classList", + "Element interface: attribute className", + "Element interface: attribute customElementRegistry", + "Element interface: attribute firstElementChild", + "Element interface: attribute id", + "Element interface: attribute lastElementChild", + "Element interface: attribute localName", "Element interface: attribute namespaceURI", + "Element interface: attribute nextElementSibling", + "Element interface: attribute onfullscreenchange", + "Element interface: attribute onfullscreenerror", "Element interface: attribute prefix", - "Element interface: attribute localName", - "Element interface: attribute tagName", - "Element interface: attribute id", - "Element interface: attribute className", - "Element interface: attribute classList", + "Element interface: attribute previousElementSibling", + "Element interface: attribute shadowRoot", "Element interface: attribute slot", - "Element interface: operation hasAttributes()", - "Element interface: attribute attributes", - "Element interface: operation getAttributeNames()", + "Element interface: attribute tagName", + "Element interface: existence and properties of interface object", + "Element interface: existence and properties of interface prototype object", + "Element interface: existence and properties of interface prototype object's \"constructor\" property", + "Element interface: existence and properties of interface prototype object's @@unscopables property", + "Element interface: operation after((Node or DOMString)...)", + "Element interface: operation append((Node or DOMString)...)", + "Element interface: operation attachShadow(ShadowRootInit)", + "Element interface: operation before((Node or DOMString)...)", + "Element interface: operation closest(DOMString)", "Element interface: operation getAttribute(DOMString)", "Element interface: operation getAttributeNS(DOMString?, DOMString)", - "Element interface: operation setAttribute(DOMString, (TrustedType or DOMString))", - "Element interface: operation setAttributeNS(DOMString?, DOMString, (TrustedType or DOMString))", - "Element interface: operation removeAttribute(DOMString)", - "Element interface: operation removeAttributeNS(DOMString?, DOMString)", - "Element interface: operation toggleAttribute(DOMString, optional boolean)", - "Element interface: operation hasAttribute(DOMString)", - "Element interface: operation hasAttributeNS(DOMString?, DOMString)", + "Element interface: operation getAttributeNames()", "Element interface: operation getAttributeNode(DOMString)", "Element interface: operation getAttributeNodeNS(DOMString?, DOMString)", - "Element interface: operation setAttributeNode(Attr)", - "Element interface: operation setAttributeNodeNS(Attr)", - "Element interface: operation removeAttributeNode(Attr)", - "Element interface: operation attachShadow(ShadowRootInit)", - "Element interface: attribute shadowRoot", - "Element interface: attribute customElementRegistry", - "Element interface: operation closest(DOMString)", - "Element interface: operation matches(DOMString)", - "Element interface: operation webkitMatchesSelector(DOMString)", + "Element interface: operation getElementsByClassName(DOMString)", "Element interface: operation getElementsByTagName(DOMString)", "Element interface: operation getElementsByTagNameNS(DOMString?, DOMString)", - "Element interface: operation getElementsByClassName(DOMString)", + "Element interface: operation hasAttribute(DOMString)", + "Element interface: operation hasAttributeNS(DOMString?, DOMString)", + "Element interface: operation hasAttributes()", "Element interface: operation insertAdjacentElement(DOMString, Element)", "Element interface: operation insertAdjacentText(DOMString, DOMString)", - "Element interface: operation requestFullscreen(optional FullscreenOptions)", - "Element interface: attribute onfullscreenchange", - "Element interface: attribute onfullscreenerror", - "Element interface: attribute children", - "Element interface: attribute firstElementChild", - "Element interface: attribute lastElementChild", - "Element interface: attribute childElementCount", - "Element interface: operation prepend((Node or DOMString)...)", - "Element interface: operation append((Node or DOMString)...)", - "Element interface: operation replaceChildren((Node or DOMString)...)", + "Element interface: operation matches(DOMString)", "Element interface: operation moveBefore(Node, Node?)", + "Element interface: operation prepend((Node or DOMString)...)", "Element interface: operation querySelector(DOMString)", "Element interface: operation querySelectorAll(DOMString)", - "Element interface: attribute previousElementSibling", - "Element interface: attribute nextElementSibling", - "Element interface: operation before((Node or DOMString)...)", - "Element interface: operation after((Node or DOMString)...)", - "Element interface: operation replaceWith((Node or DOMString)...)", "Element interface: operation remove()", - "Element interface: attribute assignedSlot", - "NamedNodeMap interface: existence and properties of interface object", + "Element interface: operation removeAttribute(DOMString)", + "Element interface: operation removeAttributeNS(DOMString?, DOMString)", + "Element interface: operation removeAttributeNode(Attr)", + "Element interface: operation replaceChildren((Node or DOMString)...)", + "Element interface: operation replaceWith((Node or DOMString)...)", + "Element interface: operation requestFullscreen(optional FullscreenOptions)", + "Element interface: operation setAttribute(DOMString, (TrustedType or DOMString))", + "Element interface: operation setAttributeNS(DOMString?, DOMString, (TrustedType or DOMString))", + "Element interface: operation setAttributeNode(Attr)", + "Element interface: operation setAttributeNodeNS(Attr)", + "Element interface: operation toggleAttribute(DOMString, optional boolean)", + "Element interface: operation webkitMatchesSelector(DOMString)", + "Event interface: attribute cancelBubble", + "Event interface: attribute defaultPrevented", + "Event interface: attribute returnValue", + "Event interface: attribute srcElement", + "Event interface: operation composedPath()", + "Event interface: operation initEvent(DOMString, optional boolean, optional boolean)", + "Event interface: operation preventDefault()", + "Event interface: operation stopImmediatePropagation()", + "Event interface: operation stopPropagation()", + "EventTarget interface: operation addEventListener(DOMString, EventListener?, optional (AddEventListenerOptions or boolean))", + "EventTarget interface: operation removeEventListener(DOMString, EventListener?, optional (EventListenerOptions or boolean))", + "HTMLCollection interface object length", + "HTMLCollection interface object name", + "HTMLCollection interface: attribute length", + "HTMLCollection interface: existence and properties of interface object", + "HTMLCollection interface: existence and properties of interface prototype object", + "HTMLCollection interface: existence and properties of interface prototype object's \"constructor\" property", + "HTMLCollection interface: existence and properties of interface prototype object's @@unscopables property", + "HTMLCollection interface: operation item(unsigned long)", + "HTMLCollection interface: operation namedItem(DOMString)", + "MutationObserver interface object length", + "MutationObserver interface object name", + "MutationObserver interface: existence and properties of interface object", + "MutationObserver interface: existence and properties of interface prototype object", + "MutationObserver interface: existence and properties of interface prototype object's \"constructor\" property", + "MutationObserver interface: existence and properties of interface prototype object's @@unscopables property", + "MutationObserver interface: operation disconnect()", + "MutationObserver interface: operation observe(Node, optional MutationObserverInit)", + "MutationObserver interface: operation takeRecords()", + "MutationRecord interface object length", + "MutationRecord interface object name", + "MutationRecord interface: attribute addedNodes", + "MutationRecord interface: attribute attributeName", + "MutationRecord interface: attribute attributeNamespace", + "MutationRecord interface: attribute nextSibling", + "MutationRecord interface: attribute oldValue", + "MutationRecord interface: attribute previousSibling", + "MutationRecord interface: attribute removedNodes", + "MutationRecord interface: attribute target", + "MutationRecord interface: attribute type", + "MutationRecord interface: existence and properties of interface object", + "MutationRecord interface: existence and properties of interface prototype object", + "MutationRecord interface: existence and properties of interface prototype object's \"constructor\" property", + "MutationRecord interface: existence and properties of interface prototype object's @@unscopables property", "NamedNodeMap interface object length", "NamedNodeMap interface object name", + "NamedNodeMap interface: attribute length", + "NamedNodeMap interface: existence and properties of interface object", "NamedNodeMap interface: existence and properties of interface prototype object", "NamedNodeMap interface: existence and properties of interface prototype object's \"constructor\" property", "NamedNodeMap interface: existence and properties of interface prototype object's @@unscopables property", - "NamedNodeMap interface: attribute length", - "NamedNodeMap interface: operation item(unsigned long)", "NamedNodeMap interface: operation getNamedItem(DOMString)", "NamedNodeMap interface: operation getNamedItemNS(DOMString?, DOMString)", - "NamedNodeMap interface: operation setNamedItem(Attr)", - "NamedNodeMap interface: operation setNamedItemNS(Attr)", + "NamedNodeMap interface: operation item(unsigned long)", "NamedNodeMap interface: operation removeNamedItem(DOMString)", "NamedNodeMap interface: operation removeNamedItemNS(DOMString?, DOMString)", - "Attr interface: existence and properties of interface object", - "Attr interface object length", - "Attr interface object name", - "Attr interface: existence and properties of interface prototype object", - "Attr interface: existence and properties of interface prototype object's \"constructor\" property", - "Attr interface: existence and properties of interface prototype object's @@unscopables property", - "Attr interface: attribute namespaceURI", - "Attr interface: attribute prefix", - "Attr interface: attribute localName", - "Attr interface: attribute name", - "Attr interface: attribute value", - "Attr interface: attribute ownerElement", - "Attr interface: attribute specified", - "CharacterData interface: existence and properties of interface object", - "CharacterData interface object length", - "CharacterData interface object name", - "CharacterData interface: existence and properties of interface prototype object", - "CharacterData interface: existence and properties of interface prototype object's \"constructor\" property", - "CharacterData interface: existence and properties of interface prototype object's @@unscopables property", - "CharacterData interface: attribute data", - "CharacterData interface: attribute length", - "CharacterData interface: operation substringData(unsigned long, unsigned long)", - "CharacterData interface: operation appendData(DOMString)", - "CharacterData interface: operation insertData(unsigned long, DOMString)", - "CharacterData interface: operation deleteData(unsigned long, unsigned long)", - "CharacterData interface: operation replaceData(unsigned long, unsigned long, DOMString)", - "CharacterData interface: attribute previousElementSibling", - "CharacterData interface: attribute nextElementSibling", - "CharacterData interface: operation before((Node or DOMString)...)", - "CharacterData interface: operation after((Node or DOMString)...)", - "CharacterData interface: operation replaceWith((Node or DOMString)...)", - "CharacterData interface: operation remove()", - "Text interface: existence and properties of interface object", - "Text interface object length", - "Text interface object name", - "Text interface: existence and properties of interface prototype object", - "Text interface: existence and properties of interface prototype object's \"constructor\" property", - "Text interface: existence and properties of interface prototype object's @@unscopables property", - "Text interface: operation splitText(unsigned long)", - "Text interface: attribute wholeText", - "Text interface: attribute assignedSlot", - "CDATASection interface: existence and properties of interface object", - "CDATASection interface object length", - "CDATASection interface object name", - "CDATASection interface: existence and properties of interface prototype object", - "CDATASection interface: existence and properties of interface prototype object's \"constructor\" property", - "CDATASection interface: existence and properties of interface prototype object's @@unscopables property", - "ProcessingInstruction interface: existence and properties of interface object", + "NamedNodeMap interface: operation setNamedItem(Attr)", + "NamedNodeMap interface: operation setNamedItemNS(Attr)", + "NodeFilter interface object name", + "NodeFilter interface: constant FILTER_ACCEPT on interface object", + "NodeFilter interface: constant FILTER_ACCEPT on interface prototype object", + "NodeFilter interface: constant FILTER_REJECT on interface object", + "NodeFilter interface: constant FILTER_REJECT on interface prototype object", + "NodeFilter interface: constant FILTER_SKIP on interface object", + "NodeFilter interface: constant FILTER_SKIP on interface prototype object", + "NodeFilter interface: constant SHOW_ALL on interface object", + "NodeFilter interface: constant SHOW_ALL on interface prototype object", + "NodeFilter interface: constant SHOW_ATTRIBUTE on interface object", + "NodeFilter interface: constant SHOW_ATTRIBUTE on interface prototype object", + "NodeFilter interface: constant SHOW_CDATA_SECTION on interface object", + "NodeFilter interface: constant SHOW_CDATA_SECTION on interface prototype object", + "NodeFilter interface: constant SHOW_COMMENT on interface object", + "NodeFilter interface: constant SHOW_COMMENT on interface prototype object", + "NodeFilter interface: constant SHOW_DOCUMENT on interface object", + "NodeFilter interface: constant SHOW_DOCUMENT on interface prototype object", + "NodeFilter interface: constant SHOW_DOCUMENT_FRAGMENT on interface object", + "NodeFilter interface: constant SHOW_DOCUMENT_FRAGMENT on interface prototype object", + "NodeFilter interface: constant SHOW_DOCUMENT_TYPE on interface object", + "NodeFilter interface: constant SHOW_DOCUMENT_TYPE on interface prototype object", + "NodeFilter interface: constant SHOW_ELEMENT on interface object", + "NodeFilter interface: constant SHOW_ELEMENT on interface prototype object", + "NodeFilter interface: constant SHOW_ENTITY on interface object", + "NodeFilter interface: constant SHOW_ENTITY on interface prototype object", + "NodeFilter interface: constant SHOW_ENTITY_REFERENCE on interface object", + "NodeFilter interface: constant SHOW_ENTITY_REFERENCE on interface prototype object", + "NodeFilter interface: constant SHOW_NOTATION on interface object", + "NodeFilter interface: constant SHOW_NOTATION on interface prototype object", + "NodeFilter interface: constant SHOW_PROCESSING_INSTRUCTION on interface object", + "NodeFilter interface: constant SHOW_PROCESSING_INSTRUCTION on interface prototype object", + "NodeFilter interface: constant SHOW_TEXT on interface object", + "NodeFilter interface: constant SHOW_TEXT on interface prototype object", + "NodeFilter interface: existence and properties of interface object", + "NodeFilter interface: existence and properties of interface prototype object", + "NodeFilter interface: existence and properties of interface prototype object's \"constructor\" property", + "NodeFilter interface: existence and properties of interface prototype object's @@unscopables property", + "NodeFilter interface: operation acceptNode(Node)", + "NodeIterator interface object length", + "NodeIterator interface object name", + "NodeIterator interface: attribute filter", + "NodeIterator interface: attribute pointerBeforeReferenceNode", + "NodeIterator interface: attribute referenceNode", + "NodeIterator interface: attribute root", + "NodeIterator interface: attribute whatToShow", + "NodeIterator interface: existence and properties of interface object", + "NodeIterator interface: existence and properties of interface prototype object", + "NodeIterator interface: existence and properties of interface prototype object's \"constructor\" property", + "NodeIterator interface: existence and properties of interface prototype object's @@unscopables property", + "NodeIterator interface: operation detach()", + "NodeIterator interface: operation nextNode()", + "NodeIterator interface: operation previousNode()", + "NodeList interface object length", + "NodeList interface object name", + "NodeList interface: attribute length", + "NodeList interface: existence and properties of interface object", + "NodeList interface: existence and properties of interface prototype object", + "NodeList interface: existence and properties of interface prototype object's \"constructor\" property", + "NodeList interface: existence and properties of interface prototype object's @@unscopables property", + "NodeList interface: iterable", + "NodeList interface: operation item(unsigned long)", "ProcessingInstruction interface object length", "ProcessingInstruction interface object name", + "ProcessingInstruction interface: attribute target", + "ProcessingInstruction interface: existence and properties of interface object", "ProcessingInstruction interface: existence and properties of interface prototype object", "ProcessingInstruction interface: existence and properties of interface prototype object's \"constructor\" property", "ProcessingInstruction interface: existence and properties of interface prototype object's @@unscopables property", - "ProcessingInstruction interface: attribute target", - "Comment interface: existence and properties of interface object", - "Comment interface object length", - "Comment interface object name", - "Comment interface: existence and properties of interface prototype object", - "Comment interface: existence and properties of interface prototype object's \"constructor\" property", - "Comment interface: existence and properties of interface prototype object's @@unscopables property", - "AbstractRange interface: existence and properties of interface object", - "AbstractRange interface object length", - "AbstractRange interface object name", - "AbstractRange interface: existence and properties of interface prototype object", - "AbstractRange interface: existence and properties of interface prototype object's \"constructor\" property", - "AbstractRange interface: existence and properties of interface prototype object's @@unscopables property", - "AbstractRange interface: attribute startContainer", - "AbstractRange interface: attribute startOffset", - "AbstractRange interface: attribute endContainer", - "AbstractRange interface: attribute endOffset", - "AbstractRange interface: attribute collapsed", - "StaticRange interface: existence and properties of interface object", - "StaticRange interface object length", - "StaticRange interface object name", - "StaticRange interface: existence and properties of interface prototype object", - "StaticRange interface: existence and properties of interface prototype object's \"constructor\" property", - "StaticRange interface: existence and properties of interface prototype object's @@unscopables property", - "Range interface: existence and properties of interface object", "Range interface object length", "Range interface object name", - "Range interface: existence and properties of interface prototype object", - "Range interface: existence and properties of interface prototype object's \"constructor\" property", - "Range interface: existence and properties of interface prototype object's @@unscopables property", "Range interface: attribute commonAncestorContainer", - "Range interface: operation setStart(Node, unsigned long)", - "Range interface: operation setEnd(Node, unsigned long)", - "Range interface: operation setStartBefore(Node)", - "Range interface: operation setStartAfter(Node)", - "Range interface: operation setEndBefore(Node)", - "Range interface: operation setEndAfter(Node)", - "Range interface: operation collapse(optional boolean)", - "Range interface: operation selectNode(Node)", - "Range interface: operation selectNodeContents(Node)", - "Range interface: constant START_TO_START on interface object", - "Range interface: constant START_TO_START on interface prototype object", - "Range interface: constant START_TO_END on interface object", - "Range interface: constant START_TO_END on interface prototype object", "Range interface: constant END_TO_END on interface object", "Range interface: constant END_TO_END on interface prototype object", "Range interface: constant END_TO_START on interface object", "Range interface: constant END_TO_START on interface prototype object", + "Range interface: constant START_TO_END on interface object", + "Range interface: constant START_TO_END on interface prototype object", + "Range interface: constant START_TO_START on interface object", + "Range interface: constant START_TO_START on interface prototype object", + "Range interface: existence and properties of interface object", + "Range interface: existence and properties of interface prototype object", + "Range interface: existence and properties of interface prototype object's \"constructor\" property", + "Range interface: existence and properties of interface prototype object's @@unscopables property", + "Range interface: operation cloneContents()", + "Range interface: operation cloneRange()", + "Range interface: operation collapse(optional boolean)", "Range interface: operation compareBoundaryPoints(unsigned short, Range)", + "Range interface: operation comparePoint(Node, unsigned long)", "Range interface: operation deleteContents()", + "Range interface: operation detach()", "Range interface: operation extractContents()", - "Range interface: operation cloneContents()", "Range interface: operation insertNode(Node)", - "Range interface: operation surroundContents(Node)", - "Range interface: operation cloneRange()", - "Range interface: operation detach()", - "Range interface: operation isPointInRange(Node, unsigned long)", - "Range interface: operation comparePoint(Node, unsigned long)", "Range interface: operation intersectsNode(Node)", - "Range interface: stringifier", - "NodeIterator interface: existence and properties of interface object", - "NodeIterator interface object length", - "NodeIterator interface object name", - "NodeIterator interface: existence and properties of interface prototype object", - "NodeIterator interface: existence and properties of interface prototype object's \"constructor\" property", - "NodeIterator interface: existence and properties of interface prototype object's @@unscopables property", - "NodeIterator interface: attribute root", - "NodeIterator interface: attribute referenceNode", - "NodeIterator interface: attribute pointerBeforeReferenceNode", - "NodeIterator interface: attribute whatToShow", - "NodeIterator interface: attribute filter", - "NodeIterator interface: operation nextNode()", - "NodeIterator interface: operation previousNode()", - "NodeIterator interface: operation detach()", - "TreeWalker interface: existence and properties of interface object", + "Range interface: operation isPointInRange(Node, unsigned long)", + "Range interface: operation selectNode(Node)", + "Range interface: operation selectNodeContents(Node)", + "Range interface: operation setEnd(Node, unsigned long)", + "Range interface: operation setEndAfter(Node)", + "Range interface: operation setEndBefore(Node)", + "Range interface: operation setStart(Node, unsigned long)", + "Range interface: operation setStartAfter(Node)", + "Range interface: operation setStartBefore(Node)", + "Range interface: operation surroundContents(Node)", + "Range interface: stringifier", + "ShadowRoot interface object length", + "ShadowRoot interface object name", + "ShadowRoot interface: attribute clonable", + "ShadowRoot interface: attribute customElementRegistry", + "ShadowRoot interface: attribute delegatesFocus", + "ShadowRoot interface: attribute fullscreenElement", + "ShadowRoot interface: attribute host", + "ShadowRoot interface: attribute mode", + "ShadowRoot interface: attribute onslotchange", + "ShadowRoot interface: attribute serializable", + "ShadowRoot interface: attribute slotAssignment", + "ShadowRoot interface: existence and properties of interface object", + "ShadowRoot interface: existence and properties of interface prototype object", + "ShadowRoot interface: existence and properties of interface prototype object's \"constructor\" property", + "ShadowRoot interface: existence and properties of interface prototype object's @@unscopables property", + "StaticRange interface object length", + "StaticRange interface object name", + "StaticRange interface: existence and properties of interface object", + "StaticRange interface: existence and properties of interface prototype object", + "StaticRange interface: existence and properties of interface prototype object's \"constructor\" property", + "StaticRange interface: existence and properties of interface prototype object's @@unscopables property", + "Text interface object length", + "Text interface object name", + "Text interface: attribute assignedSlot", + "Text interface: attribute wholeText", + "Text interface: existence and properties of interface object", + "Text interface: existence and properties of interface prototype object", + "Text interface: existence and properties of interface prototype object's \"constructor\" property", + "Text interface: existence and properties of interface prototype object's @@unscopables property", + "Text interface: operation splitText(unsigned long)", "TreeWalker interface object length", "TreeWalker interface object name", + "TreeWalker interface: attribute currentNode", + "TreeWalker interface: attribute filter", + "TreeWalker interface: attribute root", + "TreeWalker interface: attribute whatToShow", + "TreeWalker interface: existence and properties of interface object", "TreeWalker interface: existence and properties of interface prototype object", "TreeWalker interface: existence and properties of interface prototype object's \"constructor\" property", "TreeWalker interface: existence and properties of interface prototype object's @@unscopables property", - "TreeWalker interface: attribute root", - "TreeWalker interface: attribute whatToShow", - "TreeWalker interface: attribute filter", - "TreeWalker interface: attribute currentNode", - "TreeWalker interface: operation parentNode()", "TreeWalker interface: operation firstChild()", "TreeWalker interface: operation lastChild()", - "TreeWalker interface: operation previousSibling()", + "TreeWalker interface: operation nextNode()", "TreeWalker interface: operation nextSibling()", + "TreeWalker interface: operation parentNode()", "TreeWalker interface: operation previousNode()", - "TreeWalker interface: operation nextNode()", - "NodeFilter interface: existence and properties of interface object", - "NodeFilter interface object name", - "NodeFilter interface: existence and properties of interface prototype object", - "NodeFilter interface: existence and properties of interface prototype object's \"constructor\" property", - "NodeFilter interface: existence and properties of interface prototype object's @@unscopables property", - "NodeFilter interface: constant FILTER_ACCEPT on interface object", - "NodeFilter interface: constant FILTER_ACCEPT on interface prototype object", - "NodeFilter interface: constant FILTER_REJECT on interface object", - "NodeFilter interface: constant FILTER_REJECT on interface prototype object", - "NodeFilter interface: constant FILTER_SKIP on interface object", - "NodeFilter interface: constant FILTER_SKIP on interface prototype object", - "NodeFilter interface: constant SHOW_ALL on interface object", - "NodeFilter interface: constant SHOW_ALL on interface prototype object", - "NodeFilter interface: constant SHOW_ELEMENT on interface object", - "NodeFilter interface: constant SHOW_ELEMENT on interface prototype object", - "NodeFilter interface: constant SHOW_ATTRIBUTE on interface object", - "NodeFilter interface: constant SHOW_ATTRIBUTE on interface prototype object", - "NodeFilter interface: constant SHOW_TEXT on interface object", - "NodeFilter interface: constant SHOW_TEXT on interface prototype object", - "NodeFilter interface: constant SHOW_CDATA_SECTION on interface object", - "NodeFilter interface: constant SHOW_CDATA_SECTION on interface prototype object", - "NodeFilter interface: constant SHOW_ENTITY_REFERENCE on interface object", - "NodeFilter interface: constant SHOW_ENTITY_REFERENCE on interface prototype object", - "NodeFilter interface: constant SHOW_ENTITY on interface object", - "NodeFilter interface: constant SHOW_ENTITY on interface prototype object", - "NodeFilter interface: constant SHOW_PROCESSING_INSTRUCTION on interface object", - "NodeFilter interface: constant SHOW_PROCESSING_INSTRUCTION on interface prototype object", - "NodeFilter interface: constant SHOW_COMMENT on interface object", - "NodeFilter interface: constant SHOW_COMMENT on interface prototype object", - "NodeFilter interface: constant SHOW_DOCUMENT on interface object", - "NodeFilter interface: constant SHOW_DOCUMENT on interface prototype object", - "NodeFilter interface: constant SHOW_DOCUMENT_TYPE on interface object", - "NodeFilter interface: constant SHOW_DOCUMENT_TYPE on interface prototype object", - "NodeFilter interface: constant SHOW_DOCUMENT_FRAGMENT on interface object", - "NodeFilter interface: constant SHOW_DOCUMENT_FRAGMENT on interface prototype object", - "NodeFilter interface: constant SHOW_NOTATION on interface object", - "NodeFilter interface: constant SHOW_NOTATION on interface prototype object", - "NodeFilter interface: operation acceptNode(Node)", - "DOMTokenList interface: existence and properties of interface object", - "DOMTokenList interface object length", - "DOMTokenList interface object name", - "DOMTokenList interface: existence and properties of interface prototype object", - "DOMTokenList interface: existence and properties of interface prototype object's \"constructor\" property", - "DOMTokenList interface: existence and properties of interface prototype object's @@unscopables property", - "DOMTokenList interface: attribute length", - "DOMTokenList interface: operation item(unsigned long)", - "DOMTokenList interface: operation contains(DOMString)", - "DOMTokenList interface: operation add(DOMString...)", - "DOMTokenList interface: operation remove(DOMString...)", - "DOMTokenList interface: operation toggle(DOMString, optional boolean)", - "DOMTokenList interface: operation replace(DOMString, DOMString)", - "DOMTokenList interface: operation supports(DOMString)", - "DOMTokenList interface: attribute value", - "DOMTokenList interface: stringifier", - "DOMTokenList interface: iterable", - "XPathResult interface: existence and properties of interface object", + "TreeWalker interface: operation previousSibling()", + "Window interface: attribute event", + "XMLDocument interface object length", + "XMLDocument interface object name", + "XMLDocument interface: existence and properties of interface object", + "XMLDocument interface: existence and properties of interface prototype object", + "XMLDocument interface: existence and properties of interface prototype object's \"constructor\" property", + "XMLDocument interface: existence and properties of interface prototype object's @@unscopables property", + "XPathEvaluator interface object length", + "XPathEvaluator interface object name", + "XPathEvaluator interface: existence and properties of interface object", + "XPathEvaluator interface: existence and properties of interface prototype object", + "XPathEvaluator interface: existence and properties of interface prototype object's \"constructor\" property", + "XPathEvaluator interface: existence and properties of interface prototype object's @@unscopables property", + "XPathEvaluator interface: operation createExpression(DOMString, optional XPathNSResolver?)", + "XPathEvaluator interface: operation createNSResolver(Node)", + "XPathEvaluator interface: operation evaluate(DOMString, Node, optional XPathNSResolver?, optional unsigned short, optional XPathResult?)", + "XPathExpression interface object length", + "XPathExpression interface object name", + "XPathExpression interface: existence and properties of interface object", + "XPathExpression interface: existence and properties of interface prototype object", + "XPathExpression interface: existence and properties of interface prototype object's \"constructor\" property", + "XPathExpression interface: existence and properties of interface prototype object's @@unscopables property", + "XPathExpression interface: operation evaluate(Node, optional unsigned short, optional XPathResult?)", "XPathResult interface object length", "XPathResult interface object name", - "XPathResult interface: existence and properties of interface prototype object", - "XPathResult interface: existence and properties of interface prototype object's \"constructor\" property", - "XPathResult interface: existence and properties of interface prototype object's @@unscopables property", + "XPathResult interface: attribute booleanValue", + "XPathResult interface: attribute invalidIteratorState", + "XPathResult interface: attribute numberValue", + "XPathResult interface: attribute resultType", + "XPathResult interface: attribute singleNodeValue", + "XPathResult interface: attribute snapshotLength", + "XPathResult interface: attribute stringValue", "XPathResult interface: constant ANY_TYPE on interface object", "XPathResult interface: constant ANY_TYPE on interface prototype object", + "XPathResult interface: constant ANY_UNORDERED_NODE_TYPE on interface object", + "XPathResult interface: constant ANY_UNORDERED_NODE_TYPE on interface prototype object", + "XPathResult interface: constant BOOLEAN_TYPE on interface object", + "XPathResult interface: constant BOOLEAN_TYPE on interface prototype object", + "XPathResult interface: constant FIRST_ORDERED_NODE_TYPE on interface object", + "XPathResult interface: constant FIRST_ORDERED_NODE_TYPE on interface prototype object", "XPathResult interface: constant NUMBER_TYPE on interface object", "XPathResult interface: constant NUMBER_TYPE on interface prototype object", + "XPathResult interface: constant ORDERED_NODE_ITERATOR_TYPE on interface object", + "XPathResult interface: constant ORDERED_NODE_ITERATOR_TYPE on interface prototype object", + "XPathResult interface: constant ORDERED_NODE_SNAPSHOT_TYPE on interface object", + "XPathResult interface: constant ORDERED_NODE_SNAPSHOT_TYPE on interface prototype object", "XPathResult interface: constant STRING_TYPE on interface object", "XPathResult interface: constant STRING_TYPE on interface prototype object", - "XPathResult interface: constant BOOLEAN_TYPE on interface object", - "XPathResult interface: constant BOOLEAN_TYPE on interface prototype object", "XPathResult interface: constant UNORDERED_NODE_ITERATOR_TYPE on interface object", "XPathResult interface: constant UNORDERED_NODE_ITERATOR_TYPE on interface prototype object", - "XPathResult interface: constant ORDERED_NODE_ITERATOR_TYPE on interface object", - "XPathResult interface: constant ORDERED_NODE_ITERATOR_TYPE on interface prototype object", "XPathResult interface: constant UNORDERED_NODE_SNAPSHOT_TYPE on interface object", "XPathResult interface: constant UNORDERED_NODE_SNAPSHOT_TYPE on interface prototype object", - "XPathResult interface: constant ORDERED_NODE_SNAPSHOT_TYPE on interface object", - "XPathResult interface: constant ORDERED_NODE_SNAPSHOT_TYPE on interface prototype object", - "XPathResult interface: constant ANY_UNORDERED_NODE_TYPE on interface object", - "XPathResult interface: constant ANY_UNORDERED_NODE_TYPE on interface prototype object", - "XPathResult interface: constant FIRST_ORDERED_NODE_TYPE on interface object", - "XPathResult interface: constant FIRST_ORDERED_NODE_TYPE on interface prototype object", - "XPathResult interface: attribute resultType", - "XPathResult interface: attribute numberValue", - "XPathResult interface: attribute stringValue", - "XPathResult interface: attribute booleanValue", - "XPathResult interface: attribute singleNodeValue", - "XPathResult interface: attribute invalidIteratorState", - "XPathResult interface: attribute snapshotLength", + "XPathResult interface: existence and properties of interface object", + "XPathResult interface: existence and properties of interface prototype object", + "XPathResult interface: existence and properties of interface prototype object's \"constructor\" property", + "XPathResult interface: existence and properties of interface prototype object's @@unscopables property", "XPathResult interface: operation iterateNext()", "XPathResult interface: operation snapshotItem(unsigned long)", - "XPathExpression interface: existence and properties of interface object", - "XPathExpression interface object length", - "XPathExpression interface object name", - "XPathExpression interface: existence and properties of interface prototype object", - "XPathExpression interface: existence and properties of interface prototype object's \"constructor\" property", - "XPathExpression interface: existence and properties of interface prototype object's @@unscopables property", - "XPathExpression interface: operation evaluate(Node, optional unsigned short, optional XPathResult?)", - "XPathEvaluator interface: existence and properties of interface object", - "XPathEvaluator interface object length", - "XPathEvaluator interface object name", - "XPathEvaluator interface: existence and properties of interface prototype object", - "XPathEvaluator interface: existence and properties of interface prototype object's \"constructor\" property", - "XPathEvaluator interface: existence and properties of interface prototype object's @@unscopables property", - "XPathEvaluator interface: operation createExpression(DOMString, optional XPathNSResolver?)", - "XPathEvaluator interface: operation createNSResolver(Node)", - "XPathEvaluator interface: operation evaluate(DOMString, Node, optional XPathNSResolver?, optional unsigned short, optional XPathResult?)", - "XSLTProcessor interface: existence and properties of interface object", "XSLTProcessor interface object length", "XSLTProcessor interface object name", + "XSLTProcessor interface: existence and properties of interface object", "XSLTProcessor interface: existence and properties of interface prototype object", "XSLTProcessor interface: existence and properties of interface prototype object's \"constructor\" property", "XSLTProcessor interface: existence and properties of interface prototype object's @@unscopables property", - "XSLTProcessor interface: operation importStylesheet(Node)", - "XSLTProcessor interface: operation transformToFragment(Node, Document)", - "XSLTProcessor interface: operation transformToDocument(Node)", - "XSLTProcessor interface: operation setParameter(DOMString, DOMString, any)", + "XSLTProcessor interface: operation clearParameters()", "XSLTProcessor interface: operation getParameter(DOMString, DOMString)", + "XSLTProcessor interface: operation importStylesheet(Node)", "XSLTProcessor interface: operation removeParameter(DOMString, DOMString)", - "XSLTProcessor interface: operation clearParameters()", "XSLTProcessor interface: operation reset()", - "Window interface: attribute event", + "XSLTProcessor interface: operation setParameter(DOMString, DOMString, any)", + "XSLTProcessor interface: operation transformToDocument(Node)", + "XSLTProcessor interface: operation transformToFragment(Node, Document)", "idl_test setup" ] }, "idlharness.window.html?include=Node": { "expectedFailures": [ - "Node interface: existence and properties of interface object", "Node interface object length", "Node interface object name", - "Node interface: existence and properties of interface prototype object", - "Node interface: existence and properties of interface prototype object's \"constructor\" property", - "Node interface: existence and properties of interface prototype object's @@unscopables property", - "Node interface: constant ELEMENT_NODE on interface object", - "Node interface: constant ELEMENT_NODE on interface prototype object", + "Node interface: attribute baseURI", + "Node interface: attribute childNodes", + "Node interface: attribute firstChild", + "Node interface: attribute isConnected", + "Node interface: attribute lastChild", + "Node interface: attribute nextSibling", + "Node interface: attribute nodeName", + "Node interface: attribute nodeType", + "Node interface: attribute nodeValue", + "Node interface: attribute ownerDocument", + "Node interface: attribute parentElement", + "Node interface: attribute parentNode", + "Node interface: attribute previousSibling", + "Node interface: attribute textContent", "Node interface: constant ATTRIBUTE_NODE on interface object", "Node interface: constant ATTRIBUTE_NODE on interface prototype object", - "Node interface: constant TEXT_NODE on interface object", - "Node interface: constant TEXT_NODE on interface prototype object", "Node interface: constant CDATA_SECTION_NODE on interface object", "Node interface: constant CDATA_SECTION_NODE on interface prototype object", - "Node interface: constant ENTITY_REFERENCE_NODE on interface object", - "Node interface: constant ENTITY_REFERENCE_NODE on interface prototype object", - "Node interface: constant ENTITY_NODE on interface object", - "Node interface: constant ENTITY_NODE on interface prototype object", - "Node interface: constant PROCESSING_INSTRUCTION_NODE on interface object", - "Node interface: constant PROCESSING_INSTRUCTION_NODE on interface prototype object", "Node interface: constant COMMENT_NODE on interface object", "Node interface: constant COMMENT_NODE on interface prototype object", - "Node interface: constant DOCUMENT_NODE on interface object", - "Node interface: constant DOCUMENT_NODE on interface prototype object", - "Node interface: constant DOCUMENT_TYPE_NODE on interface object", - "Node interface: constant DOCUMENT_TYPE_NODE on interface prototype object", "Node interface: constant DOCUMENT_FRAGMENT_NODE on interface object", "Node interface: constant DOCUMENT_FRAGMENT_NODE on interface prototype object", - "Node interface: constant NOTATION_NODE on interface object", - "Node interface: constant NOTATION_NODE on interface prototype object", - "Node interface: attribute nodeType", - "Node interface: attribute nodeName", - "Node interface: attribute baseURI", - "Node interface: attribute isConnected", - "Node interface: attribute ownerDocument", - "Node interface: operation getRootNode(optional GetRootNodeOptions)", - "Node interface: attribute parentNode", - "Node interface: attribute parentElement", - "Node interface: operation hasChildNodes()", - "Node interface: attribute childNodes", - "Node interface: attribute firstChild", - "Node interface: attribute lastChild", - "Node interface: attribute previousSibling", - "Node interface: attribute nextSibling", - "Node interface: attribute nodeValue", - "Node interface: attribute textContent", - "Node interface: operation normalize()", - "Node interface: operation cloneNode(optional boolean)", - "Node interface: operation isEqualNode(Node?)", - "Node interface: operation isSameNode(Node?)", + "Node interface: constant DOCUMENT_NODE on interface object", + "Node interface: constant DOCUMENT_NODE on interface prototype object", + "Node interface: constant DOCUMENT_POSITION_CONTAINED_BY on interface object", + "Node interface: constant DOCUMENT_POSITION_CONTAINED_BY on interface prototype object", + "Node interface: constant DOCUMENT_POSITION_CONTAINS on interface object", + "Node interface: constant DOCUMENT_POSITION_CONTAINS on interface prototype object", "Node interface: constant DOCUMENT_POSITION_DISCONNECTED on interface object", "Node interface: constant DOCUMENT_POSITION_DISCONNECTED on interface prototype object", - "Node interface: constant DOCUMENT_POSITION_PRECEDING on interface object", - "Node interface: constant DOCUMENT_POSITION_PRECEDING on interface prototype object", "Node interface: constant DOCUMENT_POSITION_FOLLOWING on interface object", "Node interface: constant DOCUMENT_POSITION_FOLLOWING on interface prototype object", - "Node interface: constant DOCUMENT_POSITION_CONTAINS on interface object", - "Node interface: constant DOCUMENT_POSITION_CONTAINS on interface prototype object", - "Node interface: constant DOCUMENT_POSITION_CONTAINED_BY on interface object", - "Node interface: constant DOCUMENT_POSITION_CONTAINED_BY on interface prototype object", "Node interface: constant DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC on interface object", "Node interface: constant DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC on interface prototype object", + "Node interface: constant DOCUMENT_POSITION_PRECEDING on interface object", + "Node interface: constant DOCUMENT_POSITION_PRECEDING on interface prototype object", + "Node interface: constant DOCUMENT_TYPE_NODE on interface object", + "Node interface: constant DOCUMENT_TYPE_NODE on interface prototype object", + "Node interface: constant ELEMENT_NODE on interface object", + "Node interface: constant ELEMENT_NODE on interface prototype object", + "Node interface: constant ENTITY_NODE on interface object", + "Node interface: constant ENTITY_NODE on interface prototype object", + "Node interface: constant ENTITY_REFERENCE_NODE on interface object", + "Node interface: constant ENTITY_REFERENCE_NODE on interface prototype object", + "Node interface: constant NOTATION_NODE on interface object", + "Node interface: constant NOTATION_NODE on interface prototype object", + "Node interface: constant PROCESSING_INSTRUCTION_NODE on interface object", + "Node interface: constant PROCESSING_INSTRUCTION_NODE on interface prototype object", + "Node interface: constant TEXT_NODE on interface object", + "Node interface: constant TEXT_NODE on interface prototype object", + "Node interface: existence and properties of interface object", + "Node interface: existence and properties of interface prototype object", + "Node interface: existence and properties of interface prototype object's \"constructor\" property", + "Node interface: existence and properties of interface prototype object's @@unscopables property", + "Node interface: operation appendChild(Node)", + "Node interface: operation cloneNode(optional boolean)", "Node interface: operation compareDocumentPosition(Node)", "Node interface: operation contains(Node?)", - "Node interface: operation lookupPrefix(DOMString?)", - "Node interface: operation lookupNamespaceURI(DOMString?)", - "Node interface: operation isDefaultNamespace(DOMString?)", + "Node interface: operation getRootNode(optional GetRootNodeOptions)", + "Node interface: operation hasChildNodes()", "Node interface: operation insertBefore(Node, Node?)", - "Node interface: operation appendChild(Node)", - "Node interface: operation replaceChild(Node, Node)", + "Node interface: operation isDefaultNamespace(DOMString?)", + "Node interface: operation isEqualNode(Node?)", + "Node interface: operation isSameNode(Node?)", + "Node interface: operation lookupNamespaceURI(DOMString?)", + "Node interface: operation lookupPrefix(DOMString?)", + "Node interface: operation normalize()", "Node interface: operation removeChild(Node)", + "Node interface: operation replaceChild(Node, Node)", "idl_test setup" ] }, @@ -819,71 +819,71 @@ "eventPathRemoved.html": true, "historical.html": { "expectedFailures": [ + "Attr member must be removed: isId", + "Attr member must be removed: schemaTypeInfo", + "DOMImplementation.getFeature() must be removed.", + "DocumentType member must be removed: entities", + "DocumentType member must be removed: internalSubset", + "DocumentType member must be removed: notations", + "Historical DOM features must be removed: async", + "Historical DOM features must be removed: commands", "Historical DOM features must be removed: createEntityReference", - "Historical DOM features must be removed: xmlEncoding", - "Historical DOM features must be removed: xmlStandalone", - "Historical DOM features must be removed: xmlVersion", - "Historical DOM features must be removed: strictErrorChecking", - "Historical DOM features must be removed: domConfig", - "Historical DOM features must be removed: normalizeDocument", - "Historical DOM features must be removed: renameNode", + "Historical DOM features must be removed: cssElementMap", "Historical DOM features must be removed: defaultCharset", + "Historical DOM features must be removed: domConfig", "Historical DOM features must be removed: height", - "Historical DOM features must be removed: width", - "Historical DOM features must be removed: commands", - "Historical DOM features must be removed: cssElementMap", - "Historical DOM features must be removed: async", + "Historical DOM features must be removed: normalizeDocument", "Historical DOM features must be removed: origin", - "document.load", - "XMLDocument.load", - "DOMImplementation.getFeature() must be removed.", + "Historical DOM features must be removed: renameNode", "Historical DOM features must be removed: schemaTypeInfo", "Historical DOM features must be removed: setIdAttribute", "Historical DOM features must be removed: setIdAttributeNS", "Historical DOM features must be removed: setIdAttributeNode", - "Attr member must be removed: schemaTypeInfo", - "Attr member must be removed: isId", - "DocumentType member must be removed: entities", - "DocumentType member must be removed: notations", - "DocumentType member must be removed: internalSubset", - "Text member must be removed: isElementContentWhitespace", - "Text member must be removed: replaceWholeText", - "Node member must be removed: hasAttributes", + "Historical DOM features must be removed: strictErrorChecking", + "Historical DOM features must be removed: width", + "Historical DOM features must be removed: xmlEncoding", + "Historical DOM features must be removed: xmlStandalone", + "Historical DOM features must be removed: xmlVersion", "Node member must be removed: attributes", - "Node member must be removed: namespaceURI", - "Node member must be removed: prefix", - "Node member must be removed: localName", - "Node member must be removed: isSupported", "Node member must be removed: getFeature", "Node member must be removed: getUserData", + "Node member must be removed: hasAttributes", + "Node member must be removed: isSupported", + "Node member must be removed: localName", + "Node member must be removed: namespaceURI", + "Node member must be removed: prefix", + "Node member must be removed: rootNode", "Node member must be removed: setUserData", - "Node member must be removed: rootNode" + "Text member must be removed: isElementContentWhitespace", + "Text member must be removed: replaceWholeText", + "XMLDocument.load", + "document.load" ] }, "idlharness.any.worker.html": { "expectedFailures": [ - "Event interface: attribute srcElement", - "Event interface: operation composedPath()", - "Event interface: operation stopPropagation()", + "AbortController interface: operation abort(optional any)", + "AbortSignal interface: attribute onabort", + "CustomEvent interface: calling initCustomEvent(DOMString, optional boolean, optional boolean, optional any) on new CustomEvent(\"foo\") with too few arguments must throw TypeError", + "CustomEvent interface: new CustomEvent(\"foo\") must inherit property \"initCustomEvent(DOMString, optional boolean, optional boolean, optional any)\" with the proper type", + "CustomEvent interface: operation initCustomEvent(DOMString, optional boolean, optional boolean, optional any)", "Event interface: attribute cancelBubble", - "Event interface: operation stopImmediatePropagation()", - "Event interface: attribute returnValue", - "Event interface: operation preventDefault()", "Event interface: attribute defaultPrevented", - "Event interface: operation initEvent(DOMString, optional boolean, optional boolean)", - "Event interface: new Event(\"foo\") must have own property \"isTrusted\"", - "Event interface: new Event(\"foo\") must inherit property \"initEvent(DOMString, optional boolean, optional boolean)\" with the proper type", + "Event interface: attribute returnValue", + "Event interface: attribute srcElement", + "Event interface: calling initEvent(DOMString, optional boolean, optional boolean) on new CustomEvent(\"foo\") with too few arguments must throw TypeError", "Event interface: calling initEvent(DOMString, optional boolean, optional boolean) on new Event(\"foo\") with too few arguments must throw TypeError", - "CustomEvent interface: operation initCustomEvent(DOMString, optional boolean, optional boolean, optional any)", - "CustomEvent interface: new CustomEvent(\"foo\") must inherit property \"initCustomEvent(DOMString, optional boolean, optional boolean, optional any)\" with the proper type", - "CustomEvent interface: calling initCustomEvent(DOMString, optional boolean, optional boolean, optional any) on new CustomEvent(\"foo\") with too few arguments must throw TypeError", "Event interface: new CustomEvent(\"foo\") must have own property \"isTrusted\"", "Event interface: new CustomEvent(\"foo\") must inherit property \"initEvent(DOMString, optional boolean, optional boolean)\" with the proper type", - "Event interface: calling initEvent(DOMString, optional boolean, optional boolean) on new CustomEvent(\"foo\") with too few arguments must throw TypeError", + "Event interface: new Event(\"foo\") must have own property \"isTrusted\"", + "Event interface: new Event(\"foo\") must inherit property \"initEvent(DOMString, optional boolean, optional boolean)\" with the proper type", + "Event interface: operation composedPath()", + "Event interface: operation initEvent(DOMString, optional boolean, optional boolean)", + "Event interface: operation preventDefault()", + "Event interface: operation stopImmediatePropagation()", + "Event interface: operation stopPropagation()", "EventTarget interface: operation addEventListener(DOMString, EventListener?, optional (AddEventListenerOptions or boolean))", - "EventTarget interface: operation removeEventListener(DOMString, EventListener?, optional (EventListenerOptions or boolean))", - "AbortController interface: operation abort(optional any)", - "AbortSignal interface: attribute onabort" + "EventTarget interface: operation removeEventListener(DOMString, EventListener?, optional (EventListenerOptions or boolean))" ] }, "interface-objects.html": false, @@ -914,142 +914,142 @@ "DOMImplementation-createHTMLDocument.html": false, "DOMImplementation-hasFeature.html": { "expectedFailures": [ - "hasFeature()", - "hasFeature(\"Core\")", - "hasFeature(\"XML\")", - "hasFeature(\"org.w3c.svg\")", - "hasFeature(\"org.w3c.dom.svg\")", - "hasFeature(\"http://www.w3.org/TR/SVG11/feature#Script\")", - "hasFeature(\"Core\", \"1.0\")", - "hasFeature(\"Core\", \"2.0\")", - "hasFeature(\"Core\", \"3.0\")", - "hasFeature(\"Core\", \"100.0\")", - "hasFeature(\"XML\", \"1.0\")", - "hasFeature(\"XML\", \"2.0\")", - "hasFeature(\"XML\", \"3.0\")", - "hasFeature(\"XML\", \"100.0\")", - "hasFeature(\"Core\", \"1\")", - "hasFeature(\"Core\", \"2\")", - "hasFeature(\"Core\", \"3\")", - "hasFeature(\"Core\", \"100\")", - "hasFeature(\"XML\", \"1\")", - "hasFeature(\"XML\", \"2\")", - "hasFeature(\"XML\", \"3\")", - "hasFeature(\"XML\", \"100\")", - "hasFeature(\"Core\", \"1.1\")", - "hasFeature(\"Core\", \"2.1\")", - "hasFeature(\"Core\", \"3.1\")", - "hasFeature(\"Core\", \"100.1\")", - "hasFeature(\"XML\", \"1.1\")", - "hasFeature(\"XML\", \"2.1\")", - "hasFeature(\"XML\", \"3.1\")", - "hasFeature(\"XML\", \"100.1\")", - "hasFeature(\"Core\", \"\")", - "hasFeature(\"XML\", \"\")", - "hasFeature(\"core\", \"\")", - "hasFeature(\"xml\", \"\")", - "hasFeature(\"CoRe\", \"\")", - "hasFeature(\"XmL\", \"\")", "hasFeature(\" Core\", \"\")", + "hasFeature(\" Core\", null)", "hasFeature(\" XML\", \"\")", - "hasFeature(\"Core \", \"\")", - "hasFeature(\"XML \", \"\")", + "hasFeature(\" XML\", null)", "hasFeature(\"Co re\", \"\")", - "hasFeature(\"XM L\", \"\")", - "hasFeature(\"aCore\", \"\")", - "hasFeature(\"aXML\", \"\")", - "hasFeature(\"Corea\", \"\")", - "hasFeature(\"XMLa\", \"\")", + "hasFeature(\"Co re\", null)", + "hasFeature(\"CoRe\", \"\")", + "hasFeature(\"CoRe\", null)", "hasFeature(\"Coare\", \"\")", - "hasFeature(\"XMaL\", \"\")", + "hasFeature(\"Coare\", null)", + "hasFeature(\"Core \", \"\")", + "hasFeature(\"Core \", null)", + "hasFeature(\"Core\")", "hasFeature(\"Core\", \" \")", - "hasFeature(\"XML\", \" \")", "hasFeature(\"Core\", \" 1.0\")", + "hasFeature(\"Core\", \" 100.0\")", "hasFeature(\"Core\", \" 2.0\")", "hasFeature(\"Core\", \" 3.0\")", - "hasFeature(\"Core\", \" 100.0\")", - "hasFeature(\"XML\", \" 1.0\")", - "hasFeature(\"XML\", \" 2.0\")", - "hasFeature(\"XML\", \" 3.0\")", - "hasFeature(\"XML\", \" 100.0\")", + "hasFeature(\"Core\", \"\")", + "hasFeature(\"Core\", \"1\")", + "hasFeature(\"Core\", \"1. 0\")", "hasFeature(\"Core\", \"1.0 \")", - "hasFeature(\"Core\", \"2.0 \")", - "hasFeature(\"Core\", \"3.0 \")", + "hasFeature(\"Core\", \"1.0\")", + "hasFeature(\"Core\", \"1.0a\")", + "hasFeature(\"Core\", \"1.1\")", + "hasFeature(\"Core\", \"1.a0\")", + "hasFeature(\"Core\", \"100\")", + "hasFeature(\"Core\", \"100. 0\")", "hasFeature(\"Core\", \"100.0 \")", - "hasFeature(\"XML\", \"1.0 \")", - "hasFeature(\"XML\", \"2.0 \")", - "hasFeature(\"XML\", \"3.0 \")", - "hasFeature(\"XML\", \"100.0 \")", - "hasFeature(\"Core\", \"1. 0\")", + "hasFeature(\"Core\", \"100.0\")", + "hasFeature(\"Core\", \"100.0a\")", + "hasFeature(\"Core\", \"100.1\")", + "hasFeature(\"Core\", \"100.a0\")", + "hasFeature(\"Core\", \"2\")", "hasFeature(\"Core\", \"2. 0\")", + "hasFeature(\"Core\", \"2.0 \")", + "hasFeature(\"Core\", \"2.0\")", + "hasFeature(\"Core\", \"2.0a\")", + "hasFeature(\"Core\", \"2.1\")", + "hasFeature(\"Core\", \"2.a0\")", + "hasFeature(\"Core\", \"3\")", "hasFeature(\"Core\", \"3. 0\")", - "hasFeature(\"Core\", \"100. 0\")", + "hasFeature(\"Core\", \"3.0 \")", + "hasFeature(\"Core\", \"3.0\")", + "hasFeature(\"Core\", \"3.0a\")", + "hasFeature(\"Core\", \"3.1\")", + "hasFeature(\"Core\", \"3.a0\")", + "hasFeature(\"Core\", \"a1.0\")", + "hasFeature(\"Core\", \"a100.0\")", + "hasFeature(\"Core\", \"a2.0\")", + "hasFeature(\"Core\", \"a3.0\")", + "hasFeature(\"Core\", 1)", + "hasFeature(\"Core\", 100)", + "hasFeature(\"Core\", 2)", + "hasFeature(\"Core\", 3)", + "hasFeature(\"Core\", null)", + "hasFeature(\"Core\", undefined)", + "hasFeature(\"Corea\", \"\")", + "hasFeature(\"Corea\", null)", + "hasFeature(\"This is filler text.\", \"\")", + "hasFeature(\"XM L\", \"\")", + "hasFeature(\"XM L\", null)", + "hasFeature(\"XML \", \"\")", + "hasFeature(\"XML \", null)", + "hasFeature(\"XML\")", + "hasFeature(\"XML\", \" \")", + "hasFeature(\"XML\", \" 1.0\")", + "hasFeature(\"XML\", \" 100.0\")", + "hasFeature(\"XML\", \" 2.0\")", + "hasFeature(\"XML\", \" 3.0\")", + "hasFeature(\"XML\", \"\")", + "hasFeature(\"XML\", \"1\")", "hasFeature(\"XML\", \"1. 0\")", + "hasFeature(\"XML\", \"1.0 \")", + "hasFeature(\"XML\", \"1.0\")", + "hasFeature(\"XML\", \"1.0a\")", + "hasFeature(\"XML\", \"1.1\")", + "hasFeature(\"XML\", \"1.a0\")", + "hasFeature(\"XML\", \"100\")", + "hasFeature(\"XML\", \"100. 0\")", + "hasFeature(\"XML\", \"100.0 \")", + "hasFeature(\"XML\", \"100.0\")", + "hasFeature(\"XML\", \"100.0a\")", + "hasFeature(\"XML\", \"100.1\")", + "hasFeature(\"XML\", \"100.a0\")", + "hasFeature(\"XML\", \"2\")", "hasFeature(\"XML\", \"2. 0\")", + "hasFeature(\"XML\", \"2.0 \")", + "hasFeature(\"XML\", \"2.0\")", + "hasFeature(\"XML\", \"2.0a\")", + "hasFeature(\"XML\", \"2.1\")", + "hasFeature(\"XML\", \"2.a0\")", + "hasFeature(\"XML\", \"3\")", "hasFeature(\"XML\", \"3. 0\")", - "hasFeature(\"XML\", \"100. 0\")", - "hasFeature(\"Core\", \"a1.0\")", - "hasFeature(\"Core\", \"a2.0\")", - "hasFeature(\"Core\", \"a3.0\")", - "hasFeature(\"Core\", \"a100.0\")", + "hasFeature(\"XML\", \"3.0 \")", + "hasFeature(\"XML\", \"3.0\")", + "hasFeature(\"XML\", \"3.0a\")", + "hasFeature(\"XML\", \"3.1\")", + "hasFeature(\"XML\", \"3.a0\")", "hasFeature(\"XML\", \"a1.0\")", + "hasFeature(\"XML\", \"a100.0\")", "hasFeature(\"XML\", \"a2.0\")", "hasFeature(\"XML\", \"a3.0\")", - "hasFeature(\"XML\", \"a100.0\")", - "hasFeature(\"Core\", \"1.0a\")", - "hasFeature(\"Core\", \"2.0a\")", - "hasFeature(\"Core\", \"3.0a\")", - "hasFeature(\"Core\", \"100.0a\")", - "hasFeature(\"XML\", \"1.0a\")", - "hasFeature(\"XML\", \"2.0a\")", - "hasFeature(\"XML\", \"3.0a\")", - "hasFeature(\"XML\", \"100.0a\")", - "hasFeature(\"Core\", \"1.a0\")", - "hasFeature(\"Core\", \"2.a0\")", - "hasFeature(\"Core\", \"3.a0\")", - "hasFeature(\"Core\", \"100.a0\")", - "hasFeature(\"XML\", \"1.a0\")", - "hasFeature(\"XML\", \"2.a0\")", - "hasFeature(\"XML\", \"3.a0\")", - "hasFeature(\"XML\", \"100.a0\")", - "hasFeature(\"Core\", 1)", - "hasFeature(\"Core\", 2)", - "hasFeature(\"Core\", 3)", - "hasFeature(\"Core\", 100)", "hasFeature(\"XML\", 1)", + "hasFeature(\"XML\", 100)", "hasFeature(\"XML\", 2)", "hasFeature(\"XML\", 3)", - "hasFeature(\"XML\", 100)", - "hasFeature(\"Core\", null)", "hasFeature(\"XML\", null)", - "hasFeature(\"core\", null)", - "hasFeature(\"xml\", null)", - "hasFeature(\"CoRe\", null)", + "hasFeature(\"XML\", undefined)", + "hasFeature(\"XMLa\", \"\")", + "hasFeature(\"XMLa\", null)", + "hasFeature(\"XMaL\", \"\")", + "hasFeature(\"XMaL\", null)", + "hasFeature(\"XmL\", \"\")", "hasFeature(\"XmL\", null)", - "hasFeature(\" Core\", null)", - "hasFeature(\" XML\", null)", - "hasFeature(\"Core \", null)", - "hasFeature(\"XML \", null)", - "hasFeature(\"Co re\", null)", - "hasFeature(\"XM L\", null)", + "hasFeature(\"aCore\", \"\")", "hasFeature(\"aCore\", null)", + "hasFeature(\"aXML\", \"\")", "hasFeature(\"aXML\", null)", - "hasFeature(\"Corea\", null)", - "hasFeature(\"XMLa\", null)", - "hasFeature(\"Coare\", null)", - "hasFeature(\"XMaL\", null)", - "hasFeature(\"Core\", undefined)", - "hasFeature(\"XML\", undefined)", - "hasFeature(\"This is filler text.\", \"\")", - "hasFeature(null, \"\")", - "hasFeature(undefined, \"\")", - "hasFeature(\"org.w3c.svg\", \"\")", - "hasFeature(\"org.w3c.svg\", \"1.0\")", - "hasFeature(\"org.w3c.svg\", \"1.1\")", + "hasFeature(\"core\", \"\")", + "hasFeature(\"core\", null)", + "hasFeature(\"http://www.w3.org/TR/SVG11/feature#Script\")", + "hasFeature(\"http://www.w3.org/TR/SVG11/feature#Script\", \"7.5\")", + "hasFeature(\"org.w3c.dom.svg\")", "hasFeature(\"org.w3c.dom.svg\", \"\")", "hasFeature(\"org.w3c.dom.svg\", \"1.0\")", "hasFeature(\"org.w3c.dom.svg\", \"1.1\")", - "hasFeature(\"http://www.w3.org/TR/SVG11/feature#Script\", \"7.5\")" + "hasFeature(\"org.w3c.svg\")", + "hasFeature(\"org.w3c.svg\", \"\")", + "hasFeature(\"org.w3c.svg\", \"1.0\")", + "hasFeature(\"org.w3c.svg\", \"1.1\")", + "hasFeature(\"xml\", \"\")", + "hasFeature(\"xml\", null)", + "hasFeature()", + "hasFeature(null, \"\")", + "hasFeature(undefined, \"\")" ] }, "Document-URL.html": false, @@ -1086,17 +1086,17 @@ "Document-createEvent.https.html": false, "Document-createProcessingInstruction.html": { "expectedFailures": [ + "Should get a ProcessingInstruction for target \"A·A\" and data \"x\".", + "Should get a ProcessingInstruction for target \"a0\" and data \"x\".", + "Should get a ProcessingInstruction for target \"xml:fail\" and data \"x\".", + "Should throw an INVALID_CHARACTER_ERR for target \"0\" and data \"x\".", "Should throw an INVALID_CHARACTER_ERR for target \"A\" and data \"?>\".", - "Should throw an INVALID_CHARACTER_ERR for target \"·A\" and data \"x\".", - "Should throw an INVALID_CHARACTER_ERR for target \"×A\" and data \"x\".", "Should throw an INVALID_CHARACTER_ERR for target \"A×\" and data \"x\".", "Should throw an INVALID_CHARACTER_ERR for target \"\\\\A\" and data \"x\".", "Should throw an INVALID_CHARACTER_ERR for target \"\\f\" and data \"x\".", - "Should throw an INVALID_CHARACTER_ERR for target 0 and data \"x\".", - "Should throw an INVALID_CHARACTER_ERR for target \"0\" and data \"x\".", - "Should get a ProcessingInstruction for target \"xml:fail\" and data \"x\".", - "Should get a ProcessingInstruction for target \"A·A\" and data \"x\".", - "Should get a ProcessingInstruction for target \"a0\" and data \"x\"." + "Should throw an INVALID_CHARACTER_ERR for target \"·A\" and data \"x\".", + "Should throw an INVALID_CHARACTER_ERR for target \"×A\" and data \"x\".", + "Should throw an INVALID_CHARACTER_ERR for target 0 and data \"x\"." ] }, "Document-createTextNode.html": false, @@ -1144,11 +1144,11 @@ "Element-siblingElement-null.html": false, "Element-tagName.html": { "expectedFailures": [ - "tagName should upper-case for HTML elements in HTML documents.", + "tagName should be updated when changing ownerDocument (createDocument with prefix)", + "tagName should be updated when changing ownerDocument (createDocument without prefix)", "tagName should not upper-case for SVG elements in HTML documents.", "tagName should not upper-case for other non-HTML namespaces", - "tagName should be updated when changing ownerDocument (createDocument without prefix)", - "tagName should be updated when changing ownerDocument (createDocument with prefix)" + "tagName should upper-case for HTML elements in HTML documents." ] }, "Element-webkitMatchesSelector.html": false, @@ -1218,72 +1218,72 @@ "attributes-namednodemap.html": false, "attributes.html": { "expectedFailures": [ - "When qualifiedName does not match the Name production, an INVALID_CHARACTER_ERR exception is to be thrown. (toggleAttribute)", - "toggleAttribute should lowercase its name argument (upper case attribute)", - "toggleAttribute should lowercase its name argument (mixed case attribute)", - "toggleAttribute should not throw even when qualifiedName starts with 'xmlns'", - "Basic functionality should be intact. (toggleAttribute)", - "toggleAttribute should not change the order of previously set attributes.", - "toggleAttribute should set the first attribute with the given name", - "toggleAttribute should set the attribute with the given qualified name", - "Toggling element with inline style should make inline style disappear", - "When qualifiedName does not match the Name production, an INVALID_CHARACTER_ERR exception is to be thrown. (setAttribute)", - "setAttribute should lowercase its name argument (upper case attribute)", - "setAttribute should lowercase its name argument (mixed case attribute)", - "setAttribute should not throw even when qualifiedName starts with 'xmlns'", - "Basic functionality should be intact.", - "setAttribute should not change the order of previously set attributes.", - "setAttribute should set the first attribute with the given name", - "setAttribute should set the attribute with the given qualified name", - "When qualifiedName does not match the Name production, an INVALID_CHARACTER_ERR exception is to be thrown. (setAttributeNS)", - "When qualifiedName does not match the QName production, an INVALID_CHARACTER_ERR exception is to be thrown.", - "null and the empty string should result in a null namespace.", "A namespace is required to use a prefix.", - "The xml prefix should not be allowed for arbitrary namespaces", - "XML-namespaced attributes don't need an xml prefix", - "The xmlns prefix should not be allowed for arbitrary namespaces", - "The xmlns qualified name should not be allowed for arbitrary namespaces", - "xmlns should be allowed as local name", - "The XMLNS namespace should require xmlns as prefix or qualified name", - "xmlns should be allowed as prefix in the XMLNS namespace", - "xmlns should be allowed as qualified name in the XMLNS namespace", - "Setting the same attribute with another prefix should not change the prefix", - "setAttribute should not throw even if a load is not allowed", - "Attributes should work in document fragments.", + "Attribute loses its owner when removed", "Attribute values should not be parsed.", - "Specified attributes should be accessible.", + "Attribute with prefix in local name", + "Attributes should work in document fragments.", + "Basic functionality of getAttributeNode/getAttributeNodeNS", + "Basic functionality of removeAttributeNode", + "Basic functionality of setAttributeNode", + "Basic functionality of setAttributeNodeNS", + "Basic functionality should be intact.", + "Basic functionality should be intact. (toggleAttribute)", "Entities in attributes should have been expanded while parsing.", - "Unset attributes return null", "First set attribute is returned by getAttribute", - "Style attributes are not normalized", - "Only lowercase attributes are returned on HTML elements (upper case attribute)", - "Only lowercase attributes are returned on HTML elements (mixed case attribute)", "First set attribute is returned with mapped attribute set first", "First set attribute is returned with mapped attribute set later", + "If attr’s element is neither null nor element, throw an InUseAttributeError.", "Non-HTML element with upper-case attribute", - "Attribute with prefix in local name", - "Attribute loses its owner when removed", - "Basic functionality of getAttributeNode/getAttributeNodeNS", - "Basic functionality of setAttributeNode", - "setAttributeNode should distinguish attributes with same local name and different namespaces", + "Only lowercase attributes are returned on HTML elements (mixed case attribute)", + "Only lowercase attributes are returned on HTML elements (upper case attribute)", + "Own property correctness with basic attributes", + "Own property correctness with namespaced attribute before same-name non-namespaced one", + "Own property correctness with non-namespaced attribute before same-name namespaced one", + "Own property correctness with two namespaced attributes with the same name-with-prefix", + "Own property names should include all qualified names for a non-HTML element in an HTML document", + "Own property names should include all qualified names for an HTML element in a non-HTML document", + "Own property names should only include all-lowercase qualified names for an HTML element in an HTML document", + "Replacing an attr by itself", + "Setting the same attribute with another prefix should not change the prefix", + "Specified attributes should be accessible.", + "Style attributes are not normalized", + "The XMLNS namespace should require xmlns as prefix or qualified name", + "The xml prefix should not be allowed for arbitrary namespaces", + "The xmlns prefix should not be allowed for arbitrary namespaces", + "The xmlns qualified name should not be allowed for arbitrary namespaces", + "Toggling element with inline style should make inline style disappear", + "Unset attributes return null", + "When qualifiedName does not match the Name production, an INVALID_CHARACTER_ERR exception is to be thrown. (setAttribute)", + "When qualifiedName does not match the Name production, an INVALID_CHARACTER_ERR exception is to be thrown. (setAttributeNS)", + "When qualifiedName does not match the Name production, an INVALID_CHARACTER_ERR exception is to be thrown. (toggleAttribute)", + "When qualifiedName does not match the QName production, an INVALID_CHARACTER_ERR exception is to be thrown.", + "XML-namespaced attributes don't need an xml prefix", + "getAttributeNames tests", + "null and the empty string should result in a null namespace.", + "setAttribute should lowercase its name argument (mixed case attribute)", + "setAttribute should lowercase its name argument (upper case attribute)", + "setAttribute should not change the order of previously set attributes.", + "setAttribute should not throw even if a load is not allowed", + "setAttribute should not throw even when qualifiedName starts with 'xmlns'", + "setAttribute should set the attribute with the given qualified name", + "setAttribute should set the first attribute with the given name", + "setAttributeNode called with an Attr that has the same name as an existing one should not change attribute order", "setAttributeNode doesn't have case-insensitivity even with an HTMLElement 1", "setAttributeNode doesn't have case-insensitivity even with an HTMLElement 2", "setAttributeNode doesn't have case-insensitivity even with an HTMLElement 3", - "Basic functionality of setAttributeNodeNS", - "If attr’s element is neither null nor element, throw an InUseAttributeError.", - "Replacing an attr by itself", - "Basic functionality of removeAttributeNode", "setAttributeNode on bound attribute should throw InUseAttributeError", + "setAttributeNode should distinguish attributes with same local name and different namespaces", "setAttributeNode, if it fires mutation events, should fire one with the new node when resetting an existing attribute (outer shell)", - "setAttributeNode called with an Attr that has the same name as an existing one should not change attribute order", - "getAttributeNames tests", - "Own property correctness with basic attributes", - "Own property correctness with non-namespaced attribute before same-name namespaced one", - "Own property correctness with namespaced attribute before same-name non-namespaced one", - "Own property correctness with two namespaced attributes with the same name-with-prefix", - "Own property names should only include all-lowercase qualified names for an HTML element in an HTML document", - "Own property names should include all qualified names for a non-HTML element in an HTML document", - "Own property names should include all qualified names for an HTML element in a non-HTML document" + "toggleAttribute should lowercase its name argument (mixed case attribute)", + "toggleAttribute should lowercase its name argument (upper case attribute)", + "toggleAttribute should not change the order of previously set attributes.", + "toggleAttribute should not throw even when qualifiedName starts with 'xmlns'", + "toggleAttribute should set the attribute with the given qualified name", + "toggleAttribute should set the first attribute with the given name", + "xmlns should be allowed as local name", + "xmlns should be allowed as prefix in the XMLNS namespace", + "xmlns should be allowed as qualified name in the XMLNS namespace" ] }, "case.html": false, @@ -1309,40 +1309,40 @@ "tentative": { "idlharness.html": { "expectedFailures": [ - "Subscriber interface: existence and properties of interface object", - "Subscriber interface object length", - "Subscriber interface object name", - "Subscriber interface: existence and properties of interface prototype object", - "Subscriber interface: existence and properties of interface prototype object's \"constructor\" property", - "Subscriber interface: existence and properties of interface prototype object's @@unscopables property", - "Subscriber interface: operation next(any)", - "Subscriber interface: operation error(any)", - "Subscriber interface: operation complete()", - "Subscriber interface: operation addTeardown(VoidFunction)", - "Subscriber interface: attribute active", - "Subscriber interface: attribute signal", - "Subscriber must be primary interface of (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })()", - "Stringification of (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })()", - "Subscriber interface: (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() must inherit property \"next(any)\" with the proper type", - "Subscriber interface: calling next(any) on (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() with too few arguments must throw TypeError", - "Subscriber interface: (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() must inherit property \"error(any)\" with the proper type", - "Subscriber interface: calling error(any) on (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() with too few arguments must throw TypeError", - "Subscriber interface: (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() must inherit property \"complete()\" with the proper type", - "Subscriber interface: (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() must inherit property \"addTeardown(VoidFunction)\" with the proper type", - "Subscriber interface: calling addTeardown(VoidFunction) on (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() with too few arguments must throw TypeError", - "Subscriber interface: (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() must inherit property \"active\" with the proper type", - "Subscriber interface: (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() must inherit property \"signal\" with the proper type", - "Observable interface: existence and properties of interface object", "Observable interface object length", "Observable interface object name", + "Observable interface: calling subscribe(optional ObserverUnion, optional SubscribeOptions) on new Observable(() => {}) with too few arguments must throw TypeError", + "Observable interface: existence and properties of interface object", "Observable interface: existence and properties of interface prototype object", "Observable interface: existence and properties of interface prototype object's \"constructor\" property", "Observable interface: existence and properties of interface prototype object's @@unscopables property", + "Observable interface: new Observable(() => {}) must inherit property \"subscribe(optional ObserverUnion, optional SubscribeOptions)\" with the proper type", "Observable interface: operation subscribe(optional ObserverUnion, optional SubscribeOptions)", "Observable must be primary interface of new Observable(() => {})", + "Stringification of (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })()", "Stringification of new Observable(() => {})", - "Observable interface: new Observable(() => {}) must inherit property \"subscribe(optional ObserverUnion, optional SubscribeOptions)\" with the proper type", - "Observable interface: calling subscribe(optional ObserverUnion, optional SubscribeOptions) on new Observable(() => {}) with too few arguments must throw TypeError" + "Subscriber interface object length", + "Subscriber interface object name", + "Subscriber interface: (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() must inherit property \"active\" with the proper type", + "Subscriber interface: (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() must inherit property \"addTeardown(VoidFunction)\" with the proper type", + "Subscriber interface: (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() must inherit property \"complete()\" with the proper type", + "Subscriber interface: (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() must inherit property \"error(any)\" with the proper type", + "Subscriber interface: (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() must inherit property \"next(any)\" with the proper type", + "Subscriber interface: (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() must inherit property \"signal\" with the proper type", + "Subscriber interface: attribute active", + "Subscriber interface: attribute signal", + "Subscriber interface: calling addTeardown(VoidFunction) on (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() with too few arguments must throw TypeError", + "Subscriber interface: calling error(any) on (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() with too few arguments must throw TypeError", + "Subscriber interface: calling next(any) on (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })() with too few arguments must throw TypeError", + "Subscriber interface: existence and properties of interface object", + "Subscriber interface: existence and properties of interface prototype object", + "Subscriber interface: existence and properties of interface prototype object's \"constructor\" property", + "Subscriber interface: existence and properties of interface prototype object's @@unscopables property", + "Subscriber interface: operation addTeardown(VoidFunction)", + "Subscriber interface: operation complete()", + "Subscriber interface: operation error(any)", + "Subscriber interface: operation next(any)", + "Subscriber must be primary interface of (() => { let s = null; new Observable(_s => s = _s).subscribe({}); return s })()" ] }, "observable-constructor.any.html": false, @@ -1459,15 +1459,15 @@ }, "historical-mutation-events.html": { "expectedFailures": [ - "The DOMSubtreeModified mutation event must not be fired.", + "The DOMAttrModified mutation event must not be fired.", + "The DOMAttributeNameChanged mutation event must not be fired.", + "The DOMCharacterDataModified mutation event must not be fired.", + "The DOMElementNameChanged mutation event must not be fired.", "The DOMNodeInserted mutation event must not be fired.", + "The DOMNodeInsertedIntoDocument mutation event must not be fired.", "The DOMNodeRemoved mutation event must not be fired.", "The DOMNodeRemovedFromDocument mutation event must not be fired.", - "The DOMNodeInsertedIntoDocument mutation event must not be fired.", - "The DOMCharacterDataModified mutation event must not be fired.", - "The DOMAttrModified mutation event must not be fired.", - "The DOMAttributeNameChanged mutation event must not be fired.", - "The DOMElementNameChanged mutation event must not be fired." + "The DOMSubtreeModified mutation event must not be fired." ] } } diff --git a/tests/wpt/runner/expectations/html.json b/tests/wpt/runner/expectations/html.json index 370de12326be78..0b6218384cf8cb 100644 --- a/tests/wpt/runner/expectations/html.json +++ b/tests/wpt/runner/expectations/html.json @@ -89,10 +89,10 @@ "base-url-worker.sub.html": false, "base-url.sub.html": { "expectedFailures": [ - "Relative URL-like from same origin classic