feat: launch apps on crabbox desktops

This commit is contained in:
Peter Steinberger 2026-05-04 07:19:21 +01:00
parent 358e85fdbe
commit 8214a13978
No known key found for this signature in database
17 changed files with 446 additions and 25 deletions

View File

@ -12,12 +12,14 @@
- Added AWS macOS desktop lease plumbing for EC2 Mac Dedicated Hosts, including Screen Sharing setup and per-lease credentials.
- Added `crabbox screenshot` to save a PNG from a desktop lease without opening a VNC client.
- Added authenticated WebVNC portal support with `crabbox webvnc`, which bridges a desktop lease into the coordinator portal with short-lived bridge tickets and without exposing the remote VNC port.
- Added `crabbox desktop launch` to open a browser or app inside a visible desktop lease, including native Windows scheduled-task launch for the logged-in console session.
- Added a minimal XFCE desktop profile with panel/window manager for managed VNC leases.
- Clarified static macOS/Windows VNC as existing-host access, not Crabbox-created boxes, so `--open` no longer launches an OS credential prompt unless `--host-managed` is passed.
### Fixed
- Quoted `crabbox vnc` tunnel key paths so macOS `Application Support` lease keys can be pasted directly into a shell.
- Excluded macOS AppleDouble `._*` sidecar files from default sync manifests so native Windows archives do not transfer invalid TypeScript/package sidecars.
- Removed the static macOS managed-login path so static host VNC cannot be mistaken for a Crabbox-created external instance.
- Fixed native Windows `--shell` runs so multi-statement PowerShell scripts keep their quotes instead of being re-parsed by a nested PowerShell process.
- Skipped Linux-only GitHub Actions hydration stop markers on native Windows static targets.

View File

@ -35,6 +35,7 @@ crabbox config path
crabbox config set-broker --url <url> --token-stdin [--provider hetzner|aws]
crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--desktop] [--browser] [--profile <name>] [--idle-timeout <duration>] [--timing-json]
crabbox run [--id <lease-id-or-slug>] [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow <workflow>] -- <command...>
crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] [-- <command...>]
crabbox screenshot --id <lease-id-or-slug> [--output <path>]
crabbox sync-plan [--limit <n>]
crabbox history [--lease <lease-id>] [--owner <email>] [--org <name>] [--limit <n>] [--json]
@ -84,6 +85,7 @@ crabbox warmup --desktop --browser
crabbox run --id blue-lobster -- pnpm test:changed
crabbox vnc --id blue-lobster --open
crabbox webvnc --id blue-lobster --open
crabbox desktop launch --id blue-lobster --browser --url https://example.com
crabbox screenshot --id blue-lobster --output desktop.png
crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test'
crabbox stop blue-lobster

View File

@ -11,6 +11,7 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
- [doctor](doctor.md)
- [warmup](warmup.md)
- [run](run.md)
- [desktop](desktop.md)
- [sync-plan](sync-plan.md)
- [history](history.md)
- [logs](logs.md)

38
docs/commands/desktop.md Normal file
View File

