feat: launch apps on crabbox desktops
This commit is contained in:
parent
358e85fdbe
commit
8214a13978
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
38
docs/commands/desktop.md
Normal 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
|
||||
```
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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
210
internal/cli/desktop.go
Normal 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()
|
||||
}
|
||||
67
internal/cli/desktop_test.go
Normal file
67
internal/cli/desktop_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -41,6 +41,7 @@ func findRepo() (Repo, error) {
|
||||
func defaultExcludes() []string {
|
||||
return []string{
|
||||
".git",
|
||||
"._*",
|
||||
"node_modules",
|
||||
".turbo",
|
||||
".next",
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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",
|
||||
} {
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user