|
| 1 | +// desktop-linux: bring the PACKAGED app up inside a Linux guest and forward its |
| 2 | +// CDP port (the shared attach/forward lives in ./desktop-vm). No window server, |
| 3 | +// so the app renders into an Xvfb virtual display; ffmpeg x11grab (in the |
| 4 | +// scenario's recorder) films that display. Simpler than macOS: no Aqua, no |
| 5 | +// codesign, no launchctl — just background processes with DISPLAY set and |
| 6 | +// --no-sandbox (the chrome-sandbox needs setuid root, pointless on a throwaway |
| 7 | +// guest). The base image (executor-linux-base) carries Xvfb + ffmpeg + the |
| 8 | +// electron runtime libs. |
| 9 | +import { execFileSync } from "node:child_process"; |
| 10 | +import { existsSync } from "node:fs"; |
| 11 | +import { fileURLToPath } from "node:url"; |
| 12 | +import { basename, join } from "node:path"; |
| 13 | + |
| 14 | +import { pushDirAsTar } from "../src/vm/desktop"; |
| 15 | +import { tartVm } from "../src/vm/tart"; |
| 16 | +import { |
| 17 | + attachOrProvision, |
| 18 | + CDP_GUEST_PORT, |
| 19 | + waitGuestHttp, |
| 20 | + waitGuestPageTarget, |
| 21 | + type ProvisionedGuest, |
| 22 | +} from "./desktop-vm"; |
| 23 | + |
| 24 | +const DAEMON_PORT = 4789; |
| 25 | +const GUEST_DIR = "/home/admin/exe"; |
| 26 | +const GUEST_HOME = "/home/admin/exe-home"; |
| 27 | +const DISPLAY = ":99"; |
| 28 | + |
| 29 | +const appDir = fileURLToPath(new URL("../../apps/desktop/", import.meta.url)); |
| 30 | +const hostBundle = () => { |
| 31 | + // electron-builder names the dir `linux-<arch>-unpacked` for non-x64. |
| 32 | + const dir = join(appDir, "dist", "linux-arm64-unpacked"); |
| 33 | + return { |
| 34 | + dir, |
| 35 | + exe: join(dir, "executor-desktop"), |
| 36 | + executor: join(dir, "resources/executor/executor"), |
| 37 | + }; |
| 38 | +}; |
| 39 | + |
| 40 | +/** Build the packaged linux-arm64 bundle if it isn't on disk. The `executor` |
| 41 | + * binary is cross-compiled here via BUN_TARGET (same as the cli-linux lane); |
| 42 | + * electron-builder's `dir` target assembles the unpacked app on macOS without |
| 43 | + * Docker. */ |
| 44 | +const ensureBundle = (): void => { |
| 45 | + if (existsSync(hostBundle().dir)) return; |
| 46 | + const run = (cmd: string, args: string[], env: Record<string, string> = {}) => |
| 47 | + execFileSync(cmd, args, { cwd: appDir, stdio: "inherit", env: { ...process.env, ...env } }); |
| 48 | + run("bun", ["./scripts/build-sidecar.ts"], { BUN_TARGET: "bun-linux-arm64" }); |
| 49 | + run("bunx", ["--bun", "electron-vite", "build"]); |
| 50 | + run( |
| 51 | + "bunx", |
| 52 | + [ |
| 53 | + "--bun", |
| 54 | + "electron-builder", |
| 55 | + "--config", |
| 56 | + "electron-builder.e2e.config.ts", |
| 57 | + "--linux", |
| 58 | + "--arm64", |
| 59 | + ], |
| 60 | + { CSC_IDENTITY_AUTO_DISCOVERY: "false" }, |
| 61 | + ); |
| 62 | +}; |
| 63 | + |
| 64 | +const provisionLinux = async (): Promise<ProvisionedGuest> => { |
| 65 | + ensureBundle(); |
| 66 | + const { dir } = hostBundle(); |
| 67 | + const vm = await tartVm("linux", "arm64").provision(); |
| 68 | + try { |
| 69 | + await vm.ssh(`rm -rf ${GUEST_DIR} ${GUEST_HOME}; mkdir -p ${GUEST_HOME}/.executor`); |
| 70 | + await pushDirAsTar(vm.host, dir, GUEST_DIR); |
| 71 | + |
| 72 | + const guestApp = `${GUEST_DIR}/${basename(dir)}`; |
| 73 | + const guestExe = `${guestApp}/executor-desktop`; |
| 74 | + const guestExecutor = `${guestApp}/resources/executor/executor`; |
| 75 | + await vm.ssh(`chmod +x '${guestExe}' '${guestExecutor}' 2>/dev/null || true`); |
| 76 | + const env = `HOME=${GUEST_HOME} EXECUTOR_DATA_DIR=${GUEST_HOME}/.executor`; |
| 77 | + |
| 78 | + // A virtual display + a minimal WM (openbox) — without a window manager the |
| 79 | + // electron window doesn't map onto the framebuffer that x11grab records. |
| 80 | + await vm.ssh( |
| 81 | + `pkill Xvfb 2>/dev/null; pkill openbox 2>/dev/null; ` + |
| 82 | + `nohup Xvfb ${DISPLAY} -screen 0 1280x800x24 >/tmp/xvfb.log 2>&1 & sleep 2; ` + |
| 83 | + `DISPLAY=${DISPLAY} nohup openbox >/tmp/openbox.log 2>&1 & sleep 1; echo up`, |
| 84 | + ); |
| 85 | + |
| 86 | + // 1) the bundled daemon, supervised — the app attaches to this. |
| 87 | + await vm.ssh( |
| 88 | + `nohup env ${env} EXECUTOR_SUPERVISED=1 EXECUTOR_AUTH_TOKEN=desktop-linux-e2e EXECUTOR_CLIENT=desktop ` + |
| 89 | + `'${guestExecutor}' daemon run --foreground --port ${DAEMON_PORT} --hostname 127.0.0.1 ` + |
| 90 | + `>/tmp/executor-daemon.log 2>&1 &`, |
| 91 | + ); |
| 92 | + if (!(await waitGuestHttp(vm, `http://127.0.0.1:${DAEMON_PORT}/`))) { |
| 93 | + throw new Error( |
| 94 | + "supervised daemon never came up in the guest (see /tmp/executor-daemon.log)", |
| 95 | + ); |
| 96 | + } |
| 97 | + |
| 98 | + // 2) the packaged app on the virtual display, with CDP enabled. |
| 99 | + await vm.ssh( |
| 100 | + `nohup env ${env} DISPLAY=${DISPLAY} '${guestExe}' --no-sandbox ` + |
| 101 | + `--remote-debugging-port=${CDP_GUEST_PORT} --remote-allow-origins='*' ` + |
| 102 | + `>/tmp/executor-app.log 2>&1 &`, |
| 103 | + ); |
| 104 | + if (!(await waitGuestPageTarget(vm, CDP_GUEST_PORT))) { |
| 105 | + const log = (await vm.ssh("tail -40 /tmp/executor-app.log 2>/dev/null").catch(() => null)) |
| 106 | + ?.stdout; |
| 107 | + throw new Error(`the app's CDP page target never appeared:\n${log ?? "(no app log)"}`); |
| 108 | + } |
| 109 | + |
| 110 | + // The electron window maps tiny (10x10) under Xvfb; size it to the screen so |
| 111 | + // the x11grab recording captures the full console (CDP screenshots the |
| 112 | + // renderer surface regardless, but the film grabs the X framebuffer). |
| 113 | + await vm.ssh( |
| 114 | + `WID=$(DISPLAY=${DISPLAY} xdotool search --name executor-desktop | head -1); ` + |
| 115 | + `[ -n "$WID" ] && DISPLAY=${DISPLAY} xdotool windowsize "$WID" 1280 800 windowmove "$WID" 0 0 || true`, |
| 116 | + ); |
| 117 | + |
| 118 | + return { ip: vm.host, teardown: async () => void (await vm.discard()) }; |
| 119 | + } catch (error) { |
| 120 | + await vm.discard(); |
| 121 | + throw error; |
| 122 | + } |
| 123 | +}; |
| 124 | + |
| 125 | +export default (): Promise<(() => Promise<void>) | void> => attachOrProvision(provisionLinux); |
0 commit comments