Revert "feat: add desktop recording command"
This reverts commit 628ea8cb9a.
This commit is contained in:
parent
0e52767d21
commit
686f2e880b
@ -1,11 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 0.6.0 - Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added `crabbox record` to capture MP4 desktop recordings from UI-capable leases.
|
||||
|
||||
## 0.5.1 - 2026-05-05
|
||||
|
||||
### Added
|
||||
|
||||
@ -37,8 +37,6 @@ crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|m
|
||||
crabbox run [--id <lease-id-or-slug>] [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--tailscale] [--network auto|tailscale|public] [--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 record --id <lease-id-or-slug> [--duration 10s] [--output desktop.mp4]
|
||||
crabbox record --id <lease-id-or-slug> --while -- <local-command...>
|
||||
crabbox sync-plan [--limit <n>]
|
||||
crabbox history [--lease <lease-id>] [--owner <email>] [--org <name>] [--limit <n>] [--json]
|
||||
crabbox logs <run-id> [--json]
|
||||
@ -90,7 +88,6 @@ 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 record --id blue-lobster --duration 10s --output desktop.mp4
|
||||
crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test'
|
||||
crabbox stop blue-lobster
|
||||
```
|
||||
|
||||
@ -29,7 +29,6 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
|
||||
- [vnc](vnc.md)
|
||||
- [webvnc](webvnc.md)
|
||||
- [screenshot](screenshot.md)
|
||||
- [record](record.md)
|
||||
- [inspect](inspect.md)
|
||||
- [stop](stop.md)
|
||||
- [cleanup](cleanup.md)
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
# record
|
||||
|
||||
`crabbox record` captures an MP4 from a desktop lease without opening a VNC
|
||||
client.
|
||||
|
||||
```sh
|
||||
crabbox warmup --desktop
|
||||
crabbox record --id blue-lobster
|
||||
crabbox record --id blue-lobster --duration 10s --output desktop.mp4
|
||||
crabbox record --id blue-lobster --duration 2m --output task.mp4 --while -- ./drive-ui.sh
|
||||
```
|
||||
|
||||
The command resolves and touches the lease like `crabbox screenshot`, verifies
|
||||
that the lease has `desktop=true`, waits for the loopback desktop/VNC service,
|
||||
then streams an MP4 over SSH. Linux records `DISPLAY=:99` with `ffmpeg`
|
||||
`x11grab`. Windows, including `--windows-mode wsl2` leases, records the visible
|
||||
Windows console by creating a one-shot scheduled task inside the logged-in
|
||||
`crabbox` session and using `ffmpeg` `gdigrab`.
|
||||
|
||||
New managed Linux desktop leases install `ffmpeg` as part of the desktop
|
||||
profile. Existing desktop leases can record after `ffmpeg` is installed on the
|
||||
guest. Windows recording requires `ffmpeg.exe` on the desktop lease.
|
||||
|
||||
Use `--while -- <local-command...>` when another tool should drive the remote
|
||||
desktop while Crabbox records it. Crabbox waits until the remote recorder is
|
||||
armed, runs the local command, then stops the recorder and writes the MP4. The
|
||||
local driver gets `CRABBOX_RECORD_LEASE_ID` and `CRABBOX_RECORD_PROVIDER` in its
|
||||
environment so it can call commands such as `crabbox desktop launch`,
|
||||
`crabbox screenshot`, or a scenario runner against the same lease. `--duration`
|
||||
is a hard cap for the local driver command; if the driver is still running at
|
||||
that limit, Crabbox stops the driver, stops the recorder, and returns an error.
|
||||
The recording itself can include a small setup/teardown margin around the driver
|
||||
so it does not miss the tail of the interaction. `--while` currently supports
|
||||
POSIX desktop targets.
|
||||
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|ssh
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--network auto|tailscale|public
|
||||
--duration <duration>
|
||||
--fps <frames-per-second>
|
||||
--size <width>x<height>|auto
|
||||
--while -- <local-command...>
|
||||
--output <path>
|
||||
--reclaim
|
||||
```
|
||||
|
||||
Related docs:
|
||||
|
||||
- [Interactive desktop and VNC](../features/interactive-desktop-vnc.md)
|
||||
- [screenshot command](screenshot.md)
|
||||
@ -20,7 +20,6 @@ crabbox warmup --desktop --browser
|
||||
crabbox vnc --id blue-lobster --open
|
||||
crabbox webvnc --id blue-lobster --open
|
||||
crabbox screenshot --id blue-lobster --output desktop.png
|
||||
crabbox record --id blue-lobster --duration 10s --output desktop.mp4
|
||||
```
|
||||
|
||||
AWS Windows and EC2 Mac use the same VNC command once the desktop lease exists:
|
||||
@ -100,19 +99,6 @@ Use `crabbox screenshot` when you need a PNG without taking over the session:
|
||||
crabbox screenshot --id blue-lobster --output desktop.png
|
||||
```
|
||||
|
||||
Use `crabbox record` when a temporal UI bug needs video evidence:
|
||||
|
||||
```sh
|
||||
crabbox record --id blue-lobster --duration 10s --output desktop.mp4
|
||||
crabbox record --id blue-lobster --duration 2m --output task.mp4 --while -- ./drive-ui.sh
|
||||
```
|
||||
|
||||
The `--while` form records while a local driver command controls the desktop.
|
||||
Drivers can be deterministic scripts, Playwright/CDP flows, VNC/xdotool
|
||||
automation, or an agent wrapper. Crabbox injects `CRABBOX_RECORD_LEASE_ID` and
|
||||
`CRABBOX_RECORD_PROVIDER` into the driver environment. `--duration` is the hard
|
||||
cap for the local driver; the recording can include a small margin around it.
|
||||
|
||||
Use `crabbox desktop launch` to start a browser or app inside the visible
|
||||
session without keeping the SSH command attached:
|
||||
|
||||
@ -172,4 +158,4 @@ often machine- and user-encrypted.
|
||||
- [AWS](aws.md): AWS target matrix, capacity, AMIs, and EC2 Mac host requirements.
|
||||
- [Hetzner](hetzner.md): Linux-only managed Hetzner behavior.
|
||||
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox behavior and why VNC is not a Crabbox feature there yet.
|
||||
- [vnc command](../commands/vnc.md), [webvnc command](../commands/webvnc.md), [screenshot command](../commands/screenshot.md), [record command](../commands/record.md), [desktop command](../commands/desktop.md).
|
||||
- [vnc command](../commands/vnc.md), [webvnc command](../commands/webvnc.md), [screenshot command](../commands/screenshot.md), [desktop command](../commands/desktop.md).
|
||||
|
||||
@ -48,7 +48,7 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
|
||||
- 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 and recording commands: `internal/cli/screenshot.go`, `internal/cli/record.go`
|
||||
- Desktop screenshot command: `internal/cli/screenshot.go`
|
||||
- Interactive desktop/VNC contract: `docs/features/interactive-desktop-vnc.md`, `docs/features/vnc-linux.md`, `docs/features/vnc-windows.md`, `docs/features/vnc-macos.md`
|
||||
|
||||
Bootstrap is intentionally tiny unless optional lease capabilities are requested:
|
||||
|
||||
@ -89,8 +89,6 @@ func (a App) directCommandHelp(ctx context.Context, args []string) (error, bool)
|
||||
return a.webvnc(ctx, helpArgs), true
|
||||
case "screenshot":
|
||||
return a.screenshot(ctx, helpArgs), true
|
||||
case "record":
|
||||
return a.recordDesktop(ctx, helpArgs), true
|
||||
case "inspect":
|
||||
return a.inspect(ctx, helpArgs), true
|
||||
case "stop", "release":
|
||||
@ -153,7 +151,6 @@ Commands:
|
||||
vnc Print or open VNC connection details for a desktop lease
|
||||
webvnc Bridge a desktop lease into the authenticated web portal
|
||||
screenshot Capture a PNG from a desktop lease
|
||||
record Capture an MP4 from a desktop lease
|
||||
inspect Print lease/provider details; add --json for scripts
|
||||
stop Release a lease or delete a direct-provider machine
|
||||
cleanup Sweep expired direct-provider machines
|
||||
@ -169,7 +166,6 @@ Common Flows:
|
||||
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 record --id blue-lobster --duration 10s --output desktop.mp4
|
||||
crabbox inspect --id blue-lobster --json
|
||||
crabbox history --lease cbx_abcdef123456
|
||||
crabbox logs run_123
|
||||
|
||||
@ -520,7 +520,7 @@ func cloudInitOptionalBootstrap(cfg Config) string {
|
||||
parts = append(parts, cloudInitTailscaleBootstrap(cfg))
|
||||
}
|
||||
if cfg.Desktop {
|
||||
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot ffmpeg fonts-dejavu-core fonts-liberation iproute2 openssl
|
||||
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot fonts-dejavu-core fonts-liberation iproute2 openssl
|
||||
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
|
||||
if [ ! -s /var/lib/crabbox/vnc.password ]; then
|
||||
(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)
|
||||
|
||||
@ -39,7 +39,7 @@ func TestCloudInitDesktopProfile(t *testing.T) {
|
||||
got := cloudInit(cfg, "ssh-ed25519 test")
|
||||
for _, want := range []string{
|
||||
"xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11",
|
||||
"x11-xserver-utils xterm scrot ffmpeg",
|
||||
"x11-xserver-utils xterm scrot",
|
||||
"/etc/systemd/system/crabbox-xvfb.service",
|
||||
"/etc/systemd/system/crabbox-desktop.service",
|
||||
"/usr/local/bin/crabbox-desktop-session",
|
||||
|
||||
@ -37,7 +37,6 @@ type crabboxKongCLI struct {
|
||||
Vnc vncKongCmd `cmd:"" name:"vnc" passthrough:"" help:"Print or open VNC connection details for a desktop lease."`
|
||||
Webvnc webvncKongCmd `cmd:"" name:"webvnc" passthrough:"" help:"Bridge a desktop lease into the authenticated web portal."`
|
||||
Screenshot screenshotKongCmd `cmd:"" passthrough:"" help:"Capture a PNG from a desktop lease."`
|
||||
Record recordKongCmd `cmd:"" passthrough:"" help:"Capture an MP4 from a desktop lease."`
|
||||
Inspect inspectKongCmd `cmd:"" passthrough:"" help:"Print lease/provider details; add --json for scripts."`
|
||||
Stop stopKongCmd `cmd:"" passthrough:"" help:"Release a lease or delete a direct-provider machine."`
|
||||
Release releaseKongCmd `cmd:"" passthrough:"" help:"Alias for stop."`
|
||||
@ -174,9 +173,6 @@ type webvncKongCmd struct {
|
||||
type screenshotKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type recordKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type inspectKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
@ -308,9 +304,6 @@ func (c *webvncKongCmd) Run(ctx context.Context, app App) error { return app.w
|
||||
func (c *screenshotKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.screenshot(ctx, c.Args)
|
||||
}
|
||||
func (c *recordKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.recordDesktop(ctx, c.Args)
|
||||
}
|
||||
func (c *inspectKongCmd) Run(ctx context.Context, app App) error { return app.inspect(ctx, c.Args) }
|
||||
func (c *stopKongCmd) Run(ctx context.Context, app App) error { return app.stop(ctx, c.Args) }
|
||||
func (c *releaseKongCmd) Run(ctx context.Context, app App) error { return app.stop(ctx, c.Args) }
|
||||
|
||||
@ -1,438 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
recordWhileRecorderReadyTimeout = 10 * time.Second
|
||||
recordWhileRemoteStopHeadroom = 15 * time.Second
|
||||
)
|
||||
|
||||
func (a App) recordDesktop(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("record", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
output := fs.String("output", "", "local MP4 output path")
|
||||
duration := fs.Duration("duration", 10*time.Second, "recording duration")
|
||||
fps := fs.Int("fps", 15, "recording frame rate")
|
||||
size := fs.String("size", "1024x768", "recording size, or auto")
|
||||
while := fs.Bool("while", false, "record while a local command runs after --")
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
networkFlags := registerNetworkModeFlag(fs, defaults)
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
idFromPositional := false
|
||||
if *id == "" && fs.NArg() > 0 {
|
||||
*id = fs.Arg(0)
|
||||
idFromPositional = true
|
||||
}
|
||||
whileCommand := recordWhileCommandArgs(fs.Args(), *id, idFromPositional)
|
||||
if *while && len(whileCommand) == 0 {
|
||||
return exit(2, "usage: crabbox record --id <lease-id-or-slug> --while -- <local-command...>")
|
||||
}
|
||||
if *duration <= 0 {
|
||||
return exit(2, "--duration must be greater than zero")
|
||||
}
|
||||
if *duration > 10*time.Minute {
|
||||
return exit(2, "--duration must be 10m or less")
|
||||
}
|
||||
if *fps <= 0 || *fps > 60 {
|
||||
return exit(2, "--fps must be between 1 and 60")
|
||||
}
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Provider = *provider
|
||||
cfg.Desktop = true
|
||||
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyNetworkModeFlagOverride(&cfg, fs, networkFlags); err != nil {
|
||||
return err
|
||||
}
|
||||
if isBlacksmithProvider(cfg.Provider) {
|
||||
return exit(2, "desktop recording is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
|
||||
}
|
||||
if *id == "" && !isStaticProvider(cfg.Provider) {
|
||||
return exit(2, "usage: crabbox record --id <lease-id-or-slug> [--duration 10s] [--output <path>]")
|
||||
}
|
||||
server, target, leaseID, err := a.resolveLeaseTarget(ctx, cfg, *id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resolved, err := resolveNetworkTarget(ctx, cfg, server, target); err != nil {
|
||||
return err
|
||||
} else {
|
||||
target = resolved.Target
|
||||
}
|
||||
if isStaticProvider(cfg.Provider) && target.TargetOS != targetLinux {
|
||||
return exit(2, "desktop recordings are not captured from static %s hosts because those are existing host machines, not Crabbox-created desktops", target.TargetOS)
|
||||
}
|
||||
if target.TargetOS == targetMacOS {
|
||||
return exit(2, "desktop recording is not supported for macOS targets yet")
|
||||
}
|
||||
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)
|
||||
recordTarget := recordDesktopControlTarget(target)
|
||||
if err := waitForLoopbackVNC(ctx, &recordTarget); err != nil {
|
||||
return err
|
||||
}
|
||||
outPath := strings.TrimSpace(*output)
|
||||
if outPath == "" {
|
||||
outPath = defaultRecordingPath(leaseID, serverSlug(server))
|
||||
}
|
||||
recordOpts := recordDesktopOptions{
|
||||
Duration: *duration,
|
||||
FPS: *fps,
|
||||
Size: strings.TrimSpace(*size),
|
||||
}
|
||||
if *while {
|
||||
if recordTarget.TargetOS == targetWindows {
|
||||
return exit(2, "record --while is not supported on Windows targets yet")
|
||||
}
|
||||
if err := captureDesktopRecordingWhile(ctx, recordTarget, outPath, recordOpts, recordWhileCommandOptions{
|
||||
Command: whileCommand,
|
||||
LeaseID: leaseID,
|
||||
Provider: cfg.Provider,
|
||||
Timeout: recordOpts.Duration,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := captureDesktopRecording(ctx, recordTarget, outPath, recordOpts); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "recording: %s\n", outPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordWhileCommandArgs(args []string, id string, idFromPositional bool) []string {
|
||||
if idFromPositional && id != "" && len(args) > 0 && args[0] == id {
|
||||
return args[1:]
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func recordDesktopControlTarget(target SSHTarget) SSHTarget {
|
||||
if target.TargetOS == targetWindows {
|
||||
target.WindowsMode = windowsModeNormal
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
type recordDesktopOptions struct {
|
||||
Duration time.Duration
|
||||
FPS int
|
||||
Size string
|
||||
}
|
||||
|
||||
type recordWhileCommandOptions struct {
|
||||
Command []string
|
||||
LeaseID string
|
||||
Provider string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
func defaultRecordingPath(leaseID, slug string) string {
|
||||
name := slug
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = leaseID
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
name = "crabbox"
|
||||
}
|
||||
return "crabbox-" + normalizeLeaseSlug(name) + "-recording.mp4"
|
||||
}
|
||||
|
||||
func captureDesktopRecordingWhile(ctx context.Context, target SSHTarget, outputPath string, opts recordDesktopOptions, command recordWhileCommandOptions) error {
|
||||
if target.TargetOS == targetWindows {
|
||||
return exit(2, "record --while is not supported on Windows targets yet")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return exit(2, "create recording directory: %v", err)
|
||||
}
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return exit(2, "create recording %s: %v", outputPath, err)
|
||||
}
|
||||
ok := false
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
if !ok {
|
||||
_ = os.Remove(outputPath)
|
||||
}
|
||||
}()
|
||||
token := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
remoteStopPath := "/tmp/crabbox-record-" + token + ".stop"
|
||||
remoteReadyPath := "/tmp/crabbox-record-" + token + ".ready"
|
||||
remoteOutputPath := "/tmp/crabbox-record-" + token + ".mp4"
|
||||
recorderCtx, cancelRecorder := context.WithCancel(ctx)
|
||||
defer cancelRecorder()
|
||||
recordDone := make(chan error, 1)
|
||||
go func() {
|
||||
recordDone <- runSSHToWriter(recorderCtx, target, recordRemoteUntilStopCommand(target, opts, remoteOutputPath, remoteStopPath, remoteReadyPath), file)
|
||||
}()
|
||||
if err := waitForRecordWhileRecorderReady(ctx, target, remoteReadyPath, recordDone); err != nil {
|
||||
_ = runSSHQuiet(ctx, target, "touch "+shellQuote(remoteStopPath))
|
||||
cancelRecorder()
|
||||
recordErr := <-recordDone
|
||||
if recordErr != nil {
|
||||
return exit(5, "start recording: %v; recorder: %v", err, recordErr)
|
||||
}
|
||||
return exit(5, "start recording: %v", err)
|
||||
}
|
||||
driverErr := runRecordWhileLocalCommand(ctx, command)
|
||||
stopErr := runSSHQuiet(ctx, target, "touch "+shellQuote(remoteStopPath))
|
||||
recordErr := <-recordDone
|
||||
if recordErr == nil {
|
||||
ok = true
|
||||
}
|
||||
if driverErr != nil {
|
||||
return exit(5, "record driver command: %v", driverErr)
|
||||
}
|
||||
if stopErr != nil {
|
||||
return exit(5, "stop recording: %v", stopErr)
|
||||
}
|
||||
if recordErr != nil {
|
||||
return exit(5, "capture recording: %v", recordErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForRecordWhileRecorderReady(ctx context.Context, target SSHTarget, remoteReadyPath string, recordDone chan error) error {
|
||||
waitCtx, cancel := context.WithTimeout(ctx, recordWhileRecorderReadyTimeout)
|
||||
defer cancel()
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case err := <-recordDone:
|
||||
recordDone <- err
|
||||
if err != nil {
|
||||
return fmt.Errorf("recorder exited before ready: %w", err)
|
||||
}
|
||||
return fmt.Errorf("recorder exited before ready")
|
||||
default:
|
||||
}
|
||||
probeCtx, probeCancel := context.WithTimeout(waitCtx, 2*time.Second)
|
||||
err := runSSHQuietWithOptions(probeCtx, target, "test -f "+shellQuote(remoteReadyPath), "2", "1")
|
||||
probeCancel()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-waitCtx.Done():
|
||||
return fmt.Errorf("recorder did not become ready within %s", recordWhileRecorderReadyTimeout)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runRecordWhileLocalCommand(ctx context.Context, opts recordWhileCommandOptions) error {
|
||||
driverCtx := ctx
|
||||
cancel := func() {}
|
||||
if opts.Timeout > 0 {
|
||||
driverCtx, cancel = context.WithTimeout(ctx, opts.Timeout)
|
||||
}
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(driverCtx, opts.Command[0], opts.Command[1:]...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = append(os.Environ(),
|
||||
"CRABBOX_RECORD_LEASE_ID="+opts.LeaseID,
|
||||
"CRABBOX_RECORD_PROVIDER="+opts.Provider,
|
||||
)
|
||||
err := cmd.Run()
|
||||
if err != nil && errors.Is(driverCtx.Err(), context.DeadlineExceeded) {
|
||||
return fmt.Errorf("timed out after %s", opts.Timeout)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func captureDesktopRecording(ctx context.Context, target SSHTarget, outputPath string, opts recordDesktopOptions) error {
|
||||
target = recordDesktopControlTarget(target)
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return exit(2, "create recording directory: %v", err)
|
||||
}
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return exit(2, "create recording %s: %v", outputPath, err)
|
||||
}
|
||||
ok := false
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
if !ok {
|
||||
_ = os.Remove(outputPath)
|
||||
}
|
||||
}()
|
||||
if err := runSSHToWriter(ctx, target, recordRemoteCommand(target, opts), file); err != nil {
|
||||
return exit(5, "capture recording: %v", err)
|
||||
}
|
||||
ok = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordRemoteUntilStopCommand(target SSHTarget, opts recordDesktopOptions, remoteOutputPath, remoteStopPath, remoteReadyPath string) string {
|
||||
durationSeconds := int(opts.Duration.Round(time.Second).Seconds())
|
||||
if durationSeconds < 1 {
|
||||
durationSeconds = 1
|
||||
}
|
||||
remoteMaxSeconds := int((opts.Duration + recordWhileRemoteStopHeadroom).Round(time.Second).Seconds())
|
||||
if remoteMaxSeconds < durationSeconds {
|
||||
remoteMaxSeconds = durationSeconds
|
||||
}
|
||||
size := strings.TrimSpace(opts.Size)
|
||||
if size == "" {
|
||||
size = "1024x768"
|
||||
}
|
||||
return posixRecordUntilStopRemoteCommand(remoteMaxSeconds, opts.FPS, size, remoteOutputPath, remoteStopPath, remoteReadyPath)
|
||||
}
|
||||
|
||||
func recordRemoteCommand(target SSHTarget, opts recordDesktopOptions) string {
|
||||
durationSeconds := int(opts.Duration.Round(time.Second).Seconds())
|
||||
if durationSeconds < 1 {
|
||||
durationSeconds = 1
|
||||
}
|
||||
size := strings.TrimSpace(opts.Size)
|
||||
if size == "" {
|
||||
size = "1024x768"
|
||||
}
|
||||
if target.TargetOS == targetWindows {
|
||||
return windowsRecordRemoteCommand(durationSeconds, opts.FPS, size)
|
||||
}
|
||||
return posixRecordRemoteCommand(durationSeconds, opts.FPS, size)
|
||||
}
|
||||
|
||||
func posixRecordUntilStopRemoteCommand(durationSeconds, fps int, size, remoteOutputPath, remoteStopPath, remoteReadyPath string) string {
|
||||
sizeArg := ""
|
||||
if size != "auto" {
|
||||
sizeArg = " -video_size " + shellQuote(size)
|
||||
}
|
||||
return fmt.Sprintf(`set -eu
|
||||
export DISPLAY="${DISPLAY:-:99}"
|
||||
out=%s
|
||||
stop=%s
|
||||
ready=%s
|
||||
rm -f "$out" "$stop" "$ready"
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
sudo apt-get update -y >/tmp/crabbox-record-apt.log 2>&1 || true
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ffmpeg >>/tmp/crabbox-record-apt.log 2>&1 || true
|
||||
fi
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "no video recording tool found; warm a new --desktop lease or install ffmpeg; apt log: /tmp/crabbox-record-apt.log" >&2
|
||||
exit 127
|
||||
fi
|
||||
input="$DISPLAY"
|
||||
case "$input" in
|
||||
*.*) ;;
|
||||
*) input="${input}.0" ;;
|
||||
esac
|
||||
ffmpeg -hide_banner -loglevel error -y -f x11grab%s -framerate %d -i "$input" -t %d -pix_fmt yuv420p "$out" >/tmp/crabbox-record-ffmpeg.log 2>&1 &
|
||||
pid=$!
|
||||
printf ready > "$ready"
|
||||
while kill -0 "$pid" >/dev/null 2>&1; do
|
||||
if [ -f "$stop" ]; then
|
||||
kill -INT "$pid" >/dev/null 2>&1 || true
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
wait "$pid" || true
|
||||
test -s "$out"
|
||||
cat "$out"
|
||||
rm -f "$out" "$stop" "$ready"
|
||||
`, shellQuote(remoteOutputPath), shellQuote(remoteStopPath), shellQuote(remoteReadyPath), sizeArg, fps, durationSeconds)
|
||||
}
|
||||
|
||||
func posixRecordRemoteCommand(durationSeconds, fps int, size string) string {
|
||||
sizeArg := ""
|
||||
if size != "auto" {
|
||||
sizeArg = " -video_size " + shellQuote(size)
|
||||
}
|
||||
return fmt.Sprintf(`set -eu
|
||||
export DISPLAY="${DISPLAY:-:99}"
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
sudo apt-get update -y >/tmp/crabbox-record-apt.log 2>&1 || true
|
||||
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ffmpeg >>/tmp/crabbox-record-apt.log 2>&1 || true
|
||||
fi
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "no video recording tool found; warm a new --desktop lease or install ffmpeg; apt log: /tmp/crabbox-record-apt.log" >&2
|
||||
exit 127
|
||||
fi
|
||||
input="$DISPLAY"
|
||||
case "$input" in
|
||||
*.*) ;;
|
||||
*) input="${input}.0" ;;
|
||||
esac
|
||||
ffmpeg -hide_banner -loglevel error -y -f x11grab%s -framerate %d -i "$input" -t %d -pix_fmt yuv420p -movflags frag_keyframe+empty_moov -f mp4 pipe:1
|
||||
`, sizeArg, fps, durationSeconds)
|
||||
}
|
||||
|
||||
func windowsRecordRemoteCommand(durationSeconds, fps int, size string) string {
|
||||
sizeArgs := ""
|
||||
if size != "auto" {
|
||||
sizeArgs = `, "-video_size", ` + psQuote(size)
|
||||
}
|
||||
inner := `$ErrorActionPreference = "Stop"
|
||||
$ffmpegCommand = Get-Command ffmpeg.exe -ErrorAction SilentlyContinue
|
||||
if (-not $ffmpegCommand) { throw "no video recording tool found; install ffmpeg.exe on the Windows desktop lease" }
|
||||
$args = @("-hide_banner", "-loglevel", "error", "-y", "-f", "gdigrab"` + sizeArgs + `, "-framerate", "` + fmt.Sprint(fps) + `", "-i", "desktop", "-t", "` + fmt.Sprint(durationSeconds) + `", "-pix_fmt", "yuv420p", "__CRABBOX_RECORDING_OUT__")
|
||||
$proc = Start-Process -FilePath $ffmpegCommand.Source -ArgumentList $args -WindowStyle Hidden -Wait -PassThru
|
||||
if ($proc.ExitCode -ne 0) { throw "ffmpeg.exe failed with exit code $($proc.ExitCode)" }
|
||||
if (-not (Test-Path -LiteralPath "__CRABBOX_RECORDING_OUT__") -or ((Get-Item -LiteralPath "__CRABBOX_RECORDING_OUT__").Length -le 0)) { throw "ffmpeg.exe produced no recording" }
|
||||
Set-Content -Encoding ASCII -LiteralPath "__CRABBOX_RECORDING_DONE__" -Value "done"
|
||||
`
|
||||
return `$ErrorActionPreference = "Stop"
|
||||
$base = "C:\ProgramData\crabbox"
|
||||
$passwordPath = Join-Path $base "windows.password"
|
||||
$password = if (Test-Path -LiteralPath $passwordPath) { (Get-Content -Raw -LiteralPath $passwordPath).Trim() } else { "" }
|
||||
$taskName = "CrabboxRecord-" + [Guid]::NewGuid().ToString("N")
|
||||
$out = Join-Path $base ($taskName + ".mp4")
|
||||
$done = Join-Path $base ($taskName + ".done")
|
||||
$script = Join-Path $base ($taskName + ".ps1")
|
||||
try {
|
||||
Set-Content -Encoding UTF8 -LiteralPath $script -Value (` + psQuote(inner) + `.Replace("__CRABBOX_RECORDING_OUT__", $out).Replace("__CRABBOX_RECORDING_DONE__", $done))
|
||||
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", $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 recording task" }
|
||||
schtasks.exe /Run /TN $taskName | Out-Null
|
||||
$deadline = (Get-Date).AddSeconds(` + fmt.Sprint(durationSeconds+45) + `)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-Path -LiteralPath $done) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $done)) { throw "scheduled interactive recording did not finish" }
|
||||
$bytes = [IO.File]::ReadAllBytes($out)
|
||||
[Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)
|
||||
} finally {
|
||||
cmd.exe /c "schtasks.exe /Delete /TN $taskName /F 2>NUL" | Out-Null
|
||||
Remove-Item -Force -LiteralPath $out, $done, $script -ErrorAction SilentlyContinue
|
||||
}
|
||||
`
|
||||
}
|
||||
@ -1,140 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultRecordingPath(t *testing.T) {
|
||||
if got := defaultRecordingPath("cbx_123", "Blue Lobster"); got != "crabbox-blue-lobster-recording.mp4" {
|
||||
t.Fatalf("path=%q", got)
|
||||
}
|
||||
if got := defaultRecordingPath("cbx_123", ""); got != "crabbox-cbx-123-recording.mp4" {
|
||||
t.Fatalf("fallback path=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordWhileCommandArgsOnlyStripsPositionalID(t *testing.T) {
|
||||
if got := recordWhileCommandArgs([]string{"test", "-f", "foo"}, "test", false); strings.Join(got, " ") != "test -f foo" {
|
||||
t.Fatalf("flag id should not strip driver command, got %q", got)
|
||||
}
|
||||
if got := recordWhileCommandArgs([]string{"test", "driver"}, "test", true); strings.Join(got, " ") != "driver" {
|
||||
t.Fatalf("positional id should be stripped, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordDesktopControlTargetUsesWindowsConsoleForWSL2(t *testing.T) {
|
||||
target := recordDesktopControlTarget(SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeWSL2})
|
||||
if target.WindowsMode != windowsModeNormal {
|
||||
t.Fatalf("record desktop target should use Windows console mode, got %q", target.WindowsMode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordRemoteCommandUsesFFmpegX11Grab(t *testing.T) {
|
||||
got := recordRemoteCommand(SSHTarget{TargetOS: targetLinux}, recordDesktopOptions{
|
||||
Duration: 3 * time.Second,
|
||||
FPS: 12,
|
||||
Size: "1280x720",
|
||||
})
|
||||
for _, want := range []string{
|
||||
`DISPLAY="${DISPLAY:-:99}"`,
|
||||
"command -v ffmpeg",
|
||||
"-f x11grab",
|
||||
"-video_size '1280x720'",
|
||||
"-framerate 12",
|
||||
"-t 3",
|
||||
"-movflags frag_keyframe+empty_moov",
|
||||
"-f mp4 pipe:1",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("record command missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordRemoteUntilStopCommandUsesStopFile(t *testing.T) {
|
||||
got := recordRemoteUntilStopCommand(
|
||||
SSHTarget{TargetOS: targetLinux},
|
||||
recordDesktopOptions{Duration: 30 * time.Second, FPS: 8, Size: "auto"},
|
||||
"/tmp/out.mp4",
|
||||
"/tmp/stop",
|
||||
"/tmp/ready",
|
||||
)
|
||||
for _, want := range []string{
|
||||
"out='/tmp/out.mp4'",
|
||||
"stop='/tmp/stop'",
|
||||
"ready='/tmp/ready'",
|
||||
"-f x11grab",
|
||||
"-framerate 8",
|
||||
"-t 45",
|
||||
`printf ready > "$ready"`,
|
||||
`if [ -f "$stop" ]`,
|
||||
`kill -INT "$pid"`,
|
||||
`cat "$out"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("record until-stop command missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "-video_size") {
|
||||
t.Fatalf("auto size should omit -video_size:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordRemoteCommandSupportsWindowsInteractiveTask(t *testing.T) {
|
||||
for _, target := range []SSHTarget{
|
||||
{TargetOS: targetWindows, WindowsMode: windowsModeNormal},
|
||||
{TargetOS: targetWindows, WindowsMode: windowsModeWSL2},
|
||||
} {
|
||||
got := recordRemoteCommand(
|
||||
target,
|
||||
recordDesktopOptions{Duration: 4 * time.Second, FPS: 10, Size: "1024x768"},
|
||||
)
|
||||
assertWindowsRecordCommand(t, got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertWindowsRecordCommand(t *testing.T, got string) {
|
||||
t.Helper()
|
||||
for _, want := range []string{
|
||||
"CrabboxRecord-",
|
||||
"ffmpeg.exe",
|
||||
"gdigrab",
|
||||
"desktop",
|
||||
`"/IT"`,
|
||||
"windows.password",
|
||||
"ReadAllBytes",
|
||||
"-PassThru",
|
||||
"ExitCode",
|
||||
"ffmpeg.exe produced no recording",
|
||||
"} finally {",
|
||||
"schtasks.exe /Delete /TN $taskName /F",
|
||||
"Remove-Item -Force -LiteralPath $out, $done, $script",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("windows record command missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecordWhileLocalCommandTimesOut(t *testing.T) {
|
||||
if _, err := exec.LookPath("sleep"); err != nil {
|
||||
t.Skip("sleep command unavailable")
|
||||
}
|
||||
start := time.Now()
|
||||
err := runRecordWhileLocalCommand(t.Context(), recordWhileCommandOptions{
|
||||
Command: []string{"sleep", "5"},
|
||||
Timeout: 100 * time.Millisecond,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected timeout error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "timed out after 100ms") {
|
||||
t.Fatalf("timeout error=%v", err)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Fatalf("driver timeout took too long: %s", elapsed)
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,3 @@
|
||||
package cli
|
||||
|
||||
var version = "0.6.0"
|
||||
var version = "0.5.1"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/crabbox-plugin",
|
||||
"version": "0.6.0",
|
||||
"version": "0.5.1",
|
||||
"description": "OpenClaw plugin for running Crabbox remote testbox workflows",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
4
worker/package-lock.json
generated
4
worker/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/crabbox-worker",
|
||||
"version": "0.6.0",
|
||||
"version": "0.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/crabbox-worker",
|
||||
"version": "0.6.0",
|
||||
"version": "0.5.1",
|
||||
"dependencies": {
|
||||
"@novnc/novnc": "1.6.0",
|
||||
"aws4fetch": "^1.0.20",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/crabbox-worker",
|
||||
"version": "0.6.0",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -371,7 +371,7 @@ function optionalBootstrap(config: LeaseConfig): string {
|
||||
parts.push(tailscaleBootstrap(config));
|
||||
}
|
||||
if (config.desktop) {
|
||||
parts.push(` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot ffmpeg fonts-dejavu-core fonts-liberation iproute2 openssl
|
||||
parts.push(` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot fonts-dejavu-core fonts-liberation iproute2 openssl
|
||||
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
|
||||
if [ ! -s /var/lib/crabbox/vnc.password ]; then
|
||||
(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)
|
||||
|
||||
@ -79,7 +79,7 @@ describe("cloud-init bootstrap", () => {
|
||||
expect(got).toContain("ExecStart=/usr/bin/startxfce4");
|
||||
expect(got).toContain("systemctl is-active --quiet crabbox-desktop.service");
|
||||
expect(got).toContain("systemctl is-active --quiet crabbox-desktop-session.service");
|
||||
expect(got).toContain("x11-xserver-utils xterm scrot ffmpeg");
|
||||
expect(got).toContain("x11-xserver-utils xterm scrot");
|
||||
expect(got).toContain("xsetroot -solid '#20242b'");
|
||||
expect(got).toContain("xterm -title 'Crabbox Desktop'");
|
||||
expect(got).toContain("(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user