diff --git a/.github/scripts/windows-window-probe.ps1 b/.github/scripts/windows-window-probe.ps1 new file mode 100644 index 0000000000..8a6102515d --- /dev/null +++ b/.github/scripts/windows-window-probe.ps1 @@ -0,0 +1,92 @@ +# Runs agent-facing commands from a launcher with no console (like an app that starts an agent), and fails if any +# of them opens a visible top-level window. +param([Parameter(Mandatory)][string]$Cli, [Parameter(Mandatory)][string]$Project) +$ErrorActionPreference = 'Stop' + +Add-Type @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +public static class Windows { + delegate bool EnumProc(IntPtr h, IntPtr p); + [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc f, IntPtr p); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowText(IntPtr h, StringBuilder s, int n); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassName(IntPtr h, StringBuilder s, int n); + public static List Visible() { + var rows = new List(); + EnumWindows((h, p) => { + if (!IsWindowVisible(h)) return true; + uint pid; GetWindowThreadProcessId(h, out pid); + var t = new StringBuilder(256); var c = new StringBuilder(256); + GetWindowText(h, t, 256); GetClassName(h, c, 256); + rows.Add(new[] { h.ToString(), pid.ToString(), c.ToString(), t.ToString() }); + return true; + }, IntPtr.Zero); + return rows; + } +} +'@ + +function Snapshot { + $seen = @{} + foreach ($w in [Windows]::Visible()) { + $proc = Get-Process -Id ([int]$w[1]) -ErrorAction SilentlyContinue + $seen[$w[0]] = [pscustomobject]@{ Process = $proc.ProcessName; Id = $w[1]; Class = $w[2]; Title = $w[3]; Cmd = '' } + } + $seen +} + +# Starts `node launcher.mjs ` and reports every window that appears meanwhile. +function Watch([string]$Name, [int]$Seconds, [string[]]$CliArgs) { + $baseline = Snapshot + $p = Start-Process -FilePath node -ArgumentList (@($launcher, $Cli, $Project, $Seconds) + $CliArgs) -WindowStyle Hidden -PassThru + $found = @{} + while (-not $p.HasExited) { + foreach ($entry in (Snapshot).GetEnumerator()) { + if (-not $baseline.ContainsKey($entry.Key) -and -not $found.ContainsKey($entry.Key)) { + $row = $entry.Value + if ($row.Process -match 'chrome') { + $q = Get-CimInstance Win32_Process -Filter "ProcessId=$($row.Id)" -ErrorAction SilentlyContinue + $parent = if ($q) { Get-CimInstance Win32_Process -Filter "ProcessId=$($q.ParentProcessId)" -ErrorAction SilentlyContinue } + $row.Cmd = "pid=$($row.Id) parent=[$($parent.Name) $($parent.ProcessId) main=$((($parent.CommandLine -replace '^.*chrome-headless-shell.exe"? ',''))) ] cmd=[$((("$($q.CommandLine)") -replace '^.*chrome-headless-shell.exe"? ','').Substring(0, [Math]::Min(260, ("$($q.CommandLine)" -replace '^.*chrome-headless-shell.exe"? ','').Length)))]" + } + $found[$entry.Key] = $row + } + } + Start-Sleep -Milliseconds 50 + } + $p.WaitForExit() + Write-Host ("{0,-10} exit={1} new windows={2}" -f $Name, $p.ExitCode, $found.Count) + if ($p.ExitCode -ne 0) { Get-Content (Join-Path $env:RUNNER_TEMP "command.log") -Tail 12 | ForEach-Object { Write-Host " | $_" } } + $found.Values | ForEach-Object { Write-Host " $($_.Process) class=$($_.Class) title=[$($_.Title)] $($_.Cmd)" } + [pscustomobject]@{ Name = $Name; Exit = $p.ExitCode; Windows = $found.Count } +} + +# The launcher has no console and no window; windowsHide only reaches the CLI itself, never what the CLI starts. +$launcher = Join-Path $env:RUNNER_TEMP 'launch-cli.mjs' +@' +import { spawn } from "node:child_process"; +import { openSync } from "node:fs"; +const [cli, cwd, seconds, ...args] = process.argv.slice(2); +const log = openSync(process.env.RUNNER_TEMP + "\\command.log", "w"); +const child = spawn(process.execPath, [cli, ...args], { cwd, windowsHide: true, stdio: ["ignore", log, log] }); +const stop = Number(seconds) > 0 ? setTimeout(() => child.kill(), Number(seconds) * 1000) : undefined; +child.on("exit", (code) => { clearTimeout(stop); process.exit(code ?? 0); }); +'@ | Set-Content -Encoding utf8 $launcher + + +$runs = @( + (Watch 'render' 0 @('render', '--fps', '30', '--quality', 'draft', '--workers', '2', '--output', 'renders/probe.mp4')), + (Watch 'check' 0 @('check')), + (Watch 'check-warm' 0 @('check')), + (Watch 'snapshot' 0 @('snapshot', '--frames', '2')), + (Watch 'doctor' 0 @('doctor')), + (Watch 'preview' 20 @('preview', '--port', '3571')) +) +$bad = $runs | Where-Object { $_.Windows -gt 0 } +if ($bad) { throw "windows opened by: $(($bad | ForEach-Object Name) -join ', ')" } +$failed = $runs | Where-Object { $_.Name -in @('render', 'snapshot') -and $_.Exit -ne 0 } +if ($failed) { throw "commands failed: $(($failed | ForEach-Object Name) -join ', ')" } diff --git a/.github/workflows/windows-window-probe.yml b/.github/workflows/windows-window-probe.yml new file mode 100644 index 0000000000..4bc868eb06 --- /dev/null +++ b/.github/workflows/windows-window-probe.yml @@ -0,0 +1,56 @@ +name: Windows window probe + +permissions: + contents: read + +env: + HYPERFRAMES_NO_TELEMETRY: "1" + +on: + pull_request: + paths: + - .github/workflows/windows-window-probe.yml + - .github/scripts/windows-window-probe.ps1 + workflow_dispatch: + +jobs: + probe: + name: Agent commands open no window on windows-latest + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: ./.github/actions/install-ffmpeg-windows + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - run: bun install --frozen-lockfile --linker=hoisted + shell: pwsh + - name: Build the CLI + shell: pwsh + run: | + bun run --filter '@hyperframes/{parsers,lint,studio-server}' build + bun run --cwd packages/core build + bun run --filter @hyperframes/player build + bun run --filter @hyperframes/studio build + bun run --filter @hyperframes/inspector build + bun run --filter @hyperframes/cli build + - name: Scaffold the canary + shell: pwsh + env: + HYPERFRAMES_SKIP_SKILLS: "1" + run: | + $dir = "$env:RUNNER_TEMP\probe" + New-Item -ItemType Directory -Force -Path $dir | Out-Null + Set-Location $dir + node "$env:GITHUB_WORKSPACE\packages\cli\dist\cli.js" init canary --example blank --non-interactive --skip-skills + Copy-Item "$env:GITHUB_WORKSPACE\.github\workflows\fixtures\windows-canary.html" "canary\index.html" -Force + - name: Install Chrome before watching + shell: pwsh + run: | + Set-Location "$env:RUNNER_TEMP\probe\canary" + node "$env:GITHUB_WORKSPACE\packages\cli\dist\cli.js" browser ensure + - name: Run the commands while watching for windows + shell: pwsh + run: .github/scripts/windows-window-probe.ps1 -Cli "$env:GITHUB_WORKSPACE\packages\cli\dist\cli.js" -Project "$env:RUNNER_TEMP\probe\canary" diff --git a/bun.lock b/bun.lock index 3300f98b01..a4d1b4f44e 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.8.194", + "version": "0.8.210", "dependencies": { "@aws-sdk/client-ecr": "^3.1146.0", "@aws-sdk/client-lambda": "^3.1146.0", @@ -63,7 +63,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.8.194", + "version": "0.8.210", "bin": { "hyperframes": "./bin/hyperframes.mjs", "hyperframes-localize-fonts": "./bin/hyperframes-localize-fonts.mjs", @@ -118,7 +118,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.8.194", + "version": "0.8.210", "dependencies": { "@chenglou/pretext": "^0.0.5", "@hyperframes/lint": "workspace:*", @@ -163,15 +163,15 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.8.194", + "version": "0.8.210", "dependencies": { "@hono/node-server": "^2.0.5", "@hyperframes/core": "workspace:^", "@hyperframes/parsers": "workspace:^", "hono": "^4.6.0", "linkedom": "^0.18.12", - "puppeteer": "^25.8.0", - "puppeteer-core": "^25.8.0", + "puppeteer": "~25.11.0", + "puppeteer-core": "^25.11.0", }, "devDependencies": { "@types/node": "^25.0.10", @@ -182,7 +182,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.8.194", + "version": "0.8.210", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -203,7 +203,7 @@ }, "packages/inspector": { "name": "@hyperframes/inspector", - "version": "0.8.194", + "version": "0.8.210", "bin": { "hyperframes-inspector": "./dist/server.js", }, @@ -226,7 +226,7 @@ }, "packages/lint": { "name": "@hyperframes/lint", - "version": "0.8.194", + "version": "0.8.210", "dependencies": { "@hyperframes/parsers": "workspace:*", "acorn": "^8.17.0", @@ -246,7 +246,7 @@ }, "packages/parsers": { "name": "@hyperframes/parsers", - "version": "0.8.194", + "version": "0.8.210", "dependencies": { "@babel/parser": "^7.27.0", "acorn": "^8.17.0", @@ -267,7 +267,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.8.194", + "version": "0.8.210", "dependencies": { "@hyperframes/core": "workspace:*", }, @@ -282,7 +282,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.8.194", + "version": "0.8.210", "dependencies": { "@hono/node-server": "^2.0.5", "@hyperframes/core": "workspace:*", @@ -293,8 +293,8 @@ "hono": "^4.6.0", "linkedom": "^0.18.12", "postcss": "^8.4.0", - "puppeteer": "^25.2.1", - "puppeteer-core": "^25.2.1", + "puppeteer": "~25.11.0", + "puppeteer-core": "^25.11.0", }, "devDependencies": { "@hyperframes/aws-lambda": "workspace:*", @@ -308,7 +308,7 @@ }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.8.194", + "version": "0.8.210", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", @@ -338,7 +338,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.8.194", + "version": "0.8.210", "dependencies": { "html2canvas": "^1.4.1", }, @@ -351,7 +351,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.8.194", + "version": "0.8.210", "dependencies": { "@base-ui/react": "1.7.0", "@codemirror/autocomplete": "^6.20.1", @@ -405,7 +405,7 @@ }, "packages/studio-server": { "name": "@hyperframes/studio-server", - "version": "0.8.194", + "version": "0.8.210", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", @@ -1553,7 +1553,7 @@ "es6-error": ["es6-error@4.1.1", "", {}, "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg=="], - "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -1955,7 +1955,7 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "puppeteer": ["puppeteer@25.8.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.1", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1666840", "lilconfig": "^3.1.3", "puppeteer-core": "25.8.0", "typed-query-selector": "^2.12.2" }, "bin": { "puppeteer": "lib/puppeteer/node/cli.js" } }, "sha512-3gcUJ+Jfodb5zNa/lWLZukBUwYiRIwAc8WRICoqfi+ZYmNWqpsPFyanTU3Gw/lhgII9aotVbAmhT6/NKHWgyUA=="], + "puppeteer": ["puppeteer@25.11.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.2", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1680125", "lilconfig": "^3.1.3", "puppeteer-core": "25.11.0", "typed-query-selector": "^2.12.2" }, "bin": { "puppeteer": "lib/puppeteer/node/cli.js" } }, "sha512-n+psxZFH/qlJ3gwTZMxTLUZoE98FAOEjT/BSxHXvPmNCGAfN/kq0UnIZ8m9RB4rAVvJr7zRHUnKKg98GLMUyyQ=="], "puppeteer-core": ["puppeteer-core@25.11.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.2", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1680125", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.3", "ws": "^8.21.3" } }, "sha512-Ujym7JbxoAqanaxGcHgmTomfhC01HfDCSuGZ8Hs9boHrDjzbNVMnFRJo2KtbDT9LCRgeJoFnkn9JQZ41eEUrLw=="], @@ -2343,12 +2343,6 @@ "path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - "puppeteer/@puppeteer/browsers": ["@puppeteer/browsers@3.2.1", "", { "dependencies": { "modern-tar": "^0.8.0", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-KDz+3qDRdBAlRlMjmKyj6dEs33YHTk/xRHEENSXq6TNnhgoU15ruSHtEBeVF6OZ9tBDY55Se4P0nFMNsipzU9A=="], - - "puppeteer/devtools-protocol": ["devtools-protocol@0.0.1666840", "", {}, "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg=="], - - "puppeteer/puppeteer-core": ["puppeteer-core@25.8.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.1", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1666840", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.2", "ws": "^8.21.1" } }, "sha512-LDOrawV8vfCVk+yLj2ozvajNP4Sv3OV9y3Tpiyy2g2Z+aQlbcozP6KJfI4iSBq7YQER+86ihEtPa5ioiZyWxMQ=="], - "puppeteer-core/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], @@ -2447,12 +2441,6 @@ "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], - "puppeteer/@puppeteer/browsers/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], - - "puppeteer/puppeteer-core/webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.2", "", {}, "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA=="], - - "puppeteer/puppeteer-core/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - "tsup/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], "tsup/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], @@ -2633,12 +2621,6 @@ "google-gax/retry-request/teeny-request/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "puppeteer/@puppeteer/browsers/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="], - - "puppeteer/@puppeteer/browsers/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - - "puppeteer/@puppeteer/browsers/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], - "@puppeteer/browsers/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "@puppeteer/browsers/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -2650,19 +2632,5 @@ "google-gax/retry-request/teeny-request/http-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "google-gax/retry-request/teeny-request/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "puppeteer/@puppeteer/browsers/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "puppeteer/@puppeteer/browsers/yargs/cliui/wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - - "puppeteer/@puppeteer/browsers/yargs/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "puppeteer/@puppeteer/browsers/yargs/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "puppeteer/@puppeteer/browsers/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "puppeteer/@puppeteer/browsers/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - - "puppeteer/@puppeteer/browsers/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], } } diff --git a/packages/cli/src/backgroundChecksWorker.ts b/packages/cli/src/backgroundChecksWorker.ts index 0b0f287bbc..0d3b589402 100644 --- a/packages/cli/src/backgroundChecksWorker.ts +++ b/packages/cli/src/backgroundChecksWorker.ts @@ -1,3 +1,4 @@ +import "./utils/hideChildConsoles.js"; import { checkForUpdate } from "./utils/updateCheck.js"; import { refreshSkillsCache } from "./utils/skillsUpdateCheck.js"; diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2c3638f68d..05498db21f 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -31,6 +31,7 @@ for (const stream of [process.stdout, process.stderr]) { // probe lands in a directory that does not contain the bundled worker. // We emit the worker entry next to cli.js (see tsup.config.ts) and tell // the pool where to find it via the published env-var override. +import "./utils/hideChildConsoles.js"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { existsSync } from "node:fs"; diff --git a/packages/cli/src/fontLocalizeCli.ts b/packages/cli/src/fontLocalizeCli.ts index 9ed72c32bc..4f033a4289 100644 --- a/packages/cli/src/fontLocalizeCli.ts +++ b/packages/cli/src/fontLocalizeCli.ts @@ -1,4 +1,5 @@ // fallow-ignore-file unused-file +import "./utils/hideChildConsoles.js"; import { injectDeterministicFontFaces } from "@hyperframes/producer"; import { runFontLocalize, stampFontVersions } from "./fontLocalize.js"; import { PRODUCER_VERSION, VERSION } from "./version.js"; diff --git a/packages/cli/src/hangReport.ts b/packages/cli/src/hangReport.ts index 10d8dcb9ac..3cbc6a01fc 100644 --- a/packages/cli/src/hangReport.ts +++ b/packages/cli/src/hangReport.ts @@ -1,3 +1,4 @@ +import "./utils/hideChildConsoles.js"; import { writeFileSync } from "node:fs"; import { isAbsolute, join } from "node:path"; diff --git a/packages/cli/src/renderSetupWorker.ts b/packages/cli/src/renderSetupWorker.ts index ca2f48ac0d..2093abcffa 100644 --- a/packages/cli/src/renderSetupWorker.ts +++ b/packages/cli/src/renderSetupWorker.ts @@ -1,3 +1,4 @@ +import "./utils/hideChildConsoles.js"; import { constants, setPriority } from "node:os"; import { runBrowserInstall } from "./browser/installRun.js"; import { ensureBrowser, releaseOwnedBrowserInstallLock } from "./browser/manager.js"; diff --git a/packages/cli/src/utils/desktopApp.ts b/packages/cli/src/utils/desktopApp.ts index a802abf737..3f43f288c6 100644 --- a/packages/cli/src/utils/desktopApp.ts +++ b/packages/cli/src/utils/desktopApp.ts @@ -72,7 +72,7 @@ const openWithBundle = (bundleId: string, dir: string): boolean => /** Single-instance app: a running copy is forwarded the folder. Only a refused spawn counts as failed. */ function launchApp(executable: string, dir: string): boolean { try { - const child = spawn(executable, [dir], { detached: true, stdio: "ignore" }); + const child = spawn(executable, [dir], { detached: true, stdio: "ignore", windowsHide: false }); child.on("error", () => {}); child.unref(); return true; diff --git a/packages/cli/src/utils/hideChildConsoles.test.ts b/packages/cli/src/utils/hideChildConsoles.test.ts new file mode 100644 index 0000000000..30eefe3772 --- /dev/null +++ b/packages/cli/src/utils/hideChildConsoles.test.ts @@ -0,0 +1,88 @@ +import { promisify } from "node:util"; +import { describe, expect, it, vi } from "vitest"; +import { hideChildConsoles } from "./hideChildConsoles.js"; + +function fakeModule() { + const calls: unknown[][] = []; + const record = (name: string) => vi.fn((...args: unknown[]) => calls.push([name, ...args])); + const module: Record = {}; + for (const name of [ + "spawn", + "spawnSync", + "exec", + "execSync", + "execFile", + "execFileSync", + "fork", + ]) { + module[name] = record(name); + } + const execFile = module.execFile as Record; + execFile[promisify.custom] = vi.fn((...args: unknown[]) => + calls.push(["execFile.promisified", ...args]), + ); + return { module, calls }; +} + +type Entry = "spawn" | "spawnSync" | "exec" | "execSync" | "execFile" | "execFileSync" | "fork"; +type Calls = Record unknown>; + +const run = (platform: NodeJS.Platform, use: (m: Calls) => void) => { + const { module, calls } = fakeModule(); + hideChildConsoles(module, platform); + use(module as Calls); + return calls; +}; + +describe("hideChildConsoles", () => { + it("hides a console on every way of passing the options", () => { + const callback = () => {}; + const calls = run("win32", (m) => { + m.spawn("a"); + m.spawn("a", ["x"]); + m.spawnSync("a", ["x"], { cwd: "." }); + m.execFile("a", ["x"], callback); + m.exec("a", callback); + m.execSync("a", { timeout: 1 }); + m.execFileSync("a", { timeout: 1 }); + m.fork("a", ["x"], {}); + }); + expect( + calls.map(([name, , ...rest]) => [ + name, + rest.find((a) => a && typeof a === "object" && !Array.isArray(a)), + ]), + ).toEqual([ + ["spawn", { windowsHide: true }], + ["spawn", { windowsHide: true }], + ["spawnSync", { windowsHide: true, cwd: "." }], + ["execFile", { windowsHide: true }], + ["exec", { windowsHide: true }], + ["execSync", { windowsHide: true, timeout: 1 }], + ["execFileSync", { windowsHide: true, timeout: 1 }], + ["fork", { windowsHide: true }], + ]); + expect(calls[3]?.at(-1)).toBe(callback); + }); + + it("leaves a caller that wants the window alone", () => { + const calls = run("win32", (m) => + m.spawn("browser", [], { windowsHide: false, detached: true }), + ); + expect(calls[0]?.[3]).toEqual({ windowsHide: false, detached: true }); + }); + + it("hides the promisified execFile too", async () => { + const { module, calls } = fakeModule(); + hideChildConsoles(module, "win32"); + await promisify(module.execFile as never)("a", ["x"]); + expect(calls[0]).toEqual(["execFile.promisified", "a", ["x"], { windowsHide: true }]); + }); + + it("does nothing off Windows", () => { + const { module } = fakeModule(); + const before = { ...module }; + hideChildConsoles(module, "linux"); + expect(module).toEqual(before); + }); +}); diff --git a/packages/cli/src/utils/hideChildConsoles.ts b/packages/cli/src/utils/hideChildConsoles.ts new file mode 100644 index 0000000000..c94a76983d --- /dev/null +++ b/packages/cli/src/utils/hideChildConsoles.ts @@ -0,0 +1,42 @@ +import childProcess from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +import { promisify } from "node:util"; + +type Spawner = (command: string, ...rest: unknown[]) => unknown; +type ChildProcessModule = Record; + +const ENTRY_POINTS = ["spawn", "spawnSync", "exec", "execSync", "execFile", "execFileSync", "fork"]; + +/** The call's arguments with `windowsHide: true` set unless the caller chose either way. */ +function hidden(command: string, rest: unknown[]): unknown[] { + const args = [...rest]; + const at = args.findIndex((arg) => arg && typeof arg === "object" && !Array.isArray(arg)); + if (at >= 0) { + args[at] = { windowsHide: true, ...(args[at] as object) }; + } else { + const callback = args.findIndex((arg) => typeof arg === "function"); + args.splice(callback < 0 ? args.length : callback, 0, { windowsHide: true }); + } + return [command, ...args]; +} + +/** Windows opens a console window for every child that does not say `windowsHide`; `false` opts out. */ +export function hideChildConsoles( + target: ChildProcessModule = childProcess as unknown as ChildProcessModule, + platform: NodeJS.Platform = process.platform, +): void { + if (platform !== "win32") return; + for (const name of ENTRY_POINTS) { + const original = target[name] as Spawner & { [promisify.custom]?: Spawner }; + const wrapped: Spawner & { [promisify.custom]?: Spawner } = (command, ...rest) => + original(...(hidden(command, rest) as [string, ...unknown[]])); + const custom = original[promisify.custom]; + if (custom) + wrapped[promisify.custom] = (command, ...rest) => + custom(...(hidden(command, rest) as [string])); + target[name] = wrapped; + } + if (target === (childProcess as unknown as ChildProcessModule)) syncBuiltinESMExports(); +} + +hideChildConsoles(); diff --git a/packages/cli/src/utils/openBrowser.test.ts b/packages/cli/src/utils/openBrowser.test.ts index d4b42113b8..8598c3f880 100644 --- a/packages/cli/src/utils/openBrowser.test.ts +++ b/packages/cli/src/utils/openBrowser.test.ts @@ -29,7 +29,7 @@ describe("openBrowser", () => { "--remote-debugging-port=9222", "http://localhost:3002", ], - { detached: true, stdio: "ignore" }, + { detached: true, stdio: "ignore", windowsHide: false }, ); expect(unref).toHaveBeenCalledOnce(); }); diff --git a/packages/cli/src/utils/openBrowser.ts b/packages/cli/src/utils/openBrowser.ts index a71ba11f8a..09a9d87301 100644 --- a/packages/cli/src/utils/openBrowser.ts +++ b/packages/cli/src/utils/openBrowser.ts @@ -75,6 +75,7 @@ export function openBrowser(url: string, options: OpenBrowserOptions = {}): void const child = spawn(options.browserPath, args, { detached: true, stdio: "ignore", + windowsHide: false, }); child.on("error", () => {}); child.unref(); diff --git a/packages/cli/src/warmCheck.ts b/packages/cli/src/warmCheck.ts index cde3c0f1b4..ac76be1608 100644 --- a/packages/cli/src/warmCheck.ts +++ b/packages/cli/src/warmCheck.ts @@ -1,3 +1,4 @@ +import "./utils/hideChildConsoles.js"; import { randomBytes } from "node:crypto"; import { unlinkSync } from "node:fs"; import { connect, createServer, type Socket } from "node:net"; diff --git a/packages/cli/src/warmCheckClient.ts b/packages/cli/src/warmCheckClient.ts index 28d7efc722..9b30c0aaf7 100644 --- a/packages/cli/src/warmCheckClient.ts +++ b/packages/cli/src/warmCheckClient.ts @@ -1,3 +1,4 @@ +import "./utils/hideChildConsoles.js"; import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { dirname, join } from "node:path"; diff --git a/packages/cli/src/whisper/sherpaWorker.ts b/packages/cli/src/whisper/sherpaWorker.ts index 3aec04cd55..8a524500b7 100644 --- a/packages/cli/src/whisper/sherpaWorker.ts +++ b/packages/cli/src/whisper/sherpaWorker.ts @@ -1,3 +1,4 @@ +import "../utils/hideChildConsoles.js"; import { createRequire } from "node:module"; import { droppedSpeechGaps, diff --git a/packages/engine/package.json b/packages/engine/package.json index b46df51f45..956e771ba9 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -99,8 +99,8 @@ "@hyperframes/parsers": "workspace:^", "hono": "^4.6.0", "linkedom": "^0.18.12", - "puppeteer": "^25.8.0", - "puppeteer-core": "^25.8.0" + "puppeteer": "~25.11.0", + "puppeteer-core": "^25.11.0" }, "devDependencies": { "@types/node": "^25.0.10", diff --git a/packages/engine/src/services/aacEncode.ts b/packages/engine/src/services/aacEncode.ts index 901692877a..31c3d52259 100644 --- a/packages/engine/src/services/aacEncode.ts +++ b/packages/engine/src/services/aacEncode.ts @@ -65,10 +65,15 @@ function supportsSettsDuration(binary = getFfmpegBinary()): Promise { let supported = settsDurationSupport.get(binary); if (!supported) { supported = new Promise((resolve) => { - execFile(binary, ["-hide_banner", "-h", "bsf=setts"], (error, stdout) => { - // A binary that cannot even print help fails the encode with its own error, not this one. - resolve(error !== null || stdout.includes("duration")); - }); + execFile( + binary, + ["-hide_banner", "-h", "bsf=setts"], + { windowsHide: true }, + (error, stdout) => { + // A binary that cannot even print help fails the encode with its own error, not this one. + resolve(error !== null || stdout.includes("duration")); + }, + ); }); settsDurationSupport.set(binary, supported); } diff --git a/packages/engine/src/services/chromeHostCeiling.ts b/packages/engine/src/services/chromeHostCeiling.ts index a2dbe4f0ed..35d6bff1bc 100644 --- a/packages/engine/src/services/chromeHostCeiling.ts +++ b/packages/engine/src/services/chromeHostCeiling.ts @@ -69,8 +69,12 @@ export function shellStarts(binary: string, timeoutMs = 10_000): boolean { if (answer === undefined) { const probe = ["--no-sandbox", "--dump-dom", "about:blank"]; answer = - spawnSync(binary, probe, { stdio: "ignore", timeout: timeoutMs, killSignal: "SIGKILL" }) - ?.status === 0; + spawnSync(binary, probe, { + stdio: "ignore", + timeout: timeoutMs, + killSignal: "SIGKILL", + windowsHide: true, + })?.status === 0; if (!answer) debug("skipping %s: it did not start", binary); starts.set(binary, answer); } diff --git a/packages/engine/src/services/puppeteerLauncher.test.ts b/packages/engine/src/services/puppeteerLauncher.test.ts new file mode 100644 index 0000000000..f7235115d7 --- /dev/null +++ b/packages/engine/src/services/puppeteerLauncher.test.ts @@ -0,0 +1,24 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +// @puppeteer/browsers before 3.2.2 started Chrome detached on every platform. On Windows a detached Chrome has no +// console, so Windows Terminal opens a window for each Chrome process of a render. +const FIRST_SAFE = [3, 2, 2]; + +function launcherVersion(): number[] { + const fromEngine = createRequire(new URL("../../package.json", import.meta.url)); + const fromPuppeteer = createRequire(fromEngine.resolve("puppeteer/package.json")); + const fromCore = createRequire(fromPuppeteer.resolve("puppeteer-core/package.json")); + return (fromCore("@puppeteer/browsers/package.json") as { version: string }).version + .split(".") + .map(Number); +} + +describe("the Puppeteer the engine launches Chrome with", () => { + it("does not start Chrome detached on Windows", () => { + const version = launcherVersion(); + const behind = FIRST_SAFE.findIndex((part, i) => version[i] !== part); + const atLeast = behind === -1 || (version[behind] ?? 0) > (FIRST_SAFE[behind] ?? 0); + expect(atLeast, `@puppeteer/browsers ${version.join(".")}`).toBe(true); + }); +}); diff --git a/packages/engine/src/utils/ffprobe.ts b/packages/engine/src/utils/ffprobe.ts index d6aedf7153..3d689a99d2 100644 --- a/packages/engine/src/utils/ffprobe.ts +++ b/packages/engine/src/utils/ffprobe.ts @@ -474,7 +474,10 @@ function ffprobeMajor(): Promise { runtimeFfprobeMajor ??= new Promise((resolve) => { let out = ""; const binary = getFfprobeBinary(); - const proc = spawn(binary, ["-version"], { stdio: ["ignore", "pipe", "ignore"] }); + const proc = spawn(binary, ["-version"], { + stdio: ["ignore", "pipe", "ignore"], + windowsHide: true, + }); proc.stdout.on("data", (chunk) => (out += String(chunk))); proc.on("error", () => resolve(undefined)); proc.on("close", () => resolve(Number(/version n?(\d+)/.exec(out)?.[1]) || undefined)); diff --git a/packages/producer/package.json b/packages/producer/package.json index 51802aa2e1..138a1f25d9 100644 --- a/packages/producer/package.json +++ b/packages/producer/package.json @@ -84,8 +84,8 @@ "hono": "^4.6.0", "linkedom": "^0.18.12", "postcss": "^8.4.0", - "puppeteer": "^25.2.1", - "puppeteer-core": "^25.2.1" + "puppeteer": "~25.11.0", + "puppeteer-core": "^25.11.0" }, "devDependencies": { "@hyperframes/aws-lambda": "workspace:*",