diff --git a/browse/bin/stage-windows-browse-launcher b/browse/bin/stage-windows-browse-launcher new file mode 100755 index 000000000..37e03ce10 --- /dev/null +++ b/browse/bin/stage-windows-browse-launcher @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +PLATFORM="${1:-$(uname -s)}" +case "$PLATFORM" in + MINGW*|MSYS*|CYGWIN*|Windows_NT) ;; + *) exit 0 ;; +esac + +ROOT="${2:-$(cd "$(dirname "$0")/../.." && pwd -P)}" +if command -v cygpath >/dev/null 2>&1; then + ROOT="$(cygpath -u "$ROOT" 2>/dev/null || printf '%s' "$ROOT")" +fi +ROOT="$(cd "$ROOT" && pwd -P)" + +BUN_CMD="${BUN_CMD:-bun}" +SOURCE="$ROOT/browse/bin/windows-browse" +DESTINATION="$ROOT/browse/dist/browse" + +if [ ! -f "$SOURCE" ]; then + echo "ERROR: Windows browse launcher source is missing: $SOURCE" >&2 + exit 2 +fi + +mkdir -p "$(dirname "$DESTINATION")" + +# MSYS cp resolves an extensionless destination to an existing .exe and can +# overwrite browse.exe. Bun writes the exact extensionless path. +GSTACK_LAUNCHER_SOURCE="$SOURCE" \ +GSTACK_LAUNCHER_DESTINATION="$DESTINATION" \ + "$BUN_CMD" -e 'await Bun.write(process.env.GSTACK_LAUNCHER_DESTINATION, Bun.file(process.env.GSTACK_LAUNCHER_SOURCE))' diff --git a/browse/bin/windows-browse b/browse/bin/windows-browse new file mode 100755 index 000000000..d5a5c0a77 --- /dev/null +++ b/browse/bin/windows-browse @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +is_complete_gstack_source() { + local root="$1" + [ -f "$root/package.json" ] && + [ -f "$root/browse/src/cli.ts" ] && + [ -f "$root/browse/dist/server-node.mjs" ] && + [ -f "$root/node_modules/playwright/package.json" ] && + [ -f "$root/node_modules/diff/package.json" ] +} + +normalize_candidate() { + local candidate="$1" + if command -v cygpath >/dev/null 2>&1; then + candidate="$(cygpath -u "$candidate" 2>/dev/null || printf '%s' "$candidate")" + fi + (cd "$candidate" 2>/dev/null && pwd -P) || true +} + +SOURCE_ROOT="" +if [ -n "${GSTACK_SOURCE_ROOT:-}" ]; then + SOURCE_ROOT="$(normalize_candidate "$GSTACK_SOURCE_ROOT")" + if [ -z "$SOURCE_ROOT" ] || ! is_complete_gstack_source "$SOURCE_ROOT"; then + echo "ERROR: GSTACK_SOURCE_ROOT does not contain a complete gstack source install: $GSTACK_SOURCE_ROOT" >&2 + exit 2 + fi +else + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + for candidate in \ + "$SCRIPT_DIR/../.." \ + "$HOME/.gstack/repos/gstack" \ + "$HOME/.claude/skills/gstack" \ + "$HOME/.codex/skills/gstack"; do + resolved="$(normalize_candidate "$candidate")" + if [ -n "$resolved" ] && is_complete_gstack_source "$resolved"; then + SOURCE_ROOT="$resolved" + break + fi + done +fi + +if [ -z "$SOURCE_ROOT" ]; then + echo "ERROR: no complete gstack source install was found." >&2 + echo "Expected browse/src/cli.ts, browse/dist/server-node.mjs, and installed dependencies." >&2 + echo "Set GSTACK_SOURCE_ROOT to the full gstack checkout and retry." >&2 + exit 2 +fi + +if ! command -v bun >/dev/null 2>&1; then + echo "ERROR: Bun is required for the Windows gstack browser launcher." >&2 + exit 3 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "ERROR: Node.js is required for the Windows gstack browser server." >&2 + exit 3 +fi + +cd "$SOURCE_ROOT" +exec bun run "$SOURCE_ROOT/browse/src/cli.ts" "$@" diff --git a/scripts/build.sh b/scripts/build.sh index 67acf6dc0..c31e3b1f3 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -30,6 +30,10 @@ esac "$BUN_CMD" build --compile make-pdf/src/cli.ts --outfile make-pdf/dist/pdf "$BUN_CMD" build --compile bin/gstack-global-discover.ts --outfile bin/gstack-global-discover bash browse/scripts/build-node-server.sh +# Smart App Control can block the locally compiled unsigned browse.exe. +# Browser skills use an extensionless source launcher through the installed Bun +# runtime while the native binary remains available for signed release workflows. +BUN_CMD="$BUN_CMD" bash browse/bin/stage-windows-browse-launcher "$(uname -s)" "$ROOT" bash scripts/write-version-files.sh browse/dist/.version design/dist/.version make-pdf/dist/.version chmod +x browse/dist/browse browse/dist/find-browse design/dist/design make-pdf/dist/pdf bin/gstack-global-discover rm -f .*.bun-build diff --git a/setup b/setup index 275236cd3..903db5feb 100755 --- a/setup +++ b/setup @@ -380,6 +380,8 @@ if [ ! -x "$BROWSE_BIN" ]; then NEEDS_BUILD=1 elif [ -n "$(find "$SOURCE_GSTACK_DIR/browse/src" -type f -newer "$BROWSE_BIN" -print -quit 2>/dev/null)" ]; then NEEDS_BUILD=1 +elif [ "$IS_WINDOWS" -eq 1 ] && { [ "$SOURCE_GSTACK_DIR/browse/bin/windows-browse" -nt "$BROWSE_BIN" ] || [ "$SOURCE_GSTACK_DIR/browse/bin/stage-windows-browse-launcher" -nt "$BROWSE_BIN" ]; }; then + NEEDS_BUILD=1 elif [ "$SOURCE_GSTACK_DIR/package.json" -nt "$BROWSE_BIN" ]; then NEEDS_BUILD=1 elif [ -f "$SOURCE_GSTACK_DIR/bun.lock" ] && [ "$SOURCE_GSTACK_DIR/bun.lock" -nt "$BROWSE_BIN" ]; then diff --git a/test/windows-appcontrol-launcher.test.ts b/test/windows-appcontrol-launcher.test.ts new file mode 100644 index 000000000..979027425 --- /dev/null +++ b/test/windows-appcontrol-launcher.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { basename, join, resolve } from 'path'; +import { spawnSync } from 'child_process'; + +const ROOT = resolve(import.meta.dir, '..'); +const POSIX_LAUNCHER = join(ROOT, 'browse', 'bin', 'windows-browse'); +const STAGE_LAUNCHER = join(ROOT, 'browse', 'bin', 'stage-windows-browse-launcher'); +const BUILD_SCRIPT = readFileSync(join(ROOT, 'scripts', 'build.sh'), 'utf8'); +const SETUP_SCRIPT = readFileSync(join(ROOT, 'setup'), 'utf8'); +const BASH = process.platform === 'win32' + ? join(process.env.ProgramFiles || 'C:\\Program Files', 'Git', 'bin', 'bash.exe') + : 'bash'; +const tempRoots: string[] = []; + +function makeCompleteSource(): string { + const root = mkdtempSync(join(tmpdir(), 'gstack-appcontrol-source-')); + tempRoots.push(root); + for (const relative of [ + 'browse/src', + 'browse/dist', + 'node_modules/playwright', + 'node_modules/diff', + ]) { + mkdirSync(join(root, relative), { recursive: true }); + } + writeFileSync(join(root, 'package.json'), '{}\n'); + writeFileSync( + join(root, 'browse/src/cli.ts'), + 'console.log(`cwd=${process.cwd().replace(/\\\\/g, "/")}`);\n' + + 'for (const arg of Bun.argv.slice(2)) console.log(`arg=${arg}`);\n', + ); + writeFileSync(join(root, 'browse/dist/server-node.mjs'), '// fixture\n'); + writeFileSync(join(root, 'node_modules/playwright/package.json'), '{}\n'); + writeFileSync(join(root, 'node_modules/diff/package.json'), '{}\n'); + return root; +} + +afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('Windows Smart App Control browse launcher', () => { + test('source launcher discovers complete installs without a hardcoded user path', () => { + const posix = readFileSync(POSIX_LAUNCHER, 'utf8'); + + expect(posix).toContain('GSTACK_SOURCE_ROOT'); + expect(posix).toContain('browse/src/cli.ts'); + expect(posix).toContain('browse/dist/server-node.mjs'); + expect(posix).toContain('node_modules'); + expect(posix).not.toMatch(/Users[\\/]Administrator|Users[\\/]garry/i); + expect(posix).toContain('$HOME/.gstack/repos/gstack'); + expect(posix).toContain('$HOME/.claude/skills/gstack'); + }); + + test('POSIX launcher forwards arguments through Bun from the discovered source root', () => { + const sourceRoot = makeCompleteSource(); + if (process.platform === 'win32') expect(existsSync(BASH)).toBe(true); + + const result = spawnSync(BASH, ['browse/bin/windows-browse', 'goto', 'http://127.0.0.1:4322/'], { + encoding: 'utf8', + cwd: ROOT, + env: { + ...process.env, + GSTACK_SOURCE_ROOT: sourceRoot, + }, + }); + + expect(result.status).toBe(0); + const cwdLine = result.stdout.split(/\r?\n/).find(line => line.startsWith('cwd=')); + expect(cwdLine?.replace(/\\/g, '/').endsWith(`/${basename(sourceRoot)}`)).toBe(true); + expect(result.stdout).toContain('arg=goto'); + expect(result.stdout).toContain('arg=http://127.0.0.1:4322/'); + }); + + test('explicit incomplete source roots fail closed', () => { + const incomplete = mkdtempSync(join(tmpdir(), 'gstack-appcontrol-incomplete-')); + tempRoots.push(incomplete); + const result = spawnSync(BASH, ['browse/bin/windows-browse', 'status'], { + encoding: 'utf8', + cwd: ROOT, + env: { ...process.env, GSTACK_SOURCE_ROOT: incomplete }, + }); + expect(result.status).toBe(2); + expect(result.stderr).toContain('does not contain a complete gstack source install'); + }); + + test('stager no-ops off Windows and preserves browse.exe while installing the exact launcher', () => { + const stageRoot = mkdtempSync(join(tmpdir(), 'gstack-appcontrol-stage-')); + tempRoots.push(stageRoot); + mkdirSync(join(stageRoot, 'browse/bin'), { recursive: true }); + mkdirSync(join(stageRoot, 'browse/dist'), { recursive: true }); + writeFileSync(join(stageRoot, 'browse/bin/windows-browse'), '#!/usr/bin/env bash\necho staged\n'); + writeFileSync(join(stageRoot, 'browse/dist/browse.exe'), 'native sentinel\n'); + const shellRoot = stageRoot.replace(/\\/g, '/'); + + const nonWindows = spawnSync(BASH, ['browse/bin/stage-windows-browse-launcher', 'Linux', shellRoot], { + encoding: 'utf8', + cwd: ROOT, + }); + expect(nonWindows.status).toBe(0); + expect(existsSync(join(stageRoot, 'browse/dist/browse'))).toBe(false); + + const windows = spawnSync(BASH, ['browse/bin/stage-windows-browse-launcher', 'MINGW64_NT-10.0', shellRoot], { + encoding: 'utf8', + cwd: ROOT, + }); + expect(windows.status).toBe(0); + expect(readFileSync(join(stageRoot, 'browse/dist/browse'), 'utf8')).toBe( + readFileSync(join(stageRoot, 'browse/bin/windows-browse'), 'utf8'), + ); + expect(readFileSync(join(stageRoot, 'browse/dist/browse.exe'), 'utf8')).toBe('native sentinel\n'); + }); + + test('Windows build stages the extensionless launcher after compiling', () => { + expect(BUILD_SCRIPT).toContain('bash browse/bin/stage-windows-browse-launcher'); + expect(BUILD_SCRIPT.indexOf('build --compile browse/src/cli.ts')).toBeLessThan( + BUILD_SCRIPT.indexOf('bash browse/bin/stage-windows-browse-launcher'), + ); + }); + + test('setup rebuilds when either Windows launcher file changes', () => { + expect(SETUP_SCRIPT).toContain('browse/bin/windows-browse" -nt "$BROWSE_BIN'); + expect(SETUP_SCRIPT).toContain('browse/bin/stage-windows-browse-launcher" -nt "$BROWSE_BIN'); + }); +});