Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions browse/bin/stage-windows-browse-launcher
Original file line number Diff line number Diff line change
@@ -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))'
61 changes: 61 additions & 0 deletions browse/bin/windows-browse
Original file line number Diff line number Diff line change
@@ -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" "$@"
4 changes: 4 additions & 0 deletions scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions setup
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions test/windows-appcontrol-launcher.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});