@ -0,0 +1,38 @@
# desktop
`crabbox desktop launch` starts an app inside a desktop lease without taking
over VNC manually.
```sh
crabbox warmup --desktop --browser
crabbox desktop launch --id blue-lobster --browser --url https://example.com
crabbox desktop launch --id blue-lobster -- xterm
```
The command resolves and touches the lease, verifies `desktop=true`, waits for
the loopback VNC service, then starts the process detached from the SSH session.
With `--browser`, Crabbox probes the target browser the same way `run --browser`
does and launches `BROWSER` when no explicit command is provided.
On Windows, SSH sessions cannot directly own the visible console desktop, so
Crabbox writes a one-shot PowerShell launcher under `C:\ProgramData\crabbox` and
runs it as an interactive scheduled task for the logged-in `crabbox` user. The
launcher minimizes existing windows, starts the app, and tries to foreground
the new process. On Linux and macOS, the command is detached with `setsid` or
`nohup`.
Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|ssh
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
--static-user <user>
--static-port <port>
--static-work-root <path>
--browser
--url <url>
--reclaim
```

View File

@ -27,6 +27,8 @@ The intended contract is:
the desktop session;
- `crabbox run --id <lease> --browser -- <command...>` injects browser env
without requiring a desktop;
- `crabbox desktop launch --id <lease> --browser --url <url>` opens a browser
or app in the visible desktop and detaches it from SSH;
- desktop services bind to loopback on the runner and are reachable through SSH
tunnels only;
- screenshots, traces, videos, and browser profiles remain regular command

View File

@ -40,6 +40,7 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
- CLI cloud-init bootstrap: `internal/cli/bootstrap.go`
- Worker cloud-init bootstrap: `worker/src/bootstrap.ts`
- Desktop/browser capability flags, env injection, and VNC checks: `internal/cli/capabilities.go`, `internal/cli/run.go`
- Desktop app launch into visible sessions: `internal/cli/desktop.go`
- VNC tunnel command: `internal/cli/vnc.go`
- WebVNC portal bridge: `internal/cli/webvnc.go`, `worker/src/portal.ts`, `worker/src/fleet.ts`
- Desktop screenshot command: `internal/cli/screenshot.go`

View File

@ -84,6 +84,8 @@ func (a App) Run(ctx context.Context, args []string) error {
return a.warmup(ctx, args[1:])
case "run":
return a.runCommand(ctx, args[1:])
case "desktop":
return a.desktop(ctx, args[1:])
case "sync-plan":
return a.syncPlan(ctx, args[1:])
case "status":
@ -134,6 +136,7 @@ Commands:
doctor Check local and broker/provider readiness
warmup Lease a box and wait until it is ready
run Sync the repo, run a remote command, stream output
desktop Launch apps into a visible desktop session
sync-plan Show local sync manifest size hotspots
history List recorded remote runs
logs Print recorded run logs
@ -163,6 +166,7 @@ Common Flows:
crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test'
crabbox ssh --id blue-lobster
crabbox vnc --id blue-lobster --open
crabbox desktop launch --id blue-lobster --browser --url https://example.com
crabbox webvnc --id blue-lobster --open
crabbox screenshot --id blue-lobster --output desktop.png
crabbox inspect --id blue-lobster --json

View File

@ -130,6 +130,8 @@ $workRoot = ` + psQuote(workRoot) + `
$vncPasswordPath = "C:\ProgramData\crabbox\vnc.password"
$windowsUsernamePath = "C:\ProgramData\crabbox\windows.username"
$windowsPasswordPath = "C:\ProgramData\crabbox\windows.password"
$userVNCStartupPath = "C:\ProgramData\crabbox\start-user-vnc.ps1"
$userVNCStartupCommandPath = Join-Path (Join-Path (Join-Path "C:\Users" $user) "AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup") "crabbox-user-vnc.cmd"
$setupCompletePath = "C:\ProgramData\crabbox\setup-complete"
$openSSHZip = "$env:TEMP\OpenSSH-Win64.zip"
$gitInstaller = "$env:TEMP\Git-2.52.0-64-bit.exe"
@ -201,8 +203,36 @@ if (-not (Test-Path -LiteralPath "C:\Program Files\TightVNC\tvnserver.exe")) {
"SET_ACCEPTHTTPCONNECTIONS=1", "VALUE_OF_ACCEPTHTTPCONNECTIONS=0"
) -Wait
}
Get-Service -Name tvnserver -ErrorAction SilentlyContinue | Set-Service -StartupType Automatic
Start-Service -Name tvnserver -ErrorAction SilentlyContinue
$userVNCStartup = @'
$ErrorActionPreference = "SilentlyContinue"
$base = "C:\ProgramData\crabbox"
$password = (Get-Content -Raw -LiteralPath (Join-Path $base "vnc.password")).Trim()
$serverKey = "HKCU:\Software\TightVNC\Server"
$serviceKey = "HKLM:\Software\TightVNC\Server"
$serviceConfig = Get-ItemProperty -Path $serviceKey -ErrorAction SilentlyContinue
New-Item -Force -Path $serverKey | Out-Null
New-ItemProperty -Force -Path $serverKey -Name UseVncAuthentication -PropertyType DWord -Value 1 | Out-Null
if ($serviceConfig -and $serviceConfig.Password) {
New-ItemProperty -Force -Path $serverKey -Name Password -PropertyType Binary -Value $serviceConfig.Password | Out-Null
}
New-ItemProperty -Force -Path $serverKey -Name UseControlAuthentication -PropertyType DWord -Value 1 | Out-Null
if ($serviceConfig -and $serviceConfig.ControlPassword) {
New-ItemProperty -Force -Path $serverKey -Name ControlPassword -PropertyType Binary -Value $serviceConfig.ControlPassword | Out-Null
}
New-ItemProperty -Force -Path $serverKey -Name AllowLoopback -PropertyType DWord -Value 1 | Out-Null
New-ItemProperty -Force -Path $serverKey -Name AcceptHttpConnections -PropertyType DWord -Value 0 | Out-Null
$exe = "C:\Program Files\TightVNC\tvnserver.exe"
Get-Process tvnserver -ErrorAction SilentlyContinue | Where-Object { $_.SessionId -eq (Get-Process -Id $PID).SessionId } | Stop-Process -Force -ErrorAction SilentlyContinue
Start-Process -FilePath $exe -ArgumentList "-run" -WindowStyle Minimized
'@
Set-Content -Encoding UTF8 -LiteralPath $userVNCStartupPath -Value $userVNCStartup
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $userVNCStartupCommandPath) | Out-Null
Set-Content -Encoding ASCII -LiteralPath $userVNCStartupCommandPath -Value ('@echo off' + [Environment]::NewLine + 'powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $userVNCStartupPath + '"' + [Environment]::NewLine)
$startupTask = "CrabboxUserVNC"
cmd.exe /c "schtasks.exe /Delete /TN $startupTask /F 2>NUL" | Out-Null
schtasks.exe /Create /TN $startupTask /SC ONCE /ST ((Get-Date).AddMinutes(1).ToString("HH:mm")) /TR "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File $userVNCStartupPath" /RU $user /IT /F | Out-Null
Get-Service -Name tvnserver -ErrorAction SilentlyContinue | Set-Service -StartupType Disabled
Stop-Service -Name tvnserver -Force -ErrorAction SilentlyContinue
$winlogon = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
Set-ItemProperty -Path $winlogon -Name AutoAdminLogon -Value "1" -Type String
Set-ItemProperty -Path $winlogon -Name ForceAutoLogon -Value "1" -Type String

View File

@ -101,6 +101,11 @@ func TestAWSUserDataWindowsProfile(t *testing.T) {
"tightvnc-2.8.85-gpl-setup-64bit.msi",
"VALUE_OF_PASSWORD=$vncPassword",
"VALUE_OF_ALLOWLOOPBACK=1",
"CrabboxUserVNC",
"crabbox-user-vnc.cmd",
"start-user-vnc.ps1",
"-run",
"Set-Service -StartupType Disabled",
"New-CrabboxPassword",
"${userSID}:F",
`C:\ProgramData\crabbox\vnc.password`,

View File

@ -126,22 +126,7 @@ func probeStaticDesktop(ctx context.Context, target SSHTarget) error {
func probeBrowserEnv(ctx context.Context, cfg Config, target SSHTarget) (map[string]string, error) {
var script string
if isWindowsNativeTarget(target) {
script = powershellCommand(`$ErrorActionPreference = "SilentlyContinue"
$paths = @()
$cmd = Get-Command chrome.exe -ErrorAction SilentlyContinue
if ($cmd) { $paths += $cmd.Source }
$cmd = Get-Command msedge.exe -ErrorAction SilentlyContinue
if ($cmd) { $paths += $cmd.Source }
$paths += @(
"$Env:ProgramFiles\Google\Chrome\Application\chrome.exe",
"${Env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
"$Env:ProgramFiles\Microsoft\Edge\Application\msedge.exe",
"${Env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe"
)
$path = $paths | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
if (-not $path) { exit 1 }
Write-Output ("BROWSER=" + $path)
Write-Output ("CHROME_BIN=" + $path)`)
script = windowsBrowserProbeScript()
} else if cfg.TargetOS == targetMacOS || target.TargetOS == targetMacOS {
script = `path="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; test -x "$path" || exit 1; printf 'BROWSER=%s\nCHROME_BIN=%s\n' "$path" "$path"`
} else {
@ -169,6 +154,25 @@ printf 'BROWSER=%s\nCHROME_BIN=%s\n' "$path" "$path"`
return env, nil
}
func windowsBrowserProbeScript() string {
return `$ErrorActionPreference = "SilentlyContinue"
$paths = @()
$cmd = Get-Command chrome.exe -ErrorAction SilentlyContinue
if ($cmd) { $paths += $cmd.Source }
$cmd = Get-Command msedge.exe -ErrorAction SilentlyContinue
if ($cmd) { $paths += $cmd.Source }
$paths += @(
"$Env:ProgramFiles\Google\Chrome\Application\chrome.exe",
"${Env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
"$Env:ProgramFiles\Microsoft\Edge\Application\msedge.exe",
"${Env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe"
)
$path = $paths | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
if (-not $path) { exit 1 }
Write-Output ("BROWSER=" + $path)
Write-Output ("CHROME_BIN=" + $path)`
}
func parseEnvLines(input string) map[string]string {
env := map[string]string{}
for _, line := range strings.Split(input, "\n") {

210
internal/cli/desktop.go Normal file
View File

@ -0,0 +1,210 @@
package cli
import (
"bytes"
"context"
"fmt"
"strings"
)
func (a App) desktop(ctx context.Context, args []string) error {
if len(args) == 0 {
return exit(2, "usage: crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] -- <command...>")
}
switch args[0] {
case "launch":
return a.desktopLaunch(ctx, args[1:])
default:
return exit(2, "unknown desktop command %q", args[0])
}
}
func (a App) desktopLaunch(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("desktop launch", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
id := fs.String("id", "", "lease id or slug")
browser := fs.Bool("browser", false, "launch the target browser")
url := fs.String("url", "", "URL to pass to the launched browser")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
targetFlags := registerTargetFlags(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
positionalID := false
if *id == "" && fs.NArg() > 0 {
*id = fs.Arg(0)
positionalID = true
}
cfg, err := loadConfig()
if err != nil {
return err
}
cfg.Provider = *provider
cfg.Desktop = true
cfg.Browser = *browser
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
return err
}
if err := validateRequestedCapabilities(cfg); err != nil {
return err
}
if *id == "" && !isStaticProvider(cfg.Provider) {
return exit(2, "usage: crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] -- <command...>")
}
server, target, leaseID, err := a.resolveLeaseTarget(ctx, cfg, *id)
if err != nil {
return err
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return err
}
repo, err := findRepo()
if err != nil {
return err
}
if err := claimLeaseForRepoConfig(leaseID, serverSlug(server), cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil {
return err
}
a.touchActiveLeaseBestEffort(ctx, cfg, server, leaseID)
if err := waitForLoopbackVNC(ctx, &target); err != nil {
return err
}
env, err := requestedCapabilityEnv(ctx, cfg, target)
if err != nil {
return err
}
command := fs.Args()
if positionalID && len(command) > 0 && command[0] == *id {
command = command[1:]
}
if *browser {
if len(command) == 0 {
if env["BROWSER"] == "" {
return exit(2, "browser=true requested but target did not report BROWSER")
}
command = []string{env["BROWSER"]}
if strings.TrimSpace(*url) != "" {
command = append(command, strings.TrimSpace(*url))
}
} else if strings.TrimSpace(*url) != "" {
command = append(command, strings.TrimSpace(*url))
}
}
if len(command) == 0 {
return exit(2, "usage: crabbox desktop launch --id <lease-id-or-slug> -- <command...>")
}
workdir := remoteJoin(cfg, leaseID, repo.Name)
if err := runSSHQuiet(ctx, target, desktopLaunchRemoteCommand(target, workdir, env, command)); err != nil {
return exit(5, "launch desktop command: %v", err)
}
fmt.Fprintf(a.Stdout, "launched: %s\n", strings.Join(command, " "))
return nil
}
func desktopLaunchRemoteCommand(target SSHTarget, workdir string, env map[string]string, command []string) string {
if isWindowsNativeTarget(target) {
return windowsDesktopLaunchRemoteCommand(workdir, env, command)
}
if target.TargetOS == targetMacOS {
return posixDesktopLaunchRemoteCommand(workdir, env, command)
}
return posixDesktopLaunchRemoteCommand(workdir, env, command)
}
func posixDesktopLaunchRemoteCommand(workdir string, env map[string]string, command []string) string {
var b bytes.Buffer
b.WriteString("set -eu\n")
if workdir != "" {
b.WriteString("cd " + shellQuote(workdir) + "\n")
}
for key, value := range env {
b.WriteString(key + "=" + shellQuote(value) + "\n")
b.WriteString("export " + key + "\n")
}
b.WriteString("log=${TMPDIR:-/tmp}/crabbox-desktop-launch.log\n")
b.WriteString("if command -v setsid >/dev/null 2>&1; then\n")
b.WriteString(" setsid ")
writeShellArgv(&b, command)
b.WriteString(" >\"$log\" 2>&1 < /dev/null &\n")
b.WriteString("else\n")
b.WriteString(" nohup ")
writeShellArgv(&b, command)
b.WriteString(" >\"$log\" 2>&1 < /dev/null &\n")
b.WriteString("fi\n")
return b.String()
}
func writeShellArgv(b *bytes.Buffer, command []string) {
for i, arg := range command {
if i > 0 {
b.WriteByte(' ')
}
b.WriteString(shellQuote(arg))
}
}
func windowsDesktopLaunchRemoteCommand(workdir string, env map[string]string, command []string) string {
inner := windowsDesktopLaunchScript(workdir, env, command)
return `$ErrorActionPreference = "Stop"
$base = "C:\ProgramData\crabbox"
$usernamePath = Join-Path $base "windows.username"
$passwordPath = Join-Path $base "windows.password"
$username = if (Test-Path -LiteralPath $usernamePath) { Get-Content -Raw -LiteralPath $usernamePath } else { $env:USERNAME }
$username = $username.Trim()
$password = if (Test-Path -LiteralPath $passwordPath) { (Get-Content -Raw -LiteralPath $passwordPath).Trim() } else { "" }
$taskName = "CrabboxDesktopLaunch-" + [Guid]::NewGuid().ToString("N")
$script = Join-Path $base ($taskName + ".ps1")
Set-Content -Encoding UTF8 -LiteralPath $script -Value ` + psQuote(inner) + `
cmd.exe /c "schtasks.exe /Delete /TN $taskName /F 2>NUL" | Out-Null
$startTime = (Get-Date).AddMinutes(1).ToString("HH:mm")
$createArgs = @("/Create", "/TN", $taskName, "/SC", "ONCE", "/ST", $startTime, "/TR", "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File $script", "/RU", $username, "/IT", "/F")
& schtasks.exe @createArgs | Out-Null
if ($LASTEXITCODE -ne 0 -and $password -ne "") {
& schtasks.exe @($createArgs + @("/RP", $password)) | Out-Null
}
if ($LASTEXITCODE -ne 0) { throw "failed to create interactive desktop launch task" }
& schtasks.exe /Run /TN $taskName | Out-Null
Start-Sleep -Seconds 2
& schtasks.exe /Delete /TN $taskName /F | Out-Null
Remove-Item -Force -LiteralPath $script -ErrorAction SilentlyContinue
`
}
func windowsDesktopLaunchScript(workdir string, env map[string]string, command []string) string {
var b bytes.Buffer
b.WriteString("$ErrorActionPreference = \"Stop\"\n")
if workdir != "" {
b.WriteString("Set-Location -LiteralPath " + psQuote(workdir) + "\n")
}
for key, value := range env {
b.WriteString("$env:" + key + " = " + psQuote(value) + "\n")
}
b.WriteString("$file = " + psQuote(command[0]) + "\n")
b.WriteString("$arguments = @(")
for i, arg := range command[1:] {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(psQuote(arg))
}
b.WriteString(")\n")
b.WriteString(`try {
$shell = New-Object -ComObject Shell.Application
$shell.MinimizeAll()
Start-Sleep -Milliseconds 250
} catch {}
$process = Start-Process -FilePath $file -ArgumentList $arguments -WorkingDirectory (Get-Location).Path -WindowStyle Normal -PassThru
Start-Sleep -Seconds 2
try {
$wshell = New-Object -ComObject WScript.Shell
$names = @()
if ($process -and $process.ProcessName) { $names += $process.ProcessName }
$names += [IO.Path]::GetFileNameWithoutExtension($file)
foreach ($name in ($names | Where-Object { $_ } | Select-Object -Unique)) {
if ($wshell.AppActivate($name)) { break }
}
} catch {}
`)
return b.String()
}

View File

@ -0,0 +1,67 @@
package cli
import (
"strings"
"testing"
)
func TestDesktopLaunchRemoteCommandUsesDetachedPOSIXSession(t *testing.T) {
got := desktopLaunchRemoteCommand(
SSHTarget{TargetOS: targetLinux},
"/work/crabbox/cbx_1/repo",
map[string]string{"DISPLAY": ":99", "BROWSER": "/usr/bin/chromium"},
[]string{"/usr/bin/chromium", "https://example.com"},
)
for _, want := range []string{
"cd '/work/crabbox/cbx_1/repo'",
"DISPLAY=':99'",
"BROWSER='/usr/bin/chromium'",
"setsid '/usr/bin/chromium' 'https://example.com'",
"crabbox-desktop-launch.log",
} {
if !strings.Contains(got, want) {
t.Fatalf("desktop launch command missing %q:\n%s", want, got)
}
}
}
func TestWindowsDesktopLaunchRemoteCommandUsesInteractiveTask(t *testing.T) {
got := desktopLaunchRemoteCommand(
SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeNormal},
`C:\crabbox\cbx_1\repo`,
map[string]string{"BROWSER": `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`},
[]string{`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, "https://example.com"},
)
for _, want := range []string{
"CrabboxDesktopLaunch-",
"windows.username",
"windows.password",
"schtasks.exe /Delete",
`"/IT"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("windows desktop launch command missing %q:\n%s", want, got)
}
}
}
func TestWindowsDesktopLaunchScriptStartsAndForegroundsProcess(t *testing.T) {
got := windowsDesktopLaunchScript(
`C:\crabbox\cbx_1\repo`,
map[string]string{"BROWSER": `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`},
[]string{`C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`, "https://example.com"},
)
for _, want := range []string{
`Set-Location -LiteralPath 'C:\crabbox\cbx_1\repo'`,
`$env:BROWSER = 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe'`,
"Shell.Application",
"MinimizeAll",
"Start-Process -FilePath $file",
"WScript.Shell",
"AppActivate",
} {
if !strings.Contains(got, want) {
t.Fatalf("windows desktop launch script missing %q:\n%s", want, got)
}
}
}

View File

@ -41,6 +41,7 @@ func findRepo() (Repo, error) {
func defaultExcludes() []string {
return []string{
".git",
"._*",
"node_modules",
".turbo",
".next",

View File

@ -66,6 +66,29 @@ func TestSyncManifestPrunesNestedDefaultExcludes(t *testing.T) {
}
}
func TestSyncManifestPrunesAppleDoubleSidecars(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init")
runGit(t, dir, "config", "user.email", "test@example.com")
runGit(t, dir, "config", "user.name", "Test")
writeFile(t, filepath.Join(dir, "src", "index.ts"), "export const ok = true\n")
writeFile(t, filepath.Join(dir, "src", "._index.ts"), "appledouble")
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-m", "init")
manifest, err := syncManifest(dir, configuredExcludes(baseConfig()))
if err != nil {
t.Fatal(err)
}
got := strings.Join(manifest.Files, ",")
if !strings.Contains(got, "src/index.ts") {
t.Fatalf("manifest missing real source file: %q", got)
}
if strings.Contains(got, "._index.ts") {
t.Fatalf("manifest should exclude AppleDouble sidecars: %q", got)
}
}
func TestSyncManifestRecordsTrackedDeletes(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init")

View File

@ -142,15 +142,30 @@ $bitmap.Dispose()
'@.Replace("__CRABBOX_SCREENSHOT_OUT__", $out.Replace("\", "\\")) | Set-Content -Encoding ASCII -LiteralPath $script
cmd.exe /c "schtasks.exe /Delete /TN $taskName /F 2>NUL" | Out-Null
$startTime = (Get-Date).AddMinutes(1).ToString("HH:mm")
schtasks.exe /Create /TN $taskName /SC ONCE /ST $startTime /TR "powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script" /RU $env:USERNAME /RP $password /RL HIGHEST /IT /F | Out-Null
$createArgs = @("/Create", "/TN", $taskName, "/SC", "ONCE", "/ST", $startTime, "/TR", "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File $script", "/RU", $env:USERNAME, "/IT", "/F")
& schtasks.exe @createArgs | Out-Null
if ($LASTEXITCODE -ne 0 -and $password -ne "") {
& schtasks.exe @($createArgs + @("/RP", $password)) | Out-Null
}
if ($LASTEXITCODE -ne 0) { throw "failed to create interactive screenshot task" }
schtasks.exe /Run /TN $taskName | Out-Null
for ($i = 0; $i -lt 30; $i++) {
if (Test-Path -LiteralPath $out) {
$bytes = [IO.File]::ReadAllBytes($out)
[Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)
schtasks.exe /Delete /TN $taskName /F | Out-Null
Remove-Item -Force -LiteralPath $out, $script -ErrorAction SilentlyContinue
exit 0
try {
$stream = [IO.File]::Open($out, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
try {
$bytes = New-Object byte[] $stream.Length
$read = $stream.Read($bytes, 0, $bytes.Length)
[Console]::OpenStandardOutput().Write($bytes, 0, $read)
} finally {
$stream.Dispose()
}
schtasks.exe /Delete /TN $taskName /F | Out-Null
Remove-Item -Force -LiteralPath $out, $script -ErrorAction SilentlyContinue
exit 0
} catch {
Start-Sleep -Milliseconds 500
}
}
Start-Sleep -Milliseconds 500
}

View File

@ -34,7 +34,7 @@ func TestScreenshotRemoteCommandSupportsWindowsAndMacOS(t *testing.T) {
for _, want := range []string{
"System.Windows.Forms",
"ImageFormat]::Png",
"schtasks.exe /Create",
"& schtasks.exe @createArgs",
"/IT",
"windows.password",
} {

View File

@ -40,6 +40,22 @@ func TestVNCPasswordCommandSupportsManagedTargets(t *testing.T) {
}
}
func TestWindowsBrowserProbeScriptIsRawPowerShell(t *testing.T) {
got := windowsBrowserProbeScript()
for _, want := range []string{
"Get-Command msedge.exe",
`${Env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe`,
`Write-Output ("BROWSER=" + $path)`,
} {
if !strings.Contains(got, want) {
t.Fatalf("windows browser probe missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "EncodedCommand") {
t.Fatalf("browser probe should be raw PowerShell before SSH wrapping:\n%s", got)
}
}
func TestOpenURLCommandIncludesURL(t *testing.T) {
name, args := openURLCommand("vnc://localhost:5901")
if name == "" {